From cef9dea98c52929dae8771e1f798129690afb871 Mon Sep 17 00:00:00 2001 From: Abhishek Uniyal <56363630+uniyalabhishek@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:40:21 +0200 Subject: [PATCH 1/5] feat: add rofl key genearation use case --- docs/build/use-cases/key-generation.mdx | 585 ++++++++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 docs/build/use-cases/key-generation.mdx diff --git a/docs/build/use-cases/key-generation.mdx b/docs/build/use-cases/key-generation.mdx new file mode 100644 index 0000000000..3760065053 --- /dev/null +++ b/docs/build/use-cases/key-generation.mdx @@ -0,0 +1,585 @@ +--- +description: Generate an EVM key inside ROFL via appd and use it to sign and send transactions on Base. +tags: [ROFL, appd, keys, EVM] +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ROFL Key Generation (EVM / Base) + +This chapter shows how to build a tiny TypeScript app that **generates a +secp256k1 key inside ROFL** via the [appd REST API], derives an **EVM +address**, and **signs & sends** EIP‑1559 transactions on **Base Sepolia**. +We also include an optional **contract deployment** step. + +[appd REST API]: ../rofl/features/appd.md + +## Prerequisites + +This guide requires: + +- **Node.js 20+** and **Docker** (or Podman). +- **Oasis CLI** and at least **120 TEST** tokens in your wallet. +- A Base Sepolia faucet or funds to test sending ETH. + +Check [Quickstart Prerequisites] for setup details. + +[Quickstart Prerequisites]: ../rofl/quickstart#prerequisites + +## Init App + +Initialize a new ROFL app and enter the project directory: + +```shell +oasis rofl init rofl-keygen +cd rofl-keygen +```` + +## Create App + +Create the app on Testnet (100 TEST deposit): + +```shell +oasis rofl create --network testnet +``` + +The CLI prints the **ROFL App ID** (e.g., `rofl1...`). We will expose it +via an HTTP endpoint as well. + +## Install minimal deps + +We keep dependencies lean: + +```shell +npm init -y +npm i express ethers zod dotenv +npm i -D typescript tsx @types/node @types/express hardhat +npx tsc --init --rootDir src --outDir dist --module NodeNext --target ES2022 +``` + +## App structure + +We'll add four small TS files and one Solidity contract: + +``` +src/ + appd.ts # talks to appd over /run/rofl-appd.sock + evm.ts # ethers helpers (provider, wallet, tx) + keys.ts # tiny helpers (checksum) + server.ts # HTTP API to drive the demo +contracts/ + Counter.sol # optional sample contract +``` + +### `src/appd.ts` — keygen over UNIX socket + +This calls `POST /rofl/v1/keys/generate` and returns a **0x‑hex private +key**. Outside ROFL, it can fall back to a **dev key** for local tests. + +
+ src/appd.ts + +```ts +import { request } from "node:http"; +import { existsSync } from "node:fs"; + +const APPD_SOCKET = "/run/rofl-appd.sock"; +type KeyKind = "secp256k1" | "ed25519" | "raw-256" | "raw-386"; + +export async function generateKey( + keyId: string, + kind: KeyKind = "secp256k1" +): Promise { + if (!existsSync(APPD_SOCKET)) { + throw new Error("appd socket missing: /run/rofl-appd.sock"); + } + const body = JSON.stringify({ key_id: keyId, kind }); + return new Promise((resolve, reject) => { + const req = request( + { + method: "POST", + socketPath: APPD_SOCKET, + path: "/rofl/v1/keys/generate", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body).toString() + } + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (data += c)); + res.on("end", () => { + try { + const { key } = JSON.parse(data); + if (!key) throw new Error(`Bad response: ${data}`); + resolve(key.startsWith("0x") ? key : `0x${key}`); + } catch (e) { + reject(e); + } + }); + } + ); + req.on("error", reject); + req.write(body); + req.end(); + }); +} + +export async function getAppId(): Promise { + if (!existsSync(APPD_SOCKET)) { + throw new Error("appd socket missing: /run/rofl-appd.sock"); + } + return new Promise((resolve, reject) => { + const req = request( + { method: "GET", socketPath: APPD_SOCKET, path: "/rofl/v1/app/id" }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (data += c)); + res.on("end", () => { + const id = data.trim(); + if (!/^rofl1[0-9a-z]+$/.test(id)) { + return reject(new Error(`Bad app id: ${data}`)); + } + resolve(id); + }); + } + ); + req.on("error", reject); + req.end(); + }); +} + +export async function getEvmPrivateKey(keyId: string): Promise { + try { + return await generateKey(keyId, "secp256k1"); + } catch (e) { + const allow = process.env.ALLOW_LOCAL_DEV === "true"; + const pk = process.env.LOCAL_DEV_PK; + if (allow && pk && /^0x[0-9a-fA-F]{64}$/.test(pk)) return pk; + throw e; + } +} +``` + +
+ +### `src/evm.ts` — ethers helpers + +
+ src/evm.ts + +```ts +import { + JsonRpcProvider, + Wallet, + parseEther, + type TransactionReceipt +} from "ethers"; + +export function makeProvider(rpcUrl: string, chainId: number) { + return new JsonRpcProvider(rpcUrl, chainId); +} + +export function connectWallet( + pkHex: string, + rpcUrl: string, + chainId: number +): Wallet { + return new Wallet(pkHex).connect(makeProvider(rpcUrl, chainId)); +} + +export async function signPersonalMessage(w: Wallet, msg: string) { + return w.signMessage(msg); +} + +export async function sendEth( + w: Wallet, + to: string, + amountEth: string +): Promise { + const tx = await w.sendTransaction({ to, value: parseEther(amountEth) }); + const rcpt = await tx.wait(); + if (!rcpt) throw new Error("tx dropped before confirmation"); + return rcpt; +} +``` + +
+ +### `src/keys.ts` — tiny helpers + +
+ src/keys.ts + +```ts +import { Wallet, getAddress } from "ethers"; + +export function privateKeyToWallet(pkHex: string): Wallet { + return new Wallet(pkHex); +} + +export function checksumAddress(addr: string): string { + return getAddress(addr); +} +``` + +
+ +### `src/server.ts` — minimal HTTP API + +We expose endpoints to fetch the address, sign a message, and send ETH. +All signing uses the **ROFL‑generated key** (or a dev key when allowed). + +
+ src/server.ts + +```ts +import "dotenv/config"; +import express from "express"; +import { z } from "zod"; +import { getEvmPrivateKey, getAppId } from "./appd.js"; +import { privateKeyToWallet, checksumAddress } from "./keys.js"; +import { makeProvider, signPersonalMessage, sendEth } from "./evm.js"; + +const app = express(); +app.use(express.json()); + +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"); + +app.get("/health", (_req, res) => res.json({ ok: true })); + +app.get("/app-id", async (_req, res) => { + try { + res.json({ appId: await getAppId() }); + } catch (e: any) { + res.status(500).json({ error: e?.message ?? "internal" }); + } +}); + +app.get("/info", async (_req, res) => { + const rpcHost = new URL(RPC_URL).host; + const appId = await getAppId().catch(() => null); + res.json({ keyId: KEY_ID, chainId: CHAIN_ID, rpcHost, appId }); +}); + +app.get("/address", async (_req, res) => { + try { + const pk = await getEvmPrivateKey(KEY_ID); + const w = privateKeyToWallet(pk).connect(makeProvider(RPC_URL, CHAIN_ID)); + res.json({ keyId: KEY_ID, address: checksumAddress(w.address) }); + } catch (e: any) { + res.status(500).json({ error: e?.message ?? "internal" }); + } +}); + +app.post("/sign-message", async (req, res) => { + try { + const { message } = z.object({ message: z.string().min(1) }).parse(req.body); + const pk = await getEvmPrivateKey(KEY_ID); + const sig = await signPersonalMessage(privateKeyToWallet(pk), message); + const addr = checksumAddress(privateKeyToWallet(pk).address); + res.json({ signature: sig, address: addr }); + } catch (e: any) { + res.status(400).json({ error: e?.message ?? "bad request" }); + } +}); + +app.post("/send-eth", async (req, res) => { + try { + const schema = z.object({ + to: z.string().regex(/^0x[0-9a-fA-F]{40}$/), + amount: z.string().regex(/^\d+(\.\d+)?$/) + }); + const { to, amount } = schema.parse(req.body); + const pk = await getEvmPrivateKey(KEY_ID); + const w = privateKeyToWallet(pk).connect(makeProvider(RPC_URL, CHAIN_ID)); + const rcpt = await sendEth(w, checksumAddress(to), amount); + res.json({ txHash: rcpt.hash, status: rcpt.status }); + } catch (e: any) { + res.status(400).json({ error: e?.message ?? "bad request" }); + } +}); + +const port = Number(process.env.PORT ?? "8080"); +app.listen(port, () => console.log(`keygen demo listening on :${port}`)); +``` + +
+ +### Optional: deploy a sample contract + +Add a tiny counter and a deploy script. The Docker build will compile it. + +
+ contracts/Counter.sol + +```solidity +// 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); } +} +``` + +
+ +
+ scripts/deploy-contract.ts + +```ts +import "dotenv/config"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getEvmPrivateKey } from "../src/appd.js"; +import { privateKeyToWallet } from "../src/keys.js"; +import { makeProvider } from "../src/evm.js"; +import { ContractFactory } from "ethers"; + +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"); + +async function main() { + const p = join(process.cwd(), "artifacts", "contracts", "Counter.sol", "Counter.json"); + const { abi, bytecode } = JSON.parse(readFileSync(p, "utf8")); + const pk = await getEvmPrivateKey(KEY_ID); + const wallet = privateKeyToWallet(pk).connect(makeProvider(RPC_URL, CHAIN_ID)); + const factory = new ContractFactory(abi, bytecode, wallet); + const c = await factory.deploy(); + const rcpt = await c.deploymentTransaction()?.wait(); + await c.waitForDeployment(); + console.log(JSON.stringify({ contractAddress: c.target, txHash: rcpt?.hash }, null, 2)); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +
+ +## Hardhat (contracts only) + +Minimal config to compile `Counter.sol`: + +
+ hardhat.config.cjs + +```cjs +/** @type import('hardhat/config').HardhatUserConfig */ +module.exports = { + solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } }, + paths: { sources: "./contracts", artifacts: "./artifacts", cache: "./cache" } +}; +``` + +
+ +Compile locally (optional): + +```shell +npx hardhat compile +``` + +## Containerize + +Add a compose file that mounts the **appd socket** provided by ROFL and +exposes port **8080**. We set Base Sepolia RPC defaults. + +
+ compose.yaml + +```yaml +services: + demo: + image: docker.io/YOURUSER/rofl-keygen:0.1.0 + platform: linux/amd64 + environment: + - PORT=${PORT:-8080} + - KEY_ID=${KEY_ID:-evm:base:sepolia} + - BASE_RPC_URL=${BASE_RPC_URL:-https://sepolia.base.org} + - BASE_CHAIN_ID=${BASE_CHAIN_ID:-84532} + ports: + - "8080:8080" + volumes: + - /run/rofl-appd.sock:/run/rofl-appd.sock +``` + +
+ +Add a Dockerfile that builds TS and compiles the contract (optional): + +
+ Dockerfile + +```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.cjs ./ +RUN npm run build || npx tsc \ + && npx hardhat compile \ + && npm prune --omit=dev + +ENV NODE_ENV=production +ENV PORT=8080 +EXPOSE 8080 +CMD ["node", "dist/server.js"] +``` + +
+ +## Scripts + +Add handy scripts to `package.json`: + +```json +{ + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "dev": "tsx src/server.ts", + "deploy-counter": "node scripts/deploy-contract.js" + } +} +``` + +## Build the image + +ROFL runs on **x86_64/TDX**, so build amd64 images: + +```shell +docker buildx build --platform linux/amd64 \ + -t docker.io/YOURUSER/rofl-keygen:0.1.0 --push . +``` + +(Optionally pin the digest and use `image: ...@sha256:...` in compose.) + +## Build ROFL bundle + + + + ```shell + oasis rofl build + ``` + + + ```shell + docker run --platform linux/amd64 --volume .:/src \ + -it ghcr.io/oasisprotocol/rofl-dev:main oasis rofl build + ``` + + + +Then publish the enclave identities and config: + +```shell +oasis rofl update +``` + +## Deploy + +Deploy to a Testnet provider: + +```shell +oasis rofl deploy +``` + +Find your public HTTPS URL: + +```shell +oasis rofl machine show +``` + +Look for the **Proxy** section (e.g., `https://p8080.mXXX.test-proxy...`). + +## End‑to‑end (Base Sepolia) + +1. **Get App ID** + + ```shell + curl -s https://YOUR-PROXY/app-id | jq + ``` + +2. **Get address and fund it** + + ```shell + curl -s https://YOUR-PROXY/address | jq + # Use a Base Sepolia faucet to send test ETH to the address. + ``` + +3. **Sign a message** + + ```shell + curl -s -X POST https://YOUR-PROXY/sign-message \ + -H 'content-type: application/json' \ + -d '{"message":"hello from rofl"}' | jq + ``` + +4. **Send ETH back to yourself** + + ```shell + curl -s -X POST https://YOUR-PROXY/send-eth \ + -H 'content-type: application/json' \ + -d '{"to":"0xYourSepoliaAddr","amount":"0.001"}' | jq + ``` + +5. **Optional: deploy the counter** + + If you baked `Counter.sol` into the image, exec into the container or + include a route that loads the artifact and deploys it. With the + provided script: + + ```shell + node scripts/deploy-contract.js + ``` + +## Security & notes + +* **Never** log private keys. Provider logs are not encrypted at rest. +* The appd socket `/run/rofl-appd.sock` exists **only inside ROFL**. +* For local dev (outside ROFL), you can use: + + ```shell + export ALLOW_LOCAL_DEV=true + export LOCAL_DEV_PK=0x<64-hex-dev-key> + npm run dev + ``` + + Do **not** use a dev key in production. +* Public RPCs may rate‑limit; prefer a dedicated Base RPC URL. + +## API summary + +* `GET /health` → `{ ok }` +* `GET /app-id` → `{ appId }` +* `GET /info` → `{ keyId, chainId, rpcHost, appId }` +* `GET /address` → `{ keyId, address }` +* `POST /sign-message { message }` → `{ signature, address }` +* `POST /send-eth { to, amount }` → `{ txHash, status }` + +That’s it! You generated a key in ROFL with **appd**, signed messages, +and moved ETH on Base Sepolia with a few lines of TypeScript. 🎉 + +:::example Price Oracle Demo + +You can fetch a complete example shown in this chapter from +https://github.com/oasisprotocol/demo-rofl-keygen. + +::: From 43968110c1a653001cfcb5913881c34a044ee2ab Mon Sep 17 00:00:00 2001 From: Abhishek Uniyal <56363630+uniyalabhishek@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:49:56 +0200 Subject: [PATCH 2/5] fix: sidebar --- sidebarBuild.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/sidebarBuild.ts b/sidebarBuild.ts index 93b07751c5..eaf7cf0cb9 100644 --- a/sidebarBuild.ts +++ b/sidebarBuild.ts @@ -19,6 +19,7 @@ export const sidebarBuild: SidebarsConfig = { items: [ 'build/use-cases/price-oracle', 'build/use-cases/tgbot', + 'build/use-cases/key-generation', ] }, { From eb5d127679f93677f5cc467527e8cc4c4c6e4803 Mon Sep 17 00:00:00 2001 From: Abhishek Uniyal <56363630+uniyalabhishek@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:58:35 +0200 Subject: [PATCH 3/5] fix: rofl app term --- docs/build/use-cases/key-generation.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/build/use-cases/key-generation.mdx b/docs/build/use-cases/key-generation.mdx index 3760065053..92cd8ebe4f 100644 --- a/docs/build/use-cases/key-generation.mdx +++ b/docs/build/use-cases/key-generation.mdx @@ -29,7 +29,7 @@ Check [Quickstart Prerequisites] for setup details. ## Init App -Initialize a new ROFL app and enter the project directory: +Initialize a new app using the [Oasis CLI]: ```shell oasis rofl init rofl-keygen @@ -44,7 +44,7 @@ Create the app on Testnet (100 TEST deposit): oasis rofl create --network testnet ``` -The CLI prints the **ROFL App ID** (e.g., `rofl1...`). We will expose it +The CLI prints the **App ID** (e.g., `rofl1...`). We will expose it via an HTTP endpoint as well. ## Install minimal deps From f881b477d5e1d9b1ab60ada4411933dafd05447f Mon Sep 17 00:00:00 2001 From: Abhishek Uniyal <56363630+uniyalabhishek@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:02:46 +0200 Subject: [PATCH 4/5] fix: example typo --- docs/build/use-cases/key-generation.mdx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/build/use-cases/key-generation.mdx b/docs/build/use-cases/key-generation.mdx index 92cd8ebe4f..747514a90c 100644 --- a/docs/build/use-cases/key-generation.mdx +++ b/docs/build/use-cases/key-generation.mdx @@ -565,19 +565,10 @@ Look for the **Proxy** section (e.g., `https://p8080.mXXX.test-proxy...`). Do **not** use a dev key in production. * Public RPCs may rate‑limit; prefer a dedicated Base RPC URL. -## API summary - -* `GET /health` → `{ ok }` -* `GET /app-id` → `{ appId }` -* `GET /info` → `{ keyId, chainId, rpcHost, appId }` -* `GET /address` → `{ keyId, address }` -* `POST /sign-message { message }` → `{ signature, address }` -* `POST /send-eth { to, amount }` → `{ txHash, status }` - That’s it! You generated a key in ROFL with **appd**, signed messages, and moved ETH on Base Sepolia with a few lines of TypeScript. 🎉 -:::example Price Oracle Demo +:::example Key Generation Demo You can fetch a complete example shown in this chapter from https://github.com/oasisprotocol/demo-rofl-keygen. From 9d3224fd248e819eb242a65b334b05a3febd1639 Mon Sep 17 00:00:00 2001 From: Abhishek Uniyal <56363630+uniyalabhishek@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:39:34 +0200 Subject: [PATCH 5/5] fix: feedback changes --- docs/build/use-cases/key-generation.mdx | 420 +++++++++++++----------- 1 file changed, 235 insertions(+), 185 deletions(-) diff --git a/docs/build/use-cases/key-generation.mdx b/docs/build/use-cases/key-generation.mdx index 747514a90c..73d0263b0d 100644 --- a/docs/build/use-cases/key-generation.mdx +++ b/docs/build/use-cases/key-generation.mdx @@ -1,6 +1,6 @@ --- -description: Generate an EVM key inside ROFL via appd and use it to sign and send transactions on Base. -tags: [ROFL, appd, keys, EVM] +description: Generate an Ethereum-compatible key inside ROFL via appd and use it to sign and send transactions on Base. +tags: [ROFL, appd, KMS, EVM] --- import Tabs from '@theme/Tabs'; @@ -10,8 +10,9 @@ import TabItem from '@theme/TabItem'; This chapter shows how to build a tiny TypeScript app that **generates a secp256k1 key inside ROFL** via the [appd REST API], derives an **EVM -address**, and **signs & sends** EIP‑1559 transactions on **Base Sepolia**. -We also include an optional **contract deployment** step. +address**, **signs** a message, **deploys a contract**, and **sends** +EIP‑1559 transactions on **Base Sepolia**. We use a simple **smoke test** +that prints to logs. [appd REST API]: ../rofl/features/appd.md @@ -34,7 +35,7 @@ Initialize a new app using the [Oasis CLI]: ```shell oasis rofl init rofl-keygen cd rofl-keygen -```` +``` ## Create App @@ -44,38 +45,58 @@ Create the app on Testnet (100 TEST deposit): oasis rofl create --network testnet ``` -The CLI prints the **App ID** (e.g., `rofl1...`). We will expose it -via an HTTP endpoint as well. +The CLI prints the **App ID** (e.g., `rofl1...`). + +## Init a Hardhat (TypeScript) project + +```shell +npx hardhat --init +``` -## Install minimal deps +When prompted, **choose TypeScript** and accept the defaults. -We keep dependencies lean: +Now add the small runtime deps we use outside of Hardhat: ```shell -npm init -y -npm i express ethers zod dotenv -npm i -D typescript tsx @types/node @types/express hardhat -npx tsc --init --rootDir src --outDir dist --module NodeNext --target ES2022 +npm i ethers dotenv +npm i -D tsx +``` + +Using Hardhat’s TypeScript template, it already created a `tsconfig.json`. +Add the following so our app code compiles to `dist/`: + +```json +// tsconfig.json +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} ``` ## App structure -We'll add four small TS files and one Solidity contract: +We'll add a few small TS files and one Solidity contract: ``` src/ - appd.ts # talks to appd over /run/rofl-appd.sock - evm.ts # ethers helpers (provider, wallet, tx) - keys.ts # tiny helpers (checksum) - server.ts # HTTP API to drive the demo +├── appd.ts # talks to appd over /run/rofl-appd.sock +├── 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 # optional sample contract +└── Counter.sol # sample contract ``` ### `src/appd.ts` — keygen over UNIX socket -This calls `POST /rofl/v1/keys/generate` and returns a **0x‑hex private -key**. Outside ROFL, it can fall back to a **dev key** for local tests. +This calls `POST /rofl/v1/keys/generate` and returns a **0x‑hex secret +key**. Outside ROFL, it can fall back to a **dev secret key** for local +tests when explicitly allowed.
src/appd.ts @@ -152,12 +173,12 @@ export async function getAppId(): Promise { }); } -export async function getEvmPrivateKey(keyId: string): Promise { +export async function getEvmSecretKey(keyId: string): Promise { try { return await generateKey(keyId, "secp256k1"); } catch (e) { const allow = process.env.ALLOW_LOCAL_DEV === "true"; - const pk = process.env.LOCAL_DEV_PK; + const pk = process.env.LOCAL_DEV_SK; if (allow && pk && /^0x[0-9a-fA-F]{64}$/.test(pk)) return pk; throw e; } @@ -176,7 +197,8 @@ import { JsonRpcProvider, Wallet, parseEther, - type TransactionReceipt + type TransactionReceipt, + ContractFactory } from "ethers"; export function makeProvider(rpcUrl: string, chainId: number) { @@ -184,26 +206,49 @@ export function makeProvider(rpcUrl: string, chainId: number) { } export function connectWallet( - pkHex: string, + skHex: string, rpcUrl: string, chainId: number ): Wallet { - return new Wallet(pkHex).connect(makeProvider(rpcUrl, chainId)); + const w = new Wallet(skHex); + return w.connect(makeProvider(rpcUrl, chainId)); } -export async function signPersonalMessage(w: Wallet, msg: string) { - return w.signMessage(msg); +export async function signPersonalMessage(wallet: Wallet, msg: string) { + return wallet.signMessage(msg); } export async function sendEth( - w: Wallet, + wallet: Wallet, to: string, amountEth: string ): Promise { - const tx = await w.sendTransaction({ to, value: parseEther(amountEth) }); - const rcpt = await tx.wait(); - if (!rcpt) throw new Error("tx dropped before confirmation"); - return rcpt; + 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 }; } ``` @@ -217,8 +262,8 @@ export async function sendEth( ```ts import { Wallet, getAddress } from "ethers"; -export function privateKeyToWallet(pkHex: string): Wallet { - return new Wallet(pkHex); +export function secretKeyToWallet(skHex: string): Wallet { + return new Wallet(skHex); } export function checksumAddress(addr: string): string { @@ -228,92 +273,92 @@ export function checksumAddress(addr: string): string {
-### `src/server.ts` — minimal HTTP API +### `src/scripts/smoke-test.ts` — single end‑to‑end flow -We expose endpoints to fetch the address, sign a message, and send ETH. -All signing uses the **ROFL‑generated key** (or a dev key when allowed). +This script prints the App ID (inside ROFL), address, a signed message, +waits for funding, and deploys the counter contract.
- src/server.ts + src/scripts/smoke-test.ts ```ts import "dotenv/config"; -import express from "express"; -import { z } from "zod"; -import { getEvmPrivateKey, getAppId } from "./appd.js"; -import { privateKeyToWallet, checksumAddress } from "./keys.js"; -import { makeProvider, signPersonalMessage, sendEth } from "./evm.js"; - -const app = express(); -app.use(express.json()); +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 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"); +const KEY_ID = process.env.KEY_ID ?? "evm:base:sepolia"; -app.get("/health", (_req, res) => res.json({ ok: true })); +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} -app.get("/app-id", async (_req, res) => { - try { - res.json({ appId: await getAppId() }); - } catch (e: any) { - res.status(500).json({ error: e?.message ?? "internal" }); +async function waitForFunding( + provider: JsonRpcProvider, + addr: string, + minWei: bigint = 1n, + timeoutMs = 15 * 60 * 1000, + pollMs = 5_000 +): Promise { + 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."); +} -app.get("/info", async (_req, res) => { - const rpcHost = new URL(RPC_URL).host; +async function main() { const appId = await getAppId().catch(() => null); - res.json({ keyId: KEY_ID, chainId: CHAIN_ID, rpcHost, appId }); -}); + console.log(`ROFL App ID: ${appId ?? "(unavailable outside ROFL)"}`); -app.get("/address", async (_req, res) => { - try { - const pk = await getEvmPrivateKey(KEY_ID); - const w = privateKeyToWallet(pk).connect(makeProvider(RPC_URL, CHAIN_ID)); - res.json({ keyId: KEY_ID, address: checksumAddress(w.address) }); - } catch (e: any) { - res.status(500).json({ error: e?.message ?? "internal" }); - } -}); + const sk = await getEvmSecretKey(KEY_ID); + const wallet = secretKeyToWallet(sk).connect(makeProvider(RPC_URL, CHAIN_ID)); + const addr = checksumAddress(await wallet.getAddress()); + console.log(`EVM address (Base Sepolia): ${addr}`); -app.post("/sign-message", async (req, res) => { - try { - const { message } = z.object({ message: z.string().min(1) }).parse(req.body); - const pk = await getEvmPrivateKey(KEY_ID); - const sig = await signPersonalMessage(privateKeyToWallet(pk), message); - const addr = checksumAddress(privateKeyToWallet(pk).address); - res.json({ signature: sig, address: addr }); - } catch (e: any) { - res.status(400).json({ error: e?.message ?? "bad request" }); + 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`); -app.post("/send-eth", async (req, res) => { - try { - const schema = z.object({ - to: z.string().regex(/^0x[0-9a-fA-F]{40}$/), - amount: z.string().regex(/^\d+(\.\d+)?$/) - }); - const { to, amount } = schema.parse(req.body); - const pk = await getEvmPrivateKey(KEY_ID); - const w = privateKeyToWallet(pk).connect(makeProvider(RPC_URL, CHAIN_ID)); - const rcpt = await sendEth(w, checksumAddress(to), amount); - res.json({ txHash: rcpt.hash, status: rcpt.status }); - } catch (e: any) { - res.status(400).json({ error: e?.message ?? "bad request" }); + 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})`); -const port = Number(process.env.PORT ?? "8080"); -app.listen(port, () => console.log(`keygen demo listening on :${port}`)); + console.log("Smoke test completed successfully!"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); ```
-### Optional: deploy a sample contract - -Add a tiny counter and a deploy script. The Docker build will compile it. +### `contracts/Counter.sol` — minimal sample
contracts/Counter.sol @@ -335,35 +380,60 @@ contract Counter {
+### `src/scripts/deploy-contract.ts` — generic deployer +
- scripts/deploy-contract.ts + src/scripts/deploy-contract.ts ```ts import "dotenv/config"; import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { getEvmPrivateKey } from "../src/appd.js"; -import { privateKeyToWallet } from "../src/keys.js"; -import { makeProvider } from "../src/evm.js"; -import { ContractFactory } from "ethers"; +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 p = join(process.cwd(), "artifacts", "contracts", "Counter.sol", "Counter.json"); - const { abi, bytecode } = JSON.parse(readFileSync(p, "utf8")); - const pk = await getEvmPrivateKey(KEY_ID); - const wallet = privateKeyToWallet(pk).connect(makeProvider(RPC_URL, CHAIN_ID)); - const factory = new ContractFactory(abi, bytecode, wallet); - const c = await factory.deploy(); - const rcpt = await c.deploymentTransaction()?.wait(); - await c.waitForDeployment(); - console.log(JSON.stringify({ contractAddress: c.target, txHash: rcpt?.hash }, null, 2)); + const [artifactPath, ctorJson = "[]"] = process.argv.slice(2); + if (!artifactPath) { + console.error("Usage: npm run deploy-contract -- '[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); + 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); }); +main().catch((e) => { + console.error(e); + process.exit(1); +}); ```
@@ -373,14 +443,26 @@ main().catch((e) => { console.error(e); process.exit(1); }); Minimal config to compile `Counter.sol`:
- hardhat.config.cjs + hardhat.config.ts -```cjs -/** @type import('hardhat/config').HardhatUserConfig */ -module.exports = { - solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } }, - paths: { sources: "./contracts", artifacts: "./artifacts", cache: "./cache" } +```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; ```
@@ -393,8 +475,7 @@ npx hardhat compile ## Containerize -Add a compose file that mounts the **appd socket** provided by ROFL and -exposes port **8080**. We set Base Sepolia RPC defaults. +Mount the **appd socket** provided by ROFL. No public ports are exposed.
compose.yaml @@ -405,19 +486,17 @@ services: image: docker.io/YOURUSER/rofl-keygen:0.1.0 platform: linux/amd64 environment: - - PORT=${PORT:-8080} - KEY_ID=${KEY_ID:-evm:base:sepolia} - BASE_RPC_URL=${BASE_RPC_URL:-https://sepolia.base.org} - BASE_CHAIN_ID=${BASE_CHAIN_ID:-84532} - ports: - - "8080:8080" volumes: - /run/rofl-appd.sock:/run/rofl-appd.sock ```
-Add a Dockerfile that builds TS and compiles the contract (optional): +Add a Dockerfile that builds TS and compiles the contract, runs the +**smoke test** once, then idles so you can inspect logs.
Dockerfile @@ -432,15 +511,11 @@ RUN npm ci COPY tsconfig.json ./ COPY src ./src COPY contracts ./contracts -COPY hardhat.config.cjs ./ -RUN npm run build || npx tsc \ - && npx hardhat compile \ - && npm prune --omit=dev +COPY hardhat.config.ts ./ +RUN npm run build && npx hardhat compile && npm prune --omit=dev ENV NODE_ENV=production -ENV PORT=8080 -EXPOSE 8080 -CMD ["node", "dist/server.js"] +CMD ["sh", "-c", "node dist/scripts/smoke-test.js || true; tail -f /dev/null"] ```
@@ -453,9 +528,16 @@ Add handy scripts to `package.json`: { "scripts": { "build": "tsc -p tsconfig.json", - "start": "node dist/server.js", - "dev": "tsx src/server.ts", - "deploy-counter": "node scripts/deploy-contract.js" + "build:all": "npm run build && npm run compile:contracts", + "smoke-test": "node dist/scripts/smoke-test.js", + "get-address": "tsx src/scripts/get-address.ts", + "sign-message": "tsx src/scripts/sign-message.ts", + "send-eth": "tsx src/scripts/send-eth.ts", + "get-app-id": "tsx src/scripts/get-app-id.ts", + "deploy-contract:dev": "tsx src/scripts/deploy-contract.ts", + "deploy-contract": "node dist/scripts/deploy-contract.js", + "compile:contracts": "hardhat compile", + "deploy-counter": "npm run compile:contracts && node dist/scripts/deploy-contract.js ./artifacts/contracts/Counter.sol/Counter.json \"[]\"" } } ``` @@ -501,72 +583,40 @@ Deploy to a Testnet provider: oasis rofl deploy ``` -Find your public HTTPS URL: - -```shell -oasis rofl machine show -``` - -Look for the **Proxy** section (e.g., `https://p8080.mXXX.test-proxy...`). - ## End‑to‑end (Base Sepolia) -1. **Get App ID** - - ```shell - curl -s https://YOUR-PROXY/app-id | jq - ``` - -2. **Get address and fund it** +1. **View smoke‑test logs** ```shell - curl -s https://YOUR-PROXY/address | jq - # Use a Base Sepolia faucet to send test ETH to the address. + oasis rofl machine logs ``` -3. **Sign a message** + You should see: - ```shell - curl -s -X POST https://YOUR-PROXY/sign-message \ - -H 'content-type: application/json' \ - -d '{"message":"hello from rofl"}' | jq - ``` + * App ID + * EVM address and a signed message + * A prompt to fund the address + * After funding: a Counter.sol deployment -4. **Send ETH back to yourself** +2. **Local dev (optional)** ```shell - curl -s -X POST https://YOUR-PROXY/send-eth \ - -H 'content-type: application/json' \ - -d '{"to":"0xYourSepoliaAddr","amount":"0.001"}' | jq - ``` - -5. **Optional: deploy the counter** - - If you baked `Counter.sol` into the image, exec into the container or - include a route that loads the artifact and deploys it. With the - provided script: - - ```shell - node scripts/deploy-contract.js + export ALLOW_LOCAL_DEV=true + export LOCAL_DEV_SK=0x<64-hex-dev-secret-key> # DO NOT USE IN PROD + npm run get-address + npm run sign-message -- "hello from rofl" + npm run deploy-counter + npm run send-eth -- 0xYourSepoliaAddress 0.001 ``` ## Security & notes -* **Never** log private keys. Provider logs are not encrypted at rest. -* The appd socket `/run/rofl-appd.sock` exists **only inside ROFL**. -* For local dev (outside ROFL), you can use: - - ```shell - export ALLOW_LOCAL_DEV=true - export LOCAL_DEV_PK=0x<64-hex-dev-key> - npm run dev - ``` - - Do **not** use a dev key in production. -* Public RPCs may rate‑limit; prefer a dedicated Base RPC URL. +- **Never** log secret keys. Provider logs are not encrypted at rest. +- The appd socket `/run/rofl-appd.sock` exists **only inside ROFL**. +- Public RPCs may rate‑limit; prefer a dedicated Base RPC URL. That’s it! You generated a key in ROFL with **appd**, signed messages, -and moved ETH on Base Sepolia with a few lines of TypeScript. 🎉 +deployed a contract, and moved ETH on Base Sepolia. :::example Key Generation Demo