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/LICENSE +21 -21
- package/README.md +5 -4
- package/bin/baconservices-mcp.js +8 -8
- package/package.json +4 -2
- package/src/catalog.js +71 -71
- package/src/config.js +103 -103
- package/src/contracts.js +212 -194
- package/src/ledger.js +165 -165
- package/src/money.js +26 -26
- package/src/networks.js +39 -39
- package/src/payment.js +625 -621
- package/src/server.js +1 -1
- package/src/wallet.js +60 -60
package/src/payment.js
CHANGED
|
@@ -1,621 +1,625 @@
|
|
|
1
|
-
// One purchase, start to finish: the payment state machine.
|
|
2
|
-
//
|
|
3
|
-
// input checked → quoted (402 from Bacon) → requirement verified → limits checked
|
|
4
|
-
// → funds checked → confirmed → RESERVED in the ledger → signed → sent
|
|
5
|
-
// → SPENT (delivered) | RELEASED (Bacon refused before taking money) | UNCERTAIN (unknown: counts)
|
|
6
|
-
//
|
|
7
|
-
// Nothing is signed until every check before it has passed; any check that cannot be completed
|
|
8
|
-
// refuses (fails closed). One purchase signs exactly one payment, with a fresh EIP-3009 nonce, for
|
|
9
|
-
// exactly the quoted request body. A lost connection is retried once with the SAME signed payment
|
|
10
|
-
// and the SAME body, which Bacon answers idempotently; a new signature is never made on retry.
|
|
11
|
-
|
|
12
|
-
import { createHash } from 'node:crypto';
|
|
13
|
-
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
-
import { dirname, resolve } from 'node:path';
|
|
15
|
-
import { x402Client, x402HTTPClient } from '@x402/core/client';
|
|
16
|
-
import { ExactEvmScheme } from '@x402/evm/exact/client';
|
|
17
|
-
import Ajv2020 from 'ajv/dist/2020.js';
|
|
18
|
-
import addFormats from 'ajv-formats';
|
|
19
|
-
import { endpointFor } from './catalog.js';
|
|
20
|
-
import { contractId } from './contracts.js';
|
|
21
|
-
import { atomicFromWire, formatUsdc, parseUsdc } from './money.js';
|
|
22
|
-
import { sameAddress } from './networks.js';
|
|
23
|
-
|
|
24
|
-
/** A purchase that stopped. `charged`: false (nothing taken), 'unknown', or true. */
|
|
25
|
-
export class Refusal extends Error {
|
|
26
|
-
constructor(message, { charged = false, details = {} } = {}) {
|
|
27
|
-
super(message);
|
|
28
|
-
this.charged = charged;
|
|
29
|
-
this.details = details;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Bacon's answers that mean the payment was not used (its PaidExecution refuses or fails before settling).
|
|
34
|
-
const NOT_CHARGED_CODES = new Set(['execution_failed', 'not_accepted', 'busy', 'invalid_input', 'capability_unavailable', 'unsupported_capability']);
|
|
35
|
-
|
|
36
|
-
const QUOTE_TIMEOUT_MS = 60_000;
|
|
37
|
-
// A paid call waits for Bacon's own declared budget (limits.max_seconds) plus a margin, or this.
|
|
38
|
-
const PAY_TIMEOUT_MS = 120_000;
|
|
39
|
-
const PAY_MARGIN_MS = 30_000;
|
|
40
|
-
// Whatever a capability declares, a JSON result larger than this is never handed to the model.
|
|
41
|
-
export const HARD_MAX_RESULT_BYTES = 256 * 1024;
|
|
42
|
-
|
|
43
|
-
const ajv = new Ajv2020({ strict: false, allErrors: false });
|
|
44
|
-
addFormats(ajv);
|
|
45
|
-
const validators = new WeakMap();
|
|
46
|
-
|
|
47
|
-
/** How long to wait for a paid call to this catalog entry. */
|
|
48
|
-
export function payTimeoutMs(entry) {
|
|
49
|
-
const seconds = entry?.limits?.max_seconds;
|
|
50
|
-
return Number.isInteger(seconds) && seconds > 0 ? seconds * 1000 + PAY_MARGIN_MS : PAY_TIMEOUT_MS;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export class Purchaser {
|
|
54
|
-
constructor({ config, account, fetch, readBalance, ledger, confirm }) {
|
|
55
|
-
this.config = config;
|
|
56
|
-
this.account = account;
|
|
57
|
-
this.fetch = fetch;
|
|
58
|
-
this.readBalance = readBalance;
|
|
59
|
-
this.ledger = ledger;
|
|
60
|
-
this.confirm = confirm;
|
|
61
|
-
// This process's total (reserved, spent or uncertain): the per-session ceiling.
|
|
62
|
-
this.sessionCommitted = 0n;
|
|
63
|
-
if (account) {
|
|
64
|
-
const client = new x402Client()
|
|
65
|
-
.register(config.network.id, new ExactEvmScheme(account))
|
|
66
|
-
// The library's own lock, at the same per-call ceiling: a second check of the signed amount.
|
|
67
|
-
.setSpendControls({ maxAmountPerPayment: `$${formatUsdc(config.limits.perCall)}` });
|
|
68
|
-
this.http = new x402HTTPClient(client);
|
|
69
|
-
} else {
|
|
70
|
-
this.http = new x402HTTPClient(new x402Client());
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Asks Bacon for the authoritative price (an empty request: nothing is recorded or charged) and verifies it. */
|
|
75
|
-
async quote(service) {
|
|
76
|
-
const endpoint = endpointFor(this.config.apiUrl, service.id);
|
|
77
|
-
const response = await this.#post(endpoint, '{}', {}, QUOTE_TIMEOUT_MS);
|
|
78
|
-
const required = await this.#requirementFrom(response, endpoint);
|
|
79
|
-
return this.verifyRequirement(required, service, endpoint);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The 402's requirement, checked against everything this package pins: x402 v2, this exact
|
|
84
|
-
* endpoint, scheme "exact" on the configured network, that network's USDC and EIP-712 domain,
|
|
85
|
-
* Bacon's receiving address, and the catalog's price. Returns the requirement narrowed to the
|
|
86
|
-
* one accepted option, so the library cannot pick another.
|
|
87
|
-
*/
|
|
88
|
-
verifyRequirement(required, service, endpoint) {
|
|
89
|
-
const { network, payTo } = this.config;
|
|
90
|
-
const refuse = (why) => {
|
|
91
|
-
throw new Refusal(`Not paying: ${why}. Nothing was signed.`);
|
|
92
|
-
};
|
|
93
|
-
if (required?.x402Version !== 2) {
|
|
94
|
-
refuse('Bacon did not answer with an x402 v2 payment requirement');
|
|
95
|
-
}
|
|
96
|
-
if (required.resource?.url !== endpoint) {
|
|
97
|
-
refuse(`the payment requirement is for ${required.resource?.url ?? 'an unnamed resource'}, not ${endpoint}`);
|
|
98
|
-
}
|
|
99
|
-
const accepted = (required.accepts ?? []).find((a) => a.scheme === 'exact' && a.network === network.id);
|
|
100
|
-
if (!accepted) {
|
|
101
|
-
refuse(`Bacon did not offer an "exact" payment on ${network.label} (${network.id})`);
|
|
102
|
-
}
|
|
103
|
-
if (!sameAddress(accepted.asset, network.usdc.address) || accepted.extra?.name !== network.usdc.name || accepted.extra?.version !== network.usdc.version) {
|
|
104
|
-
refuse(`the asset is not ${network.label} USDC`);
|
|
105
|
-
}
|
|
106
|
-
if (!sameAddress(accepted.payTo, payTo)) {
|
|
107
|
-
refuse(`the payment would go to ${accepted.payTo}, which is not Bacon's receiving address ${payTo}`);
|
|
108
|
-
}
|
|
109
|
-
const amount = atomicFromWire(accepted.amount);
|
|
110
|
-
if (amount === null || amount <= 0n) {
|
|
111
|
-
refuse('the price is not a positive amount');
|
|
112
|
-
}
|
|
113
|
-
if (amount !== BigInt(service.entry.price.amount)) {
|
|
114
|
-
refuse(`Bacon asks ${formatUsdc(amount)} USDC but its catalog lists ${formatUsdc(service.entry.price.amount)} USDC for ${service.tool}`);
|
|
115
|
-
}
|
|
116
|
-
if (!Number.isInteger(accepted.maxTimeoutSeconds) || accepted.maxTimeoutSeconds < 1 || accepted.maxTimeoutSeconds > 3600) {
|
|
117
|
-
refuse('the payment window is not between 1 second and 1 hour');
|
|
118
|
-
}
|
|
119
|
-
return { required: { ...required, accepts: [accepted] }, accepted, amount };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** The limits a price must fit, as a list of reasons it does not (empty when it fits). */
|
|
123
|
-
async limitProblems(amount, maxPrice = null) {
|
|
124
|
-
const { limits, network } = this.config;
|
|
125
|
-
const problems = [];
|
|
126
|
-
if (amount > limits.perCall) {
|
|
127
|
-
problems.push(`${formatUsdc(amount)} USDC is above your per-call limit of ${formatUsdc(limits.perCall)} USDC (BACON_MAX_PER_CALL)`);
|
|
128
|
-
}
|
|
129
|
-
if (maxPrice !== null && amount > maxPrice) {
|
|
130
|
-
problems.push(`${formatUsdc(amount)} USDC is above the max_price_usdc of ${formatUsdc(maxPrice)} given for this call`);
|
|
131
|
-
}
|
|
132
|
-
if (this.sessionCommitted + amount > limits.perSession) {
|
|
133
|
-
problems.push(`this session has used ${formatUsdc(this.sessionCommitted)} of its ${formatUsdc(limits.perSession)} USDC limit (BACON_MAX_PER_SESSION)`);
|
|
134
|
-
}
|
|
135
|
-
const day = await this.ledger.spentLastDay(network.id);
|
|
136
|
-
if (day + amount > limits.perDay) {
|
|
137
|
-
problems.push(`the last 24 hours have used ${formatUsdc(day)} of your ${formatUsdc(limits.perDay)} USDC daily limit (BACON_MAX_PER_DAY)`);
|
|
138
|
-
}
|
|
139
|
-
return problems;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Whether the wallet holds at least `amount`. Throws a Refusal when the balance cannot be read. */
|
|
143
|
-
async hasFunds(amount) {
|
|
144
|
-
const balance = await this.balance();
|
|
145
|
-
return balance >= amount;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async balance() {
|
|
149
|
-
try {
|
|
150
|
-
return BigInt(await this.readBalance(this.config.network.usdc.address, this.account.address));
|
|
151
|
-
} catch {
|
|
152
|
-
throw new Refusal(`Not paying: the wallet's USDC balance on ${this.config.network.label} could not be checked (RPC ${this.config.rpcUrl} did not answer; set BACON_RPC_URL to another). Nothing was signed.`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Buys one run of `service` with the tool arguments `args`. */
|
|
157
|
-
async buy(service, args) {
|
|
158
|
-
const { network, apiUrl } = this.config;
|
|
159
|
-
const contract = service.contractDef;
|
|
160
|
-
const endpoint = endpointFor(apiUrl, service.id);
|
|
161
|
-
|
|
162
|
-
// 1. Input, options, destination and the caller's price ceiling: all before any request.
|
|
163
|
-
const image = contract.input === 'url' ? prepareUrl(args, service.entry.input) : prepareImage(args, service.entry.limits);
|
|
164
|
-
const options = prepareOptions(args, contract);
|
|
165
|
-
const saveTo = prepareSaveTo(args);
|
|
166
|
-
let maxPrice = null;
|
|
167
|
-
if (args.max_price_usdc !== undefined && args.max_price_usdc !== null) {
|
|
168
|
-
try {
|
|
169
|
-
maxPrice = parseUsdc(String(args.max_price_usdc), 'max_price_usdc');
|
|
170
|
-
} catch (error) {
|
|
171
|
-
throw new Refusal(error.message);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
const body = JSON.stringify({ ...image.body, ...options });
|
|
175
|
-
const maxRequest = service.entry.limits.max_request_bytes;
|
|
176
|
-
if (Number.isInteger(maxRequest) && Buffer.byteLength(body) > maxRequest) {
|
|
177
|
-
throw new Refusal(`The request would be ${Buffer.byteLength(body)} bytes; Bacon takes at most ${maxRequest} for ${service.tool}. Nothing was sent.`);
|
|
178
|
-
}
|
|
179
|
-
const fingerprint = sha256(JSON.stringify({ contract: contractId(service.id, contract.version), image: image.identity, options }));
|
|
180
|
-
|
|
181
|
-
// 2. Bought this exact thing a moment ago? Hand that back instead of paying again.
|
|
182
|
-
if (!args.allow_repeat) {
|
|
183
|
-
const earlier = await this.ledger.recent(network.id, fingerprint);
|
|
184
|
-
if (earlier) {
|
|
185
|
-
return this.#earlier(earlier, saveTo);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 3. Bacon's authoritative price for this exact request, verified.
|
|
190
|
-
const quoteResponse = await this.#post(endpoint, body, {}, QUOTE_TIMEOUT_MS);
|
|
191
|
-
const { required, amount } = this.verifyRequirement(await this.#requirementFrom(quoteResponse, endpoint), service, endpoint);
|
|
192
|
-
|
|
193
|
-
// 4. Limits, then funds.
|
|
194
|
-
const problems = await this.limitProblems(amount, maxPrice);
|
|
195
|
-
if (problems.length) {
|
|
196
|
-
throw new Refusal(`Not paying: ${problems.join('; ')}. Nothing was signed.`);
|
|
197
|
-
}
|
|
198
|
-
if (!(await this.hasFunds(amount))) {
|
|
199
|
-
throw new Refusal(`Not paying: the wallet does not hold ${formatUsdc(amount)} USDC on ${network.label}. Add USDC on ${network.label} to the wallet set in BACON_WALLET_PRIVATE_KEY / BACON_WALLET_KEY_FILE and try again. Nothing was signed.`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// 5. The buyer's confirmation, where the host can ask.
|
|
203
|
-
const what = `Pay ${formatUsdc(amount)} USDC${network.testnet ? ' (test money)' : ''} on ${network.label} to Bacon Services to run ${service.tool}?`;
|
|
204
|
-
if (!(await this.confirm(what))) {
|
|
205
|
-
throw new Refusal('Payment declined. Nothing was signed.');
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// 6. Reserve: duplicate, daily and session ceilings once more, atomically with the other processes.
|
|
209
|
-
const reservation = await this.ledger.reserve({
|
|
210
|
-
network: network.id,
|
|
211
|
-
capability: service.id,
|
|
212
|
-
fingerprint,
|
|
213
|
-
amount,
|
|
214
|
-
perDay: this.config.limits.perDay,
|
|
215
|
-
allowRepeat: Boolean(args.allow_repeat),
|
|
216
|
-
sessionCheck: (a) => (this.sessionCommitted + a > this.config.limits.perSession ? 'the per-session limit' : null),
|
|
217
|
-
});
|
|
218
|
-
if (reservation.duplicate) {
|
|
219
|
-
return this.#earlier(reservation.duplicate, saveTo);
|
|
220
|
-
}
|
|
221
|
-
if (reservation.refused) {
|
|
222
|
-
throw new Refusal(`Not paying: ${reservation.refused === 'day' ? 'the daily limit (BACON_MAX_PER_DAY) would be exceeded' : 'the per-session limit (BACON_MAX_PER_SESSION) would be exceeded'}. Nothing was signed.`);
|
|
223
|
-
}
|
|
224
|
-
const entry = reservation.entry;
|
|
225
|
-
this.sessionCommitted += amount;
|
|
226
|
-
|
|
227
|
-
// 7. Sign exactly this requirement, and check what was signed.
|
|
228
|
-
let header;
|
|
229
|
-
try {
|
|
230
|
-
const payload = await this.http.createPaymentPayload(required);
|
|
231
|
-
const auth = payload.payload?.authorization;
|
|
232
|
-
if (payload.accepted?.amount !== required.accepts[0].amount || !auth || auth.value !== required.accepts[0].amount || !sameAddress(auth.to, this.config.payTo)) {
|
|
233
|
-
throw new Error('the signed payment does not match the quote');
|
|
234
|
-
}
|
|
235
|
-
header = this.http.encodePaymentSignatureHeader(payload);
|
|
236
|
-
} catch (error) {
|
|
237
|
-
await this.#release(entry, amount);
|
|
238
|
-
throw new Refusal(`Not paying: the payment could not be signed (${error.message}). Nothing was sent.`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// 8. Send it, with the same body. One retry with the same payment if the connection drops.
|
|
242
|
-
let response;
|
|
243
|
-
try {
|
|
244
|
-
response = await this.#post(endpoint, body, header, payTimeoutMs(service.entry), { retryOnce: true });
|
|
245
|
-
} catch {
|
|
246
|
-
await this.ledger.update(entry.id, 'uncertain');
|
|
247
|
-
throw new Refusal(`The signed payment of ${formatUsdc(amount)} USDC was sent but no answer came back, so it may have been charged. It counts against your limits. Nothing will be re-sent automatically.`, { charged: 'unknown' });
|
|
248
|
-
}
|
|
249
|
-
return this.#settle(response, { entry, amount, service, contract, saveTo });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async #settle(response, { entry, amount, service, contract, saveTo }) {
|
|
253
|
-
const { network, apiUrl } = this.config;
|
|
254
|
-
const text = await response.text().catch(() => '');
|
|
255
|
-
let data = null;
|
|
256
|
-
try {
|
|
257
|
-
data = JSON.parse(text);
|
|
258
|
-
} catch {
|
|
259
|
-
// not JSON: classified below
|
|
260
|
-
}
|
|
261
|
-
const receipt = this.#receipt(response);
|
|
262
|
-
|
|
263
|
-
if (contract.output.kind === 'json' && response.status === 200 && data?.ok === true && data.result !== undefined) {
|
|
264
|
-
return this.#deliverJson(data, { entry, amount, service, saveTo, receipt });
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (contract.output.kind === 'artifact' && response.status === 200 && data?.ok === true && typeof data.artifact?.url === 'string') {
|
|
268
|
-
const transaction = data.payment?.transaction ?? receipt?.transaction ?? null;
|
|
269
|
-
const artifact = {
|
|
270
|
-
url: data.artifact.url,
|
|
271
|
-
mime: data.artifact.mime ?? null,
|
|
272
|
-
width: data.artifact.width ?? null,
|
|
273
|
-
height: data.artifact.height ?? null,
|
|
274
|
-
bytes: data.artifact.bytes ?? null,
|
|
275
|
-
expires_at: data.artifact.expires_at ?? null,
|
|
276
|
-
};
|
|
277
|
-
await this.ledger.update(entry.id, 'spent', { request_id: data.request_id ?? null, transaction, artifact });
|
|
278
|
-
const result = {
|
|
279
|
-
ok: true,
|
|
280
|
-
service: service.tool,
|
|
281
|
-
paid_usdc: formatUsdc(amount),
|
|
282
|
-
network: network.id,
|
|
283
|
-
testnet: network.testnet,
|
|
284
|
-
transaction,
|
|
285
|
-
transaction_url: transaction ? network.explorer + transaction : null,
|
|
286
|
-
request_id: data.request_id ?? null,
|
|
287
|
-
artifact,
|
|
288
|
-
};
|
|
289
|
-
const warnings = [];
|
|
290
|
-
if (artifact.mime !== contract.output.mime) {
|
|
291
|
-
warnings.push(`Bacon returned ${artifact.mime}, expected ${contract.output.mime}.`);
|
|
292
|
-
}
|
|
293
|
-
if (saveTo) {
|
|
294
|
-
const saved = await this.#download(artifact.url, saveTo.path, saveTo.overwrite);
|
|
295
|
-
if (saved.ok) {
|
|
296
|
-
result.saved_to = saveTo.path;
|
|
297
|
-
} else {
|
|
298
|
-
warnings.push(`Paid and delivered, but the file was not saved (${saved.reason}); download it from artifact.url before it expires.`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
if (new URL(artifact.url).origin !== new URL(apiUrl).origin) {
|
|
302
|
-
warnings.push('The artifact URL is not on Bacon\'s own address.');
|
|
303
|
-
}
|
|
304
|
-
if (warnings.length) {
|
|
305
|
-
result.warnings = warnings;
|
|
306
|
-
}
|
|
307
|
-
return result;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (response.status === 202 && data?.ok === true) {
|
|
311
|
-
await this.ledger.update(entry.id, 'spent', { request_id: data.request_id ?? null, transaction: data.payment?.transaction ?? null });
|
|
312
|
-
return { ok: true, service: service.tool, paid_usdc: formatUsdc(amount), status: 'queued', result_url: data.result_url ?? null, request_id: data.request_id ?? null,
|
|
313
|
-
warnings: ['Bacon accepted this as a background job; poll result_url for the file.'] };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const reason = this.#reason(response, data);
|
|
317
|
-
const requestId = data?.request_id ?? null;
|
|
318
|
-
const notCharged = (response.status === 402 && !response.headers.get('payment-response'))
|
|
319
|
-
|| (response.status !== 402 && response.status !== 409 && NOT_CHARGED_CODES.has(data?.code));
|
|
320
|
-
if (notCharged) {
|
|
321
|
-
await this.#release(entry, amount);
|
|
322
|
-
throw new Refusal(`Bacon did not take the payment: ${reason}. You have not been charged.`, { details: { request_id: requestId } });
|
|
323
|
-
}
|
|
324
|
-
await this.ledger.update(entry.id, 'uncertain', { request_id: requestId });
|
|
325
|
-
throw new Refusal(`The payment of ${formatUsdc(amount)} USDC may have been charged, but no result came back (${reason}${requestId ? `; Bacon request id ${requestId}` : ''}). It counts against your limits; contact Bacon with the request id if you were charged.`, { charged: 'unknown', details: { request_id: requestId } });
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* A JSON result (output.kind "json"): paid and delivered inline. Handed to the model only within
|
|
330
|
-
* the capability's declared max_response_bytes (and this package's hard cap); checked against
|
|
331
|
-
* the catalog's output schema, a mismatch reported as a warning (the money is already spent).
|
|
332
|
-
*/
|
|
333
|
-
async #deliverJson(data, { entry, amount, service, saveTo, receipt }) {
|
|
334
|
-
const { network } = this.config;
|
|
335
|
-
const transaction = data.payment?.transaction ?? receipt?.transaction ?? null;
|
|
336
|
-
const resultText = JSON.stringify(data.result);
|
|
337
|
-
const size = Buffer.byteLength(resultText);
|
|
338
|
-
const cap = Math.min(service.entry.limits.max_response_bytes, HARD_MAX_RESULT_BYTES);
|
|
339
|
-
const fits = size <= cap;
|
|
340
|
-
const warnings = [];
|
|
341
|
-
if (!fits) {
|
|
342
|
-
warnings.push(`The result is ${size} bytes, above the ${cap} bytes this service declares, so it is not shown${saveTo ? '' : '; pass save_to to keep it'}.`);
|
|
343
|
-
}
|
|
344
|
-
const valid = schemaCheck(service.entry.output.schema, data);
|
|
345
|
-
if (valid !== true) {
|
|
346
|
-
warnings.push(`The result does not match the service's declared output schema (${valid}).`);
|
|
347
|
-
}
|
|
348
|
-
await this.ledger.update(entry.id, 'spent', { request_id: data.request_id ?? null, transaction, ...(fits ? { result: data.result } : {}) });
|
|
349
|
-
|
|
350
|
-
const result = {
|
|
351
|
-
ok: true,
|
|
352
|
-
service: service.tool,
|
|
353
|
-
paid_usdc: formatUsdc(amount),
|
|
354
|
-
network: network.id,
|
|
355
|
-
testnet: network.testnet,
|
|
356
|
-
transaction,
|
|
357
|
-
transaction_url: transaction ? network.explorer + transaction : null,
|
|
358
|
-
request_id: data.request_id ?? null,
|
|
359
|
-
result: fits ? data.result : null,
|
|
360
|
-
};
|
|
361
|
-
if (saveTo) {
|
|
362
|
-
try {
|
|
363
|
-
writeFileSync(saveTo.path, `${JSON.stringify(data.result, null, 2)}\n`, { flag: saveTo.overwrite ? 'w' : 'wx' });
|
|
364
|
-
result.saved_to = saveTo.path;
|
|
365
|
-
} catch (error) {
|
|
366
|
-
warnings.push(`Paid and delivered, but the file was not saved (${error.code === 'EEXIST' ? `${saveTo.path} already exists` : error.message}).`);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (warnings.length) {
|
|
370
|
-
result.warnings = warnings;
|
|
371
|
-
}
|
|
372
|
-
return result;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
#reason(response, data) {
|
|
376
|
-
let reason = data?.error;
|
|
377
|
-
if (response.status === 402) {
|
|
378
|
-
try {
|
|
379
|
-
reason = this.http.getPaymentRequiredResponse((name) => response.headers.get(name), data)?.error ?? reason;
|
|
380
|
-
} catch {
|
|
381
|
-
// keep the body's reason
|
|
382
|
-
}
|
|
383
|
-
if (/insufficient/i.test(String(reason))) {
|
|
384
|
-
return 'the wallet does not have enough USDC (insufficient_funds)';
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return `${response.status}${reason ? ` ${reason}` : ''}`;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
#receipt(response) {
|
|
391
|
-
try {
|
|
392
|
-
return response.headers.get('payment-response') ? this.http.getPaymentSettleResponse((name) => response.headers.get(name)) : null;
|
|
393
|
-
} catch {
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async #release(entry, amount) {
|
|
399
|
-
await this.ledger.update(entry.id, 'released');
|
|
400
|
-
this.sessionCommitted -= amount;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
async #earlier(entry, saveTo) {
|
|
404
|
-
if (entry.status === 'spent' && entry.result !== undefined) {
|
|
405
|
-
const result = {
|
|
406
|
-
ok: true,
|
|
407
|
-
repeat: true,
|
|
408
|
-
note: `Already bought at ${new Date(entry.created_at).toISOString()}; not charged again. Pass allow_repeat: true to buy it again.`,
|
|
409
|
-
service: entry.capability,
|
|
410
|
-
paid_usdc: formatUsdc(entry.amount),
|
|
411
|
-
transaction: entry.transaction ?? null,
|
|
412
|
-
request_id: entry.request_id ?? null,
|
|
413
|
-
result: entry.result,
|
|
414
|
-
};
|
|
415
|
-
if (saveTo) {
|
|
416
|
-
try {
|
|
417
|
-
writeFileSync(saveTo.path, `${JSON.stringify(entry.result, null, 2)}\n`, { flag: saveTo.overwrite ? 'w' : 'wx' });
|
|
418
|
-
result.saved_to = saveTo.path;
|
|
419
|
-
} catch (error) {
|
|
420
|
-
result.warnings = [`The file was not saved (${error.message}).`];
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
return result;
|
|
424
|
-
}
|
|
425
|
-
if (entry.status === 'spent' && !entry.artifact?.url) {
|
|
426
|
-
throw new Refusal(`Not paying: the same request was bought at ${new Date(entry.created_at).toISOString()}${entry.request_id ? ` (Bacon request id ${entry.request_id})` : ''}, but its result was too large to keep. Pass allow_repeat: true to buy it again.`);
|
|
427
|
-
}
|
|
428
|
-
if (entry.status !== 'spent' || !entry.artifact?.url) {
|
|
429
|
-
throw new Refusal(`Not paying: the same request was started at ${new Date(entry.created_at).toISOString()} and its outcome is ${entry.status === 'reserved' ? 'still pending' : 'unknown'}${entry.request_id ? ` (Bacon request id ${entry.request_id})` : ''}, so it may have been charged. Pass allow_repeat: true to buy it again anyway.`, { charged: 'unknown' });
|
|
430
|
-
}
|
|
431
|
-
const result = {
|
|
432
|
-
ok: true,
|
|
433
|
-
repeat: true,
|
|
434
|
-
note: `Already bought at ${new Date(entry.created_at).toISOString()}; not charged again. Pass allow_repeat: true to buy it again.`,
|
|
435
|
-
service: entry.capability,
|
|
436
|
-
paid_usdc: formatUsdc(entry.amount),
|
|
437
|
-
transaction: entry.transaction ?? null,
|
|
438
|
-
request_id: entry.request_id ?? null,
|
|
439
|
-
artifact: entry.artifact,
|
|
440
|
-
};
|
|
441
|
-
if (saveTo) {
|
|
442
|
-
const saved = await this.#download(entry.artifact.url, saveTo.path, saveTo.overwrite);
|
|
443
|
-
if (saved.ok) {
|
|
444
|
-
result.saved_to = saveTo.path;
|
|
445
|
-
} else {
|
|
446
|
-
result.warnings = [`The file was not saved (${saved.reason}).`];
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
return result;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
async #download(url, path, overwrite) {
|
|
453
|
-
try {
|
|
454
|
-
const response = await this.fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
455
|
-
if (!response.ok) {
|
|
456
|
-
return { ok: false, reason: `download answered ${response.status}` };
|
|
457
|
-
}
|
|
458
|
-
writeFileSync(path, Buffer.from(await response.arrayBuffer()), { flag: overwrite ? 'w' : 'wx' });
|
|
459
|
-
return { ok: true };
|
|
460
|
-
} catch (error) {
|
|
461
|
-
return { ok: false, reason: error.code === 'EEXIST' ? `${path} already exists` : error.message };
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
async #requirementFrom(response, endpoint) {
|
|
466
|
-
if (response.status !== 402) {
|
|
467
|
-
const data = await response.json().catch(() => null);
|
|
468
|
-
throw new Refusal(`Bacon refused the request before any payment (${response.status}${data?.error ? ` ${data.error}` : ''}). Nothing was signed.`);
|
|
469
|
-
}
|
|
470
|
-
try {
|
|
471
|
-
const body = await response.clone().json().catch(() => undefined);
|
|
472
|
-
return this.http.getPaymentRequiredResponse((name) => response.headers.get(name), body);
|
|
473
|
-
} catch {
|
|
474
|
-
throw new Refusal(`Not paying: ${endpoint} answered 402 without a readable payment requirement. Nothing was signed.`);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
async #post(url, body, paymentHeaders, timeout, { retryOnce = false } = {}) {
|
|
479
|
-
const send = () => this.fetch(url, {
|
|
480
|
-
method: 'POST',
|
|
481
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...paymentHeaders },
|
|
482
|
-
body,
|
|
483
|
-
signal: AbortSignal.timeout(timeout),
|
|
484
|
-
});
|
|
485
|
-
try {
|
|
486
|
-
return await send();
|
|
487
|
-
} catch (error) {
|
|
488
|
-
if (retryOnce) {
|
|
489
|
-
return send();
|
|
490
|
-
}
|
|
491
|
-
throw new Refusal(`Could not reach Bacon at ${url} (${error.name === 'TimeoutError' ? 'timed out' : error.message}). Nothing was signed.`);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const SIGNATURES = [
|
|
497
|
-
['image/png', (b) => b.length > 8 && b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG'],
|
|
498
|
-
['image/jpeg', (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff],
|
|
499
|
-
['image/webp', (b) => b.length > 12 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP'],
|
|
500
|
-
];
|
|
501
|
-
|
|
502
|
-
/** Exactly one of image_url (https) or image_path (a local PNG/JPEG/WebP within Bacon's size limit). */
|
|
503
|
-
export function prepareImage(args, limits) {
|
|
504
|
-
const hasUrl = typeof args.image_url === 'string' && args.image_url.trim() !== '';
|
|
505
|
-
const hasPath = typeof args.image_path === 'string' && args.image_path.trim() !== '';
|
|
506
|
-
if (hasUrl === hasPath) {
|
|
507
|
-
throw new Refusal('Give exactly one of image_url or image_path.');
|
|
508
|
-
}
|
|
509
|
-
if (hasUrl) {
|
|
510
|
-
let url;
|
|
511
|
-
try {
|
|
512
|
-
url = new URL(args.image_url.trim());
|
|
513
|
-
} catch {
|
|
514
|
-
throw new Refusal('image_url is not a URL.');
|
|
515
|
-
}
|
|
516
|
-
if (url.protocol !== 'https:') {
|
|
517
|
-
throw new Refusal('image_url must be a public https URL.');
|
|
518
|
-
}
|
|
519
|
-
return { body: { image_url: url.href }, identity: `url:${url.href}` };
|
|
520
|
-
}
|
|
521
|
-
const path = resolve(args.image_path.trim());
|
|
522
|
-
let size;
|
|
523
|
-
try {
|
|
524
|
-
size = statSync(path).size;
|
|
525
|
-
} catch {
|
|
526
|
-
throw new Refusal(`image_path ${path} cannot be read.`);
|
|
527
|
-
}
|
|
528
|
-
if (size > limits.max_bytes) {
|
|
529
|
-
throw new Refusal(`image_path is ${size} bytes; Bacon takes at most ${limits.max_bytes}.`);
|
|
530
|
-
}
|
|
531
|
-
const bytes = readFileSync(path);
|
|
532
|
-
const mime = SIGNATURES.find(([, test]) => test(bytes))?.[0];
|
|
533
|
-
if (!mime || !limits.formats.includes(mime)) {
|
|
534
|
-
throw new Refusal(`image_path must be ${limits.formats.map((f) => f.replace('image/', '').toUpperCase()).join(', ')}.`);
|
|
535
|
-
}
|
|
536
|
-
return { body: { image_base64: bytes.toString('base64') }, identity: `sha256:${sha256(bytes)}` };
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* One public web page for a URL capability: http(s), no user:pass@, ports 80/443 only, within
|
|
541
|
-
* the catalog's maxLength. Bacon checks it again (and that it is public) before quoting.
|
|
542
|
-
*/
|
|
543
|
-
export function prepareUrl(args, inputSchema) {
|
|
544
|
-
if (typeof args.url !== 'string' || args.url.trim() === '') {
|
|
545
|
-
throw new Refusal('Give url: the address of one public web page.');
|
|
546
|
-
}
|
|
547
|
-
const raw = args.url.trim();
|
|
548
|
-
const maxLength = inputSchema?.properties?.url?.maxLength ?? 2048;
|
|
549
|
-
if (raw.length > maxLength) {
|
|
550
|
-
throw new Refusal(`url is ${raw.length} characters; Bacon takes at most ${maxLength}.`);
|
|
551
|
-
}
|
|
552
|
-
let url;
|
|
553
|
-
try {
|
|
554
|
-
url = new URL(raw);
|
|
555
|
-
} catch {
|
|
556
|
-
throw new Refusal('url is not a URL.');
|
|
557
|
-
}
|
|
558
|
-
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
559
|
-
throw new Refusal('url must be an http or https address.');
|
|
560
|
-
}
|
|
561
|
-
if (url.username || url.password) {
|
|
562
|
-
throw new Refusal('url must not contain a user name or password.');
|
|
563
|
-
}
|
|
564
|
-
if (url.port !== '') {
|
|
565
|
-
throw new Refusal('url must use the standard port (80 or 443).');
|
|
566
|
-
}
|
|
567
|
-
return { body: { url: url.href }, identity: `page:${url.href}` };
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/** The contract's options, defaults filled in: integers within their bounds, booleans as booleans. */
|
|
571
|
-
export function prepareOptions(args, contract) {
|
|
572
|
-
const options = {};
|
|
573
|
-
for (const [name, spec] of Object.entries(contract.options)) {
|
|
574
|
-
const value = args[name] ?? spec.default;
|
|
575
|
-
if (spec.type === 'boolean') {
|
|
576
|
-
if (typeof value !== 'boolean') {
|
|
577
|
-
throw new Refusal(`${name} must be true or false.`);
|
|
578
|
-
}
|
|
579
|
-
} else if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
return
|
|
621
|
-
}
|
|
1
|
+
// One purchase, start to finish: the payment state machine.
|
|
2
|
+
//
|
|
3
|
+
// input checked → quoted (402 from Bacon) → requirement verified → limits checked
|
|
4
|
+
// → funds checked → confirmed → RESERVED in the ledger → signed → sent
|
|
5
|
+
// → SPENT (delivered) | RELEASED (Bacon refused before taking money) | UNCERTAIN (unknown: counts)
|
|
6
|
+
//
|
|
7
|
+
// Nothing is signed until every check before it has passed; any check that cannot be completed
|
|
8
|
+
// refuses (fails closed). One purchase signs exactly one payment, with a fresh EIP-3009 nonce, for
|
|
9
|
+
// exactly the quoted request body. A lost connection is retried once with the SAME signed payment
|
|
10
|
+
// and the SAME body, which Bacon answers idempotently; a new signature is never made on retry.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, resolve } from 'node:path';
|
|
15
|
+
import { x402Client, x402HTTPClient } from '@x402/core/client';
|
|
16
|
+
import { ExactEvmScheme } from '@x402/evm/exact/client';
|
|
17
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
18
|
+
import addFormats from 'ajv-formats';
|
|
19
|
+
import { endpointFor } from './catalog.js';
|
|
20
|
+
import { contractId } from './contracts.js';
|
|
21
|
+
import { atomicFromWire, formatUsdc, parseUsdc } from './money.js';
|
|
22
|
+
import { sameAddress } from './networks.js';
|
|
23
|
+
|
|
24
|
+
/** A purchase that stopped. `charged`: false (nothing taken), 'unknown', or true. */
|
|
25
|
+
export class Refusal extends Error {
|
|
26
|
+
constructor(message, { charged = false, details = {} } = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.charged = charged;
|
|
29
|
+
this.details = details;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Bacon's answers that mean the payment was not used (its PaidExecution refuses or fails before settling).
|
|
34
|
+
const NOT_CHARGED_CODES = new Set(['execution_failed', 'not_accepted', 'busy', 'invalid_input', 'capability_unavailable', 'unsupported_capability']);
|
|
35
|
+
|
|
36
|
+
const QUOTE_TIMEOUT_MS = 60_000;
|
|
37
|
+
// A paid call waits for Bacon's own declared budget (limits.max_seconds) plus a margin, or this.
|
|
38
|
+
const PAY_TIMEOUT_MS = 120_000;
|
|
39
|
+
const PAY_MARGIN_MS = 30_000;
|
|
40
|
+
// Whatever a capability declares, a JSON result larger than this is never handed to the model.
|
|
41
|
+
export const HARD_MAX_RESULT_BYTES = 256 * 1024;
|
|
42
|
+
|
|
43
|
+
const ajv = new Ajv2020({ strict: false, allErrors: false });
|
|
44
|
+
addFormats(ajv);
|
|
45
|
+
const validators = new WeakMap();
|
|
46
|
+
|
|
47
|
+
/** How long to wait for a paid call to this catalog entry. */
|
|
48
|
+
export function payTimeoutMs(entry) {
|
|
49
|
+
const seconds = entry?.limits?.max_seconds;
|
|
50
|
+
return Number.isInteger(seconds) && seconds > 0 ? seconds * 1000 + PAY_MARGIN_MS : PAY_TIMEOUT_MS;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Purchaser {
|
|
54
|
+
constructor({ config, account, fetch, readBalance, ledger, confirm }) {
|
|
55
|
+
this.config = config;
|
|
56
|
+
this.account = account;
|
|
57
|
+
this.fetch = fetch;
|
|
58
|
+
this.readBalance = readBalance;
|
|
59
|
+
this.ledger = ledger;
|
|
60
|
+
this.confirm = confirm;
|
|
61
|
+
// This process's total (reserved, spent or uncertain): the per-session ceiling.
|
|
62
|
+
this.sessionCommitted = 0n;
|
|
63
|
+
if (account) {
|
|
64
|
+
const client = new x402Client()
|
|
65
|
+
.register(config.network.id, new ExactEvmScheme(account))
|
|
66
|
+
// The library's own lock, at the same per-call ceiling: a second check of the signed amount.
|
|
67
|
+
.setSpendControls({ maxAmountPerPayment: `$${formatUsdc(config.limits.perCall)}` });
|
|
68
|
+
this.http = new x402HTTPClient(client);
|
|
69
|
+
} else {
|
|
70
|
+
this.http = new x402HTTPClient(new x402Client());
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Asks Bacon for the authoritative price (an empty request: nothing is recorded or charged) and verifies it. */
|
|
75
|
+
async quote(service) {
|
|
76
|
+
const endpoint = endpointFor(this.config.apiUrl, service.id);
|
|
77
|
+
const response = await this.#post(endpoint, '{}', {}, QUOTE_TIMEOUT_MS);
|
|
78
|
+
const required = await this.#requirementFrom(response, endpoint);
|
|
79
|
+
return this.verifyRequirement(required, service, endpoint);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The 402's requirement, checked against everything this package pins: x402 v2, this exact
|
|
84
|
+
* endpoint, scheme "exact" on the configured network, that network's USDC and EIP-712 domain,
|
|
85
|
+
* Bacon's receiving address, and the catalog's price. Returns the requirement narrowed to the
|
|
86
|
+
* one accepted option, so the library cannot pick another.
|
|
87
|
+
*/
|
|
88
|
+
verifyRequirement(required, service, endpoint) {
|
|
89
|
+
const { network, payTo } = this.config;
|
|
90
|
+
const refuse = (why) => {
|
|
91
|
+
throw new Refusal(`Not paying: ${why}. Nothing was signed.`);
|
|
92
|
+
};
|
|
93
|
+
if (required?.x402Version !== 2) {
|
|
94
|
+
refuse('Bacon did not answer with an x402 v2 payment requirement');
|
|
95
|
+
}
|
|
96
|
+
if (required.resource?.url !== endpoint) {
|
|
97
|
+
refuse(`the payment requirement is for ${required.resource?.url ?? 'an unnamed resource'}, not ${endpoint}`);
|
|
98
|
+
}
|
|
99
|
+
const accepted = (required.accepts ?? []).find((a) => a.scheme === 'exact' && a.network === network.id);
|
|
100
|
+
if (!accepted) {
|
|
101
|
+
refuse(`Bacon did not offer an "exact" payment on ${network.label} (${network.id})`);
|
|
102
|
+
}
|
|
103
|
+
if (!sameAddress(accepted.asset, network.usdc.address) || accepted.extra?.name !== network.usdc.name || accepted.extra?.version !== network.usdc.version) {
|
|
104
|
+
refuse(`the asset is not ${network.label} USDC`);
|
|
105
|
+
}
|
|
106
|
+
if (!sameAddress(accepted.payTo, payTo)) {
|
|
107
|
+
refuse(`the payment would go to ${accepted.payTo}, which is not Bacon's receiving address ${payTo}`);
|
|
108
|
+
}
|
|
109
|
+
const amount = atomicFromWire(accepted.amount);
|
|
110
|
+
if (amount === null || amount <= 0n) {
|
|
111
|
+
refuse('the price is not a positive amount');
|
|
112
|
+
}
|
|
113
|
+
if (amount !== BigInt(service.entry.price.amount)) {
|
|
114
|
+
refuse(`Bacon asks ${formatUsdc(amount)} USDC but its catalog lists ${formatUsdc(service.entry.price.amount)} USDC for ${service.tool}`);
|
|
115
|
+
}
|
|
116
|
+
if (!Number.isInteger(accepted.maxTimeoutSeconds) || accepted.maxTimeoutSeconds < 1 || accepted.maxTimeoutSeconds > 3600) {
|
|
117
|
+
refuse('the payment window is not between 1 second and 1 hour');
|
|
118
|
+
}
|
|
119
|
+
return { required: { ...required, accepts: [accepted] }, accepted, amount };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The limits a price must fit, as a list of reasons it does not (empty when it fits). */
|
|
123
|
+
async limitProblems(amount, maxPrice = null) {
|
|
124
|
+
const { limits, network } = this.config;
|
|
125
|
+
const problems = [];
|
|
126
|
+
if (amount > limits.perCall) {
|
|
127
|
+
problems.push(`${formatUsdc(amount)} USDC is above your per-call limit of ${formatUsdc(limits.perCall)} USDC (BACON_MAX_PER_CALL)`);
|
|
128
|
+
}
|
|
129
|
+
if (maxPrice !== null && amount > maxPrice) {
|
|
130
|
+
problems.push(`${formatUsdc(amount)} USDC is above the max_price_usdc of ${formatUsdc(maxPrice)} given for this call`);
|
|
131
|
+
}
|
|
132
|
+
if (this.sessionCommitted + amount > limits.perSession) {
|
|
133
|
+
problems.push(`this session has used ${formatUsdc(this.sessionCommitted)} of its ${formatUsdc(limits.perSession)} USDC limit (BACON_MAX_PER_SESSION)`);
|
|
134
|
+
}
|
|
135
|
+
const day = await this.ledger.spentLastDay(network.id);
|
|
136
|
+
if (day + amount > limits.perDay) {
|
|
137
|
+
problems.push(`the last 24 hours have used ${formatUsdc(day)} of your ${formatUsdc(limits.perDay)} USDC daily limit (BACON_MAX_PER_DAY)`);
|
|
138
|
+
}
|
|
139
|
+
return problems;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether the wallet holds at least `amount`. Throws a Refusal when the balance cannot be read. */
|
|
143
|
+
async hasFunds(amount) {
|
|
144
|
+
const balance = await this.balance();
|
|
145
|
+
return balance >= amount;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async balance() {
|
|
149
|
+
try {
|
|
150
|
+
return BigInt(await this.readBalance(this.config.network.usdc.address, this.account.address));
|
|
151
|
+
} catch {
|
|
152
|
+
throw new Refusal(`Not paying: the wallet's USDC balance on ${this.config.network.label} could not be checked (RPC ${this.config.rpcUrl} did not answer; set BACON_RPC_URL to another). Nothing was signed.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Buys one run of `service` with the tool arguments `args`. */
|
|
157
|
+
async buy(service, args) {
|
|
158
|
+
const { network, apiUrl } = this.config;
|
|
159
|
+
const contract = service.contractDef;
|
|
160
|
+
const endpoint = endpointFor(apiUrl, service.id);
|
|
161
|
+
|
|
162
|
+
// 1. Input, options, destination and the caller's price ceiling: all before any request.
|
|
163
|
+
const image = contract.input === 'url' ? prepareUrl(args, service.entry.input) : prepareImage(args, service.entry.limits);
|
|
164
|
+
const options = prepareOptions(args, contract);
|
|
165
|
+
const saveTo = prepareSaveTo(args);
|
|
166
|
+
let maxPrice = null;
|
|
167
|
+
if (args.max_price_usdc !== undefined && args.max_price_usdc !== null) {
|
|
168
|
+
try {
|
|
169
|
+
maxPrice = parseUsdc(String(args.max_price_usdc), 'max_price_usdc');
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Refusal(error.message);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const body = JSON.stringify({ ...image.body, ...options });
|
|
175
|
+
const maxRequest = service.entry.limits.max_request_bytes;
|
|
176
|
+
if (Number.isInteger(maxRequest) && Buffer.byteLength(body) > maxRequest) {
|
|
177
|
+
throw new Refusal(`The request would be ${Buffer.byteLength(body)} bytes; Bacon takes at most ${maxRequest} for ${service.tool}. Nothing was sent.`);
|
|
178
|
+
}
|
|
179
|
+
const fingerprint = sha256(JSON.stringify({ contract: contractId(service.id, contract.version), image: image.identity, options }));
|
|
180
|
+
|
|
181
|
+
// 2. Bought this exact thing a moment ago? Hand that back instead of paying again.
|
|
182
|
+
if (!args.allow_repeat) {
|
|
183
|
+
const earlier = await this.ledger.recent(network.id, fingerprint);
|
|
184
|
+
if (earlier) {
|
|
185
|
+
return this.#earlier(earlier, saveTo);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3. Bacon's authoritative price for this exact request, verified.
|
|
190
|
+
const quoteResponse = await this.#post(endpoint, body, {}, QUOTE_TIMEOUT_MS);
|
|
191
|
+
const { required, amount } = this.verifyRequirement(await this.#requirementFrom(quoteResponse, endpoint), service, endpoint);
|
|
192
|
+
|
|
193
|
+
// 4. Limits, then funds.
|
|
194
|
+
const problems = await this.limitProblems(amount, maxPrice);
|
|
195
|
+
if (problems.length) {
|
|
196
|
+
throw new Refusal(`Not paying: ${problems.join('; ')}. Nothing was signed.`);
|
|
197
|
+
}
|
|
198
|
+
if (!(await this.hasFunds(amount))) {
|
|
199
|
+
throw new Refusal(`Not paying: the wallet does not hold ${formatUsdc(amount)} USDC on ${network.label}. Add USDC on ${network.label} to the wallet set in BACON_WALLET_PRIVATE_KEY / BACON_WALLET_KEY_FILE and try again. Nothing was signed.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 5. The buyer's confirmation, where the host can ask.
|
|
203
|
+
const what = `Pay ${formatUsdc(amount)} USDC${network.testnet ? ' (test money)' : ''} on ${network.label} to Bacon Services to run ${service.tool}?`;
|
|
204
|
+
if (!(await this.confirm(what))) {
|
|
205
|
+
throw new Refusal('Payment declined. Nothing was signed.');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 6. Reserve: duplicate, daily and session ceilings once more, atomically with the other processes.
|
|
209
|
+
const reservation = await this.ledger.reserve({
|
|
210
|
+
network: network.id,
|
|
211
|
+
capability: service.id,
|
|
212
|
+
fingerprint,
|
|
213
|
+
amount,
|
|
214
|
+
perDay: this.config.limits.perDay,
|
|
215
|
+
allowRepeat: Boolean(args.allow_repeat),
|
|
216
|
+
sessionCheck: (a) => (this.sessionCommitted + a > this.config.limits.perSession ? 'the per-session limit' : null),
|
|
217
|
+
});
|
|
218
|
+
if (reservation.duplicate) {
|
|
219
|
+
return this.#earlier(reservation.duplicate, saveTo);
|
|
220
|
+
}
|
|
221
|
+
if (reservation.refused) {
|
|
222
|
+
throw new Refusal(`Not paying: ${reservation.refused === 'day' ? 'the daily limit (BACON_MAX_PER_DAY) would be exceeded' : 'the per-session limit (BACON_MAX_PER_SESSION) would be exceeded'}. Nothing was signed.`);
|
|
223
|
+
}
|
|
224
|
+
const entry = reservation.entry;
|
|
225
|
+
this.sessionCommitted += amount;
|
|
226
|
+
|
|
227
|
+
// 7. Sign exactly this requirement, and check what was signed.
|
|
228
|
+
let header;
|
|
229
|
+
try {
|
|
230
|
+
const payload = await this.http.createPaymentPayload(required);
|
|
231
|
+
const auth = payload.payload?.authorization;
|
|
232
|
+
if (payload.accepted?.amount !== required.accepts[0].amount || !auth || auth.value !== required.accepts[0].amount || !sameAddress(auth.to, this.config.payTo)) {
|
|
233
|
+
throw new Error('the signed payment does not match the quote');
|
|
234
|
+
}
|
|
235
|
+
header = this.http.encodePaymentSignatureHeader(payload);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
await this.#release(entry, amount);
|
|
238
|
+
throw new Refusal(`Not paying: the payment could not be signed (${error.message}). Nothing was sent.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 8. Send it, with the same body. One retry with the same payment if the connection drops.
|
|
242
|
+
let response;
|
|
243
|
+
try {
|
|
244
|
+
response = await this.#post(endpoint, body, header, payTimeoutMs(service.entry), { retryOnce: true });
|
|
245
|
+
} catch {
|
|
246
|
+
await this.ledger.update(entry.id, 'uncertain');
|
|
247
|
+
throw new Refusal(`The signed payment of ${formatUsdc(amount)} USDC was sent but no answer came back, so it may have been charged. It counts against your limits. Nothing will be re-sent automatically.`, { charged: 'unknown' });
|
|
248
|
+
}
|
|
249
|
+
return this.#settle(response, { entry, amount, service, contract, saveTo });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async #settle(response, { entry, amount, service, contract, saveTo }) {
|
|
253
|
+
const { network, apiUrl } = this.config;
|
|
254
|
+
const text = await response.text().catch(() => '');
|
|
255
|
+
let data = null;
|
|
256
|
+
try {
|
|
257
|
+
data = JSON.parse(text);
|
|
258
|
+
} catch {
|
|
259
|
+
// not JSON: classified below
|
|
260
|
+
}
|
|
261
|
+
const receipt = this.#receipt(response);
|
|
262
|
+
|
|
263
|
+
if (contract.output.kind === 'json' && response.status === 200 && data?.ok === true && data.result !== undefined) {
|
|
264
|
+
return this.#deliverJson(data, { entry, amount, service, saveTo, receipt });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (contract.output.kind === 'artifact' && response.status === 200 && data?.ok === true && typeof data.artifact?.url === 'string') {
|
|
268
|
+
const transaction = data.payment?.transaction ?? receipt?.transaction ?? null;
|
|
269
|
+
const artifact = {
|
|
270
|
+
url: data.artifact.url,
|
|
271
|
+
mime: data.artifact.mime ?? null,
|
|
272
|
+
width: data.artifact.width ?? null,
|
|
273
|
+
height: data.artifact.height ?? null,
|
|
274
|
+
bytes: data.artifact.bytes ?? null,
|
|
275
|
+
expires_at: data.artifact.expires_at ?? null,
|
|
276
|
+
};
|
|
277
|
+
await this.ledger.update(entry.id, 'spent', { request_id: data.request_id ?? null, transaction, artifact });
|
|
278
|
+
const result = {
|
|
279
|
+
ok: true,
|
|
280
|
+
service: service.tool,
|
|
281
|
+
paid_usdc: formatUsdc(amount),
|
|
282
|
+
network: network.id,
|
|
283
|
+
testnet: network.testnet,
|
|
284
|
+
transaction,
|
|
285
|
+
transaction_url: transaction ? network.explorer + transaction : null,
|
|
286
|
+
request_id: data.request_id ?? null,
|
|
287
|
+
artifact,
|
|
288
|
+
};
|
|
289
|
+
const warnings = [];
|
|
290
|
+
if (artifact.mime !== contract.output.mime) {
|
|
291
|
+
warnings.push(`Bacon returned ${artifact.mime}, expected ${contract.output.mime}.`);
|
|
292
|
+
}
|
|
293
|
+
if (saveTo) {
|
|
294
|
+
const saved = await this.#download(artifact.url, saveTo.path, saveTo.overwrite);
|
|
295
|
+
if (saved.ok) {
|
|
296
|
+
result.saved_to = saveTo.path;
|
|
297
|
+
} else {
|
|
298
|
+
warnings.push(`Paid and delivered, but the file was not saved (${saved.reason}); download it from artifact.url before it expires.`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (new URL(artifact.url).origin !== new URL(apiUrl).origin) {
|
|
302
|
+
warnings.push('The artifact URL is not on Bacon\'s own address.');
|
|
303
|
+
}
|
|
304
|
+
if (warnings.length) {
|
|
305
|
+
result.warnings = warnings;
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (response.status === 202 && data?.ok === true) {
|
|
311
|
+
await this.ledger.update(entry.id, 'spent', { request_id: data.request_id ?? null, transaction: data.payment?.transaction ?? null });
|
|
312
|
+
return { ok: true, service: service.tool, paid_usdc: formatUsdc(amount), status: 'queued', result_url: data.result_url ?? null, request_id: data.request_id ?? null,
|
|
313
|
+
warnings: ['Bacon accepted this as a background job; poll result_url for the file.'] };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const reason = this.#reason(response, data);
|
|
317
|
+
const requestId = data?.request_id ?? null;
|
|
318
|
+
const notCharged = (response.status === 402 && !response.headers.get('payment-response'))
|
|
319
|
+
|| (response.status !== 402 && response.status !== 409 && NOT_CHARGED_CODES.has(data?.code));
|
|
320
|
+
if (notCharged) {
|
|
321
|
+
await this.#release(entry, amount);
|
|
322
|
+
throw new Refusal(`Bacon did not take the payment: ${reason}. You have not been charged.`, { details: { request_id: requestId } });
|
|
323
|
+
}
|
|
324
|
+
await this.ledger.update(entry.id, 'uncertain', { request_id: requestId });
|
|
325
|
+
throw new Refusal(`The payment of ${formatUsdc(amount)} USDC may have been charged, but no result came back (${reason}${requestId ? `; Bacon request id ${requestId}` : ''}). It counts against your limits; contact Bacon with the request id if you were charged.`, { charged: 'unknown', details: { request_id: requestId } });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A JSON result (output.kind "json"): paid and delivered inline. Handed to the model only within
|
|
330
|
+
* the capability's declared max_response_bytes (and this package's hard cap); checked against
|
|
331
|
+
* the catalog's output schema, a mismatch reported as a warning (the money is already spent).
|
|
332
|
+
*/
|
|
333
|
+
async #deliverJson(data, { entry, amount, service, saveTo, receipt }) {
|
|
334
|
+
const { network } = this.config;
|
|
335
|
+
const transaction = data.payment?.transaction ?? receipt?.transaction ?? null;
|
|
336
|
+
const resultText = JSON.stringify(data.result);
|
|
337
|
+
const size = Buffer.byteLength(resultText);
|
|
338
|
+
const cap = Math.min(service.entry.limits.max_response_bytes, HARD_MAX_RESULT_BYTES);
|
|
339
|
+
const fits = size <= cap;
|
|
340
|
+
const warnings = [];
|
|
341
|
+
if (!fits) {
|
|
342
|
+
warnings.push(`The result is ${size} bytes, above the ${cap} bytes this service declares, so it is not shown${saveTo ? '' : '; pass save_to to keep it'}.`);
|
|
343
|
+
}
|
|
344
|
+
const valid = schemaCheck(service.entry.output.schema, data);
|
|
345
|
+
if (valid !== true) {
|
|
346
|
+
warnings.push(`The result does not match the service's declared output schema (${valid}).`);
|
|
347
|
+
}
|
|
348
|
+
await this.ledger.update(entry.id, 'spent', { request_id: data.request_id ?? null, transaction, ...(fits ? { result: data.result } : {}) });
|
|
349
|
+
|
|
350
|
+
const result = {
|
|
351
|
+
ok: true,
|
|
352
|
+
service: service.tool,
|
|
353
|
+
paid_usdc: formatUsdc(amount),
|
|
354
|
+
network: network.id,
|
|
355
|
+
testnet: network.testnet,
|
|
356
|
+
transaction,
|
|
357
|
+
transaction_url: transaction ? network.explorer + transaction : null,
|
|
358
|
+
request_id: data.request_id ?? null,
|
|
359
|
+
result: fits ? data.result : null,
|
|
360
|
+
};
|
|
361
|
+
if (saveTo) {
|
|
362
|
+
try {
|
|
363
|
+
writeFileSync(saveTo.path, `${JSON.stringify(data.result, null, 2)}\n`, { flag: saveTo.overwrite ? 'w' : 'wx' });
|
|
364
|
+
result.saved_to = saveTo.path;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
warnings.push(`Paid and delivered, but the file was not saved (${error.code === 'EEXIST' ? `${saveTo.path} already exists` : error.message}).`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (warnings.length) {
|
|
370
|
+
result.warnings = warnings;
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#reason(response, data) {
|
|
376
|
+
let reason = data?.error;
|
|
377
|
+
if (response.status === 402) {
|
|
378
|
+
try {
|
|
379
|
+
reason = this.http.getPaymentRequiredResponse((name) => response.headers.get(name), data)?.error ?? reason;
|
|
380
|
+
} catch {
|
|
381
|
+
// keep the body's reason
|
|
382
|
+
}
|
|
383
|
+
if (/insufficient/i.test(String(reason))) {
|
|
384
|
+
return 'the wallet does not have enough USDC (insufficient_funds)';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return `${response.status}${reason ? ` ${reason}` : ''}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#receipt(response) {
|
|
391
|
+
try {
|
|
392
|
+
return response.headers.get('payment-response') ? this.http.getPaymentSettleResponse((name) => response.headers.get(name)) : null;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async #release(entry, amount) {
|
|
399
|
+
await this.ledger.update(entry.id, 'released');
|
|
400
|
+
this.sessionCommitted -= amount;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async #earlier(entry, saveTo) {
|
|
404
|
+
if (entry.status === 'spent' && entry.result !== undefined) {
|
|
405
|
+
const result = {
|
|
406
|
+
ok: true,
|
|
407
|
+
repeat: true,
|
|
408
|
+
note: `Already bought at ${new Date(entry.created_at).toISOString()}; not charged again. Pass allow_repeat: true to buy it again.`,
|
|
409
|
+
service: entry.capability,
|
|
410
|
+
paid_usdc: formatUsdc(entry.amount),
|
|
411
|
+
transaction: entry.transaction ?? null,
|
|
412
|
+
request_id: entry.request_id ?? null,
|
|
413
|
+
result: entry.result,
|
|
414
|
+
};
|
|
415
|
+
if (saveTo) {
|
|
416
|
+
try {
|
|
417
|
+
writeFileSync(saveTo.path, `${JSON.stringify(entry.result, null, 2)}\n`, { flag: saveTo.overwrite ? 'w' : 'wx' });
|
|
418
|
+
result.saved_to = saveTo.path;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
result.warnings = [`The file was not saved (${error.message}).`];
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
if (entry.status === 'spent' && !entry.artifact?.url) {
|
|
426
|
+
throw new Refusal(`Not paying: the same request was bought at ${new Date(entry.created_at).toISOString()}${entry.request_id ? ` (Bacon request id ${entry.request_id})` : ''}, but its result was too large to keep. Pass allow_repeat: true to buy it again.`);
|
|
427
|
+
}
|
|
428
|
+
if (entry.status !== 'spent' || !entry.artifact?.url) {
|
|
429
|
+
throw new Refusal(`Not paying: the same request was started at ${new Date(entry.created_at).toISOString()} and its outcome is ${entry.status === 'reserved' ? 'still pending' : 'unknown'}${entry.request_id ? ` (Bacon request id ${entry.request_id})` : ''}, so it may have been charged. Pass allow_repeat: true to buy it again anyway.`, { charged: 'unknown' });
|
|
430
|
+
}
|
|
431
|
+
const result = {
|
|
432
|
+
ok: true,
|
|
433
|
+
repeat: true,
|
|
434
|
+
note: `Already bought at ${new Date(entry.created_at).toISOString()}; not charged again. Pass allow_repeat: true to buy it again.`,
|
|
435
|
+
service: entry.capability,
|
|
436
|
+
paid_usdc: formatUsdc(entry.amount),
|
|
437
|
+
transaction: entry.transaction ?? null,
|
|
438
|
+
request_id: entry.request_id ?? null,
|
|
439
|
+
artifact: entry.artifact,
|
|
440
|
+
};
|
|
441
|
+
if (saveTo) {
|
|
442
|
+
const saved = await this.#download(entry.artifact.url, saveTo.path, saveTo.overwrite);
|
|
443
|
+
if (saved.ok) {
|
|
444
|
+
result.saved_to = saveTo.path;
|
|
445
|
+
} else {
|
|
446
|
+
result.warnings = [`The file was not saved (${saved.reason}).`];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async #download(url, path, overwrite) {
|
|
453
|
+
try {
|
|
454
|
+
const response = await this.fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
return { ok: false, reason: `download answered ${response.status}` };
|
|
457
|
+
}
|
|
458
|
+
writeFileSync(path, Buffer.from(await response.arrayBuffer()), { flag: overwrite ? 'w' : 'wx' });
|
|
459
|
+
return { ok: true };
|
|
460
|
+
} catch (error) {
|
|
461
|
+
return { ok: false, reason: error.code === 'EEXIST' ? `${path} already exists` : error.message };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async #requirementFrom(response, endpoint) {
|
|
466
|
+
if (response.status !== 402) {
|
|
467
|
+
const data = await response.json().catch(() => null);
|
|
468
|
+
throw new Refusal(`Bacon refused the request before any payment (${response.status}${data?.error ? ` ${data.error}` : ''}). Nothing was signed.`);
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const body = await response.clone().json().catch(() => undefined);
|
|
472
|
+
return this.http.getPaymentRequiredResponse((name) => response.headers.get(name), body);
|
|
473
|
+
} catch {
|
|
474
|
+
throw new Refusal(`Not paying: ${endpoint} answered 402 without a readable payment requirement. Nothing was signed.`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async #post(url, body, paymentHeaders, timeout, { retryOnce = false } = {}) {
|
|
479
|
+
const send = () => this.fetch(url, {
|
|
480
|
+
method: 'POST',
|
|
481
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...paymentHeaders },
|
|
482
|
+
body,
|
|
483
|
+
signal: AbortSignal.timeout(timeout),
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
return await send();
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (retryOnce) {
|
|
489
|
+
return send();
|
|
490
|
+
}
|
|
491
|
+
throw new Refusal(`Could not reach Bacon at ${url} (${error.name === 'TimeoutError' ? 'timed out' : error.message}). Nothing was signed.`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const SIGNATURES = [
|
|
497
|
+
['image/png', (b) => b.length > 8 && b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG'],
|
|
498
|
+
['image/jpeg', (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff],
|
|
499
|
+
['image/webp', (b) => b.length > 12 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP'],
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
/** Exactly one of image_url (https) or image_path (a local PNG/JPEG/WebP within Bacon's size limit). */
|
|
503
|
+
export function prepareImage(args, limits) {
|
|
504
|
+
const hasUrl = typeof args.image_url === 'string' && args.image_url.trim() !== '';
|
|
505
|
+
const hasPath = typeof args.image_path === 'string' && args.image_path.trim() !== '';
|
|
506
|
+
if (hasUrl === hasPath) {
|
|
507
|
+
throw new Refusal('Give exactly one of image_url or image_path.');
|
|
508
|
+
}
|
|
509
|
+
if (hasUrl) {
|
|
510
|
+
let url;
|
|
511
|
+
try {
|
|
512
|
+
url = new URL(args.image_url.trim());
|
|
513
|
+
} catch {
|
|
514
|
+
throw new Refusal('image_url is not a URL.');
|
|
515
|
+
}
|
|
516
|
+
if (url.protocol !== 'https:') {
|
|
517
|
+
throw new Refusal('image_url must be a public https URL.');
|
|
518
|
+
}
|
|
519
|
+
return { body: { image_url: url.href }, identity: `url:${url.href}` };
|
|
520
|
+
}
|
|
521
|
+
const path = resolve(args.image_path.trim());
|
|
522
|
+
let size;
|
|
523
|
+
try {
|
|
524
|
+
size = statSync(path).size;
|
|
525
|
+
} catch {
|
|
526
|
+
throw new Refusal(`image_path ${path} cannot be read.`);
|
|
527
|
+
}
|
|
528
|
+
if (size > limits.max_bytes) {
|
|
529
|
+
throw new Refusal(`image_path is ${size} bytes; Bacon takes at most ${limits.max_bytes}.`);
|
|
530
|
+
}
|
|
531
|
+
const bytes = readFileSync(path);
|
|
532
|
+
const mime = SIGNATURES.find(([, test]) => test(bytes))?.[0];
|
|
533
|
+
if (!mime || !limits.formats.includes(mime)) {
|
|
534
|
+
throw new Refusal(`image_path must be ${limits.formats.map((f) => f.replace('image/', '').toUpperCase()).join(', ')}.`);
|
|
535
|
+
}
|
|
536
|
+
return { body: { image_base64: bytes.toString('base64') }, identity: `sha256:${sha256(bytes)}` };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* One public web page for a URL capability: http(s), no user:pass@, ports 80/443 only, within
|
|
541
|
+
* the catalog's maxLength. Bacon checks it again (and that it is public) before quoting.
|
|
542
|
+
*/
|
|
543
|
+
export function prepareUrl(args, inputSchema) {
|
|
544
|
+
if (typeof args.url !== 'string' || args.url.trim() === '') {
|
|
545
|
+
throw new Refusal('Give url: the address of one public web page.');
|
|
546
|
+
}
|
|
547
|
+
const raw = args.url.trim();
|
|
548
|
+
const maxLength = inputSchema?.properties?.url?.maxLength ?? 2048;
|
|
549
|
+
if (raw.length > maxLength) {
|
|
550
|
+
throw new Refusal(`url is ${raw.length} characters; Bacon takes at most ${maxLength}.`);
|
|
551
|
+
}
|
|
552
|
+
let url;
|
|
553
|
+
try {
|
|
554
|
+
url = new URL(raw);
|
|
555
|
+
} catch {
|
|
556
|
+
throw new Refusal('url is not a URL.');
|
|
557
|
+
}
|
|
558
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
559
|
+
throw new Refusal('url must be an http or https address.');
|
|
560
|
+
}
|
|
561
|
+
if (url.username || url.password) {
|
|
562
|
+
throw new Refusal('url must not contain a user name or password.');
|
|
563
|
+
}
|
|
564
|
+
if (url.port !== '') {
|
|
565
|
+
throw new Refusal('url must use the standard port (80 or 443).');
|
|
566
|
+
}
|
|
567
|
+
return { body: { url: url.href }, identity: `page:${url.href}` };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The contract's options, defaults filled in: integers within their bounds, booleans as booleans, named values from their list. */
|
|
571
|
+
export function prepareOptions(args, contract) {
|
|
572
|
+
const options = {};
|
|
573
|
+
for (const [name, spec] of Object.entries(contract.options)) {
|
|
574
|
+
const value = args[name] ?? spec.default;
|
|
575
|
+
if (spec.type === 'boolean') {
|
|
576
|
+
if (typeof value !== 'boolean') {
|
|
577
|
+
throw new Refusal(`${name} must be true or false.`);
|
|
578
|
+
}
|
|
579
|
+
} else if (spec.enum) {
|
|
580
|
+
if (!spec.enum.includes(value)) {
|
|
581
|
+
throw new Refusal(`${name} must be one of: ${spec.enum.join(', ')}.`);
|
|
582
|
+
}
|
|
583
|
+
} else if (!Number.isInteger(value) || value < spec.minimum || value > spec.maximum) {
|
|
584
|
+
throw new Refusal(`${name} must be a whole number from ${spec.minimum} to ${spec.maximum}.`);
|
|
585
|
+
}
|
|
586
|
+
options[name] = value;
|
|
587
|
+
}
|
|
588
|
+
return options;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** true, or a short description of the first way `data` breaks `schema`. */
|
|
592
|
+
export function schemaCheck(schema, data) {
|
|
593
|
+
let validate = validators.get(schema);
|
|
594
|
+
if (!validate) {
|
|
595
|
+
try {
|
|
596
|
+
validate = ajv.compile(schema);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return `the schema could not be read: ${error.message}`;
|
|
599
|
+
}
|
|
600
|
+
validators.set(schema, validate);
|
|
601
|
+
}
|
|
602
|
+
if (validate(data)) {
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
const [first] = validate.errors ?? [];
|
|
606
|
+
return first ? `${first.instancePath || '/'} ${first.message}` : 'invalid';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function prepareSaveTo(args) {
|
|
610
|
+
if (typeof args.save_to !== 'string' || args.save_to.trim() === '') {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
const path = resolve(args.save_to.trim());
|
|
614
|
+
if (!existsSync(dirname(path))) {
|
|
615
|
+
throw new Refusal(`save_to: the folder ${dirname(path)} does not exist.`);
|
|
616
|
+
}
|
|
617
|
+
if (existsSync(path) && !args.overwrite) {
|
|
618
|
+
throw new Refusal(`save_to: ${path} already exists (pass overwrite: true to replace it).`);
|
|
619
|
+
}
|
|
620
|
+
return { path, overwrite: Boolean(args.overwrite) };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function sha256(data) {
|
|
624
|
+
return createHash('sha256').update(data).digest('hex');
|
|
625
|
+
}
|