diff --git a/docs/build/use-cases/key-generation.mdx b/docs/build/use-cases/key-generation.mdx new file mode 100644 index 0000000000..73d0263b0d --- /dev/null +++ b/docs/build/use-cases/key-generation.mdx @@ -0,0 +1,626 @@ +--- +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'; +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**, **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 + +## 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 app using the [Oasis CLI]: + +```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 **App ID** (e.g., `rofl1...`). + +## Init a Hardhat (TypeScript) project + +```shell +npx hardhat --init +``` + +When prompted, **choose TypeScript** and accept the defaults. + +Now add the small runtime deps we use outside of Hardhat: + +```shell +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 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, 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` — keygen over UNIX socket + +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 + +```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 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_SK; + 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, + 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 { + 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 }; +} +``` + +
+ +### `src/keys.ts` — tiny helpers + +
+ src/keys.ts + +```ts +import { Wallet, getAddress } from "ethers"; + +export function secretKeyToWallet(skHex: string): Wallet { + return new Wallet(skHex); +} + +export function checksumAddress(addr: string): string { + return getAddress(addr); +} +``` + +
+ +### `src/scripts/smoke-test.ts` — single end‑to‑end flow + +This script prints the App ID (inside ROFL), address, a signed message, +waits for funding, and deploys the counter contract. + +
+ src/scripts/smoke-test.ts + +```ts +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 { + 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 { + 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); + 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); +}); +``` + +
+ +### `contracts/Counter.sol` — minimal sample + +
+ 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); } +} +``` + +
+ +### `src/scripts/deploy-contract.ts` — generic deployer + +
+ src/scripts/deploy-contract.ts + +```ts +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 -- '[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); +}); +``` + +
+ +## Hardhat (contracts only) + +Minimal config to compile `Counter.sol`: + +
+ hardhat.config.ts + +```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; +``` + +
+ +Compile locally (optional): + +```shell +npx hardhat compile +``` + +## Containerize + +Mount the **appd socket** provided by ROFL. No public ports are exposed. + +
+ compose.yaml + +```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 +``` + +
+ +Add a Dockerfile that builds TS and compiles the contract, runs the +**smoke test** once, then idles so you can inspect logs. + +
+ 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.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"] +``` + +
+ +## Scripts + +Add handy scripts to `package.json`: + +```json +{ + "scripts": { + "build": "tsc -p tsconfig.json", + "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 \"[]\"" + } +} +``` + +## 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 +``` + +## End‑to‑end (Base Sepolia) + +1. **View smoke‑test logs** + + ```shell + oasis rofl machine logs + ``` + + You should see: + + * App ID + * EVM address and a signed message + * A prompt to fund the address + * After funding: a Counter.sol deployment + +2. **Local dev (optional)** + + ```shell + 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 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, +deployed a contract, and moved ETH on Base Sepolia. + +:::example Key Generation Demo + +You can fetch a complete example shown in this chapter from +https://github.com/oasisprotocol/demo-rofl-keygen. + +::: 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', ] }, {