diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a42fd785..d124761a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,6 +167,9 @@ A nibblet can be given money to spend, as virtual cards or as crypto wallets tha - **Closing:** `card_kill` (the nibblet, its own cards), Kill in the UI (the person, any), and deleting a nibblet close cards at the provider and delete their files. A card the provider can't settle yet (a wallet whose funding isn't seen, so there's nothing to sweep) stays `closing`, counting in full, and the server tries again every minute; after a day it's recorded as closed with a warning in the log. A nibblet with a card that can't be closed isn't deleted, so the card's record (and a wallet's id, from which its key comes) isn't lost. Every minute the server also checks open cards with their providers: it records what's been spent, notices cards the provider closed, and closes single-use cards that have been used where the provider doesn't do that itself. - **Slash** (`packages/bank-slash`, `USD`): virtual cards on the person's Slash account, set up with `SLASH_API_KEY` (with the cards.view and cards.manage permissions), plus `SLASH_ACCOUNT_ID` for the account cards are made under and whose balance Settings shows (Slash picks the first commercial account otherwise). Single-use cards are Slash's own `isSingleUse`, which Slash closes after the first authorisation; both kinds carry a `collective` (never resetting) utilisation limit of the card's limit, so Slash declines anything over it. Each card is tagged with its id in `userData`, which is how `find` recognises it. - **Wallets:** a crypto plugin funds a fresh wallet per card from its treasury with exactly the card's limit, so the limit holds on chain. The wallet's key is derived from the treasury's secret and the card's id (`deriveWalletKey`), so none is stored, and killing the card sweeps what's left back to the treasury. +- **Bitcoin** (`packages/bank-bitcoin`, `BTC`): `BITCOIN_TREASURY_KEY` (hex or WIF) and `BITCOIN_NETWORK` (`mainnet`, `testnet`, `signet` or `regtest`, with no default, so a test key never lands on mainnet by accident), plus `BITCOIN_ESPLORA_URL` (mempool.space's API by default; needed for regtest) and `BITCOIN_FEE_RATE` (sat/vB, instead of Esplora's estimate). The treasury and every card wallet are native segwit (P2WPKH), signed with `@scure/btc-signer`. A card needs at least 5,000 sats. +- **Ethereum** (`packages/bank-ethereum`, `ETH`): `ETHEREUM_TREASURY_KEY` and `ETHEREUM_RPC_URL`, plus `ETHEREUM_CHAIN_ID` (checked against the RPC before anything is sent) and `ETHEREUM_ASSET` for another EVM chain's coin (`POL`, say). Plain transfers, signed with `viem`. Gas comes out of the card's own balance. The sweep pays exactly 21,000 gas at the current price, so chains that also charge an L1 data fee (Base, Optimism and other OP Stack chains) aren't supported yet: their sweeps would fail, and killed cards would stay `closing`. +- **What a wallet can't do:** a nibblet holds its wallet's key, so it can spend all of the wallet's limit, and nothing more, the moment it has it; network fees, the sweep's included, count as spent. Killing a card sweeps what's left, but a nibblet that copied the key before could race the sweep for it, so treat a crypto card's limit as given away. ## API diff --git a/apps/server/package.json b/apps/server/package.json index 5079481a..8491eb46 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,8 @@ "@huggingface/transformers": "^4.3.1", "@modelcontextprotocol/sdk": "^1.32.1", "@nibblet/bank": "workspace:*", + "@nibblet/bank-bitcoin": "workspace:*", + "@nibblet/bank-ethereum": "workspace:*", "@nibblet/bank-slash": "workspace:*", "@nibblet/db": "workspace:*", "@nibblet/protocol": "workspace:*", diff --git a/apps/server/src/banks.ts b/apps/server/src/banks.ts index e674c84c..68978213 100644 --- a/apps/server/src/banks.ts +++ b/apps/server/src/banks.ts @@ -1,11 +1,13 @@ import { loadBankPlugins, type BankPluginFactory, type LoadedBanks } from "@nibblet/bank"; +import { bitcoin } from "@nibblet/bank-bitcoin"; +import { ethereum } from "@nibblet/bank-ethereum"; import { slash } from "@nibblet/bank-slash"; /** * Every bank plugin the server knows. Each turns itself on when its settings * are in the environment (see the server's environment in `docker/compose.yaml`), and stays off otherwise. */ -export const BANK_PLUGINS: readonly BankPluginFactory[] = [slash]; +export const BANK_PLUGINS: readonly BankPluginFactory[] = [slash, bitcoin, ethereum]; export function loadBanks(env: NodeJS.ProcessEnv = process.env): LoadedBanks { const loaded = loadBankPlugins(BANK_PLUGINS, env, { diff --git a/docker/compose.yaml b/docker/compose.yaml index ec1a046f..07863348 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -94,6 +94,14 @@ services: SLASH_API_KEY: ${SLASH_API_KEY:-} SLASH_ACCOUNT_ID: ${SLASH_ACCOUNT_ID:-} SLASH_BASE_URL: ${SLASH_BASE_URL:-} + BITCOIN_TREASURY_KEY: ${BITCOIN_TREASURY_KEY:-} + BITCOIN_NETWORK: ${BITCOIN_NETWORK:-} + BITCOIN_ESPLORA_URL: ${BITCOIN_ESPLORA_URL:-} + BITCOIN_FEE_RATE: ${BITCOIN_FEE_RATE:-} + ETHEREUM_TREASURY_KEY: ${ETHEREUM_TREASURY_KEY:-} + ETHEREUM_RPC_URL: ${ETHEREUM_RPC_URL:-} + ETHEREUM_CHAIN_ID: ${ETHEREUM_CHAIN_ID:-} + ETHEREUM_ASSET: ${ETHEREUM_ASSET:-} # Reaches the host MCP bridge; Docker Desktop provides this name, Linux needs the mapping. extra_hosts: - host.docker.internal:host-gateway diff --git a/packages/bank-bitcoin/package.json b/packages/bank-bitcoin/package.json new file mode 100644 index 00000000..faafdaf0 --- /dev/null +++ b/packages/bank-bitcoin/package.json @@ -0,0 +1,19 @@ +{ + "name": "@nibblet/bank-bitcoin", + "version": "0.3.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p ." + }, + "dependencies": { + "@nibblet/bank": "workspace:*", + "@nibblet/protocol": "workspace:*", + "@scure/base": "^2.4.0", + "@scure/btc-signer": "^2.4.1", + "zod": "^4.6.7" + } +} diff --git a/packages/bank-bitcoin/src/bitcoin.test.ts b/packages/bank-bitcoin/src/bitcoin.test.ts new file mode 100644 index 00000000..d0f69cf4 --- /dev/null +++ b/packages/bank-bitcoin/src/bitcoin.test.ts @@ -0,0 +1,260 @@ +import { BankError, deriveWalletKey } from "@nibblet/bank"; +import { hex } from "@scure/base"; +import { Address, OutScript, p2wpkh, TEST_NETWORK, Transaction, WIF } from "@scure/btc-signer"; +import { pubECDSA } from "@scure/btc-signer/utils.js"; +import { describe, expect, it } from "vitest"; +import { bitcoin, bitcoinPlugin, Esplora, MIN_CARD_SATS } from "./index.ts"; + +const regtest = { ...TEST_NETWORK, bech32: "bcrt" }; +const treasuryKey = new Uint8Array(32).fill(9); +const treasury = p2wpkh(pubECDSA(treasuryKey), regtest); + +interface Coin { + txid: string; + vout: number; + value: number; + script: string; + spent: boolean; +} + +/** + * An Esplora over an in-memory UTXO set: broadcasts are parsed, checked to + * spend coins that exist, and applied. + */ +function fakeChain() { + const coins: Coin[] = []; + /** How to answer the next broadcast instead, and whether it went out first, as a flaky gateway's can. */ + let failNext: { status: number; relayed: boolean } | null = null; + const broadcasts: Transaction[] = []; + const scriptOf = (address: string) => + hex.encode(OutScript.encode(Address(regtest).decode(address))); + const fund = (address: string, value: number) => + coins.push({ + txid: hex.encode(crypto.getRandomValues(new Uint8Array(32))), + vout: 0, + value, + script: scriptOf(address), + spent: false, + }); + const json = (body: unknown) => new Response(JSON.stringify(body)); + const fetch: typeof globalThis.fetch = async (input, init) => { + const path = new URL(String(input)).pathname.replace(/^\/api/, ""); + if (path === "/tx" && init?.method === "POST") { + const fail = failNext; + failNext = null; + if (fail && !fail.relayed) return new Response("rejected", { status: fail.status }); + const tx = Transaction.fromRaw(hex.decode(String(init.body))); + for (let i = 0; i < tx.inputsLength; i++) { + const input = tx.getInput(i); + const txid = input.txid ? hex.encode(input.txid) : ""; + const coin = coins.find((c) => c.txid === txid && c.vout === input.index && !c.spent); + if (!coin) return new Response("bad-txns-inputs-missingorspent", { status: 400 }); + coin.spent = true; + } + for (let i = 0; i < tx.outputsLength; i++) { + const out = tx.getOutput(i); + coins.push({ + txid: tx.id, + vout: i, + value: Number(out.amount ?? 0n), + script: out.script ? hex.encode(out.script) : "", + spent: false, + }); + } + broadcasts.push(tx); + if (fail) return new Response("Bad Gateway", { status: fail.status }); + return new Response(tx.id); + } + if (path === "/fee-estimates") return json({ "1": 20.5, "3": 10.2, "6": 5 }); + const utxo = /^\/address\/([^/]+)\/utxo$/.exec(path); + if (utxo?.[1]) { + const script = scriptOf(utxo[1]); + return json( + coins + .filter((c) => c.script === script && !c.spent) + .map((c) => ({ txid: c.txid, vout: c.vout, value: c.value, status: {} })), + ); + } + const address = /^\/address\/([^/]+)$/.exec(path); + if (address?.[1]) { + const mine = coins.filter((c) => c.script === scriptOf(address[1] ?? "")); + const sum = (list: Coin[]) => list.reduce((s, c) => s + c.value, 0); + return json({ + chain_stats: { funded_txo_sum: sum(mine), spent_txo_sum: sum(mine.filter((c) => c.spent)) }, + mempool_stats: { funded_txo_sum: 0, spent_txo_sum: 0 }, + }); + } + return new Response("not found", { status: 404 }); + }; + return { + fetch, + coins, + broadcasts, + fund, + failNextBroadcast: (status: number, relayed: boolean) => { + failNext = { status, relayed }; + }, + }; +} + +function setUp() { + const chain = fakeChain(); + chain.fund(treasury.address ?? "", 1_000_000); + const plugin = bitcoinPlugin({ + treasuryKey, + network: "regtest", + esplora: new Esplora("http://esplora/api", chain.fetch), + }); + return { chain, plugin }; +} + +const request = { + cardId: "card_1", + nibbletId: "nib_1", + label: "x", + kind: "multi_use" as const, + limit: 100_000n, +}; + +describe("bitcoin", () => { + it("funds a fresh wallet with exactly the limit, from the treasury", async () => { + const { chain, plugin } = setUp(); + const card = await plugin.issue(request); + const key = deriveWalletKey(treasuryKey, "bitcoin", "card_1"); + const address = p2wpkh(pubECDSA(key), regtest).address; + expect(card.ref).toBe(address); + expect(card.secret).toEqual({ + type: "wallet", + chain: "bitcoin", + network: "regtest", + address, + privateKey: hex.encode(key), + wif: WIF(regtest).encode(key), + }); + const [tx] = chain.broadcasts; + const outputs = Array.from({ length: tx?.outputsLength ?? 0 }, (_, i) => tx?.getOutput(i)); + const toCard = outputs.find((o) => o?.amount === 100_000n); + expect(toCard).toBeTruthy(); + // The rest comes back as change, less a fee at the estimated rate. + const change = outputs.find((o) => o?.amount !== 100_000n)?.amount ?? 0n; + expect(1_000_000n - 100_000n - change).toBeGreaterThan(0n); + expect(1_000_000n - 100_000n - change).toBeLessThan(5_000n); + expect(await plugin.balance()).toBe(change); + }); + + it("gives the same key back for a restarted container", async () => { + const { plugin } = setUp(); + const card = await plugin.issue(request); + const ref = { id: "card_1", ref: card.ref, kind: "multi_use" as const, limit: 100_000n }; + expect(await plugin.reveal(ref)).toEqual(card.secret); + }); + + it("refuses a card too small to use, or bigger than the treasury", async () => { + const { plugin } = setUp(); + await expect(plugin.issue({ ...request, limit: MIN_CARD_SATS - 1n })).rejects.toThrow( + /at least/, + ); + await expect(plugin.issue({ ...request, limit: 2_000_000n })).rejects.toThrow( + /doesn't have enough/, + ); + }); + + it("counts nothing spent until the wallet sends, then what's gone", async () => { + const { chain, plugin } = setUp(); + const card = await plugin.issue(request); + const ref = { id: "card_1", ref: card.ref, kind: "single_use" as const, limit: 100_000n }; + expect(await plugin.inspect(ref)).toEqual({ open: true, spent: 0n, used: false }); + // The nibblet pays a shop 30,000 sats, with 70,000 less a fee back as change. + const funding = chain.coins.find((c) => c.value === 100_000 && !c.spent); + if (!funding) throw new Error("no funding coin"); + funding.spent = true; + chain.fund(card.ref, 69_000); + expect(await plugin.inspect(ref)).toEqual({ open: true, spent: 31_000n, used: true }); + }); + + it("sweeps what's left back to the treasury when it's killed", async () => { + const { chain, plugin } = setUp(); + const card = await plugin.issue(request); + const ref = { id: "card_1", ref: card.ref, kind: "multi_use" as const, limit: 100_000n }; + const before = await plugin.balance(); + const closed = await plugin.close(ref); + const sweep = chain.broadcasts.at(-1); + expect(sweep?.outputsLength).toBe(1); + expect(sweep?.getOutput(0).script).toEqual(treasury.script); + const returned = sweep?.getOutput(0).amount ?? 0n; + expect(returned).toBe(100_000n - 110n * 11n); + // The sweep's fee is all that went. + expect(closed).toEqual({ open: false, spent: 100_000n - returned, used: false, settled: true }); + expect(await plugin.balance()).toBe((before ?? 0n) + returned); + // An empty wallet has nothing to sweep. + expect(await plugin.close(ref)).toEqual({ + open: false, + spent: 100_000n, + used: true, + settled: true, + }); + }); + + it("finds a wallet once it's been funded, and not before", async () => { + const { plugin } = setUp(); + expect(await plugin.find("card_1")).toBeNull(); + const card = await plugin.issue(request); + expect(await plugin.find("card_1")).toEqual({ ref: card.ref, display: card.ref }); + }); + + it("won't settle a wallet whose funding isn't seen yet", async () => { + const { chain, plugin } = setUp(); + const key = deriveWalletKey(treasuryKey, "bitcoin", "card_2"); + const address = p2wpkh(pubECDSA(key), regtest).address ?? ""; + const ref = { id: "card_2", ref: address, kind: "multi_use" as const, limit: 100_000n }; + expect(await plugin.close(ref)).toEqual({ + open: false, + spent: 0n, + used: false, + settled: false, + }); + // Once it shows up, closing sweeps it. + chain.fund(address, 100_000); + expect(await plugin.close(ref)).toMatchObject({ settled: true, used: false }); + expect(chain.broadcasts.at(-1)?.getOutput(0).script).toEqual(treasury.script); + }); + + it("refuses with messages that are safe to show", async () => { + const { plugin } = setUp(); + const err = await plugin.issue({ ...request, limit: 2_000_000n }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(BankError); + }); + + it("only takes a refused broadcast as nothing made", async () => { + // A gateway error after the transaction went out leaves it unclear, so + // the server keeps the card and looks for it, rather than forgetting it. + const { chain, plugin } = setUp(); + chain.failNextBroadcast(502, true); + const unclear = plugin.issue(request); + await expect(unclear).rejects.toThrow(/502/); + await expect(unclear).rejects.not.toBeInstanceOf(BankError); + expect(chain.broadcasts).toHaveLength(1); + chain.failNextBroadcast(400, false); + await expect(plugin.issue({ ...request, cardId: "card_2" })).rejects.toBeInstanceOf(BankError); + }); + + it("turns on from its settings, and refuses wrong ones", () => { + const deps = { fetch: globalThis.fetch, log: () => {} }; + const key = hex.encode(treasuryKey); + expect(bitcoin.fromEnv({}, deps)).toBeNull(); + expect(() => bitcoin.fromEnv({ BITCOIN_TREASURY_KEY: key }, deps)).toThrow(/BITCOIN_NETWORK/); + expect( + bitcoin.fromEnv({ BITCOIN_TREASURY_KEY: key, BITCOIN_NETWORK: "signet" }, deps)?.label, + ).toBe("Bitcoin (signet)"); + expect(() => + bitcoin.fromEnv({ BITCOIN_TREASURY_KEY: key, BITCOIN_NETWORK: "regtest" }, deps), + ).toThrow(/BITCOIN_ESPLORA_URL/); + expect(() => + bitcoin.fromEnv({ BITCOIN_TREASURY_KEY: "nope", BITCOIN_NETWORK: "mainnet" }, deps), + ).toThrow(/isn't a hex or mainnet WIF/); + const wif = WIF(TEST_NETWORK).encode(treasuryKey); + expect( + bitcoin.fromEnv({ BITCOIN_TREASURY_KEY: wif, BITCOIN_NETWORK: "testnet" }, deps)?.id, + ).toBe("bitcoin"); + }); +}); diff --git a/packages/bank-bitcoin/src/index.ts b/packages/bank-bitcoin/src/index.ts new file mode 100644 index 00000000..deceb7c2 --- /dev/null +++ b/packages/bank-bitcoin/src/index.ts @@ -0,0 +1,307 @@ +import { + BankError, + bytesHex, + defineBankFactory, + defineBankPlugin, + deriveWalletKey, + hexBytes, + oneAtATime, + requireEnv, + walletSpent, + type BankDeps, + type BankEnv, + type BankPlugin, + type CardRef, + type CardUsage, +} from "@nibblet/bank"; +import type { CardSecret } from "@nibblet/protocol"; +import { hex } from "@scure/base"; +import { NETWORK, p2wpkh, selectUTXO, TEST_NETWORK, Transaction, WIF } from "@scure/btc-signer"; +import { pubECDSA } from "@scure/btc-signer/utils.js"; +import { z } from "zod"; + +/** + * Bitcoin, as wallets that work like cards. The treasury is one native + * segwit (P2WPKH) key on the server. Each card is a fresh wallet, its key + * derived from the treasury's and the card's id, funded with exactly the + * card's limit, so the nibblet can't spend more than that whatever it does. + * Killing the card sweeps what's left back to the treasury. A single-use + * wallet is swept once it has sent anything. The chain is read and written + * through an Esplora API (mempool.space's by default). + * + * Settings: `BITCOIN_TREASURY_KEY` (hex or WIF), `BITCOIN_NETWORK` + * (`mainnet`, `testnet`, `signet` or `regtest`; required, so a test key + * can't end up on mainnet by default), and optionally `BITCOIN_ESPLORA_URL` + * (required for regtest) and `BITCOIN_FEE_RATE` (sat/vB, instead of asking + * Esplora). + */ + +const Network = z.enum(["mainnet", "testnet", "signet", "regtest"]); +type Network = z.infer; + +type BtcNetwork = typeof NETWORK; + +const NETWORKS: Record = { + mainnet: NETWORK, + testnet: TEST_NETWORK, + signet: TEST_NETWORK, + regtest: { ...TEST_NETWORK, bech32: "bcrt" }, +}; + +const ESPLORA: Record = { + mainnet: "https://mempool.space/api", + testnet: "https://mempool.space/testnet4/api", + signet: "https://mempool.space/signet/api", + regtest: null, +}; + +/** Below this a card's wallet would cost more to use than it holds. */ +export const MIN_CARD_SATS = 5_000n; +/** Outputs smaller than this aren't relayed. */ +const DUST_SATS = 546n; +/** When Esplora has no estimate (regtest, say). */ +const FALLBACK_FEE_RATE = 2n; + +const Utxos = z.array( + z.object({ + txid: z.string().regex(/^[0-9a-f]{64}$/), + vout: z.int().min(0), + value: z.int().min(0), + }), +); +const Stats = z.object({ funded_txo_sum: z.number(), spent_txo_sum: z.number() }); +const AddressInfo = z.object({ chain_stats: Stats, mempool_stats: Stats }); +const FeeEstimates = z.record(z.string(), z.number()); + +/** The parts of Esplora's API a wallet needs. */ +export class Esplora { + private readonly base: string; + private readonly fetch: typeof globalThis.fetch; + + constructor(base: string, fetch: typeof globalThis.fetch) { + this.base = base.replace(/\/+$/, ""); + this.fetch = fetch; + } + + async utxos(address: string) { + return Utxos.parse(await this.json(`/address/${address}/utxo`)); + } + + /** Confirmed and unconfirmed together: what's come in, and what's gone out. */ + async totals(address: string): Promise<{ funded: bigint; spent: bigint }> { + const { chain_stats: c, mempool_stats: m } = AddressInfo.parse( + await this.json(`/address/${address}`), + ); + return { + funded: BigInt(c.funded_txo_sum + m.funded_txo_sum), + spent: BigInt(c.spent_txo_sum + m.spent_txo_sum), + }; + } + + /** Sat/vB for confirmation in about three blocks, or null without an estimate. */ + async feeRate(): Promise { + const estimates = FeeEstimates.parse(await this.json("/fee-estimates")); + const rate = estimates["3"] ?? estimates["6"] ?? estimates["1"]; + return rate === undefined ? null : BigInt(Math.max(1, Math.ceil(rate))); + } + + async broadcast(txHex: string): Promise { + const res = await this.fetch(`${this.base}/tx`, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: txHex, + signal: AbortSignal.timeout(30_000), + }); + const text = (await res.text()).trim(); + // Only a refusal means it went nowhere; a server error could come after + // it was relayed, so the server is left to look for what was made. + if (res.status >= 400 && res.status < 500) { + throw new BankError(`The Bitcoin network refused the payment: ${text || res.status}`); + } + if (!res.ok) throw new Error(`Esplora said ${res.status} to a broadcast: ${text}`); + return text; + } + + private async json(path: string): Promise { + const res = await this.fetch(`${this.base}${path}`, { signal: AbortSignal.timeout(30_000) }); + if (!res.ok) throw new Error(`Esplora: HTTP ${res.status} for ${path}`); + return res.json(); + } +} + +export interface BitcoinOptions { + /** The treasury's private key, 32 bytes. */ + treasuryKey: Uint8Array; + network: Network; + esplora: Esplora; + /** Sat/vB to pay, instead of asking Esplora. */ + feeRate?: bigint; +} + +/** About a P2WPKH spend's virtual size: overhead, inputs and outputs. */ +function sweepVsize(inputs: number): bigint { + return BigInt(Math.ceil(10.5 + 68 * inputs + 31)); +} + +export function bitcoinPlugin(options: BitcoinOptions): BankPlugin { + const network = NETWORKS[options.network]; + const { esplora } = options; + const treasury = p2wpkh(pubECDSA(options.treasuryKey), network); + const treasuryAddress = addressOf(treasury); + const treasurySends = oneAtATime(); + + function wallet(cardId: string) { + const key = deriveWalletKey(options.treasuryKey, "bitcoin", cardId); + const payment = p2wpkh(pubECDSA(key), network); + return { key, payment, address: addressOf(payment) }; + } + + function secret(cardId: string): CardSecret { + const { key, address } = wallet(cardId); + return { + type: "wallet", + chain: "bitcoin", + network: options.network, + address, + privateKey: bytesHex(key), + wif: WIF(network).encode(key), + }; + } + + async function feeRate(): Promise { + return options.feeRate ?? (await esplora.feeRate()) ?? FALLBACK_FEE_RATE; + } + + async function usage(card: CardRef): Promise { + const { funded, spent } = await esplora.totals(wallet(card.id).address); + const used = spent > 0n; + return { open: true, spent: walletSpent(card.limit, funded - spent, used), used }; + } + + return defineBankPlugin({ + id: "bitcoin", + label: options.network === "mainnet" ? "Bitcoin" : `Bitcoin (${options.network})`, + asset: { code: "BTC", decimals: 8 }, + + async balance() { + const { funded, spent } = await esplora.totals(treasuryAddress); + return funded - spent; + }, + + issue: (request) => + treasurySends(async () => { + if (request.limit < MIN_CARD_SATS) { + throw new BankError( + `A Bitcoin card needs at least ${MIN_CARD_SATS} sats, to cover its fees`, + ); + } + const { address } = wallet(request.cardId); + const coins = (await esplora.utxos(treasuryAddress)).map((u) => ({ + txid: hex.decode(u.txid), + index: u.vout, + witnessUtxo: { script: treasury.script, amount: BigInt(u.value) }, + })); + const selected = selectUTXO(coins, [{ address, amount: request.limit }], "default", { + feePerByte: await feeRate(), + changeAddress: treasuryAddress, + network, + bip69: true, + createTx: true, + }); + if (!selected?.tx) { + throw new BankError("The Bitcoin treasury doesn't have enough to fund that card"); + } + selected.tx.sign(options.treasuryKey); + selected.tx.finalize(); + await esplora.broadcast(selected.tx.hex); + return { ref: address, display: address, secret: secret(request.cardId) }; + }), + + async find(cardId) { + const { address } = wallet(cardId); + const { funded } = await esplora.totals(address); + return funded > 0n ? { ref: address, display: address } : null; + }, + + async reveal(card) { + return secret(card.id); + }, + + inspect: usage, + + close: (card) => + treasurySends(async () => { + const before = await usage(card); + const { key, payment, address } = wallet(card.id); + const { funded } = await esplora.totals(address); + // Its funding isn't seen yet, so there's nothing to sweep; try again later. + if (funded === 0n) return { open: false, spent: 0n, used: false, settled: false }; + const coins = await esplora.utxos(address); + const total = coins.reduce((sum, u) => sum + BigInt(u.value), 0n); + const fee = sweepVsize(coins.length) * (await feeRate()); + let returned = 0n; + if (coins.length > 0 && total - fee >= DUST_SATS) { + const tx = new Transaction(); + for (const u of coins) { + tx.addInput({ + txid: hex.decode(u.txid), + index: u.vout, + witnessUtxo: { script: payment.script, amount: BigInt(u.value) }, + }); + } + tx.addOutputAddress(treasuryAddress, total - fee, network); + tx.sign(key); + tx.finalize(); + await esplora.broadcast(tx.hex); + returned = total - fee; + } + const spent = returned >= card.limit ? 0n : card.limit - returned; + return { open: false, spent, used: before.used, settled: true }; + }), + }); +} + +function addressOf(payment: { address?: string | undefined }): string { + if (!payment.address) throw new Error("No address for that key"); + return payment.address; +} + +/** A 32-byte key from hex, or from WIF for this network. */ +export function parseKey(text: string, network: Network): Uint8Array { + const trimmed = text.trim(); + const key = /^(0x)?[0-9a-f]{64}$/i.test(trimmed) + ? hexBytes(trimmed) + : WIF(NETWORKS[network]).decode(trimmed); + if (key.length !== 32) throw new Error("BITCOIN_TREASURY_KEY isn't a 32-byte key"); + return key; +} + +export const bitcoin = defineBankFactory({ + id: "bitcoin", + env: ["BITCOIN_TREASURY_KEY", "BITCOIN_NETWORK", "BITCOIN_ESPLORA_URL", "BITCOIN_FEE_RATE"], + fromEnv(env: BankEnv, deps: BankDeps) { + if (!env.BITCOIN_TREASURY_KEY?.trim()) return null; + const network = Network.safeParse(env.BITCOIN_NETWORK?.trim()); + if (!network.success) { + throw new Error("BITCOIN_NETWORK should be mainnet, testnet, signet or regtest"); + } + let treasuryKey: Uint8Array; + try { + treasuryKey = parseKey(requireEnv(env, "BITCOIN_TREASURY_KEY"), network.data); + } catch { + throw new Error(`BITCOIN_TREASURY_KEY isn't a hex or ${network.data} WIF private key`); + } + const url = env.BITCOIN_ESPLORA_URL?.trim() || ESPLORA[network.data]; + if (!url) throw new Error(`BITCOIN_ESPLORA_URL is needed for ${network.data}`); + const rate = env.BITCOIN_FEE_RATE?.trim(); + if (rate && !/^[1-9]\d*$/.test(rate)) { + throw new Error("BITCOIN_FEE_RATE should be a whole number of sat/vB"); + } + return bitcoinPlugin({ + treasuryKey, + network: network.data, + esplora: new Esplora(url, deps.fetch), + ...(rate ? { feeRate: BigInt(rate) } : {}), + }); + }, +}); diff --git a/packages/bank-bitcoin/tsconfig.json b/packages/bank-bitcoin/tsconfig.json new file mode 100644 index 00000000..564a5990 --- /dev/null +++ b/packages/bank-bitcoin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/bank-ethereum/package.json b/packages/bank-ethereum/package.json new file mode 100644 index 00000000..bdb7deb0 --- /dev/null +++ b/packages/bank-ethereum/package.json @@ -0,0 +1,18 @@ +{ + "name": "@nibblet/bank-ethereum", + "version": "0.3.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p ." + }, + "dependencies": { + "@nibblet/bank": "workspace:*", + "@nibblet/protocol": "workspace:*", + "viem": "^2.57.7", + "zod": "^4.6.7" + } +} diff --git a/packages/bank-ethereum/src/ethereum.test.ts b/packages/bank-ethereum/src/ethereum.test.ts new file mode 100644 index 00000000..c67ea75a --- /dev/null +++ b/packages/bank-ethereum/src/ethereum.test.ts @@ -0,0 +1,228 @@ +import { BankError, deriveWalletKey } from "@nibblet/bank"; +import { custom, isAddress, isHex, parseTransaction, type Address, type Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { describe, expect, it } from "vitest"; +import { ethereum, ethereumPlugin, viemClient, type EvmClient } from "./index.ts"; + +const treasuryKey = new Uint8Array(32).fill(5); +const treasuryHex: Hex = `0x${Buffer.from(treasuryKey).toString("hex")}`; +const treasury = privateKeyToAccount(treasuryHex).address; +const GWEI = 10n ** 9n; +const ETH = 10n ** 18n; + +function address(text: string): Address { + if (!isAddress(text)) throw new Error(`${text} isn't an address`); + return text; +} + +function hex(text: string): Hex { + if (!isHex(text)) throw new Error(`${text} isn't hex`); + return text; +} + +/** A chain of plain accounts, with gas at a fixed price. */ +class FakeChain implements EvmClient { + readonly balances = new Map(); + readonly nonces = new Map(); + readonly sent: { from: Address; to: Address; value: bigint }[] = []; + id = 11155111; + price = 2n * GWEI; + + async chainId() { + return this.id; + } + async balance(address: Address) { + return this.balances.get(address) ?? 0n; + } + async nonce(address: Address) { + return this.nonces.get(address) ?? 0; + } + async gasPrice() { + return this.price; + } + async transfer(key: Hex, to: Address, value: bigint, gasPrice: bigint) { + const from = privateKeyToAccount(key).address; + const cost = value + 21_000n * gasPrice; + const have = await this.balance(from); + if (have < cost) throw new Error("insufficient funds"); + this.balances.set(from, have - cost); + this.balances.set(to, (await this.balance(to)) + value); + this.nonces.set(from, (await this.nonce(from)) + 1); + this.sent.push({ from, to, value }); + return hex("0xabc"); + } +} + +function setUp(opts: { chainId?: number } = {}) { + const chain = new FakeChain(); + chain.balances.set(treasury, 10n * ETH); + return { chain, plugin: ethereumPlugin({ treasuryKey, client: chain, ...opts }) }; +} + +const request = { + cardId: "card_1", + nibbletId: "nib_1", + label: "x", + kind: "multi_use" as const, + limit: ETH / 10n, +}; + +describe("ethereum", () => { + it("funds a fresh account with exactly the limit", async () => { + const { chain, plugin } = setUp(); + const card = await plugin.issue(request); + const key = hex( + `0x${Buffer.from(deriveWalletKey(treasuryKey, "ethereum", "card_1")).toString("hex")}`, + ); + const address = privateKeyToAccount(key).address; + expect(card).toEqual({ + ref: address, + display: address, + secret: { + type: "wallet", + chain: "ethereum", + network: "11155111", + address, + privateKey: key, + wif: null, + }, + }); + expect(chain.sent).toEqual([{ from: treasury, to: address, value: ETH / 10n }]); + expect( + await plugin.reveal({ id: "card_1", ref: address, kind: "multi_use", limit: 1n }), + ).toEqual(card.secret); + }); + + it("refuses a card the treasury can't fund with its gas", async () => { + const { plugin } = setUp(); + await expect(plugin.issue({ ...request, limit: 10n * ETH })).rejects.toThrow( + /doesn't have enough/, + ); + }); + + it("refuses to work on another chain than it was set up for", async () => { + const { plugin } = setUp({ chainId: 1 }); + await expect(plugin.issue(request)).rejects.toThrow("The RPC is on chain 11155111, not 1"); + }); + + it("counts spending once the account has sent, and sweeps the rest back", async () => { + const { chain, plugin } = setUp(); + const card = await plugin.issue(request); + const ref = { id: "card_1", ref: card.ref, kind: "multi_use" as const, limit: ETH / 10n }; + expect(await plugin.inspect(ref)).toEqual({ open: true, spent: 0n, used: false }); + // The nibblet pays someone 0.03 ETH, plus gas. + const key = hex(card.secret.type === "wallet" ? card.secret.privateKey : ""); + const shop = address("0x000000000000000000000000000000000000dEaD"); + await chain.transfer(key, shop, 3n * 10n ** 16n, chain.price); + const paid = 3n * 10n ** 16n + 21_000n * chain.price; + expect(await plugin.inspect(ref)).toEqual({ open: true, spent: paid, used: true }); + + const before = await chain.balance(treasury); + const closed = await plugin.close(ref); + const sweepFee = 21_000n * chain.price; + expect(await chain.balance(address(card.ref))).toBe(0n); + expect(await chain.balance(treasury)).toBe(before + ETH / 10n - paid - sweepFee); + expect(closed).toEqual({ open: false, spent: paid + sweepFee, used: true, settled: true }); + }); + + it("leaves dust it can't pay gas to move", async () => { + const { chain, plugin } = setUp(); + const card = await plugin.issue({ ...request, limit: 1000n }); + const ref = { id: "card_1", ref: card.ref, kind: "multi_use" as const, limit: 1000n }; + const sends = chain.sent.length; + expect(await plugin.close(ref)).toEqual({ + open: false, + spent: 1000n, + used: false, + settled: true, + }); + expect(chain.sent.length).toBe(sends); + }); + + it("finds a funded account, and won't settle one whose funding isn't seen", async () => { + const { chain, plugin } = setUp(); + expect(await plugin.find("card_1")).toBeNull(); + const card = await plugin.issue(request); + expect(await plugin.find("card_1")).toEqual({ ref: card.ref, display: card.ref }); + const unseen = { id: "card_2", ref: "", kind: "multi_use" as const, limit: ETH / 10n }; + const sends = chain.sent.length; + expect(await plugin.close(unseen)).toEqual({ + open: false, + spent: 0n, + used: false, + settled: false, + }); + expect(chain.sent.length).toBe(sends); + }); + + it("refuses with messages that are safe to show", async () => { + const { plugin } = setUp({ chainId: 1 }); + const err = await plugin.issue(request).catch((e: unknown) => e); + expect(err).toBeInstanceOf(BankError); + }); + + it("signs real transfers through viem", async () => { + const raw: Hex[] = []; + const client = viemClient( + custom({ + async request({ method, params }: { method: string; params?: unknown }) { + switch (method) { + case "eth_chainId": + return "0xaa36a7"; + case "eth_getTransactionCount": + return "0x7"; + case "eth_gasPrice": + return "0x77359400"; + case "eth_getBalance": + return "0xde0b6b3a7640000"; + case "eth_sendRawTransaction": { + const [tx] = Array.isArray(params) ? params : []; + if (typeof tx !== "string") throw new Error("no tx"); + raw.push(hex(tx)); + return "0x1234"; + } + default: + throw new Error(`unexpected ${method}`); + } + }, + }), + ); + expect(await client.chainId()).toBe(11155111); + expect(await client.balance(treasury)).toBe(ETH); + const to = address("0x000000000000000000000000000000000000dEaD"); + await client.transfer(treasuryHex, to, 12345n, 2n * GWEI); + const [signed] = raw; + if (!signed) throw new Error("nothing sent"); + const tx = parseTransaction(signed); + expect(tx).toMatchObject({ + to: to.toLowerCase(), + value: 12345n, + gas: 21000n, + nonce: 7, + chainId: 11155111, + }); + // Signed by the treasury: signing the same transaction again gives the same bytes. + const { r: _r, s: _s, v: _v, yParity: _y, ...unsigned } = tx; + expect(await privateKeyToAccount(treasuryHex).signTransaction(unsigned)).toBe(signed); + }); + + it("turns on from its settings, and refuses wrong ones", () => { + const deps = { fetch: globalThis.fetch, log: () => {} }; + const base = { ETHEREUM_TREASURY_KEY: treasuryHex, ETHEREUM_RPC_URL: "https://rpc.example" }; + expect(ethereum.fromEnv({}, deps)).toBeNull(); + expect(ethereum.fromEnv(base, deps)?.label).toBe("Ethereum"); + expect(ethereum.fromEnv({ ...base, ETHEREUM_ASSET: "POL" }, deps)?.asset).toEqual({ + code: "POL", + decimals: 18, + }); + expect(() => ethereum.fromEnv({ ETHEREUM_TREASURY_KEY: treasuryHex }, deps)).toThrow( + /ETHEREUM_RPC_URL/, + ); + expect(() => ethereum.fromEnv({ ...base, ETHEREUM_TREASURY_KEY: "0x12" }, deps)).toThrow( + /32-byte/, + ); + expect(() => ethereum.fromEnv({ ...base, ETHEREUM_CHAIN_ID: "main" }, deps)).toThrow( + /chain id/, + ); + }); +}); diff --git a/packages/bank-ethereum/src/index.ts b/packages/bank-ethereum/src/index.ts new file mode 100644 index 00000000..e090af60 --- /dev/null +++ b/packages/bank-ethereum/src/index.ts @@ -0,0 +1,226 @@ +import { + BankError, + bytesHex, + defineBankFactory, + defineBankPlugin, + deriveWalletKey, + hexBytes, + oneAtATime, + requireEnv, + walletSpent, + type BankDeps, + type BankEnv, + type BankPlugin, + type CardRef, + type CardUsage, +} from "@nibblet/bank"; +import type { CardSecret } from "@nibblet/protocol"; +import { + createPublicClient, + createWalletClient, + http, + type Address, + type Hex, + type Transport, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +/** + * Ether (or an EVM chain's own coin), as wallets that work like cards: the + * same scheme as Bitcoin. Each card is a fresh account, its key derived from + * the treasury's and the card's id, funded with exactly the card's limit, + * and swept back to the treasury when it's killed. Gas comes out of the + * card's own balance. + * + * Settings: `ETHEREUM_TREASURY_KEY` (hex), `ETHEREUM_RPC_URL`, and optionally + * `ETHEREUM_CHAIN_ID` (checked against the RPC, so a key meant for a testnet + * isn't used on mainnet by mistake) and `ETHEREUM_ASSET` (`ETH` by default; + * `POL` for Polygon, say). + */ + +/** A plain transfer always costs this much gas. */ +const TRANSFER_GAS = 21_000n; + +/** What a wallet needs from the chain. */ +export interface EvmClient { + chainId(): Promise; + /** Including what's still pending, so a fresh funding counts. */ + balance(address: Address): Promise; + nonce(address: Address): Promise; + gasPrice(): Promise; + /** Sends `value` from the account with this key; resolves with the transaction's hash. */ + transfer(key: Hex, to: Address, value: bigint, gasPrice: bigint): Promise; +} + +/** An EvmClient over a JSON-RPC endpoint, signing locally. */ +export function viemClient(transport: Transport): EvmClient { + const reader = createPublicClient({ transport }); + const writer = createWalletClient({ transport }); + return { + chainId: () => reader.getChainId(), + balance: (address) => reader.getBalance({ address, blockTag: "pending" }), + nonce: (address) => reader.getTransactionCount({ address, blockTag: "pending" }), + gasPrice: () => reader.getGasPrice(), + async transfer(key, to, value, gasPrice) { + const account = privateKeyToAccount(key); + return writer.sendTransaction({ + account, + to, + value, + gas: TRANSFER_GAS, + gasPrice, + nonce: await reader.getTransactionCount({ address: account.address, blockTag: "pending" }), + chain: null, + }); + }, + }; +} + +export interface EthereumOptions { + treasuryKey: Uint8Array; + client: EvmClient; + /** The chain it must be on, if given. */ + chainId?: number; + asset?: string; +} + +function hexKey(bytes: Uint8Array): Hex { + return `0x${bytesHex(bytes)}`; +} + +export function ethereumPlugin(options: EthereumOptions): BankPlugin { + const { client } = options; + const treasuryKey = hexKey(options.treasuryKey); + const treasury = privateKeyToAccount(treasuryKey).address; + const treasurySends = oneAtATime(); + let checkedChain: Promise | null = null; + + /** The chain id, checked once against the one it was set up for. */ + function chain(): Promise { + checkedChain ??= client.chainId().then((id) => { + if (options.chainId !== undefined && id !== options.chainId) { + throw new BankError(`The RPC is on chain ${id}, not ${options.chainId}`); + } + return id; + }); + // A failed check is tried again next time. + checkedChain.catch(() => { + checkedChain = null; + }); + return checkedChain; + } + + function wallet(cardId: string) { + const key = hexKey(deriveWalletKey(options.treasuryKey, "ethereum", cardId)); + return { key, address: privateKeyToAccount(key).address }; + } + + async function secret(cardId: string): Promise { + const { key, address } = wallet(cardId); + return { + type: "wallet", + chain: "ethereum", + network: String(await chain()), + address, + privateKey: key, + wif: null, + }; + } + + async function usage(card: CardRef): Promise { + const { address } = wallet(card.id); + const [balance, nonce] = await Promise.all([client.balance(address), client.nonce(address)]); + const used = nonce > 0; + return { open: true, spent: walletSpent(card.limit, balance, used), used }; + } + + const asset = options.asset ?? "ETH"; + return defineBankPlugin({ + id: "ethereum", + label: asset === "ETH" ? "Ethereum" : `Ethereum (${asset})`, + asset: { code: asset, decimals: 18 }, + + async balance() { + await chain(); + return client.balance(treasury); + }, + + issue: (request) => + treasurySends(async () => { + await chain(); + const { address } = wallet(request.cardId); + const gasPrice = await client.gasPrice(); + const needed = request.limit + TRANSFER_GAS * gasPrice; + if ((await client.balance(treasury)) < needed) { + throw new BankError( + "The Ethereum treasury doesn't have enough to fund that card and its gas", + ); + } + await client.transfer(treasuryKey, address, request.limit, gasPrice); + return { ref: address, display: address, secret: await secret(request.cardId) }; + }), + + async find(cardId) { + await chain(); + const { address } = wallet(cardId); + const [balance, nonce] = await Promise.all([client.balance(address), client.nonce(address)]); + return balance > 0n || nonce > 0 ? { ref: address, display: address } : null; + }, + + reveal: (card) => secret(card.id), + + inspect: usage, + + close: (card) => + treasurySends(async () => { + await chain(); + const before = await usage(card); + const { key, address } = wallet(card.id); + const balance = await client.balance(address); + // Its funding isn't seen yet, so there's nothing to sweep; try again later. + if (balance === 0n && !before.used) { + return { open: false, spent: 0n, used: false, settled: false }; + } + const gasPrice = await client.gasPrice(); + const fee = TRANSFER_GAS * gasPrice; + let returned = 0n; + if (balance > fee) { + await client.transfer(key, treasury, balance - fee, gasPrice); + returned = balance - fee; + } + const spent = returned >= card.limit ? 0n : card.limit - returned; + return { open: false, spent, used: before.used, settled: true }; + }), + }); +} + +export const ethereum = defineBankFactory({ + id: "ethereum", + env: ["ETHEREUM_TREASURY_KEY", "ETHEREUM_RPC_URL", "ETHEREUM_CHAIN_ID", "ETHEREUM_ASSET"], + fromEnv(env: BankEnv, deps: BankDeps) { + if (!env.ETHEREUM_TREASURY_KEY?.trim()) return null; + let treasuryKey: Uint8Array; + try { + treasuryKey = hexBytes(requireEnv(env, "ETHEREUM_TREASURY_KEY")); + } catch { + throw new Error("ETHEREUM_TREASURY_KEY isn't a hex private key"); + } + if (treasuryKey.length !== 32) throw new Error("ETHEREUM_TREASURY_KEY isn't a 32-byte key"); + const url = requireEnv(env, "ETHEREUM_RPC_URL"); + if (!/^https?:\/\//.test(url)) throw new Error("ETHEREUM_RPC_URL should be an http(s) URL"); + const chainId = env.ETHEREUM_CHAIN_ID?.trim(); + if (chainId && !/^[1-9]\d*$/.test(chainId)) { + throw new Error("ETHEREUM_CHAIN_ID should be a chain id, e.g. 1 or 11155111"); + } + const asset = env.ETHEREUM_ASSET?.trim(); + if (asset && !/^[A-Z0-9]{2,10}$/.test(asset)) { + throw new Error("ETHEREUM_ASSET should be a ticker, e.g. ETH"); + } + return ethereumPlugin({ + treasuryKey, + client: viemClient(http(url, { timeout: 30_000, fetchFn: deps.fetch })), + ...(chainId ? { chainId: Number(chainId) } : {}), + ...(asset ? { asset } : {}), + }); + }, +}); diff --git a/packages/bank-ethereum/tsconfig.json b/packages/bank-ethereum/tsconfig.json new file mode 100644 index 00000000..564a5990 --- /dev/null +++ b/packages/bank-ethereum/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/bank/src/bank.test.ts b/packages/bank/src/bank.test.ts index 3ea56638..0f522d75 100644 --- a/packages/bank/src/bank.test.ts +++ b/packages/bank/src/bank.test.ts @@ -9,6 +9,8 @@ import { loadBankPlugins, periodStart, requireEnv, + oneAtATime, + walletSpent, type BankPlugin, type BudgetCard, } from "./index.ts"; @@ -174,3 +176,27 @@ describe("loading plugins", () => { expect(() => loadBankPlugins([configured, configured], {}, deps)).toThrow(/Two bank plugins/); }); }); + +describe("wallets", () => { + it("count nothing spent until the wallet has sent something", () => { + expect(walletSpent(1000n, 0n, false)).toBe(0n); + expect(walletSpent(1000n, 400n, true)).toBe(600n); + expect(walletSpent(1000n, 1500n, true)).toBe(0n); + }); + + it("run treasury work one piece at a time, even after a failure", async () => { + const queue = oneAtATime(); + const order: string[] = []; + const slow = (name: string, ms: number, fail = false) => + queue(async () => { + order.push(`${name} start`); + await new Promise((r) => setTimeout(r, ms)); + order.push(`${name} end`); + if (fail) throw new Error(name); + return name; + }); + const results = await Promise.allSettled([slow("a", 20, true), slow("b", 1), slow("c", 1)]); + expect(order).toEqual(["a start", "a end", "b start", "b end", "c start", "c end"]); + expect(results.map((r) => r.status)).toEqual(["rejected", "fulfilled", "fulfilled"]); + }); +}); diff --git a/packages/bank/src/index.ts b/packages/bank/src/index.ts index 9259b11b..e1290c57 100644 --- a/packages/bank/src/index.ts +++ b/packages/bank/src/index.ts @@ -2,6 +2,7 @@ import type { AssetInfo, BankProviderId, CardKind, CardSecret } from "@nibblet/p export * from "./budget.ts"; export * from "./keys.ts"; +export * from "./wallet.ts"; /** * The contract every bank plugin fulfils. A plugin is somewhere money comes diff --git a/packages/bank/src/wallet.ts b/packages/bank/src/wallet.ts new file mode 100644 index 00000000..451fb409 --- /dev/null +++ b/packages/bank/src/wallet.ts @@ -0,0 +1,26 @@ +/** + * What crypto plugins share: a wallet per card, funded from one treasury. + */ + +/** + * Runs work one piece at a time, in order. Treasury sends go through one, so + * two cards at once don't pick the same coins or the same nonce. + */ +export function oneAtATime(): (work: () => Promise) => Promise { + let tail: Promise = Promise.resolve(); + return (work: () => Promise): Promise => { + const next = tail.then(work, work); + tail = next.catch(() => undefined); + return next; + }; +} + +/** + * What's gone out of a card's wallet: nothing until it has sent anything + * (so a funding payment that isn't seen yet doesn't read as spent), then + * whatever of its limit isn't still there, fees included. + */ +export function walletSpent(limit: bigint, balance: bigint, hasSent: boolean): bigint { + if (!hasSent) return 0n; + return balance >= limit ? 0n : limit - balance; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a5ce0d1..16dcc859 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,6 +321,12 @@ importers: '@nibblet/bank': specifier: workspace:* version: link:../../packages/bank + '@nibblet/bank-bitcoin': + specifier: workspace:* + version: link:../../packages/bank-bitcoin + '@nibblet/bank-ethereum': + specifier: workspace:* + version: link:../../packages/bank-ethereum '@nibblet/bank-slash': specifier: workspace:* version: link:../../packages/bank-slash @@ -441,6 +447,39 @@ importers: specifier: workspace:* version: link:../protocol + packages/bank-bitcoin: + dependencies: + '@nibblet/bank': + specifier: workspace:* + version: link:../bank + '@nibblet/protocol': + specifier: workspace:* + version: link:../protocol + '@scure/base': + specifier: ^2.4.0 + version: 2.4.0 + '@scure/btc-signer': + specifier: ^2.4.1 + version: 2.4.1 + zod: + specifier: ^4.6.7 + version: 4.6.7 + + packages/bank-ethereum: + dependencies: + '@nibblet/bank': + specifier: workspace:* + version: link:../bank + '@nibblet/protocol': + specifier: workspace:* + version: link:../protocol + viem: + specifier: ^2.57.7 + version: 2.57.7(typescript@7.0.2)(zod@4.6.7) + zod: + specifier: ^4.6.7 + version: 4.6.7 + packages/bank-slash: dependencies: '@nibblet/bank': @@ -564,6 +603,9 @@ importers: packages: + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.296': resolution: {integrity: sha512-Wrhy7rxQVUNowQRDpSZD+3MFSBAlN4TINZSpKD8PKpEValCG6x05N855MuITvwgkH0EeoyNLRCsowJaIHa8V9Q==} cpu: [arm64] @@ -1881,6 +1923,18 @@ packages: engines: {node: '>=22.19.0'} hasBin: true + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@2.4.0': + resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -2090,6 +2144,21 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/base@2.4.0': + resolution: {integrity: sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@scure/btc-signer@2.4.1': + resolution: {integrity: sha512-ch6h66o9nPhEoe/KciMMUJeH2MBPB8UAJI1if35Fxl6885lKTsc9YoCRqTW0/RjI0Vv9GSg1tmKtHHODyAw81g==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2538,6 +2607,17 @@ packages: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3185,6 +3265,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -3496,6 +3579,11 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -3845,6 +3933,10 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + micro-packed@0.11.1: + resolution: {integrity: sha512-agQGBnBCw8OoKQZMUxcaQPaOowdfxyEmBsq4CNAXAXCyB8KxlRG0lTl5GADJIsMEDaFb0trhXBHKYsjTWa5kuw==} + engines: {node: '>= 20.19.0'} + micromark-core-commonmark@2.0.4: resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} @@ -4157,6 +4249,14 @@ packages: cpu: [x64] os: [win32] + ox@0.14.56: + resolution: {integrity: sha512-Feinlj+deWEam6RIwurRE+eHZ2N3hoUb3AAjQ10BmxRCYIymfqNkv0eZ+4ln4GkgDmSGLD2rGtTMqsFYSVDC6w==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -4852,6 +4952,14 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + viem@2.57.7: + resolution: {integrity: sha512-x3UqqrcQuESaRVK6a0BvNmoNP0SVhu913uxEAxYLJx1lNYnj6ylTntfUdakCUKjk1+T+63gkdwFGJQ2O5C/nRw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5103,6 +5211,8 @@ packages: snapshots: + '@adraffy/ens-normalize@1.11.1': {} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.296': optional: true @@ -6142,6 +6252,16 @@ snapshots: - bufferutil - utf-8-validate + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/curves@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + '@noble/hashes@1.8.0': {} '@noble/hashes@2.4.0': {} @@ -6284,6 +6404,28 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@scure/base@1.2.6': {} + + '@scure/base@2.4.0': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/btc-signer@2.4.1': + dependencies: + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@scure/base': 2.4.0 + micro-packed: 0.11.1 + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@7.2.0': {} @@ -6645,6 +6787,11 @@ snapshots: abbrev@4.0.0: {} + abitype@1.2.3(typescript@7.0.2)(zod@4.6.7): + optionalDependencies: + typescript: 7.0.2 + zod: 4.6.7 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -7366,6 +7513,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.1: {} + eventsource-parser@3.1.1: {} eventsource@3.0.7: @@ -7783,6 +7932,10 @@ snapshots: isexe@4.0.0: {} + isows@1.0.7(ws@8.21.0): + dependencies: + ws: 8.21.0 + jake@10.9.4: dependencies: async: 3.2.6 @@ -8178,6 +8331,10 @@ snapshots: merge-descriptors@2.0.0: {} + micro-packed@0.11.1: + dependencies: + '@scure/base': 2.4.0 + micromark-core-commonmark@2.0.4: dependencies: decode-named-character-reference: 1.3.0 @@ -8582,6 +8739,21 @@ snapshots: opencode-windows-x64@1.19.0: optional: true + ox@0.14.56(typescript@7.0.2)(zod@4.6.7): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@7.0.2)(zod@4.6.7) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - zod + p-cancelable@2.1.1: {} p-limit@2.3.0: @@ -9427,6 +9599,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + viem@2.57.7(typescript@7.0.2)(zod@4.6.7): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@7.0.2)(zod@4.6.7) + isows: 1.0.7(ws@8.21.0) + ox: 0.14.56(typescript@7.0.2)(zod@4.6.7) + ws: 8.21.0 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15): dependencies: lightningcss: 1.33.0