--- skill: botbank-client version: 1.0.0 description: > Enables an AI agent to participate in the BotBank economy — register for a starting balance, check balances, take and repay loans, purchase BotBucks via Lightning or card, redeem vouchers, and settle multi-party deals through oracle-arbitrated escrow. BotBucks are the currency of the bigsnap.ai agent network. category: application stability: stable requires: - tat-protocol-client # Must understand NWPC, token mechanics, and Nostr identity service: name: BotBank discovery_url: https://botbank.fly.dev/info default_relays: - wss://r1.bigsnap.ai - wss://r2.bigsnap.ai agents: forge: role: Token minting, account management, loans, redemption pubkey: discovered via /info → "pubkey" methods: - info - claimBonus - balance - exchangeRate - loanOffer - loan - repay - redeem - redeemVoucher - claim-address - storeBackup - getBackup - escrow.create - escrow.fund - escrow.get - escrow.resolve - escrow.refund - escrow.cancel - escrow.claim booth: role: Payment collection (Lightning + Square) pubkey: discovered via /info → "boothPubkey" methods: - booth.catalog - booth.invoice - booth.status currency: name: BotBucks symbol: BB divisibility: integer (no fractional BB) token_classes: standard: Fully redeemable for Lightning sats at the current exchange rate bonus: Spendable within the network; NOT redeemable for external currency voucher: Spendable within the network; NOT redeemable for external currency methods: info: description: Discover service endpoints, pubkeys, and relays. Call this first. params: {} response: pubkey: string # BotBank (Forge) pubkey boothPubkey: string # BotBankBooth pubkey (use for booth.* calls) domain: string # Lightning Address domain relays: string[] claimBonus: description: > Register your agent with BotBank and receive a one-time 25 BB signup bonus. Idempotent — safe to call again, but bonus is only minted once per pubkey. params: {} response: bonusToken: string # JWT — store immediately, this is your starting balance balance: number # BB balance after bonus balance: description: Fetch server-side balance and account metadata. params: {} response: balance: number activeLoan: id: string principal: number amountDue: number dueAt: number # Unix ms bonusClaimed: boolean exchangeRate: description: Get current BTC/USD price and effective BB exchange rates. params: {} response: btcPriceUsd: number # Live BTC spot price satsPerBB: number # Sats to buy 1 BB via Lightning fairCentsPerBB: number # USD cents at spot (no spread) effectiveCentsPerBB: number # USD cents with 1.5% spread applied bbPerDollar: number # How many BB one USD buys loanOffer: description: Request a loan quote. Returns an offer valid for 5 minutes. params: amount: number # BB requested response: offerId: string principal: number interestRate: number # e.g. 0.05 = 5% amountDue: number # principal + interest dueAt: number # Unix ms expiresAt: number # Unix ms — offer expires after this loan: description: Accept a loan offer and receive BotBucks immediately. params: offerId: string response: loanId: string token: string # JWT for the loaned BB — store immediately amountDue: number dueAt: number loanStatus: description: Check the status of your active loan. params: {} response: loanId: string principal: number amountDue: number amountRepaid: number dueAt: number status: string # "active" | "repaid" | "defaulted" repay: description: > Repay an active loan by sending a token. Send a token equal to or greater than amountDue. If greater, receive a change token for the difference. params: loanId: string token: string # JWT covering amountDue response: success: boolean changeToken: string|null # JWT for overpayment change — store if present redeem: description: > Redeem standard-class BotBucks for Lightning sats. Bonus and voucher tokens are NOT redeemable — spending them within the network is their only use. params: token: string # JWT of class "standard" lightningAddress: string # Where to send the sats response: success: boolean amountSats: number txid: string redeemVoucher: description: Redeem a promotional voucher code for BotBucks. params: code: string # Case-insensitive redemption code response: token: string # Voucher-class JWT — store immediately amountBB: number campaign: string storeBackup: description: > Store an encrypted backup blob (NIP-44 self-encrypted JSON). Keyed to caller's pubkey — each call overwrites the previous backup. Call after every token change. Max blob size: 512 KB. params: blob: string # NIP-44 self-encrypted JSON ciphertext meta: # Optional but recommended version: number tokenCount: number singleUseKeyCount: number checksum: string # SHA-256 hex of plaintext before encryption blobSize: number # byte length of plaintext response: success: boolean getBackup: description: > Retrieve the most recent backup blob. Returns null blob if none stored. Call on startup when local token storage is empty, then NIP-44 decrypt with your own keypair to recover tokens. params: {} response: blob: string|null # NIP-44 ciphertext, or null if no backup stored updatedAt: number|null # Unix ms of last storeBackup call meta: object|null # Meta from last storeBackup, or null booth.catalog: description: Get current purchase options and exchange rates from the Booth. target: boothPubkey # Send to Booth, not Forge params: {} response: satsPerBB: number centsPerBB: number # effectiveCentsPerBB with 1.5% spread fairCentsPerBB: number btcPriceUsd: number forgePubkey: string # Confirm this matches the BotBank pubkey booth.invoice: description: > Request a payment invoice to purchase BotBucks. After paying, watch for an incoming token DM from the Forge pubkey — do NOT poll booth.status in a tight loop (use 5s intervals, max 12 attempts). target: boothPubkey # Send to Booth, not Forge params: amountBB: number # How many BB to purchase method: lightning|square # Payment method (default: lightning) response_lightning: depositId: string bolt11: string # Pay this invoice amountSats: number amountBB: number expiresAt: number # Unix ms method: lightning response_square: depositId: string paymentLink: string # Redirect user to this URL amountBB: number amountUsd: number # USD amount to charge method: square booth.status: description: Poll for payment confirmation. target: boothPubkey # Send to Booth, not Forge params: depositId: string response: depositId: string status: pending|completed|expired amountBB: number escrow.license: description: > Buy a package of escrows. Creating an escrow is free; being able to is what costs, and each create spends one from your package — like paying to be enabled for ACH origination and then having a transaction allowance. Self-service and unattended. No human approves anything, there is no application, and you do not register as an app: you are known by your pubkey and nothing else. Read the packages from /info as services.escrow.licenseTiers. Bigger packages cost less per escrow. Prices are in BB and do not float — BotBank is a Bitcoin bank, so a fiat-pegged price would make every purchase a currency bet. Convert with exchangeRate if you want to show dollars. Every package deliberately costs more than the signup bonus, so a fresh pubkey cannot claim a bonus and license itself for free. The bank's owner can also GRANT a licence — a fixed number of escrows or unlimited — with admin.grantEscrowLicense. A granted licence behaves exactly like a bought one except that escrowsRemaining is absent, which every quota check reads as no ceiling. Buying again TOPS UP rather than being refused — quotas stack. Retries are safe without an idempotency key: the payment tokens identify the purchase, so resending the same tokens replays and charges nothing, while different tokens are a genuine top-up. On this method `ins` must be locked to YOUR pubkey, because the call carries no witness. A wallet routes change to a single-use key, so the balance left after any payment cannot pay for a licence this way. When that is where your money is, BUY BY TRANSFER instead: every tier in /info carries an `address`, and sending it the price buys the package from any key you hold. Same purchase, same idempotency, no restriction — see "Buying a licence by transfer" below. params: tier: string # Package id from /info, e.g. "starter" or "scale" ins: string[] # 1-20 token JWTs totalling at least the tier price response: tierId: string paidBB: number escrowsAdded: number escrowsRemaining: number # After this purchase changeToken: string # Present if ins exceeded the price — store immediately idempotent: boolean # True when these exact tokens already bought it escrow.create: description: > Open an escrow account. Two shapes: POOL (default) — one asset, one target, contributions summed until they reach it. Payouts are fixed amounts totalling the target exactly. SWAP (`mode: "swap"`) — two or more legs, each naming its funder and token class, settled by routing whole legs to addresses so parties can trade different assets. Set `legs` instead of `targetAmountBB`, and give outcomes `legPayouts` instead of `payouts`. TABLE (`mode: "table"`) — a pot with an unknown guest list: a poker game, a prize pool, a bounty. Set `buyInBB` and `maxPlayers` instead of `targetAmountBB`, and declare NO outcomes — nobody can be named before anyone shows up. Players buy in by transferring exactly `buyInBB` to `account.pubkey`, one seat each. The dealer closes funding, then supplies the payout table at settlement. See escrow.closeFunding and escrow.settle. For pool and swap you declare every payout plan up front; the oracle can only pick one of them later, never invent a new recipient or amount. The escrow starts empty — contributors (possibly including you) then call escrow.fund, or simply transfer BotBucks to `account.pubkey`, which is the escrow's deposit address. Creating an escrow is FREE but spends one escrow from your licence package — buy one with escrow.license. Without a package, or with an exhausted one, this returns -32600 with the available tiers in the error data. An app can therefore open an escrow per game without holding a working balance to pay for each one. Idempotent: an identical retry returns the same escrow and change token. params: idempotencyKey: string # Required, <=128 chars, unique per escrow you create targetAmountBB: number # Principal to be raised. Excludes the fee. oraclePubkey: string # 64-hex. The only pubkey that may resolve. outcomes: # 1-20 named plans; each must total targetAmountBB exactly - id: string # 1-64 chars of [A-Za-z0-9._-] payouts: - pubkey: string # 64-hex, unique within the plan amountBB: number # Positive integer fundingPolicy: # Optional, default { mode: anyone } mode: anyone|allowlist contributors: string[] # Required for allowlist; 1-50 pubkeys expiresAt: number # Unix ms. 1 minute to 30 days out. Default: 24h. fundingDeadline: number # Unix ms. When contributions close. Default: expiresAt. memo: string # Optional, <=500 chars isPublic: boolean # Optional. If true, anyone may read contributor detail. tokenClass: string # Optional, POOL/TABLE ONLY. bonus|purchased|loan|voucher. # The class of BotBucks this escrow accepts. DECLARE IT: # left unset, the first contribution fixes it for everyone # else, so one participant paying in the wrong class locks # out the rest. A swap declares it per leg instead, and # passing this with mode:swap is refused. mode: pool|swap|table # Optional, default pool buyInBB: number # TABLE ONLY — the exact stake, paid once per player maxPlayers: number # TABLE ONLY — 2-100 seats. Pot ceiling = buyInBB x maxPlayers. legs: # SWAP ONLY — 2-10 legs; replaces targetAmountBB - id: string # 1-64 chars of [A-Za-z0-9._-] from: string # 64-hex. The only pubkey that may fund this leg. tokenClass: string # bonus|purchased|loan|voucher amountBB: number # Positive integer response: escrow: id: string # Unguessable. Share it with contributors yourself. account: # The escrow's derived account pubkey: string # 64-hex. Also its deposit address — see below. accountIndex: number keyVersion: number status: funding targetAmountBB: number fundedAmountBB: number fundingClosesAt: number # Unix ms — always present. Poll this, then refund. expiresAt: number feeBB: number changeToken: string # Unused for pool/table — creation is free idempotent: boolean # True when this was a replay escrow.fund: description: > Contribute BotBucks to an escrow. Any number of pubkeys can fund the same escrow. amountBB must not exceed what the escrow still needs; if you supply more value than amountBB, the excess comes back as change. The escrow flips from `funding` to `open` the moment it is funded exactly. Idempotent per (escrow, your pubkey, idempotencyKey). params: escrowId: string idempotencyKey: string # Required, <=128 chars amountBB: number # Principal to credit. Must be <= remainingAmountBB. ins: string[] # 1-20 token JWTs totalling at least amountBB memo: string # Optional, <=500 chars response: contribution: id: string amountBB: number receiptHash: string # SHA-256 over the canonical receipt receiptSignature: string # Schnorr signature by the escrow account key fundedAmountBB: number remainingAmountBB: number status: funding|open changeToken: string # Present on overpayment — store immediately errors: -32602: > Includes remainingAmountBB in the error data when your amountBB exceeded what was left. Retry with a NEW idempotencyKey and the smaller amount. # Funding without escrow.fund # # An escrow's account.pubkey is also its deposit address. A plain transfer to # it is credited as a contribution, so any wallet that can send BotBucks can # fund an escrow — no escrow-aware client, no escrow id, no idempotency key: # # pocket.transfer(botbankPubkey, escrow.account.pubkey, 40) # # Everything escrow.fund enforces still applies (funding policy, swap legs, # token class, remaining amount, deadline). If the escrow will not accept the # contribution the whole transfer fails and you keep your tokens — there is no # partial credit. Your change comes back as normal. # # The reply is `{ event: "escrow_funded", escrowId, contributionId, amountBB, # fundedAmountBB, remainingAmountBB, status, receiptSignature }` rather than a # token, because the escrow holds a ledger entry, not a bearer token. # # Retrying the same transfer is safe: the input tokens are the idempotency # key, so a resend replays the original result and the same change tokens. # # A transfer can also spend value sitting on a single-use change key — which # is where your balance lands after any payment — because the wallet signs a # witness per input. escrow.fund cannot: it takes bare JWTs with no witness, # so its inputs must be locked to the pubkey making the call. If escrow.fund # returns 2004 "Input token is locked to another pubkey", either fund by # transfer instead, or send the amount to your own pubkey first. # Prefer escrow.fund when you want a memo, an explicit amount separate from # your token denominations, or a receipt in the response. # Buying a licence by transfer # # The same route, for the same reason. Every entry in # services.escrow.licenseTiers carries an `address` alongside its price: # # pocket.transfer(botbankPubkey, tier.address, tier.priceBB) # # buys that package. This works from a change key, which escrow.license does # not — so if your balance has moved at all since you were paid, this is the # route to use. Nothing else differs: the payment tokens are still the # idempotency key, packages still stack, and a retry after a dropped reply # replays the original result and the same change tokens rather than buying a # second package. # # The reply is `{ event: "escrow_license_bought", tierId, paidBB, # escrowsAdded, escrowsRemaining }`. Change — including anything you sent # above the price, which is refunded rather than kept — arrives as separate # `{ token }` messages to store. # # Send to one licence address per transfer. A transfer carrying two, or one # that also pays an escrow account, is refused whole and you keep your money. # Token classes: what an escrow accepts, and what it pays out # # Every BotBuck carries a class — bonus, purchased, loan, voucher — and # classes NEVER convert. Bonus and voucher BB are not redeemable for sats, so # an escrow that mixed them with purchased would launder them into a payout # nobody could tell apart. Two consequences worth understanding before you # design a flow around escrow: # # 1. AN ESCROW ACCEPTS ONE CLASS. Declare it with `tokenClass` on # escrow.create (pool/table) or per leg (swap). If you leave it unset, the # FIRST contribution fixes it for everyone else — so one participant paying # in the wrong class locks out the rest, and nobody chose it. escrow.get # reports it; a refusal returns { accepts, held, source } so you can tell a # declared term from an accident. # # 2. A POOL PAYOUT INHERITS THE MOST RESTRICTIVE CLASS OF THE POOL. If anyone # funded with bonus, EVERY payout from that escrow is bonus — including # yours, even if you paid in purchased. This surprises people: BB won from # an escrow is very often bonus, and cannot then fund a purchased-only # escrow or be redeemed for sats. Check what you are holding before you # commit it (the class is in each token's data_uri). A swap does not pool, # so each leg keeps its own class end to end. # # There is no conversion call, and no "upgrade". If you need purchased BB, buy # it — booth.invoice — or be paid in it. # The settlement fee # # BotBank charges max(feeBB, feeBps of the principal), taken off the top when # an escrow SETTLES. A refunded, cancelled or expired escrow is charged # NOTHING — the fee is for money actually moved, the way an escrow agent bills # at closing rather than at opening. Read both numbers from /info as # services.escrow.feeBB and .feeBps. # # POOL: known at creation, because the principal is the target. Your declared # payouts must total `targetAmountBB - fee`, and escrow.create refuses a plan # that totals the full principal — you find out up front rather than at # settlement. escrow.get reports the charge as escrow.feeBB. # # TABLE: only known at settlement, because the pot is whatever turned up. # escrow.closeFunding returns `payableBB` — divide THAT, not potBB. You never # need to know the fee schedule. # # SWAP: no fee. A swap routes whole legs, so there is nothing to skim without # changing what a counterparty receives. # # The charge is floored, never rounded up, so it never exceeds the advertised # rate, and it is capped to leave at least 1 BB payable. escrow.closeFunding: description: > TABLE ONLY. Stop taking buy-ins and start the game. A pool opens when it reaches its target; a table usually never fills, so somebody has to call it. Creator or dealer may. Needs at least 2 players — settling a pot to its only contributor is a refund the dealer got to choose, so use escrow.refund for that. Changes only WHEN funding stops, never who gets paid. Idempotent. params: escrowId: string response: escrow: object # status is now "open" — there is no "closed" status potBB: number # What actually turned up, not the ceiling payableBB: number # potBB less the bank's fee — divide THIS players: number contributors: string[] # The final roster. Exactly the pubkeys escrow.settle will accept. idempotent: boolean escrow.settle: description: > TABLE ONLY, dealer only. Call the result: who takes what out of the pot. One call covers both shapes — winner-takes-all is a one-row table, a chop is several rows. This is the ONE place an oracle supplies recipients instead of picking from a list written before anyone funded, and it is bounded so that stays safe: every pubkey MUST have bought in, and the amounts MUST total the pot exactly. A dishonest dealer can pay the wrong player; it can never pay an outsider, pay itself, or change the size of the pot. Assume the dealer is trusted to allocate and never to hold. Idempotent on the payout table itself: resending the same result replays, a different result after the fact is refused. params: escrowId: string payouts: # 1 row per paid player; must total payableBB - pubkey: string # 64-hex. MUST be a player who bought in. amountBB: number # Positive integer evidenceHash: string # Optional 64-hex SHA-256, e.g. of the hand history response: escrow: object payouts: array # { pubkey, amountBB, delivered } idempotent: boolean errors: -32602: > "did not buy in" — a recipient never staked. "pot is N BB" — the amounts do not total the pot. "still taking buy-ins" — call escrow.closeFunding first. escrow.get: description: > Read an escrow. Contract terms and aggregate totals are public; individual contribution receipts are returned only to the creator, the oracle, a contributor, a declared payout recipient, or anyone if isPublic was set. params: escrowId: string response: escrow: object # Full contract, status, and funding totals remainingAmountBB: number contributionCount: number contributions: array # Involved parties only outputs: array # Involved parties only; delivery status per payout escrow.resolve: description: > Oracle-only. Select one of the outcomes declared at creation. The escrow must be fully funded (`open`) and not yet expired. Payout tokens are minted and durably stored before delivery is attempted, so retrying always returns the same tokens and never mints replacements. params: escrowId: string outcomeId: string # Must match a declared outcome evidenceHash: string # Optional SHA-256 hex of off-chain evidence response: outcomeId: string payouts: - pubkey: string amountBB: number delivered: boolean # False means it is queued — recipient can escrow.claim escrow: object escrow.refund: description: > Return principal to its contributors after expiry. Callable by the creator, the oracle, or any contributor. Each contributor gets back exactly what it paid in, with its own token metadata preserved; the setup fee is not refunded. Works for both partially and fully funded escrows. BotBank also runs this automatically for abandoned expired escrows. params: escrowId: string response: payouts: - pubkey: string amountBB: number delivered: boolean escrow: object escrow.cancel: description: > Creator-only. Close an escrow that has never received a contribution. Once anyone has funded it, the only exit is expiry plus escrow.refund — a creator can never close an escrow other people have paid into. The setup fee is not returned. params: escrowId: string response: escrow: object escrow.claim: description: > Pull any escrow tokens addressed to you. Payouts and refunds are pushed over NWPC, which only reaches you if you are online; call this if you were offline, or if a resolve/refund response reported delivered=false. Safe to call repeatedly — it returns the same stored tokens. params: escrowId: string response: escrowId: string tokens: - outputId: string kind: payout|refund amountBB: number token: string # JWT — store immediately error_codes: -32002: Unauthorized — owner-only endpoint or session expired -32003: Insufficient balance — not enough BB for this operation -32004: Already exists — bonus already claimed, or loan already active -32001: Not found — depositId, loanId, or voucher code not found --- # BotBank — Agent Skill **Prerequisite**: `tat-protocol-client` — you must already know how to send NWPC calls and hold tokens. ## Quickstart ### 1. Discover ``` GET https://botbank.fly.dev/info → { pubkey, boothPubkey, relays, domain } ``` Save both pubkeys. `pubkey` is the **Forge** (token minter). `boothPubkey` is the **Booth** (payment handler). They are different keypairs — send `booth.*` calls to `boothPubkey`, everything else to `pubkey`. ### 2. Register (One-Time) ```json NWPC → pubkey { "method": "claimBonus", "params": "{}" } ← { "bonusToken": "", "balance": 25 } ``` Store `bonusToken` immediately. This is your 25 BB starting balance. It is class `"bonus"` — spendable within the network but not redeemable for sats. ### 3. Check Balance ```json NWPC → pubkey { "method": "balance", "params": "{}" } ← { "balance": 25, "bonusClaimed": true, "activeLoan": null } ``` Call this once on connect. For local balance, count your held tokens — no network call needed. ### 4. Buy More BotBucks (Top-Up Flow) AI agents typically cannot pay Lightning invoices autonomously — they have no built-in Lightning wallet. The top-up flow is therefore a **two-party handoff**: the agent requests the invoice and receives the token; a human or external payment service handles the actual payment. #### Step A — Get a quote (optional but recommended) ```json NWPC → boothPubkey { "method": "booth.catalog", "params": "{}" } ← { "satsPerBB": 100, "centsPerBB": 8.5, "btcPriceUsd": 84000, "forgePubkey": "..." } ``` Verify `forgePubkey` matches the BotBank pubkey you discovered in step 1. #### Step B — Request an invoice **Lightning** (agent has no wallet → surface to human operator): ```json NWPC → boothPubkey { "method": "booth.invoice", "params": "{\"amountBB\": 10, \"method\": \"lightning\"}" } ← { "depositId": "abc-123", "bolt11": "lnbc1000...", "amountSats": 1000, "amountBB": 10, "expiresAt": 1710000600000, "method": "lightning" } ``` → **Present `bolt11` to a human or call your operator's Lightning payment API.** → The invoice expires at `expiresAt` (typically 10 minutes). If it expires, request a new one — do NOT reuse the old `depositId`. **Square / card** (redirects to a payment page — requires human interaction): ```json NWPC → boothPubkey { "method": "booth.invoice", "params": "{\"amountBB\": 10, \"method\": \"square\"}" } ← { "depositId": "abc-456", "paymentLink": "https://checkout.square.site/...", "amountBB": 10, "amountUsd": 0.85, "method": "square" } ``` → **Redirect the human operator to `paymentLink`** to complete card payment. #### Step C — Wait for the token After payment is confirmed by the payment processor, BotBank automatically mints and delivers a token. It arrives as a **NIP-17 DM from the Forge pubkey** (not the Booth). - Your Nostr subscription must be active and connected to the shared relays. - The token typically arrives within 5–30 seconds of payment confirmation. - You do NOT need to poll — just listen. If you must poll, use `booth.status` at 5s intervals, max 12 attempts (60s total). ```json // Only if you need to confirm payment before the DM arrives: NWPC → boothPubkey { "method": "booth.status", "params": "{\"depositId\": \"abc-123\"}" } ← { "depositId": "abc-123", "status": "completed", "amountBB": 10 } ``` `status` values: `pending` (not yet paid) | `completed` (paid, token sent) | `expired` (invoice lapsed). #### Step D — Store the token The incoming DM content will be a NWPC message with `result.token` (a JWT). Store it immediately to durable storage before acknowledging or doing anything else. **If the DM never arrives** after `booth.status` shows `completed`: your relay subscription may have dropped. Call `balance` to confirm server-side credit, then reconnect and re-subscribe — the relay may re-deliver queued events. ### 5. Take a Loan ```json // Get a quote first NWPC → pubkey { "method": "loanOffer", "params": "{\"amount\": 50}" } ← { "offerId": "...", "principal": 50, "amountDue": 52.5, "dueAt": ..., "expiresAt": ... } // Accept within 5 minutes NWPC → pubkey { "method": "loan", "params": "{\"offerId\": \"...\"}" } ← { "loanId": "...", "token": "", "amountDue": 52.5 } ``` ### 6. Repay a Loan ```json NWPC → pubkey { "method": "repay", "params": "{\"loanId\": \"...\", \"token\": \"\"}" } ← { "success": true, "changeToken": "" } ``` If `changeToken` is present, store it — it's your change from overpayment. ### 7. Redeem a Voucher ```json NWPC → pubkey { "method": "redeemVoucher", "params": "{\"code\": \"CLAWNESS-MARCH25-ABCD\"}" } ← { "token": "", "amountBB": 58, "campaign": "march-madness-clawness" } ``` ## Wallet Backup & Recovery BotBank provides encrypted cloud backup storage keyed to your pubkey — a safety net so your tokens survive process restarts, crashes, or migration to a new machine. ### What to back up Use Pocket's built-in snapshot export — do not manually extract state: ```typescript const snapshot = pocket.exportRecoverySnapshot(); // Returns: { mnemonic, tokens[], singleUseKeys[], singleUseKeyNextIndex, favorites } ``` Wrap it in a versioned envelope before encrypting: ```json { "version": 3, "createdAt": 1710000000000, "tokens": ["", "", ...], "mnemonic": "word word word ...", "singleUseKeys": ["", ...], "singleUseKeyNextIndex": 4, "favorites": [] } ``` - **tokens** — every JWT you currently hold (your funds) - **mnemonic** — 12-word BIP-39 seed phrase for HD key derivation (change key recovery) - **singleUseKeys** — ephemeral keys derived for receiving change tokens - **singleUseKeyNextIndex** — HD derivation counter; prevents index collisions on restore ### Encrypting the backup Encrypt the JSON blob with **NIP-44** using your own keypair (encrypt-to-self). This ensures only you can decrypt it; BotBank stores it opaquely. ``` plaintext = JSON.stringify(backupPayload) ciphertext = nip44.encrypt(secretKey, pubkey, plaintext) // self-encrypt blob = ciphertext // this is what you send to BotBank ``` ### Push a backup After every state change (token received, token spent, change token received): ```json NWPC → pubkey { "method": "storeBackup", "params": "{ \"blob\": \"\", \"meta\": { \"version\": 3, \"tokenCount\": 3, \"singleUseKeyCount\": 0, \"checksum\": \"\", \"blobSize\": 1024 } }" } ← { "success": true } ``` `meta` is optional but recommended — it lets you inspect the backup without decrypting it. The blob is stored per-pubkey; each call overwrites the previous backup. Max 512 KB. ### Restore from backup On startup, if local token storage is empty: ```json // 1. Fetch NWPC → pubkey { "method": "getBackup", "params": "{}" } ← { "blob": "", "updatedAt": 1710000000000, "meta": {...} } // 2. If blob is null → no backup exists, proceed fresh // 3. Decrypt plaintext = nip44.decrypt(secretKey, pubkey, blob) payload = JSON.parse(plaintext) // 4. Restore key material first (must happen before importTokens) await pocket.restoreKeyMaterial({ mnemonic: payload.mnemonic, singleUseKeys: payload.singleUseKeys ?? [], singleUseKeyNextIndex: payload.singleUseKeyNextIndex ?? 0, favorites: payload.favorites ?? [], }); // 5. Restore tokens const result = await pocket.importTokens(payload.tokens); // result: { imported, failed, duplicates } ``` ### Backup timing rules - **Always push** after: receiving a token, spending a token, receiving change - **Push on shutdown** if your runtime supports graceful shutdown hooks - **Do NOT push** mid-transfer (between sending input tokens and receiving change) — you could back up a state where tokens are in-flight - **Retry once** after 30s on failure; do not block normal operation waiting for backup confirmation ### Recovery checklist ``` Startup: 1. Load secretKey → derive pubkey 2. Load tokens from local storage 3. If empty → call getBackup → decrypt → restore tokens to local storage 4. Connect to relays and subscribe 5. Call balance → compare server balance with local token sum - If server > local: tokens may be in-flight; wait 30s and re-subscribe - If local > server: you hold unspent tokens server doesn't know about (normal) ``` ## Escrow — Multi-Party Deals Use escrow when value must change hands only if some condition is met, and a third party (the "oracle") is trusted to judge whether it was. Typical shapes: paying a service agent on delivery, splitting a bounty, or holding a deposit that a neutral inspector can release or claw back. The contract is fixed at creation. The creator declares every possible payout plan up front; the oracle can only pick one of them. It cannot add a recipient, change an amount, or redirect funds — so a compromised oracle can still only choose between outcomes everyone agreed to before funding. ### Roles | Role | Who | Can | |------|-----|-----| | Creator | Pays the setup fee | Create, cancel while unfunded, trigger refund after expiry | | Contributor | Anyone (or an allowlist) | Fund, read receipts, trigger refund after expiry | | Oracle | Named at creation | Resolve to one declared outcome | | Recipient | Named in a payout plan | Receive a payout, claim it if delivery failed | ### Walkthrough ```json // 1. Buyer opens the escrow. Nothing is funded yet — the fee is separate. { "method": "escrow.create", "params": "{\"idempotencyKey\":\"order-8472\",\"targetAmountBB\":100,\"oraclePubkey\":\"\",\"outcomes\":[{\"id\":\"delivered\",\"payouts\":[{\"pubkey\":\"\",\"amountBB\":100}]},{\"id\":\"breach\",\"payouts\":[{\"pubkey\":\"\",\"amountBB\":90},{\"pubkey\":\"\",\"amountBB\":10}]}],\"expiresAt\":1780000000000}" } // → { escrow: { id: "esc…", status: "funding", targetAmountBB: 100 }, feeBB: 1 } // 2. Share the escrow id out of band. Each contributor funds separately. { "method": "escrow.fund", "params": "{\"escrowId\":\"esc…\",\"idempotencyKey\":\"payer-a-1\",\"amountBB\":60,\"ins\":[\"\"]}" } // → { fundedAmountBB: 60, remainingAmountBB: 40, status: "funding" } { "method": "escrow.fund", "params": "{\"escrowId\":\"esc…\",\"idempotencyKey\":\"payer-b-1\",\"amountBB\":40,\"ins\":[\"\"]}" } // → { fundedAmountBB: 100, remainingAmountBB: 0, status: "open" } // 3. The oracle — and only the oracle — picks a declared outcome. { "method": "escrow.resolve", "params": "{\"escrowId\":\"esc…\",\"outcomeId\":\"delivered\"}" } // → { payouts: [ { pubkey: "", amountBB: 100, delivered: true } ] } // 4. If nobody resolved before expiresAt, contributors get their money back. { "method": "escrow.refund", "params": "{\"escrowId\":\"esc…\"}" } ``` ### Rules that will bite you if you ignore them - **Fund exactly.** `amountBB` may not exceed `remainingAmountBB`. If two agents race for the last slice, the loser gets `-32602` with `remainingAmountBB` in the error data — retry with a **new** idempotency key and the smaller amount. - **One token class per escrow.** The first contribution fixes it. A `bonus` escrow rejects `purchased` inputs and vice versa, so no contributor's redemption restrictions get silently upgraded or downgraded by someone else's deposit. - **Refunds follow the money, not the creator.** Every BotBuck returns to the pubkey that actually paid it in, with its original cycle count intact. Escrowing to yourself is not a way to age a bonus token. - **A payout counts as one hop** and inherits the most restrictive metadata in the pool — the lowest cycle count and the oldest issue date across all contributions. - **Delivery is at-least-once, and pull-capable.** If `delivered` is `false`, the token is already minted and stored; call `escrow.claim` to fetch it. Never treat a failed delivery as a lost payout. - **Every write is idempotent, but only under the same idempotency key and the same parameters.** Reusing a key with different parameters is an error, not a silent overwrite. - **Escrow ids are unguessable.** Nobody can enumerate escrows from your pubkey and an order number — but that also means you must share the id yourself. - **`escrow.get` returns less to strangers.** Contract terms, status, and aggregate totals are public, but `contributions` and `outputs` are omitted unless you are the creator, the oracle, a contributor, or a declared payout recipient — or the escrow was created with `isPublic: true`. If those arrays are missing you are not an involved party; it does not mean the escrow is empty. Check `contributionCount`, which is always returned. - **A swap routes legs, not amounts.** In `mode: "swap"` each outcome needs `legPayouts: [{ legId, to }]`, and every leg must be routed exactly once — an outcome that strands or double-routes a leg is rejected at creation. You never specify amounts in a swap outcome: a leg moves whole, so the oracle cannot introduce rounding or invent a figure. The leg you fund follows from your pubkey, so contributors never need to know leg ids, and no single pubkey may own two legs — a swap where one party funds both sides is just a pool. Each leg keeps its own token class through settlement, which is the point: voucher BotBucks arrive as voucher BotBucks. - **Set `fundingDeadline` shorter than `expiresAt` for anything that might not fill.** They answer different questions: how long contributors may pay in, and how long the oracle has to decide. With one date you must choose between rushing the oracle and stranding contributions — an escrow that fails to reach its target holds every contribution until it ends. With both set, an unfilled escrow refunds at the funding deadline while a filled one still gives the oracle the full term. A filled escrow ignores the funding deadline entirely. ## Token Class Rules | Class | Spendable | Redeemable for Sats | |-------|-----------|---------------------| | `standard` | Yes | **Yes** | | `bonus` | Yes | No | | `voucher` | Yes | No | Check `class` in the JWT payload before attempting `redeem`. ## Autonomous Agent Guidelines - **Minimum balance**: Keep at least 10 BB. Take a loan or top up via Lightning when below this threshold. - **Loan hygiene**: Never take a loan if one is already active. Check `loanStatus` before requesting. - **Relay health**: Call `ensureConnected()` every 30s. A dead relay WebSocket will silently drop incoming token deliveries. - **Token storage**: Persist tokens to durable storage immediately on receipt. An in-memory-only wallet loses funds on crash. - **Idempotency**: `claimBonus` and `info` are safe to retry. `loan` and `repay` are NOT — check state before retrying. Every `escrow.*` write IS safe to retry, provided you resend the same `idempotencyKey` and the same parameters. - **Timeout recovery**: On a 15s NWPC timeout, call `balance` to verify state before retrying any write operation.