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
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ A nibblet can be given money to spend, as virtual cards or as crypto wallets tha
- **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.
- **Card states:** a card's row goes in as `pending` before the plugin is asked for it, so one the provider made before something failed (a timeout after it was sent) is never lost: every minute the server asks the plugin to `find` pending cards it isn't still making, closes any that were made, and forgets ones that haven't shown up in an hour. A plugin throws a `BankError` only for a refusal, where nothing was made; its message is the only kind passed on, since other errors can carry credentials (an RPC URL with its key), so they're logged on the server instead.
- **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.

## API
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@huggingface/transformers": "^4.3.1",
"@modelcontextprotocol/sdk": "^1.32.1",
"@nibblet/bank": "workspace:*",
"@nibblet/bank-slash": "workspace:*",
"@nibblet/db": "workspace:*",
"@nibblet/protocol": "workspace:*",
"croner": "^10.0.1",
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/banks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { loadBankPlugins, type BankPluginFactory, type LoadedBanks } from "@nibblet/bank";
import { slash } from "@nibblet/bank-slash";

/**
* Every bank plugin the server knows. Each turns itself on when its settings
* are in the environment (see `docker/.env.example`), and stays off otherwise.
* are in the environment (see the server's environment in `docker/compose.yaml`), and stays off otherwise.
*/
export const BANK_PLUGINS: readonly BankPluginFactory[] = [];
export const BANK_PLUGINS: readonly BankPluginFactory[] = [slash];

export function loadBanks(env: NodeJS.ProcessEnv = process.env): LoadedBanks {
const loaded = loadBankPlugins(BANK_PLUGINS, env, {
Expand Down
4 changes: 4 additions & 0 deletions docker/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ services:
NIBBLET_HOST_NAME: ${NIBBLET_HOST_NAME:-}
NIBBLET_HOST_PLATFORM: ${NIBBLET_HOST_PLATFORM:-other}
NIBBLET_LAN_URLS: ${NIBBLET_LAN_URLS:-}
# Banks for nibblets' cards (packages/bank-*); each is off until its settings are set.
SLASH_API_KEY: ${SLASH_API_KEY:-}
SLASH_ACCOUNT_ID: ${SLASH_ACCOUNT_ID:-}
SLASH_BASE_URL: ${SLASH_BASE_URL:-}
# Reaches the host MCP bridge; Docker Desktop provides this name, Linux needs the mapping.
extra_hosts:
- host.docker.internal:host-gateway
Expand Down
17 changes: 17 additions & 0 deletions packages/bank-slash/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@nibblet/bank-slash",
"version": "0.3.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p ."
},
"dependencies": {
"@nibblet/bank": "workspace:*",
"@nibblet/protocol": "workspace:*",
"zod": "^4.6.7"
}
}
264 changes: 264 additions & 0 deletions packages/bank-slash/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import {
BankError,
defineBankFactory,
defineBankPlugin,
maskedCard,
requireEnv,
type BankDeps,
type BankEnv,
type BankPlugin,
type CardRef,
type CardUsage,
} from "@nibblet/bank";
import type { CardSecret } from "@nibblet/protocol";
import { z } from "zod";

/**
* Virtual cards from Slash (slash.com), in US dollars, on the person's own
* Slash account. A single-use card is Slash's own `isSingleUse`, which Slash
* closes after the first authorisation; a multi-use one stays open until
* it's killed. Both carry a `collective` utilisation limit (one that never
* resets) of the card's limit, so Slash declines anything over it whatever
* the server thinks.
*
* Settings: `SLASH_API_KEY` (with the cards.view and cards.manage
* permissions), and optionally `SLASH_ACCOUNT_ID` (the account cards are
* made under, and whose balance Settings shows; Slash picks the first
* commercial account otherwise) and `SLASH_BASE_URL`.
*/

export const SLASH_API = "https://api.joinslash.com";

/** How many pages of recent cards `find` looks through. */
const FIND_PAGES = 3;

const Money = z.object({ amountCents: z.number() });

const SlashCard = z.object({
id: z.string(),
last4: z.string(),
status: z.enum(["active", "paused", "inactive", "closed"]),
expiryMonth: z.string(),
expiryYear: z.string(),
pan: z.string().optional(),
cvv: z.string().optional(),
userData: z.unknown().optional(),
});
type SlashCard = z.infer<typeof SlashCard>;

const CardList = z.object({
items: z.array(SlashCard),
metadata: z.object({ nextCursor: z.string().optional() }).optional(),
});

const Utilization = z.object({ spend: Money });

const Balances = z.object({ balances: z.array(z.object({ available: Money })) });

const ErrorBody = z.union([z.object({ message: z.string() }), z.object({ error: z.string() })]);

/** The card id the server tagged a Slash card with, if it has one. */
const Tagged = z.object({ userData: z.object({ nibbletCard: z.string() }) });

/**
* Slash said no. Its own messages are safe to show, and a refused request
* made nothing.
*/
export class SlashError extends BankError {
readonly status: number;
constructor(status: number, message: string) {
super(`Slash: ${message}`);
this.status = status;
}
}

export interface SlashOptions {
apiKey: string;
accountId?: string;
baseUrl?: string;
}

/** A whole number of cents for Slash, which takes them as a JSON number. */
function cents(amount: bigint): number {
if (amount > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new BankError("That's more than a card can hold");
}
return Number(amount);
}

export function slashPlugin(options: SlashOptions, deps: Pick<BankDeps, "fetch">): BankPlugin {
const base = (options.baseUrl ?? SLASH_API).replace(/\/+$/, "");

async function call(method: string, path: string, body?: object): Promise<unknown> {
const res = await deps.fetch(`${base}${path}`, {
method,
headers: {
"x-api-key": options.apiKey,
accept: "application/json",
...(body ? { "content-type": "application/json" } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(30_000),
});
const text = await res.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
// Not JSON; the status says enough.
}
if (res.status >= 400 && res.status < 500) {
const error = ErrorBody.safeParse(parsed);
const said = error.success
? "message" in error.data
? error.data.message
: error.data.error
: `HTTP ${res.status}`;
throw new SlashError(res.status, said);
}
// A server error leaves it unclear whether anything happened.
if (!res.ok) throw new Error(`Slash: HTTP ${res.status} for ${method} ${path}`);
return parsed;
}

const getCard = async (id: string, withNumber = false) =>
SlashCard.parse(
await call(
"GET",
`/card/${encodeURIComponent(id)}${withNumber ? "?include_pan=true&include_cvv=true" : ""}`,
),
);

function secret(card: SlashCard): CardSecret {
if (!card.pan || !card.cvv) {
throw new BankError(
"Slash didn't send the card's number. The API key needs the cards.view permission to hand it over.",
);
}
return {
type: "card",
pan: card.pan,
cvv: card.cvv,
expMonth: card.expiryMonth,
expYear: card.expiryYear,
billingAddress: null,
};
}

async function usage(card: CardRef): Promise<CardUsage> {
const id = encodeURIComponent(card.ref);
const [state, used] = await Promise.all([
getCard(card.ref),
call("GET", `/card/${id}/utilization`).then((v) => Utilization.parse(v)),
]);
const spent = BigInt(Math.max(0, Math.round(used.spend.amountCents)));
return { open: state.status !== "closed", spent, used: spent > 0n };
}

const closeAt = (id: string) =>
call("PATCH", `/card/${encodeURIComponent(id)}`, { status: "closed" });

return defineBankPlugin({
id: "slash",
label: "Slash",
asset: { code: "USD", decimals: 2 },

async balance() {
if (!options.accountId) return null;
const { balances } = Balances.parse(
await call("GET", `/account/${encodeURIComponent(options.accountId)}/balance`),
);
return balances.reduce((sum, b) => sum + BigInt(Math.round(b.available.amountCents)), 0n);
},

async issue(request) {
const made = SlashCard.parse(
await call("POST", "/card", {
type: "virtual",
name: request.label,
isSingleUse: request.kind === "single_use",
...(options.accountId ? { accountId: options.accountId } : {}),
// So `find` can tell which card is which.
userData: { nibbletCard: request.cardId, nibblet: request.nibbletId },
spendingConstraint: {
spendingRule: {
utilizationLimit: {
preset: "collective",
limitAmount: { amountCents: cents(request.limit) },
},
},
},
}),
);
try {
return {
ref: made.id,
display: maskedCard(made.last4),
secret: secret(await getCard(made.id, true)),
};
} catch (err) {
// A card nobody can use shouldn't stay open. If it can't be closed,
// the error mustn't read as "nothing was made": the server then
// finds the card and closes it later.
const closed = await closeAt(made.id).then(
() => true,
() => false,
);
if (closed) throw err;
throw new Error(`Slash card ${made.id} couldn't be used or closed: ${String(err)}`);
}
},

async find(cardId) {
let cursor: string | undefined;
for (let page = 0; page < FIND_PAGES; page++) {
const query = new URLSearchParams({ sort: "createdAt", sortDirection: "DESC" });
if (options.accountId) query.set("filter:accountId", options.accountId);
if (cursor) query.set("cursor", cursor);
const list = CardList.parse(await call("GET", `/card?${query}`));
for (const card of list.items) {
const tagged = Tagged.safeParse(card);
if (tagged.success && tagged.data.userData.nibbletCard === cardId) {
return { ref: card.id, display: maskedCard(card.last4) };
}
}
cursor = list.metadata?.nextCursor;
if (!cursor) break;
}
return null;
},

async reveal(card) {
return secret(await getCard(card.ref, true));
},

inspect: usage,

async close(card) {
const current = await getCard(card.ref);
if (current.status !== "closed") await closeAt(card.ref);
return { ...(await usage(card)), open: false, settled: true };
},
});
}

export const slash = defineBankFactory({
id: "slash",
env: ["SLASH_API_KEY", "SLASH_ACCOUNT_ID", "SLASH_BASE_URL"],
fromEnv(env: BankEnv, deps: BankDeps) {
if (!env.SLASH_API_KEY?.trim()) return null;
const baseUrl = env.SLASH_BASE_URL?.trim();
if (baseUrl && !/^https:\/\//.test(baseUrl)) {
throw new Error("SLASH_BASE_URL should be an https URL");
}
const accountId = env.SLASH_ACCOUNT_ID?.trim();
return slashPlugin(
{
apiKey: requireEnv(env, "SLASH_API_KEY"),
...(accountId ? { accountId } : {}),
...(baseUrl ? { baseUrl } : {}),
},
deps,
);
},
});
Loading
Loading