on Robinhood Chain · testnet

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.

  1. Install#

    shell
    $ npm i @unlinked/sdk @unlinked/miner viem

    The 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.

  2. 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.

  3. 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()
    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 state for 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.

progress.ts
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
})
onProgress stages, in order
StageFieldsMeaning
epochattemptReading the current epoch (from the background poll, refreshed after 60 s).
waitingreason, untilMs, secondsNot enough time or budget left in this epoch: waiting for the next one (window, headroom, cost_cap, keeper, pacing).
buildingepochId, firstOpBuilding the op from the gas profile, the PVG quote and the epoch's fees.
miningfound, solutions, hashes, hashrate, eta, etaP95, work, userOpHashMining the 8 sub-solutions in Web Workers (signing runs in parallel).
signinguserOpHashThe account signs the UserOperation.
submittinguserOpHash, delaySecSending to the relay (eth_sendUserOperation, or pow_sendUserOperation with a delay).
helduserOpHash, forwardAtThe relay accepted the op and holds it for the delay you asked for.
submitteduserOpHashCo-signed and forwarded to the bundler.
includedreceipt, successIn a block. success mirrors UserOperationEvent.success.
failederror, userOpHash, stateFinal 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.ts
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.

SdkError codes worth handling
codeWhat happenedWhat to do
RELAY_RPCThe relay refused the op; RelayRpcError has reason, failMask and retryAt.Show the reason. PACING and BUDGET_EXHAUSTED carry retryAt.
COST_CAPFees are above the paymaster's per-op cap this epoch.The SDK waits for the next epoch by itself; surface the wait.
EPOCH_WAIT_TIMEOUTNo epoch had room for this op on this device within maxWaitMs.Use more threads or retry later.
REMINE_LIMITThe op was rejected again after two re-mines.Retry later; the epoch or quotes are moving fast.
HASH_MISMATCHThe relay computed a different userOpHash.Check the relay URL and chain; never retry blindly.
DEPLOYMENT_MISSINGNo paymaster address for this chain.Wait for the deployment or pass your own addresses.
RECEIPT_TIMEOUTNo receipt before the op's validity window closed.Refresh the account state before sending again.
RELAY_HTTPNetwork error or timeout talking to the relay.Retry with backoff.
ABORTEDYour AbortSignal fired.Nothing was sent unless the stage was submitting or later.

Next steps#