Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/banks.ts
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down
8 changes: 8 additions & 0 deletions docker/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions packages/bank-bitcoin/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
260 changes: 260 additions & 0 deletions packages/bank-bitcoin/src/bitcoin.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading