Oasis introduced the framework for runtime off-chain logic ( ROFL) to help build and run apps off-chain while ensuring privacy and maintaining trust with on-chain verifiability. There are many moving parts to building with ROFL.
In this tutorial, I will demonstrate how to build a tiny TypeScript app, generating a secp256k1 key inside ROFL. It will be using the @oasisprotocol/rofl-client TypeScript SDK, which talks to the appd REST API under the hood. The TypeScript app will also:

There will be a simple smoke test that prints to logs.

Prerequisites

To do the steps described in this guide, you will need:

Node.js 20+ and Docker (or Podman)Oasis CLI and a minimum of 120 TEST tokens in your wallet (Oasis Testnet faucet)Some Base Sepiola test ETH (Base Sepiola faucet)

For the setup details, please refer to the documentation on Quickstart Prerequisites.

Init App

The first step is to initialize a new app using the Oasis CLI.

oasis rofl init rofl-keygen
cd rofl-keygen

Create App

At the time of creating the app on the Testnet, you will be required to deposit tokens. Assign 100 TEST tokens at this point.

oasis rofl create –network testnet

As output, the CLI will produce the App ID, denoted by rofl1….

Init a Hardhat (TypeScript) project

Now, you are ready to kickstart the project.

npx hardhat init

Since we are showcasing a TypeScript app, choose TypeScript when prompted, and then accept the defaults.
The nextstep would be to add the small runtime deps for use outside of Hardhat.

npm i @oasisprotocol/rofl-client ethers dotenv @types/node
npm i -D tsx

Hardhat’s TypeScript template automatically creates a tsconfig.json. We need to add a small script so that the app code can compile to dist/.

// tsconfig.json
{
“compilerOptions”: {
“rootDir”: “./src”,
“outDir”: “./dist”
},
“include”: [“src”]
}

App structure

In this section, we will add a few small TS files and one Solidity contract.

src/
├── appd.ts # thin wrapper over @oasisprotocol/rofl-client
├── evm.ts # ethers helpers (provider, wallet, tx, deploy)
├── keys.ts # tiny helpers (checksum)
└── scripts/
├── deploy-contract.ts # generic deploy script for compiled artifacts
└── smoke-test.ts # end-to-end demo (logs)
contracts/
└── Counter.sol # sample contractsrc/appd.ts — thin wrapper over the SDK Here, you will need to use the official client to talk to appd (UNIX socket). We will also need to keep an explicit local‑dev fallback when running outside ROFL.

src/appd.ts

import {existsSync} from ‘node:fs’;
import {
RoflClient,
KeyKind,
ROFL_SOCKET_PATH
} from ‘@oasisprotocol/rofl-client’;

const client = new RoflClient(); // UDS: /run/rofl-appd.sock

export async function getAppId(): Promise<string> {
return client.getAppId();
}

/**
* Generates (or deterministically re-derives) a secp256k1 key inside ROFL and
* returns it as a 0x-prefixed hex string (for ethers.js Wallet).
*
* Local development ONLY (outside ROFL): If the socket is missing and you set
* ALLOW_LOCAL_DEV=true and LOCAL_DEV_SK=0x<64-hex>, that value is used.
*/
export async function getEvmSecretKey(keyId: string): Promise<string> {
if (existsSync(ROFL_SOCKET_PATH)) {
const hex = await client.generateKey(keyId, KeyKind.SECP256K1);
return hex.startsWith(‘0x’) ? hex : `0x${hex}`;
}
const allow = process.env.ALLOW_LOCAL_DEV === ‘true’;
const pk = process.env.LOCAL_DEV_SK;
if (allow && pk && /^0x[0-9a-fA-F]{64}$/.test(pk)) return pk;
throw new Error(
‘rofl-appd socket not found and no LOCAL_DEV_SK provided (dev only).’
);
}

2. src/evm.ts — ethers helpers

import {
JsonRpcProvider,
Wallet,
parseEther,
type TransactionReceipt,
ContractFactory
} from “ethers”;

export function makeProvider(rpcUrl: string, chainId: number) {
return new JsonRpcProvider(rpcUrl, chainId);
}

export function connectWallet(
skHex: string,
rpcUrl: string,
chainId: number
): Wallet {
const w = new Wallet(skHex);
return w.connect(makeProvider(rpcUrl, chainId));
}

export async function signPersonalMessage(wallet: Wallet, msg: string) {
return wallet.signMessage(msg);
}

export async function sendEth(
wallet: Wallet,
to: string,
amountEth: string
): Promise<TransactionReceipt> {
const tx = await wallet.sendTransaction({
to,
value: parseEther(amountEth)
});
const receipt = await tx.wait();
if (receipt == null) {
throw new Error(“Transaction dropped or replaced before confirmation”);
}
return receipt;
}

export async function deployContract(
wallet: Wallet,
abi: any[],
bytecode: string,
args: unknown[] = []
): Promise<{ address: string; receipt: TransactionReceipt }> {
const factory = new ContractFactory(abi, bytecode, wallet);
const contract = await factory.deploy(…args);
const deployTx = contract.deploymentTransaction();
const receipt = await deployTx?.wait();
await contract.waitForDeployment();
if (!receipt) {
throw new Error(“Deployment TX not mined”);
}
return { address: contract.target as string, receipt };
}

3. src/keys.ts — tiny helpers

import { Wallet, getAddress } from “ethers”;

export function secretKeyToWallet(skHex: string): Wallet {
return new Wallet(skHex);
}

export function checksumAddress(addr: string): string {
return getAddress(addr);
}

4. src/scripts/smoke-test.ts — single end‑to‑end flow

This is an important step as this script has multiple functions:

print the App ID (inside ROFL), address, and a signed messagewait for fundingdeploy the counter contractimport “dotenv/config”;
import { readFileSync } from “node:fs”;
import { join } from “node:path”;
import { getAppId, getEvmSecretKey } from “../appd.js”;
import { secretKeyToWallet, checksumAddress } from “../keys.js”;
import { makeProvider, signPersonalMessage, sendEth, deployContract } from “../evm.js”;
import { formatEther, JsonRpcProvider } from “ethers”;

const RPC_URL = process.env.BASE_RPC_URL ?? “https://sepolia.base.org”;
const CHAIN_ID = Number(process.env.BASE_CHAIN_ID ?? “84532”);
const KEY_ID = process.env.KEY_ID ?? “evm:base:sepolia”;

function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

async function waitForFunding(
provider: JsonRpcProvider,
addr: string,
minWei: bigint = 1n,
timeoutMs = 15 * 60 * 1000,
pollMs = 5_000
): Promise<bigint> {
const start = Date.now();
while (Date.now() – start < timeoutMs) {
const bal = await provider.getBalance(addr);
if (bal >= minWei) return bal;
console.log(`Waiting for funding… current balance=${formatEther(bal)} ETH`);
await sleep(pollMs);
}
throw new Error(“Timed out waiting for funding.”);
}

async function main() {
const appId = await getAppId().catch(() => null);
console.log(`ROFL App ID: ${appId ?? “(unavailable outside ROFL)”}`);

const sk = await getEvmSecretKey(KEY_ID);
// NOTE: This demo trusts the configured RPC provider. For production, prefer a
// light client (for example, Helios) so you can verify remote chain state.
const wallet = secretKeyToWallet(sk).connect(makeProvider(RPC_URL, CHAIN_ID));
const addr = checksumAddress(await wallet.getAddress());
console.log(`EVM address (Base Sepolia): ${addr}`);

const msg = “hello from rofl”;
const sig = await signPersonalMessage(wallet, msg);
console.log(`Signed message: “${msg}”`);
console.log(`Signature: ${sig}`);

const provider = wallet.provider as JsonRpcProvider;

let bal = await provider.getBalance(addr);
if (bal === 0n) {
console.log(“Please fund the above address with Base Sepolia ETH to continue.”);
bal = await waitForFunding(provider, addr);
}
console.log(`Balance detected: ${formatEther(bal)} ETH`);

const artifactPath = join(process.cwd(), “artifacts”, “contracts”, “Counter.sol”, “Counter.json”);
const artifact = JSON.parse(readFileSync(artifactPath, “utf8”));
if (!artifact?.abi || !artifact?.bytecode) {
throw new Error(“Counter artifact missing abi/bytecode”);
}
const { address: contractAddress, receipt: deployRcpt } =
await deployContract(wallet, artifact.abi, artifact.bytecode, []);
console.log(`Deployed Counter at ${contractAddress} (tx=${deployRcpt.hash})`);

console.log(“Smoke test completed successfully!”);
}

main().catch((e) => {
console.error(e);
process.exit(1);
});

5. contracts/Counter.sol — minimal sample

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
uint256 private _value;
event Incremented(uint256 v);
event Set(uint256 v);

function current() external view returns (uint256) { return _value; }
function inc() external { unchecked { _value += 1; } emit Incremented(_value); }
function set(uint256 v) external { _value = v; emit Set(v); }
}

6. src/scripts/deploy-contract.ts — generic deployer

import “dotenv/config”;
import { readFileSync } from “node:fs”;
import { getEvmSecretKey } from “../appd.js”;
import { secretKeyToWallet } from “../keys.js”;
import { makeProvider, deployContract } from “../evm.js”;

const KEY_ID = process.env.KEY_ID ?? “evm:base:sepolia”;
const RPC_URL = process.env.BASE_RPC_URL ?? “https://sepolia.base.org”;
const CHAIN_ID = Number(process.env.BASE_CHAIN_ID ?? “84532”);

/**
* Usage:
* npm run deploy-contract — ./artifacts/MyContract.json ‘[arg0, arg1]’
* The artifact must contain { abi, bytecode }.
*/
async function main() {
const [artifactPath, ctorJson = “[]”] = process.argv.slice(2);
if (!artifactPath) {
console.error(“Usage: npm run deploy-contract — <artifact.json> ‘[constructorArgsJson]'”);
process.exit(2);
}

const artifactRaw = readFileSync(artifactPath, “utf8”);
const artifact = JSON.parse(artifactRaw);
const { abi, bytecode } = artifact ?? {};
if (!abi || !bytecode) {
throw new Error(“Artifact must contain { abi, bytecode }”);
}

let args: unknown[];
try {
args = JSON.parse(ctorJson);
if (!Array.isArray(args)) throw new Error(“constructor args must be a JSON array”);
} catch (e) {
throw new Error(`Failed to parse constructor args JSON: ${String(e)}`);
}

const sk = await getEvmSecretKey(KEY_ID);
// NOTE: This demo trusts the configured RPC provider. For production, prefer a
// light client (for example, Helios) so you can verify remote chain state.
const wallet = secretKeyToWallet(sk).connect(makeProvider(RPC_URL, CHAIN_ID));
const { address, receipt } = await deployContract(wallet, abi, bytecode, args);

console.log(JSON.stringify({ contractAddress: address, txHash: receipt.hash, status: receipt.status }, null, 2));
}

main().catch((e) => {
console.error(e);
process.exit(1);
});

Hardhat (contracts only)

At this stage, we will need minimal config to compile Counter.sol

hardhat.config.ts

import type { HardhatUserConfig } from “hardhat/config”;

const config: HardhatUserConfig = {
solidity: {
version: “0.8.24”,
settings: {
optimizer: { enabled: true, runs: 200 }
}
},
paths: {
sources: “./contracts”,
artifacts: “./artifacts”,
cache: “./cache”
}
};

export default config;

Point to note is that local compilation is optional, so you can skip it if you want. Next step is a choice — either delete the existing contracts/Lock.sol file or you can update it to Solidity version 0.8.24.

npx hardhat compile

Containerize

This is an essential step. Here, you need to a Dockerfile that builds TS and compiles the contract. The file will also run the smoke test once, and then stand idle while you inspect logs.

Dockerfile

FROM node:20-alpine
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src
COPY contracts ./contracts
COPY hardhat.config.ts ./
RUN npm run build && npx hardhat compile && npm prune –omit=dev

ENV NODE_ENV=production
CMD [“sh”, “-c”, “node dist/scripts/smoke-test.js || true; tail -f /dev/null”]

Next, you must mount appd socket provided by ROFL. Rest assured that no public ports are exposed in the process.

compose.yaml

services:
demo:
image: docker.io/YOURUSER/rofl-keygen:0.1.0
platform: linux/amd64
environment:
– KEY_ID=${KEY_ID:-evm:base:sepolia}
– BASE_RPC_URL=${BASE_RPC_URL:-https://sepolia.base.org}
– BASE_CHAIN_ID=${BASE_CHAIN_ID:-84532}
volumes:
– /run/rofl-appd.sock:/run/rofl-appd.sock

Build the image

It is important to remember that ROFL only runs on Intel TDX-enabled hardware. So, if you’re compiling images on a different host, such as macOS, then passing the — platform linux/amd64 parameter is an essential extra step.

docker buildx build –platform linux/amd64
-t docker.io/YOURUSER/rofl-keygen:0.1.0 –push .

An interesting point to note here is that you can opt for extra security and verifiability. You just need to pin the digest and use image: …@sha256:… in compose.yaml.

Build ROFL bundle

There is a step that you must take before running the oasis rofl build command. Since building the image segment comes after containerization, you will need to update the services.demo.image in compose.yaml to the image you built.
For simple TypeScript projects, like this one, there is sometimes a possibility that the image size is larger than anticipated. It is thus advisable to update the rofl.yaml resources section to at least: memory: 1024 and storage.size: 4096.
Now, you are ready.

oasis rofl build

You can next publish the enclave identities and config.

oasis rofl update

Deploy

This is an easy enough step where you deploy to a Testnet provider.

oasis rofl deploy

End‑to‑end (Base Sepolia)

This is a 2-step process, although the second step is optional.
First, you view smoke‑test logs.

oasis rofl machine logs

If you have completed all the steps till now correctly, you will see in the output:

App IDEVM address and a signed messageA prompt to fund the addressOnce funding is done, a Counter.sol deployment

Next, local dev. Here, you need to run npm run build:all to compile the TypeScript code and the Solidity contract. Skip this step if not needed.

export ALLOW_LOCAL_DEV=true
export LOCAL_DEV_SK=0x<64-hex-dev-secret-key> # DO NOT USE IN PROD
npm run smoke-test

Security & notes to remember

Provider logs are not encrypted at rest. So, never log secret keys.The appd socket /run/rofl-appd.sock exists only inside ROFL.There may be rate limits in public RPCs. So, it is advisable to opt for a dedicated Base RPC URL.

There is a key generation demo in the Oasis GitHub, which you can refer to as an example of this tutorial. https://github.com/oasisprotocol/demo-rofl-keygen

Now that you have successfully generated a key in ROFL with appd, signed messages, deployed a contract, and moved ETH on Base Sepolia, let us know in the comments section your feedback. For a quick chat with the Oasis engineering team for help with specific issues, you can drop your comments in the dev-central channel in the official Discord.

Originally published at https://dev.to on February 20, 2026.

Guide To Cross-Chain Key Generation (EVM / Base) With Oasis ROFL was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

By

Leave a Reply

Your email address will not be published. Required fields are marked *