Oasis đã giới thiệu framework cho runtime off-chain logic (ROFL) để giúp xây dựng và chạy các ứng dụng off-chain trong khi đảm bảo quyền riêng tư và duy trì sự tin cậy với khả năng xác minh on-chain. Có nhiều phần di động khi xây dựng với ROFL.
Trong hướng dẫn này, tôi sẽ trình bày cách xây dựng một ứng dụng TypeScript nhỏ, tạo khóa secp256k1 bên trong ROFL. Nó sẽ sử dụng @oasisprotocol/rofl-client TypeScript SDK, giao tiếp với appd REST API ẩn bên dưới. Ứng dụng TypeScript cũng sẽ:
Sẽ có một smoke test đơn giản in ra logs.
Để thực hiện các bước được mô tả trong hướng dẫn này, bạn sẽ cần:
Để biết chi tiết về thiết lập, vui lòng tham khảo tài liệu về Điều kiện tiên quyết Khởi động nhanh.
Bước đầu tiên là khởi tạo một ứng dụng mới bằng Oasis CLI.
oasis rofl init rofl-keygen
cd rofl-keygen
Tại thời điểm tạo ứng dụng trên Testnet, bạn sẽ được yêu cầu nạp token. Phân bổ 100 TEST token tại thời điểm này.
oasis rofl create --network testnet
Ở đầu ra, CLI sẽ tạo ra App ID, được ký hiệu là rofl1….
Bây giờ, bạn đã sẵn sàng khởi động dự án.
npx hardhat init
Vì chúng ta đang giới thiệu một ứng dụng TypeScript, hãy chọn TypeScript khi được nhắc, sau đó chấp nhận các mặc định.
Bước tiếp theo sẽ là thêm các runtime deps nhỏ để sử dụng bên ngoài Hardhat.
npm i @oasisprotocol/rofl-client ethers dotenv @types/node
npm i -D tsx
Template TypeScript của Hardhat tự động tạo ra tsconfig.json. Chúng ta cần thêm một script nhỏ để mã ứng dụng có thể biên dịch sang dist/.
// tsconfig.json
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"]
}
Trong phần này, chúng ta sẽ thêm một vài file TS nhỏ và một hợp đồng 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
Đây là một bước quan trọng vì script này có nhiều chức năng:
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);
});
Ở giai đoạn này, chúng ta sẽ cần cấu hình tối thiểu để biên dịch 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;
Điểm cần lưu ý là biên dịch cục bộ là tùy chọn, vì vậy bạn có thể bỏ qua nếu muốn. Bước tiếp theo là một lựa chọn — xóa file contracts/Lock.sol hiện có hoặc bạn có thể cập nhật nó lên Solidity version 0.8.24.
npx hardhat compile
Đây là một bước thiết yếu. Ở đây, bạn cần một Dockerfile xây dựng TS và biên dịch hợp đồng. File này cũng sẽ chạy smoke test một lần, sau đó đứng yên trong khi bạn kiểm tra 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"]
Tiếp theo, bạn phải mount appd socket được cung cấp bởi ROFL. Yên tâm rằng không có cổng công khai nào được tiết lộ trong quá trình này.
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
Điều quan trọng cần nhớ là ROFL chỉ chạy trên phần cứng hỗ trợ Intel TDX. Vì vậy, nếu bạn đang biên dịch image trên máy chủ khác, chẳng hạn như macOS, thì việc truyền tham số — platform linux/amd64 là một bước bổ sung thiết yếu.
docker buildx build --platform linux/amd64 \
-t docker.io/YOURUSER/rofl-keygen:0.1.0 --push .
Một điểm thú vị cần lưu ý ở đây là bạn có thể chọn bảo mật và khả năng xác minh bổ sung. Bạn chỉ cần ghim digest và sử dụng image: …@sha256:… trong compose.yaml.
Có một bước mà bạn phải thực hiện trước khi chạy lệnh oasis rofl build. Vì xây dựng phân đoạn image đến sau quá trình containerization, bạn sẽ cần cập nhật services.demo.image trong compose.yaml thành image bạn đã xây dựng.
Đối với các dự án TypeScript đơn giản, như dự án này, đôi khi có khả năng kích thước image lớn hơn dự kiến. Do đó, nên cập nhật phần resources của rofl.yaml thành ít nhất: memory: 1024 và storage.size: 4096.
Bây giờ, bạn đã sẵn sàng.
oasis rofl build
Bạn có thể tiếp theo xuất bản các enclave identities và config.
oasis rofl update
Đây là một bước đủ dễ dàng để bạn triển khai lên nhà cung cấp Testnet.
oasis rofl deploy
Đây là quy trình 2 bước, mặc dù bước thứ hai là tùy chọn.
Đầu tiên, bạn xem smoke‑test logs.
oasis rofl machine logs
Nếu bạn đã hoàn thành tất cả các bước cho đến nay một cách chính xác, bạn sẽ thấy trong đầu ra:
Tiếp theo, local dev. Ở đây, bạn cần chạy npm run build:all để biên dịch mã TypeScript và hợp đồng Solidity. Bỏ qua bước này nếu không cần thiết.
export ALLOW_LOCAL_DEV=true
export LOCAL_DEV_SK=0x<64-hex-dev-secret-key> # DO NOT USE IN PROD
npm run smoke-test
Có một key generation demo trong Oasis GitHub, mà bạn có thể tham khảo như một ví dụ của hướng dẫn này. https://github.com/oasisprotocol/demo-rofl-keygen
Bây giờ bạn đã tạo thành công một khóa trong ROFL với appd, ký tin nhắn, triển khai hợp đồng và chuyển ETH trên Base Sepolia, hãy cho chúng tôi biết phản hồi của bạn trong phần bình luận. Để trò chuyện nhanh với đội ngũ kỹ thuật Oasis để được trợ giúp với các vấn đề cụ thể, bạn có thể để lại nhận xét của mình trong kênh dev-central trong Discord chính thức.
Được xuất bản ban đầu tại https://dev.to vào ngày 20 tháng 2 năm 2026.
Guide To Cross-Chain Key Generation (EVM / Base) With Oasis ROFL ban đầu được xuất bản trong Coinmonks trên Medium, nơi mọi người đang tiếp tục cuộc trò chuyện bằng cách làm nổi bật và phản hồi câu chuyện này.


