Docs · 01 / 07 · Quickstart
Send a sponsored UserOperation from a key that never held ETH.
About 20 lines of TypeScript. No API key, no signup, no wallet connection. Your code mines a small proof of work and the Unlinked paymaster pays the gas on Robinhood Chain testnet.
Install#
$ npm i @unlinked/sdk @unlinked/miner viemThe SDK builds the UserOperation itself (fixed gas profiles, EntryPoint v0.9, nonce key 0) on top of viem. The miner runs keccak256 in Web Workers (WASM, with a JavaScript fallback) or Node worker threads.
Point it at the relay#
There is no project id: the relay URL is the only setting. This deployment's relay is
https://relay-testnet.example. The relay checks the proof, co-signs and forwards the op to its own bundler. It is the only endpoint that ever sees your address.Send the op#
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { claimCall, createRelayClient, deployments, freshAccountState, sendPowUserOperation, startEpochPolling, transferCall, } from '@unlinked/sdk' const relay = createRelayClient('https://relay-testnet.example') const epochs = startEpochPolling(relay) // fixed 60 s background poll const owner = privateKeyToAccount(generatePrivateKey()) // never funded let state = freshAccountState(owner.address) // known locally, no read const { faucet, tokens } = deployments[46630]! const claim = await sendPowUserOperation({ relay, epochs, owner, state, call: claimCall(faucet!, tokens.mNVDA!), // first op: 7702 + claim onProgress: (p) => p.stage === 'mining' && console.log(`${p.found}/8`), }) state = claim.state // delegated, nonces advanced const to = privateKeyToAccount(generatePrivateKey()).address const call = transferCall(tokens.mNVDA!, to, 10n ** 18n) const sent = await sendPowUserOperation({ relay, epochs, owner, state, call }) console.log(sent.receipt.success, sent.userOpHash) epochs.stop()// next.config.ts: both packages ship TypeScript sources const nextConfig = { transpilePackages: ['@unlinked/sdk', '@unlinked/miner'], } export default nextConfig // Serve the miner next to your pages (the default assetsBaseUrl is /miner/): // cp node_modules/@unlinked/miner/dist/{worker.js,keccak.wasm} public/miner/ // Content-Security-Policy needs: // script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self' <relay># The current epoch: work price, fee caps, gas profiles, headroom curl -s https://relay-testnet.example -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"pow_getEpoch","params":[]}' # Status of a submitted op: held, submitted, included, rejected or expired curl -s https://relay-testnet.example -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"pow_getUserOperationStatus","params":["0x<userOpHash>"]}'Relay URL filled in from this deployment (Robinhood Chain testnet).The first op of a fresh key carries an EIP-7702 authorization for Simple7702Account v0.9. The bundler's own type-4 transaction applies it, so the key never needs ETH. Keep the returned
statefor the next op: the SDK never reads it from the chain before mining.No relay is configured for this deployment.
Show progress and let people stop#
Mining is memoryless: every hash has the same odds, so show the found sub-solutions (x/8) and an honest estimate, never a
fake percentage. Pass an AbortSignal for a Stop button.
const res = await sendPowUserOperation({
relay, epochs, owner, state, call,
delaySec: 120, // optional: the relay holds the op 2 min
signal: controller.signal, // your Stop button
onProgress: (p) => {
if (p.stage === 'waiting') show(`Next epoch in ${p.seconds} s`)
if (p.stage === 'mining') show(`${p.found}/8 · ~${Math.ceil(p.eta)} s`)
if (p.stage === 'included') show(p.success ? 'Sponsored' : 'Reverted')
},
onState: (s) => save(s), // persist the state as it advances
})| Stage | Fields | Meaning |
|---|---|---|
| epoch | attempt | Reading the current epoch (from the background poll, refreshed after 60 s). |
| waiting | reason, untilMs, seconds | Not enough time or budget left in this epoch: waiting for the next one (window, headroom, cost_cap, keeper, pacing). |
| building | epochId, firstOp | Building the op from the gas profile, the PVG quote and the epoch's fees. |
| mining | found, solutions, hashes, hashrate, eta, etaP95, work, userOpHash | Mining the 8 sub-solutions in Web Workers (signing runs in parallel). |
| signing | userOpHash | The account signs the UserOperation. |
| submitting | userOpHash, delaySec | Sending to the relay (eth_sendUserOperation, or pow_sendUserOperation with a delay). |
| held | userOpHash, forwardAt | The relay accepted the op and holds it for the delay you asked for. |
| submitted | userOpHash | Co-signed and forwarded to the bundler. |
| included | receipt, success | In a block. success mirrors UserOperationEvent.success. |
| failed | error, userOpHash, state | Final failure. The op was not sponsored; see the error codes below. |
Imported keys#
A fresh key starts at freshAccountState(address) with no read at all. A key that already has history (a stealth address
that received tokens, say) needs its state once:
import { loadAccountState } from '@unlinked/sdk'
// An imported key may already have history: read its state once, through the relay only.
let state = await loadAccountState(relay, owner.address)Handle failures#
A failed send never costs ETH: either the op was not sponsored, or it was included and only the call reverted. The SDK re-mines by itself, at most twice, when the epoch rolls or the bundler's quotes move while you mine.
| code | What happened | What to do |
|---|---|---|
| RELAY_RPC | The relay refused the op; RelayRpcError has reason, failMask and retryAt. | Show the reason. PACING and BUDGET_EXHAUSTED carry retryAt. |
| COST_CAP | Fees are above the paymaster's per-op cap this epoch. | The SDK waits for the next epoch by itself; surface the wait. |
| EPOCH_WAIT_TIMEOUT | No epoch had room for this op on this device within maxWaitMs. | Use more threads or retry later. |
| REMINE_LIMIT | The op was rejected again after two re-mines. | Retry later; the epoch or quotes are moving fast. |
| HASH_MISMATCH | The relay computed a different userOpHash. | Check the relay URL and chain; never retry blindly. |
| DEPLOYMENT_MISSING | No paymaster address for this chain. | Wait for the deployment or pass your own addresses. |
| RECEIPT_TIMEOUT | No receipt before the op's validity window closed. | Refresh the account state before sending again. |
| RELAY_HTTP | Network error or timeout talking to the relay. | Retry with backoff. |
| ABORTED | Your AbortSignal fired. | Nothing was sent unless the stage was submitting or later. |