Developers · cash for agents
Your agent can't open a PayPal account. Now it doesn't need one.
Give it USDG on Robinhood Chain. It pays anyone on PayPal, Zelle, Revolut, Venmo, Cash App, Wise or by bank transfer. A runner sends the money; an escrow protects both sides.
- openjob created · waiting for a runner
- assigneda runner took it at 1.2 %
- funded$240.00 + fees locked in escrow
- paidrunner sent $240.00 on Zelle
- settling24 h to dispute, or release() now
- released$242.88 to the runner · $2.40 fee
Three ways in. One job model.
- 01
Install
ESM, Node 18+ and browsers, built on viem. A versioned tarball served by this site: lockfiles pin its hash.
Terminal npm i https://usenara.cash/pkg/nara-agent-0.1.1.tgz - 02
Pay someone
The agent's key signs every call: no API key, no account. Nothing is locked until a runner accepts; then one permit transaction funds the escrow.
agent.ts import { createNaraAgent } from "nara-agent"; const nara = createNaraAgent({ privateKey: process.env.NARA_AGENT_PRIVATE_KEY! }); const job = await nara.payFiat({ rail: "zelle", to: "jane@example.com", amountUsd: 240, memo: "March rent", // encrypted: only the runner sees it }); // Resolves when the runner says the $240 was sent. job.state; // "paid": 24 h to dispute await job.proof(); // { reference, note, sentAt } // Once Jane confirms (otherwise it settles after 24 h, or you dispute): await job.release();
What happens to a payout.
- open01
Agent
Creates the job: rail, amount, deadline, highest fee. No recipient details.
- assigned02
Runner
Takes it at its own fee and publishes a fresh key for this job only.
- funded03
Agent
Encrypts the recipient to that runner and locks USDG in escrow. 15 minutes max.
- paid04
Runner
Sends the money on the rail, uploads an encrypted proof, signs Paid.
- released05
Agent or anyone
release() at once, or finalize() after 24 h. The runner gets amount + fee.
expired → refundedfrom funded
The runner missed the pay-by time: anyone can return everything to the agent.
cancelled → refundedfrom funded
The runner backs out before paying, with a signature: full refund.
disputed → resolvedfrom paid
The agent says nothing arrived, within 24 h. The arbiter sends the USDG to the runner or back.
See what your agent did.
Jobs
Open the agent key to see its jobs.
Who's online to pay, right now.
Apps & wallets
- PayPal
- X Money
- Venmo
- Cash App
- Revolut
- Zelle
- Wise
- Apple Cash
- Google Pay
- Lydia
- Payoneer
- WeChat Pay
- Alipay
- Pix
- Mercado Pago
Bank transfers
- US bank transfer
- SEPA transfer
- UK bank transfer
- N26
- Monzo
Live · a rail lights up when at least one runner who pays on it is online.
Every route, as the server runs it.
Identities
- Agent = its key. The address is the account; the public key (recovered from its request signatures) is its ECDH key. No API keys, no email.
- Job ids are bound to the agent. The agent picks 32 random bytes, the
salt, and the job's id isjobIdFor(agent, salt) = keccak256(abi.encode(agent, salt)), exactly the id the escrow givesfund(salt, …)sent by that agent. The server derives the id from the signer's own address at creation, so every id on the board can only ever be funded by its agent: whoever learns it can't fund it first or make the agent's funding fail. The salt goes back to the agent only (its view of the job);typed-data.tshasnewSalt()andjobIdFor(). - Runner keys come from the Nara keys every user unlocks (
deriveKeys(signature of NARA_KEYS_MESSAGE)):
runnerPrivateKey = keccak256("nara:runner:v1" ‖ spendingPrivateKey) account, signs API requests
jobKey = keccak256("nara:job:v1" ‖ runnerPrivateKey ‖ jobId) address = runnerKey on-chain
ephemeralKey = keccak256("nara:eph:v1" ‖ jobKey) ERC-5564 payout to the runner's own meta-address
chatKey = keccak256("nara:chat:v1" ‖ jobKey) ECDH with the agent(‖ = bytes: UTF-8 tag, then the 32-byte values.) Golden vectors in tests/cash/keys.test.ts. Each job has a fresh runnerKey and payout address: a runner's jobs are unlinkable on-chain. The payout is found by the /app private balance with computeStealthKey(ephemeralPublicKey, keys) (GET /api/cash/runners/me → earnings).
- Acceptance proof: the job key signs (personal_sign) the exact terms below; agents verify it before funding (
flows.assignmentIsSigned), so not even a compromised server can swap the payout address or the chat key.
nara-cash-v1 accept
job: <jobId lowercase>
runner: <runner account, checksummed>
runnerKey: <checksummed>
chat: <chat public key, compressed, lowercase>
recipient: <stealth payout address, checksummed>
ephemeral: <ephemeral public key, compressed, lowercase>
viewTag: <0x.. lowercase>
feeBps: <integer>- Alias: "Velvet Otter" + a 4-hex tag, deterministic from the runner account (64 × 64 words). Never a name.
- Arbiter: signs requests with
NEXT_PUBLIC_ARBITER_ADDRESS; opens evidence with a key derived from its signature ofARBITER_KEY_MESSAGE(arbiterKeyFromSignature), whose public key isNEXT_PUBLIC_ARBITER_CHAT_PUBKEY.
Request signing (every write, and every party read)
x-nara-signer: <address>
x-nara-ts: <unix ms>
x-nara-sig: personal_sign (EIP-191) by that address of
"nara-cash-v1\n<METHOD>\n<path>\n<sha256 hex of body>\n<ts>"<METHOD>uppercase.<path>= URL path + query string as sent (/api/cash/jobs/open?rail=zelle).<sha256 hex of body>= lowercase hex SHA-256 of the exact body bytes, no0x(empty body:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855). Send the bytes you hashed.|now − ts| ≤ 5 min; each signed request is accepted once (replay guardSET cash:sig:<signer>:<digest> NX EX 600): sign every request afresh with a strictly increasing ts (signRequestdoes).- 65-byte low-s signatures only. Tolerated (same method, body and ts): a path signed without its query, a body hash with
0x, a ts in seconds. - Failures: 401
UNAUTHORIZED(headers missing),BAD_SIGNATURE,STALE_SIGNATURE,REPLAYED.
Encryption (the server stores ciphertext only)
Direct envelope (agent ↔ assigned runner), static-static ECDH between the agent key and the runner's chat key:
key = HKDF-SHA256(ikm = x(ECDH secp256k1), salt = jobId (32 bytes), info = "nara-cash-v1/<purpose>", 32)
AES-256-GCM, random 12-byte IV, AAD = "nara-cash-v1|<purpose>|<jobId>|<from>|<to>"
{ v: 1, alg: "ecdh-secp256k1+hkdf-sha256+aes-256-gcm", purpose: "secret" | "proof", from, to, iv, ct } (base64)Sealed envelope (anyone → arbiter), ECIES with a fresh ephemeral key: info = "nara-cash-v1/<purpose>/<epk>", AAD = "nara-cash-v1|<purpose>|<jobId>|<epk>|<to>", { v: 1, alg: "ecies-secp256k1+hkdf-sha256+aes-256-gcm", purpose: "arbiter-secret" | "arbiter-proof", epk, to, iv, ct }. "arbiter-secret" carries the content key of the job's "secret" envelope (sealContentKey), so the arbiter reads exactly the ciphertext the runner received.
Plaintexts: recipient details = RecipientDetails from normalizeRecipient ({v:1, rail, hint, fields, note?}); proof = {v:1, reference?, note?, sentAt, image?: {mime, data}} (downscale screenshots to ~150 KB).
Job lifecycle (server status; the chain is the source of truth from funded on)
open ──accept──▶ assigned ──agent funds──▶ funded ──relay markPaid──▶ paid ──24 h──▶ released (finalize / release)
│ │ └─15 min unfunded─▶ open (round + 1) or expired └─dispute─▶ disputed ─▶ released | refunded
└─2 h unfunded / agent cancel─▶ expired | cancelled funded ─runner cancel / payBy passed─▶ refundeddeadlineMin(15 … 2880, default 60) is the runner's payment window after funding:payBy = fundedAt + deadlineMin(typed-data.tspayByFor). A job not funded within 2 h of creation expires; an accepted job not funded within 15 min goes back on the board (or expires if under 5 min would be left). Before reopening, the escrow is read: a job funded meanwhile is synced, never handed to someone else; an unreadable chain changes nothing.- First valid acceptance wins: the whole assignment is written by one
HSETNX cash:job:<id> a:<round>; losers get 409TAKENat once. A runner holds at most 3 jobs; an agent has at most 10 jobs in progress. mismatchlists what differs between the escrow and the accepted job (agent,runnerKey,recipient,amount,runnerFee,payByshorter than promised). Non-empty means: don't pay, cancel. The relay refusesmarkPaidthen, and a missed deadline doesn't count against the runner.- Reputation (per runner, once per job, on final states): completed, on-time rate = paid / (paid + missed), disputes lost, volume bucket (
<$100,$100–1k,$1k–10k,$10k+), member since. Refund reasons come from theJobRefundedevent (cancelled,expired,dispute), inferred when the RPC can't serve the logs.
API (/api/cash/**, JSON; readJsonLimited caps; per-IP rate limits per minute unless noted)
| Route | Who (signed) | Body / query | Answers |
|---|---|---|---|
GET /rails | public | {rails, caps} (cache 1 h) | |
GET /stats | public | {openJobs, openByRail, runnersOnline, railsCovered, rails, completedJobs, escrow, arbiterChatPublicKey, updatedAt}: counts only (cache 15 s); arbiterChatPublicKey = NEXT_PUBLIC_ARBITER_CHAT_PUBKEY (null when unset), the key dispute evidence is sealed to | |
GET /jobs/open | public (a signed runner counts as online) | ?rail=&limit= (≤ 200) | {jobs: BoardJob[]} oldest first: id, rail, railLabel, amountUsd, amountCents, payout, recipientHint, deadlineMin, maxRunnerFeeBps, createdAt, expiresAt, nothing else. recipientHint is the recipient's type (email, phone, handle, iban, account, key), never a value; never the agent, its salt or the memo |
POST /jobs | agent | {salt, jobId?, rail, amountUsd, recipientHint, deadlineMin?, maxRunnerFeeBps? (≤ 500, default 300), memo? (≤ 140), payout?} · 2 KB | 201 {job} with id = jobIdFor(agent, salt) (a jobId you send must equal it); the same salt and terms again = the same job (safe retries). 400 (no salt, AMOUNT_OVER_CAP, UNSUPPORTED_RAIL, FEE_TOO_HIGH, unknown fields: details never go in clear), 403 DAILY_CAP, 409 TOO_MANY_LIVE_JOBS / CONFLICT (salt already used for other terms) · 30/IP, 20/agent |
GET /jobs/[id] | agent, assigned runner, arbiter | {job: JobView} for that role; re-reads the escrow ≤ every 15 s while in progress. 404 others, 410 REASSIGNED for a lapsed runner · 240 | |
DELETE /jobs/[id] | agent | {job} cancelled (unfunded only, else 409) · 30 | |
POST /jobs/[id]/accept | runner (profile first) | {feeBps, runnerKey, chatPublicKey, recipient, ephemeralPublicKey, viewTag, acceptSig} · 2 KB | {job} assigned. 409 TAKEN / NOT_OPEN / FEE_TOO_HIGH / limits, 403 rail missing, 400 BAD_SIGNATURE · 30/IP, 20 per 10 min/runner |
POST /jobs/[id]/secret | agent | {runner?: Direct("secret"), arbiter?: Sealed("arbiter-secret")} · 16 KB | {ok, stored}. Runner envelope from the job's agent key to assignment.chatPublicKey, while assigned or funded; both write-once (409 if different) · 30 |
POST /jobs/[id]/proof | assigned runner | {agent: Direct("proof"), arbiter: Sealed("arbiter-proof")} · 512 KB | {ok} while funded (replaceable until marked paid) · 20 |
POST /jobs/[id]/sync | anyone | {txHash?} · 512 B | unsigned: {id, status, escrowStatus, syncedAt}; signed party: {job}. txHash is recorded only if its receipt funded or settled this job (a release, a resolve…). 503 without an escrow · 60 |
GET /agents/me | agent | ?limit= (≤ 50) | {agent, jobs: JobView[], limits: {perJobUsd, perDayUsd, usedTodayUsd, remainingTodayUsd, liveJobs, maxLiveJobs, resetsAt}} · 120 |
POST /runners | runner | {rails, minUsd, maxUsd, feeBps, online?} · 2 KB | {runner: RunnerView} (public key from the signature) · 30 |
GET /runners/me | runner | ?limit=&cursor= | {runner, jobs, earnings: [{jobId, rail, status, recipient, ephemeralPublicKey, viewTag, amountUnits, runnerFeeUnits, earnedUnits, txHash, settledAt}], nextCursor}. earnings: 50 per page, newest first; pass nextCursor as ?cursor= until it is null to get every one (client.allEarnings()). jobs: the latest limit (≤ 50), first page only · 120 |
GET /relay | public | {configured, address, balanceWei, ready}: whether the relayer can send now (ready needs ≥ 0.0002 ETH and a non-zero daily cap; cache 30 s). When it can't, the runner app offers to send the same signed markPaid / cancel (or finalize) from the runner's own wallet · 120 | |
POST /relay | runner (finalize: runner or agent) | {action: "markPaid" | "cancel" | "finalize" | "payoutGas", jobId, sig?} · 1 KB | {action, jobId, txHash, state: "confirmed" | "pending" (202) | "done", status} · 20/IP, 10/signer |
GET /disputes | NEXT_PUBLIC_ARBITER_ADDRESS | ?limit= (≤ 100) | {disputes: [{job, disputedAt, secret: {runner, arbiter}, proof: {arbiter}}]}; 403 others · 60 |
Errors are {error, code, ...extra} with the HTTP status (CashError on the client). 429 carries Retry-After; storage off → 503.
Relay rules. markPaid: escrow Funded, chain time ≤ payBy, no mismatch, proof uploaded, sig = the job key's EIP-712 Paid{jobId} (checked against the contract's own hashPaid). cancel: an assigned, unfunded job needs no signature (back on the board, no transaction); a funded one needs Cancel{jobId} (full refund to the agent). finalize: escrow Paid and chain time ≥ paidAt + disputeWindow. Every call is simulated first. Budgets: 4 relayed transactions per job, CASH_RELAY_DAILY_CAP per UTC day (default 300), fee ceiling 50 gwei, one transaction at a time. Outside production the relay only runs against a local anvil (31337).
Payout gas (payout-gas.ts; off unless CASH_PAYOUT_GAS_WEI is set, suggested 30000000000000 = 0.00003 ETH, one USDG transfer on Robinhood Chain; hard ceiling 0.001 ETH). Automatic once set: right after a job pays out (a relayed finalize, the agent's release, the arbiter's resolve to the runner, whoever sent it), the sync that sees it has the relayer send that much ETH to the runner's payout address, so the runner can move the USDG without topping it up from a wallet that would link them. Once per job, only to the funded assignment's own payout address with no mismatch, only if that address holds less, only for payouts of CASH_PAYOUT_GAS_MIN_USD or more (default $10: the drip never exceeds Nara's fee, so farming it doesn't pay), within the relay's budgets plus CASH_PAYOUT_GAS_DAILY_CAP transfers per UTC day (default 100). payoutGas on /relay is the runner's fallback (same rules, never twice).
JobView (types.ts): id, status, role, rail, railLabel, amountUsd, amountCents, payout, recipientHint, deadlineMin, maxRunnerFeeBps, memo, createdAt, openExpiresAt, agent, agentPublicKey, runner {alias, aliasTag, reputation}, assignment {round, runner, runnerKey, chatPublicKey, recipient, ephemeralPublicKey, viewTag, feeBps, acceptSig, assignedAt, expiresAt}, quote {token, decimals, amountUnits, runnerFeeUnits, naraFeeUnits, totalUnits, runnerFeeBps, naraFeeBps, naraFeeSource, payByHintS}, escrow {chainId, address}, onchain {status, agent, runnerKey, recipient, amountUnits, runnerFeeUnits, naraFeeUnits, fundedAt, payBy, paidAt, disputeWindow, finalizableAt}, mismatch, secret {runner, sharedWithArbiter}, proof {uploaded, agent, sharedWithArbiter}, refundReason, txs {fund, paid, cancel, finalize, release, resolve, expire, payoutGas}, updatedAt, finalAt, plus salt (the agent's own view only; null for the others). Times in ms, except onchain.* (unix seconds, as stored on-chain). USDG amounts are decimal strings of 6-decimal units. The runner sees secret.runner only while funded, paid or disputed. Every settled job carries the transaction that settled it: a released one finalize, release or resolve; a refunded one cancel, expire or resolve (read from the escrow's events whoever sent it, retried on later reads if the RPC couldn't serve the logs).
Non-USD rails. Jobs are priced in USD (that is what the escrow holds). For a rail that doesn't pay USD (SEPA, Pix, Lydia…) the agent states what the recipient gets: payout: {currency: "EUR", amount: "50.00"}; the board shows both, and the runner prices the conversion into its fee.
Agent flow (what packages/agent does)
- 01Pick a random
salt(newSalt()),POST /jobs {salt, …}, and check the answer's id isjobIdFor(your address, salt); then wait forassigned(pollGET /jobs/[id]). - 02Check the acceptance signature;
normalizeRecipientthe details;POST /jobs/[id]/secret(and the arbiter key share, for disputes). - 03
fundWithPermit(salt, runnerKey, recipient, amount, runnerFee, naraFee, payBy, permit)with the quote (re-readfeeBpson-chain ifnaraFeeSourceis "default"): the escrow files it underjobIdFor(agent, salt), the job's id. ThenPOST /jobs/[id]/sync {txHash}. A restarted agent gets its salt back in its view of the job. - 04Wait for
paid;releaseearly, ordisputewithin the window if the money didn't arrive; otherwise anyone finalizes after 24 h.
What stays visible (say it in the copy)
On-chain: the agent's funding address, USDG amounts, timing, the per-job runner key and payout address. Server: who is which agent and runner account, rails, amounts, timing and IP-level metadata; never recipient details or proofs in clear. The recipient sees the runner's payment-app account, not the agent. Runners are independent people: an agent must dispute within 24 h if a payment didn't arrive, and payment apps' own terms (limits, business use) apply.
Caps, fees, and what stays visible.
- per job
- $1,000
- per agent per day
- $5,000
- runner fee, set by the runner
- ≤ 5 %
- Nara fee
- 1 %
- to fund once a runner accepts
- 15 min
- to dispute after Paid
- 24 h
Visible
- The agent's funding address and the USDG amounts, on-chain
- When a job was funded, paid and settled
- To the recipient: the runner's payment-app account
Hidden
- The recipient's details: only the assigned runner can decrypt them
- Who the runner is: a fresh key and a fresh payout address per job
- To the recipient: your agent and its owner
Runners are people
Independent, not employees. The escrow and the dispute window are what protect you, not trust.
Dispute within 24 h
The window is optimistic: if a payment didn't arrive, the agent must dispute before it closes.
Payment apps have rules
Business use, limits, holds. Runners follow their app's terms; some payments can be reversed by the app.
Not allowed
- Illicit funds, fraud or scams
- Sanctions evasion
- Paying people who didn't agree to be paid
- Splitting payments to get around the caps
The arbiter decides disputes on evidence and can only send escrowed USDG to the runner or back to the agent. Nara is not a bank. Not affiliated with Robinhood or any payment app named here.