Oasis memperkenalkan kerangka kerja untuk logika off-chain runtime (ROFL) untuk membantu membangun dan menjalankan aplikasi off-chain sambil memastikan privasi dan menjaga kepercayaan dengan verifikasi on-chain. Ada banyak bagian yang bergerak dalam membangun dengan ROFL.
Dalam tutorial ini, saya akan mendemonstrasikan cara membangun aplikasi TypeScript kecil, menghasilkan kunci secp256k1 di dalam ROFL. Aplikasi ini akan menggunakan @oasisprotocol/rofl-client TypeScript SDK, yang berkomunikasi dengan appd REST API di balik layar. Aplikasi TypeScript juga akan:
Akan ada smoke test sederhana yang mencetak ke log.
Untuk melakukan langkah-langkah yang dijelaskan dalam panduan ini, Anda memerlukan:
Untuk detail pengaturan, silakan lihat dokumentasi tentang Quickstart Prerequisites.
Langkah pertama adalah menginisialisasi aplikasi baru menggunakan Oasis CLI.
oasis rofl init rofl-keygen
cd rofl-keygen
Pada saat membuat aplikasi di Testnet, Anda akan diminta untuk menyetor token. Tetapkan 100 token TEST pada titik ini.
oasis rofl create --network testnet
Sebagai output, CLI akan menghasilkan App ID, yang ditandai dengan rofl1….
Sekarang, Anda siap untuk memulai proyek.
npx hardhat init
Karena kami menampilkan aplikasi TypeScript, pilih TypeScript saat diminta, lalu terima default.
Langkah selanjutnya adalah menambahkan dependensi runtime kecil untuk digunakan di luar Hardhat.
npm i @oasisprotocol/rofl-client ethers dotenv @types/node
npm i -D tsx
Template TypeScript Hardhat secara otomatis membuat tsconfig.json. Kita perlu menambahkan skrip kecil agar kode aplikasi dapat dikompilasi ke dist/.
// tsconfig.json
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"]
}
Di bagian ini, kami akan menambahkan beberapa file TS kecil dan satu kontrak Solidity.
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 contract
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
Ini adalah langkah penting karena skrip ini memiliki beberapa fungsi:
import "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);
});
Pada tahap ini, kita akan membutuhkan konfigurasi minimal untuk mengkompilasi 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;
Yang perlu dicatat adalah bahwa kompilasi lokal bersifat opsional, jadi Anda dapat melewatinya jika mau. Langkah selanjutnya adalah pilihan — hapus file contracts/Lock.sol yang ada atau Anda dapat memperbaruinya ke Solidity versi 0.8.24.
npx hardhat compile
Ini adalah langkah penting. Di sini, Anda memerlukan Dockerfile yang membangun TS dan mengkompilasi kontrak. File ini juga akan menjalankan smoke test sekali, dan kemudian diam sambil Anda memeriksa log.
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"]
Selanjutnya, Anda harus mount appd socket yang disediakan oleh ROFL. Yakinlah bahwa tidak ada port publik yang terekspos dalam proses.
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
Penting untuk diingat bahwa ROFL hanya berjalan pada perangkat keras yang mendukung Intel TDX. Jadi, jika Anda mengkompilasi image pada host yang berbeda, seperti macOS, maka melewatkan parameter — platform linux/amd64 adalah langkah tambahan yang penting.
docker buildx build --platform linux/amd64 \
-t docker.io/YOURUSER/rofl-keygen:0.1.0 --push .
Poin menarik yang perlu dicatat di sini adalah Anda dapat memilih keamanan dan verifikasi tambahan. Anda hanya perlu pin digest dan gunakan image: …@sha256:… di compose.yaml.
Ada langkah yang harus Anda ambil sebelum menjalankan perintah oasis rofl build. Karena membangun segmen image datang setelah containerisasi, Anda perlu memperbarui services.demo.image di compose.yaml ke image yang Anda bangun.
Untuk proyek TypeScript sederhana, seperti ini, kadang-kadang ada kemungkinan bahwa ukuran image lebih besar dari yang diantisipasi. Oleh karena itu disarankan untuk memperbarui bagian resources rofl.yaml setidaknya menjadi: memory: 1024 dan storage.size: 4096.
Sekarang, Anda siap.
oasis rofl build
Anda dapat selanjutnya mempublikasikan identitas enclave dan config.
oasis rofl update
Ini adalah langkah yang cukup mudah di mana Anda deploy ke penyedia Testnet.
oasis rofl deploy
Ini adalah proses 2 langkah, meskipun langkah kedua bersifat opsional.
Pertama, Anda melihat log smoke‑test.
oasis rofl machine logs
Jika Anda telah menyelesaikan semua langkah sampai sekarang dengan benar, Anda akan melihat di output:
Selanjutnya, local dev. Di sini, Anda perlu menjalankan npm run build:all untuk mengkompilasi kode TypeScript dan kontrak Solidity. Lewati langkah ini jika tidak diperlukan.
export ALLOW_LOCAL_DEV=true
export LOCAL_DEV_SK=0x<64-hex-dev-secret-key> # DO NOT USE IN PROD
npm run smoke-test
Ada demo pembangkitan kunci di GitHub Oasis, yang dapat Anda rujuk sebagai contoh tutorial ini. https://github.com/oasisprotocol/demo-rofl-keygen
Sekarang setelah Anda berhasil menghasilkan kunci di ROFL dengan appd, menandatangani pesan, deploy kontrak, dan memindahkan ETH di Base Sepolia, beri tahu kami di bagian komentar umpan balik Anda. Untuk obrolan cepat dengan tim engineering Oasis untuk bantuan dengan masalah spesifik, Anda dapat memberikan komentar Anda di dev-central channel di Discord resmi.
Awalnya dipublikasikan di https://dev.to pada 20 Februari, 2026.
Guide To Cross-Chain Key Generation (EVM / Base) With Oasis ROFL awalnya dipublikasikan di Coinmonks di Medium, di mana orang-orang melanjutkan percakapan dengan menyoroti dan merespons cerita ini.
