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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ A turn the user didn't start (a heartbeat, a schedule, a sub-agent's result, car

## Bank: cards and wallets

A nibblet can be given money to spend, as virtual cards or as crypto wallets that work like them. Where the money comes from is a bank plugin (`packages/bank-*`) implementing `BankPlugin` from `@nibblet/bank`: it issues a card with a lifetime limit, gives its details again (`reveal`), reports what's been spent on it (`inspect`), and closes it (`close`). A plugin turns itself on from the server's environment (`apps/server/src/banks.ts` lists them), and holds its provider's credentials, which never leave the server. Settings shows each plugin, its balance, and any whose settings are wrong.
A nibblet can be given money to spend, as virtual cards or as crypto wallets that work like them. Where the money comes from is a bank plugin (`packages/bank-*`) implementing `BankPlugin` from `@nibblet/bank`: it issues a card with a lifetime limit, gives its details again (`reveal`), reports what's been spent on it (`inspect`), and closes it (`close`). Each plugin's factory describes its own settings form (`fields`: labels, hints, secrets, choices, keys the form can make, and fields that can't change while cards are open), so Settings → Bank sets any of them up without the web app knowing one bank from another. Saving checks the settings with the provider (`check`), keeps them in the `settings` table (`banks`, secrets never sent back), and loads the plugin without a restart (`apps/server/src/banks.ts`, which lists the plugins). A bank with no saved settings falls back to the server's environment, whose variables are named like its fields. A plugin holds its provider's credentials, which never leave the server. Settings shows each bank, its balance, where to top a treasury up (`fundAt`), and any whose settings are wrong. A bank can't be removed, or have its locked fields changed, while it has cards that aren't closed.

- **Limits:** the person sets each nibblet's `spendingLimits`, one per asset (`USD`, `BTC`, `ETH`), per day, week or month (UTC) or in total. A nibblet has none to begin with, so it can't have a card until it's given one, and it can't change its own. Every card counts against its period for its whole limit while it's open, since it could all still be spent, and for what was spent once it's closed. One request or kill at a time per nibblet, so two at once can't both fit the same budget.
- **The card file:** `card_request` asks the plugin for a card, stores its record (`cards`: never the number or key), and sends its details over the runner socket (`card_put`). The runner writes them to `$TMPDIR/nibblet-cards/<id>.json`, `0600` in a `0700` folder, as the agent's user, and answers `card_done` with the path, which is all the tool returns. So a card's number reaches the model only if it reads the file, and never the thread, the database or a snapshot. A card that can't be written is closed again. The folder goes with the container; when a runner reconnects, the server asks the plugins for its open cards again and rewrites them.
Expand Down
47 changes: 44 additions & 3 deletions apps/server/src/bank.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type Nibblet,
type SpendingLimit,
} from "@nibblet/protocol";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Bank, type BankStore } from "./bank.ts";
import type { CardRow } from "./store.ts";

Expand Down Expand Up @@ -118,6 +118,9 @@ class FakeIssuer {
id: "fake",
label: "Fake issuer",
asset: { code: "USD", decimals: 2 },
instrument: "card",
fundAt: null,
check: async () => {},
balance: async () => 100_000n,
issue: async (request) => {
if (this.refuseNext) throw new BankError("the account is frozen");
Expand Down Expand Up @@ -444,7 +447,7 @@ describe("Bank", () => {
const broken = new Bank({
store,
plugins: new Map([["fake", issuer.plugin()]]),
errors: new Map([["slash", "SLASH_API_KEY isn't set"]]),
errors: new Map([["slash", "API key is needed"]]),
files: { put: async () => "", remove: async () => {} },
publish: () => {},
log: () => {},
Expand All @@ -455,12 +458,50 @@ describe("Bank", () => {
id: "fake",
label: "Fake issuer",
asset: { code: "USD", decimals: 2 },
instrument: "card",
state: "ready",
detail: null,
balance: "100000",
fundAt: null,
},
],
problems: [{ id: "slash", detail: "SLASH_API_KEY isn't set" }],
problems: [{ id: "slash", detail: "API key is needed" }],
});
});

it("takes banks set up while it runs, and says so", async () => {
const published: LiveMessage[] = [];
const later = new Bank({
store,
plugins: new Map(),
files: { put: async () => "", remove: async () => {} },
publish: (m) => published.push(m),
log: () => {},
});
expect(later.enabled).toBe(false);
later.use(new Map([["fake", issuer.plugin()]]), new Map());
expect(later.enabled).toBe(true);
expect(later.providers().map((p) => p.id)).toEqual(["fake"]);
expect(published).toEqual([{ type: "bank_changed" }]);
});

it("starts checking cards once a bank's set up, if there was none when it started", async () => {
const single = (await request("5.00", "single_use")).card;
const c = issuer.cards.get("tok_1");
if (c) c.spent = 100n;
const later = new Bank({
store,
plugins: new Map(),
files: { put: async () => "", remove: async () => {} },
publish: () => {},
log: () => {},
now: () => clock,
});
later.start();
later.use(new Map([["fake", issuer.plugin()]]), new Map());
await vi.waitFor(async () =>
expect(await store.cardRow(single.id)).toMatchObject({ state: "closed" }),
);
later.stop();
});
});
64 changes: 49 additions & 15 deletions apps/server/src/bank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type BankStore = Pick<

export interface BankDeps {
store: BankStore;
/** The banks set up when it starts; `use` swaps in others. */
plugins: ReadonlyMap<BankProviderId, BankPlugin>;
/** Plugins whose settings are there but wrong, with why. */
errors?: ReadonlyMap<BankProviderId, string>;
Expand Down Expand Up @@ -74,30 +75,56 @@ export class Bank {
/** Where each open card's file is in its container, as its runner last said. */
private readonly paths = new Map<string, string>();
private timer: ReturnType<typeof setInterval> | null = null;
/** Whether the server has asked it to keep cards in step, so a bank set up later starts it. */
private running = false;
private syncing: Promise<void> | null = null;
private plugins: ReadonlyMap<BankProviderId, BankPlugin>;
private errors: ReadonlyMap<BankProviderId, string>;

constructor(deps: BankDeps) {
this.deps = deps;
this.plugins = deps.plugins;
this.errors = deps.errors ?? new Map();
}

/**
* Swaps in the banks as they're set up now. Cards at a bank that's gone
* wait, as they are, for it to come back.
*/
use(
plugins: ReadonlyMap<BankProviderId, BankPlugin>,
errors: ReadonlyMap<BankProviderId, string>,
) {
this.plugins = plugins;
this.errors = errors;
if (this.running) this.startTimer();
this.deps.publish({ type: "bank_changed" });
}

/** Whether any bank is set up, so the tools are worth offering. */
get enabled(): boolean {
return this.deps.plugins.size > 0;
return this.plugins.size > 0;
}

/** The banks nibblets can ask for cards from. */
providers(): { id: string; label: string; asset: AssetInfo }[] {
return [...this.deps.plugins.values()].map((p) => ({
return [...this.plugins.values()].map((p) => ({
id: p.id,
label: p.label,
asset: p.asset,
}));
}

async status(): Promise<BankStatus> {
async status(): Promise<Pick<BankStatus, "providers" | "problems">> {
const providers = await Promise.all(
[...this.deps.plugins.values()].map(async (plugin): Promise<BankProvider> => {
const base = { id: plugin.id, label: plugin.label, asset: plugin.asset };
[...this.plugins.values()].map(async (plugin): Promise<BankProvider> => {
const base = {
id: plugin.id,
label: plugin.label,
asset: plugin.asset,
instrument: plugin.instrument,
fundAt: plugin.fundAt,
};
try {
const balance = await plugin.balance();
return { ...base, state: "ready", detail: null, balance: balance?.toString() ?? null };
Expand All @@ -109,7 +136,7 @@ export class Bank {
}
}),
);
const problems = [...(this.deps.errors ?? [])].map(([id, detail]) => ({ id, detail }));
const problems = [...this.errors].map(([id, detail]) => ({ id, detail }));
return { providers, problems };
}

Expand Down Expand Up @@ -261,7 +288,7 @@ export class Bank {
"That card is still being made, or checked on. It'll be closed if it was made; try again in a few minutes.",
);
}
const plugin = this.deps.plugins.get(row.provider);
const plugin = this.plugins.get(row.provider);
if (!plugin) {
throw new RpcError(
"unavailable",
Expand Down Expand Up @@ -300,7 +327,7 @@ export class Bank {
// Checked in turn with kills, so a card killed meanwhile isn't written back.
const row = await this.deps.store.cardRow(id);
if (row.state !== "open") return;
const plugin = this.deps.plugins.get(row.provider);
const plugin = this.plugins.get(row.provider);
if (!plugin) return;
const details = await plugin.reveal(cardRef(row));
const path = await this.deps.files.put(nibbletId, {
Expand Down Expand Up @@ -333,20 +360,27 @@ export class Bank {
}

start(): void {
if (this.timer || !this.enabled) return;
this.timer = setInterval(() => void this.sync(), SYNC_EVERY_MS);
this.timer.unref?.();
void this.sync();
this.running = true;
this.startTimer();
}

stop(): void {
this.running = false;
if (this.timer) clearInterval(this.timer);
this.timer = null;
}

/** Checks cards every minute from the first bank on; with none, there's nothing to check. */
private startTimer(): void {
if (this.timer || !this.enabled) return;
this.timer = setInterval(() => void this.sync(), SYNC_EVERY_MS);
this.timer.unref?.();
void this.sync();
}

private async syncOnce(): Promise<void> {
for (const row of await this.deps.store.liveCards()) {
const plugin = this.deps.plugins.get(row.provider);
const plugin = this.plugins.get(row.provider);
if (!plugin) continue;
try {
await this.serially(row.nibbletId, async () => {
Expand Down Expand Up @@ -476,9 +510,9 @@ export class Bank {
}

private plugin(id: string): BankPlugin {
const plugin = this.deps.plugins.get(id);
const plugin = this.plugins.get(id);
if (plugin) return plugin;
const known = [...this.deps.plugins.keys()];
const known = [...this.plugins.keys()];
throw new RpcError(
"bad_request",
known.length
Expand Down
Loading
Loading