baconservices-mcp 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ledger.js CHANGED
@@ -1,165 +1,165 @@
1
- // The local spend ledger: one JSON file that survives restarts of the MCP process.
2
- //
3
- // It holds the daily ceiling (a rolling 24 hours, so restarting the process, or running several
4
- // at once, never opens a new budget) and the record of recent purchases that stops an accidental
5
- // retry from paying twice. Every read-check-write happens under a lock file, so two processes
6
- // cannot both pass the ceiling check with the same money. It records amounts, services, request
7
- // ids and transactions — never key material.
8
- //
9
- // Entry statuses: reserved (about to be signed/sent), spent (Bacon delivered and settled),
10
- // uncertain (sent, outcome unknown: treated as spent), released (Bacon refused it before taking
11
- // any money). Everything but released counts against the limits.
12
-
13
- import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
- import { randomUUID } from 'node:crypto';
15
- import { join } from 'node:path';
16
-
17
- const DAY_MS = 24 * 60 * 60 * 1000;
18
- const KEEP_MS = 30 * DAY_MS;
19
- const LOCK_STALE_MS = 30_000;
20
- const LOCK_WAIT_MS = 10_000;
21
- export const REPEAT_WINDOW_MS = 10 * 60 * 1000;
22
- const COUNTS = new Set(['reserved', 'spent', 'uncertain']);
23
-
24
- export class LedgerError extends Error {}
25
-
26
- export class Ledger {
27
- constructor(dir, { now = () => Date.now() } = {}) {
28
- this.dir = dir;
29
- this.file = join(dir, 'spend-ledger.json');
30
- this.lockFile = join(dir, 'spend-ledger.lock');
31
- this.now = now;
32
- }
33
-
34
- /** Total that counts against the daily ceiling on this network, in atomic units. */
35
- async spentLastDay(network) {
36
- return this.#locked((data) => sumSince(data.entries, network, this.now() - DAY_MS));
37
- }
38
-
39
- /** The latest purchase of the same request within the repeat window that was not refused, or null. */
40
- async recent(network, fingerprint) {
41
- return this.#locked((data) => {
42
- const now = this.now();
43
- return data.entries.filter((e) => e.network === network && e.fingerprint === fingerprint
44
- && COUNTS.has(e.status) && now - e.created_at < REPEAT_WINDOW_MS).pop() ?? null;
45
- });
46
- }
47
-
48
- /**
49
- * Reserves `amount` for one purchase, or says why not, in one locked step:
50
- * an earlier purchase of the same thing (unless allowRepeat), then the daily ceiling, then
51
- * `sessionCheck` (the caller's per-process ceiling).
52
- */
53
- async reserve({ network, capability, fingerprint, amount, perDay, allowRepeat, sessionCheck }) {
54
- return this.#locked((data) => {
55
- const now = this.now();
56
- const previous = data.entries.filter((e) => e.network === network && e.fingerprint === fingerprint
57
- && COUNTS.has(e.status) && now - e.created_at < REPEAT_WINDOW_MS).pop();
58
- if (previous && !allowRepeat) {
59
- return { duplicate: previous };
60
- }
61
- const spentToday = sumSince(data.entries, network, now - DAY_MS);
62
- if (spentToday + amount > perDay) {
63
- return { refused: 'day', spentToday };
64
- }
65
- const sessionProblem = sessionCheck(amount);
66
- if (sessionProblem) {
67
- return { refused: 'session', detail: sessionProblem };
68
- }
69
- const entry = { id: randomUUID(), network, capability, fingerprint, amount: amount.toString(), status: 'reserved', created_at: now, updated_at: now };
70
- data.entries.push(entry);
71
- return { entry, write: true };
72
- });
73
- }
74
-
75
- /** Records what happened to a reservation: spent, uncertain or released, with its details. */
76
- async update(id, status, details = {}) {
77
- return this.#locked((data) => {
78
- const entry = data.entries.find((e) => e.id === id);
79
- if (!entry) {
80
- throw new LedgerError(`Ledger entry ${id} is missing.`);
81
- }
82
- Object.assign(entry, details, { status, updated_at: this.now() });
83
- return { entry, write: true };
84
- });
85
- }
86
-
87
- #locked(fn) {
88
- mkdirSync(this.dir, { recursive: true });
89
- return this.#withLock(() => {
90
- const data = this.#read();
91
- const result = fn(data);
92
- if (result?.write) {
93
- const cutoff = this.now() - KEEP_MS;
94
- data.entries = data.entries.filter((e) => e.created_at >= cutoff);
95
- // A JSON result is kept only as long as a repeat could hand it back.
96
- for (const e of data.entries) {
97
- if (e.result !== undefined && this.now() - e.created_at >= REPEAT_WINDOW_MS) {
98
- delete e.result;
99
- }
100
- }
101
- const temp = `${this.file}.${process.pid}.tmp`;
102
- writeFileSync(temp, JSON.stringify(data, null, 1));
103
- renameSync(temp, this.file);
104
- delete result.write;
105
- }
106
- return result;
107
- });
108
- }
109
-
110
- #read() {
111
- let text;
112
- try {
113
- text = readFileSync(this.file, 'utf8');
114
- } catch (error) {
115
- if (error.code === 'ENOENT') {
116
- return { version: 1, entries: [] };
117
- }
118
- throw new LedgerError(`The spend ledger ${this.file} cannot be read (${error.code}). No payment is made while it is unreadable.`);
119
- }
120
- try {
121
- const data = JSON.parse(text);
122
- if (data?.version === 1 && Array.isArray(data.entries)) {
123
- return data;
124
- }
125
- } catch {
126
- // fall through
127
- }
128
- throw new LedgerError(`The spend ledger ${this.file} is damaged. No payment is made until it is repaired or removed (removing it resets the daily limit).`);
129
- }
130
-
131
- async #withLock(fn) {
132
- const started = Date.now();
133
- for (;;) {
134
- try {
135
- closeSync(openSync(this.lockFile, 'wx'));
136
- break;
137
- } catch (error) {
138
- if (error.code !== 'EEXIST') {
139
- throw new LedgerError(`The spend ledger lock ${this.lockFile} cannot be created (${error.code}).`);
140
- }
141
- try {
142
- if (Date.now() - statSync(this.lockFile).mtimeMs > LOCK_STALE_MS) {
143
- rmSync(this.lockFile, { force: true });
144
- continue;
145
- }
146
- } catch {
147
- continue;
148
- }
149
- if (Date.now() - started > LOCK_WAIT_MS) {
150
- throw new LedgerError(`The spend ledger is locked by another process (${this.lockFile}).`);
151
- }
152
- await new Promise((resolve) => setTimeout(resolve, 20));
153
- }
154
- }
155
- try {
156
- return fn();
157
- } finally {
158
- rmSync(this.lockFile, { force: true });
159
- }
160
- }
161
- }
162
-
163
- function sumSince(entries, network, since) {
164
- return entries.reduce((total, e) => (e.network === network && COUNTS.has(e.status) && e.created_at > since ? total + BigInt(e.amount) : total), 0n);
165
- }
1
+ // The local spend ledger: one JSON file that survives restarts of the MCP process.
2
+ //
3
+ // It holds the daily ceiling (a rolling 24 hours, so restarting the process, or running several
4
+ // at once, never opens a new budget) and the record of recent purchases that stops an accidental
5
+ // retry from paying twice. Every read-check-write happens under a lock file, so two processes
6
+ // cannot both pass the ceiling check with the same money. It records amounts, services, request
7
+ // ids and transactions — never key material.
8
+ //
9
+ // Entry statuses: reserved (about to be signed/sent), spent (Bacon delivered and settled),
10
+ // uncertain (sent, outcome unknown: treated as spent), released (Bacon refused it before taking
11
+ // any money). Everything but released counts against the limits.
12
+
13
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { join } from 'node:path';
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ const KEEP_MS = 30 * DAY_MS;
19
+ const LOCK_STALE_MS = 30_000;
20
+ const LOCK_WAIT_MS = 10_000;
21
+ export const REPEAT_WINDOW_MS = 10 * 60 * 1000;
22
+ const COUNTS = new Set(['reserved', 'spent', 'uncertain']);
23
+
24
+ export class LedgerError extends Error {}
25
+
26
+ export class Ledger {
27
+ constructor(dir, { now = () => Date.now() } = {}) {
28
+ this.dir = dir;
29
+ this.file = join(dir, 'spend-ledger.json');
30
+ this.lockFile = join(dir, 'spend-ledger.lock');
31
+ this.now = now;
32
+ }
33
+
34
+ /** Total that counts against the daily ceiling on this network, in atomic units. */
35
+ async spentLastDay(network) {
36
+ return this.#locked((data) => sumSince(data.entries, network, this.now() - DAY_MS));
37
+ }
38
+
39
+ /** The latest purchase of the same request within the repeat window that was not refused, or null. */
40
+ async recent(network, fingerprint) {
41
+ return this.#locked((data) => {
42
+ const now = this.now();
43
+ return data.entries.filter((e) => e.network === network && e.fingerprint === fingerprint
44
+ && COUNTS.has(e.status) && now - e.created_at < REPEAT_WINDOW_MS).pop() ?? null;
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Reserves `amount` for one purchase, or says why not, in one locked step:
50
+ * an earlier purchase of the same thing (unless allowRepeat), then the daily ceiling, then
51
+ * `sessionCheck` (the caller's per-process ceiling).
52
+ */
53
+ async reserve({ network, capability, fingerprint, amount, perDay, allowRepeat, sessionCheck }) {
54
+ return this.#locked((data) => {
55
+ const now = this.now();
56
+ const previous = data.entries.filter((e) => e.network === network && e.fingerprint === fingerprint
57
+ && COUNTS.has(e.status) && now - e.created_at < REPEAT_WINDOW_MS).pop();
58
+ if (previous && !allowRepeat) {
59
+ return { duplicate: previous };
60
+ }
61
+ const spentToday = sumSince(data.entries, network, now - DAY_MS);
62
+ if (spentToday + amount > perDay) {
63
+ return { refused: 'day', spentToday };
64
+ }
65
+ const sessionProblem = sessionCheck(amount);
66
+ if (sessionProblem) {
67
+ return { refused: 'session', detail: sessionProblem };
68
+ }
69
+ const entry = { id: randomUUID(), network, capability, fingerprint, amount: amount.toString(), status: 'reserved', created_at: now, updated_at: now };
70
+ data.entries.push(entry);
71
+ return { entry, write: true };
72
+ });
73
+ }
74
+
75
+ /** Records what happened to a reservation: spent, uncertain or released, with its details. */
76
+ async update(id, status, details = {}) {
77
+ return this.#locked((data) => {
78
+ const entry = data.entries.find((e) => e.id === id);
79
+ if (!entry) {
80
+ throw new LedgerError(`Ledger entry ${id} is missing.`);
81
+ }
82
+ Object.assign(entry, details, { status, updated_at: this.now() });
83
+ return { entry, write: true };
84
+ });
85
+ }
86
+
87
+ #locked(fn) {
88
+ mkdirSync(this.dir, { recursive: true });
89
+ return this.#withLock(() => {
90
+ const data = this.#read();
91
+ const result = fn(data);
92
+ if (result?.write) {
93
+ const cutoff = this.now() - KEEP_MS;
94
+ data.entries = data.entries.filter((e) => e.created_at >= cutoff);
95
+ // A JSON result is kept only as long as a repeat could hand it back.
96
+ for (const e of data.entries) {
97
+ if (e.result !== undefined && this.now() - e.created_at >= REPEAT_WINDOW_MS) {
98
+ delete e.result;
99
+ }
100
+ }
101
+ const temp = `${this.file}.${process.pid}.tmp`;
102
+ writeFileSync(temp, JSON.stringify(data, null, 1));
103
+ renameSync(temp, this.file);
104
+ delete result.write;
105
+ }
106
+ return result;
107
+ });
108
+ }
109
+
110
+ #read() {
111
+ let text;
112
+ try {
113
+ text = readFileSync(this.file, 'utf8');
114
+ } catch (error) {
115
+ if (error.code === 'ENOENT') {
116
+ return { version: 1, entries: [] };
117
+ }
118
+ throw new LedgerError(`The spend ledger ${this.file} cannot be read (${error.code}). No payment is made while it is unreadable.`);
119
+ }
120
+ try {
121
+ const data = JSON.parse(text);
122
+ if (data?.version === 1 && Array.isArray(data.entries)) {
123
+ return data;
124
+ }
125
+ } catch {
126
+ // fall through
127
+ }
128
+ throw new LedgerError(`The spend ledger ${this.file} is damaged. No payment is made until it is repaired or removed (removing it resets the daily limit).`);
129
+ }
130
+
131
+ async #withLock(fn) {
132
+ const started = Date.now();
133
+ for (;;) {
134
+ try {
135
+ closeSync(openSync(this.lockFile, 'wx'));
136
+ break;
137
+ } catch (error) {
138
+ if (error.code !== 'EEXIST') {
139
+ throw new LedgerError(`The spend ledger lock ${this.lockFile} cannot be created (${error.code}).`);
140
+ }
141
+ try {
142
+ if (Date.now() - statSync(this.lockFile).mtimeMs > LOCK_STALE_MS) {
143
+ rmSync(this.lockFile, { force: true });
144
+ continue;
145
+ }
146
+ } catch {
147
+ continue;
148
+ }
149
+ if (Date.now() - started > LOCK_WAIT_MS) {
150
+ throw new LedgerError(`The spend ledger is locked by another process (${this.lockFile}).`);
151
+ }
152
+ await new Promise((resolve) => setTimeout(resolve, 20));
153
+ }
154
+ }
155
+ try {
156
+ return fn();
157
+ } finally {
158
+ rmSync(this.lockFile, { force: true });
159
+ }
160
+ }
161
+ }
162
+
163
+ function sumSince(entries, network, since) {
164
+ return entries.reduce((total, e) => (e.network === network && COUNTS.has(e.status) && e.created_at > since ? total + BigInt(e.amount) : total), 0n);
165
+ }
package/src/money.js CHANGED
@@ -1,26 +1,26 @@
1
- // USDC amounts as integer atomic units (6 decimals) in BigInt: never floating point.
2
-
3
- export const USDC_DECIMALS = 6;
4
-
5
- /** "0.5" → 500000n. Refuses anything that is not a plain non-negative decimal with at most 6 places. */
6
- export function parseUsdc(text, what = 'amount') {
7
- const value = String(text ?? '').trim();
8
- const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(value);
9
- if (!match) {
10
- throw new Error(`${what} must be a USDC amount such as 0.50 (got "${value}").`);
11
- }
12
- return BigInt(match[1]) * 10n ** 6n + BigInt((match[2] ?? '').padEnd(6, '0'));
13
- }
14
-
15
- /** 200000n → "0.2". */
16
- export function formatUsdc(atomic) {
17
- const amount = BigInt(atomic);
18
- const whole = amount / 10n ** 6n;
19
- const fraction = (amount % 10n ** 6n).toString().padStart(6, '0').replace(/0+$/, '');
20
- return fraction === '' ? whole.toString() : `${whole}.${fraction}`;
21
- }
22
-
23
- /** A string of decimal digits from the wire → BigInt, or null when it is anything else. */
24
- export function atomicFromWire(value) {
25
- return typeof value === 'string' && /^\d{1,30}$/.test(value) ? BigInt(value) : null;
26
- }
1
+ // USDC amounts as integer atomic units (6 decimals) in BigInt: never floating point.
2
+
3
+ export const USDC_DECIMALS = 6;
4
+
5
+ /** "0.5" → 500000n. Refuses anything that is not a plain non-negative decimal with at most 6 places. */
6
+ export function parseUsdc(text, what = 'amount') {
7
+ const value = String(text ?? '').trim();
8
+ const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(value);
9
+ if (!match) {
10
+ throw new Error(`${what} must be a USDC amount such as 0.50 (got "${value}").`);
11
+ }
12
+ return BigInt(match[1]) * 10n ** 6n + BigInt((match[2] ?? '').padEnd(6, '0'));
13
+ }
14
+
15
+ /** 200000n → "0.2". */
16
+ export function formatUsdc(atomic) {
17
+ const amount = BigInt(atomic);
18
+ const whole = amount / 10n ** 6n;
19
+ const fraction = (amount % 10n ** 6n).toString().padStart(6, '0').replace(/0+$/, '');
20
+ return fraction === '' ? whole.toString() : `${whole}.${fraction}`;
21
+ }
22
+
23
+ /** A string of decimal digits from the wire → BigInt, or null when it is anything else. */
24
+ export function atomicFromWire(value) {
25
+ return typeof value === 'string' && /^\d{1,30}$/.test(value) ? BigInt(value) : null;
26
+ }
package/src/networks.js CHANGED
@@ -1,39 +1,39 @@
1
- // The two payment networks this package will ever sign on, and what Bacon looks like on each.
2
- //
3
- // Chain facts are pinned here, not read from the server: a 402 that names a different chain,
4
- // asset or EIP-712 domain is refused before anything is signed. USDC addresses: Circle's
5
- // published contracts (the same ones Bacon's App\Support\AgentCommerce\Networks pins).
6
-
7
- export const PRODUCTION_API_URL = 'https://baconservices.com';
8
-
9
- export const NETWORKS = {
10
- base: {
11
- key: 'base',
12
- id: 'eip155:8453',
13
- chainId: 8453,
14
- label: 'Base',
15
- testnet: false,
16
- usdc: { address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', name: 'USD Coin', version: '2' },
17
- rpcUrl: 'https://mainnet.base.org',
18
- explorer: 'https://basescan.org/tx/',
19
- // Bacon's receiving wallet on Base (production 402, checked 2026-10-17). A 402 asking to pay
20
- // anyone else is refused; a new address means a new release of this package.
21
- payTo: '0xdec990f28082da7efc9c50df26adba40b66cc4d6',
22
- },
23
- 'base-sepolia': {
24
- key: 'base-sepolia',
25
- id: 'eip155:84532',
26
- chainId: 84532,
27
- label: 'Base Sepolia (testnet)',
28
- testnet: true,
29
- usdc: { address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', name: 'USDC', version: '2' },
30
- rpcUrl: 'https://sepolia.base.org',
31
- explorer: 'https://sepolia.basescan.org/tx/',
32
- // A test copy of Bacon has its own receiving wallet: set BACON_PAY_TO.
33
- payTo: null,
34
- },
35
- };
36
-
37
- export function sameAddress(a, b) {
38
- return typeof a === 'string' && typeof b === 'string' && /^0x[0-9a-fA-F]{40}$/.test(a) && a.toLowerCase() === b.toLowerCase();
39
- }
1
+ // The two payment networks this package will ever sign on, and what Bacon looks like on each.
2
+ //
3
+ // Chain facts are pinned here, not read from the server: a 402 that names a different chain,
4
+ // asset or EIP-712 domain is refused before anything is signed. USDC addresses: Circle's
5
+ // published contracts (the same ones Bacon's App\Support\AgentCommerce\Networks pins).
6
+
7
+ export const PRODUCTION_API_URL = 'https://baconservices.com';
8
+
9
+ export const NETWORKS = {
10
+ base: {
11
+ key: 'base',
12
+ id: 'eip155:8453',
13
+ chainId: 8453,
14
+ label: 'Base',
15
+ testnet: false,
16
+ usdc: { address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', name: 'USD Coin', version: '2' },
17
+ rpcUrl: 'https://mainnet.base.org',
18
+ explorer: 'https://basescan.org/tx/',
19
+ // Bacon's receiving wallet on Base (production 402, checked 2026-10-17). A 402 asking to pay
20
+ // anyone else is refused; a new address means a new release of this package.
21
+ payTo: '0xdec990f28082da7efc9c50df26adba40b66cc4d6',
22
+ },
23
+ 'base-sepolia': {
24
+ key: 'base-sepolia',
25
+ id: 'eip155:84532',
26
+ chainId: 84532,
27
+ label: 'Base Sepolia (testnet)',
28
+ testnet: true,
29
+ usdc: { address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', name: 'USDC', version: '2' },
30
+ rpcUrl: 'https://sepolia.base.org',
31
+ explorer: 'https://sepolia.basescan.org/tx/',
32
+ // A test copy of Bacon has its own receiving wallet: set BACON_PAY_TO.
33
+ payTo: null,
34
+ },
35
+ };
36
+
37
+ export function sameAddress(a, b) {
38
+ return typeof a === 'string' && typeof b === 'string' && /^0x[0-9a-fA-F]{40}$/.test(a) && a.toLowerCase() === b.toLowerCase();
39
+ }