solverr-agent 0.0.0-stage → 0.1.1

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/dist/limits.js ADDED
@@ -0,0 +1,31 @@
1
+ import { PaymentError } from "./errors.js";
2
+ /** Validates limits and normalizes host patterns to lowercase. */
3
+ export function normalizeLimits(limits) {
4
+ for (const [name, value] of [["perPaymentUsd", limits.perPaymentUsd], ["dailyUsd", limits.dailyUsd]]) {
5
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
6
+ throw new Error(`limits.${name} must be a number of USD, 0 or more.`);
7
+ }
8
+ if (!Array.isArray(limits.allow) || limits.allow.some((host) => typeof host !== "string" || !host.trim())) {
9
+ throw new Error("limits.allow must be a list of host names.");
10
+ }
11
+ return { perPaymentUsd: limits.perPaymentUsd, dailyUsd: limits.dailyUsd, allow: limits.allow.map((host) => host.trim().toLowerCase()) };
12
+ }
13
+ export function isHostAllowed(hostname, allow) {
14
+ const host = hostname.toLowerCase();
15
+ return allow.some((pattern) => (pattern.startsWith("*.") ? host.endsWith(pattern.slice(1)) : host === pattern));
16
+ }
17
+ export function assertHostAllowed(limits, hostname) {
18
+ if (!isHostAllowed(hostname, limits.allow)) {
19
+ throw new PaymentError("host_not_allowed", `Payments to ${hostname} are not allowed. Add it to the allow list to pay this site.`);
20
+ }
21
+ }
22
+ /** Throws a `PaymentError` when paying `amountUsd` to `hostname` would break a limit. */
23
+ export function enforceLimits(limits, payment) {
24
+ assertHostAllowed(limits, payment.hostname);
25
+ if (payment.amountUsd > limits.perPaymentUsd) {
26
+ throw new PaymentError("per_payment_limit", `This payment costs about $${payment.amountUsd.toFixed(2)}, above the per-payment limit of $${limits.perPaymentUsd.toFixed(2)}.`);
27
+ }
28
+ if (payment.spentLast24hUsd + payment.amountUsd > limits.dailyUsd) {
29
+ throw new PaymentError("daily_limit", `This payment of about $${payment.amountUsd.toFixed(2)} would bring the last 24 hours to $${(payment.spentLast24hUsd + payment.amountUsd).toFixed(2)}, above the daily limit of $${limits.dailyUsd.toFixed(2)}.`);
30
+ }
31
+ }
@@ -0,0 +1,13 @@
1
+ import { type StdioServerHandle } from "@modelcontextprotocol/server/stdio";
2
+ import type { AgentWallet } from "./agent-wallet.js";
3
+ import { type SpendingLimits } from "./limits.js";
4
+ export interface McpServerOptions {
5
+ name: string;
6
+ version: string;
7
+ /** `set_limits` can lower limits and narrow the allow list, but never go above these. */
8
+ ceiling: SpendingLimits;
9
+ }
10
+ /** Checks `requested` against the ceiling configured at startup and returns the merged limits. */
11
+ export declare function limitsWithinCeiling(current: SpendingLimits, requested: Partial<SpendingLimits>, ceiling: SpendingLimits): SpendingLimits;
12
+ /** Serves the agent wallet as an MCP server over stdin and stdout. */
13
+ export declare function serveAgentMcp(agent: AgentWallet, options: McpServerOptions): StdioServerHandle;
@@ -0,0 +1,153 @@
1
+ import { PAYMENT_RESPONSE_HEADER, decodePaymentResponseHeader, readHeader } from "solverr-x402";
2
+ import { McpServer } from "@modelcontextprotocol/server";
3
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
+ import * as z from "zod/v4";
5
+ import { PaymentError, errorSummary } from "./errors.js";
6
+ import { isHostAllowed } from "./limits.js";
7
+ import { assertFetchAllowed, pinnedDispatcher } from "./url-guard.js";
8
+ /** Longest response body returned to the model, in characters. */
9
+ const MAX_BODY_CHARS = 20_000;
10
+ const TEXT_CONTENT_TYPE = /^(text\/|application\/(json|xml|[\w.+-]+\+(json|xml))|application\/x-www-form-urlencoded)/i;
11
+ const json = (value, isError = false) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }], ...(isError ? { isError: true } : {}) });
12
+ function failure(error) {
13
+ if (error instanceof PaymentError) {
14
+ const invoice = error.invoiceId ? { invoice_id: error.invoiceId } : {};
15
+ return json({ error: { code: error.code, message: error.message, funds_sent: error.fundsSent, ...invoice } }, true);
16
+ }
17
+ return json({ error: { code: "request_failed", message: errorSummary(error), funds_sent: false } }, true);
18
+ }
19
+ /** Reads a text body up to the size limit; a body that fails to arrive is described instead, so the status and receipt still reach the model. */
20
+ async function readBody(response) {
21
+ const contentType = response.headers.get("content-type") ?? "";
22
+ try {
23
+ if (contentType && !TEXT_CONTENT_TYPE.test(contentType)) {
24
+ await response.body?.cancel();
25
+ return { body: `[${contentType} body not shown]`, truncated: false };
26
+ }
27
+ if (!response.body)
28
+ return { body: "", truncated: false };
29
+ /** Read in chunks and stopped past the limit, so a huge or endless body is never held in memory. */
30
+ const reader = response.body.getReader();
31
+ const decoder = new TextDecoder();
32
+ let text = "";
33
+ for (;;) {
34
+ const { done, value } = await reader.read();
35
+ text += done ? decoder.decode() : decoder.decode(value, { stream: true });
36
+ if (text.length > MAX_BODY_CHARS) {
37
+ await reader.cancel();
38
+ return { body: text.slice(0, MAX_BODY_CHARS), truncated: true };
39
+ }
40
+ if (done)
41
+ return { body: text, truncated: false };
42
+ }
43
+ }
44
+ catch (error) {
45
+ return { body: `[body could not be read: ${errorSummary(error, 120)}]`, truncated: false };
46
+ }
47
+ }
48
+ /** Whether every host matched by `pattern` is also matched by the `ceiling` allow list. */
49
+ function isCoveredBy(pattern, ceiling) {
50
+ const normalized = pattern.trim().toLowerCase();
51
+ if (ceiling.includes(normalized))
52
+ return true;
53
+ const wildcards = ceiling.filter((entry) => entry.startsWith("*."));
54
+ return isHostAllowed(normalized.replace(/^\*\./, ""), wildcards);
55
+ }
56
+ /** Checks `requested` against the ceiling configured at startup and returns the merged limits. */
57
+ export function limitsWithinCeiling(current, requested, ceiling) {
58
+ const next = { ...current, ...requested };
59
+ if (next.perPaymentUsd > ceiling.perPaymentUsd)
60
+ throw new Error(`per_payment_usd cannot be above $${ceiling.perPaymentUsd}, the limit set when the server started.`);
61
+ if (next.dailyUsd > ceiling.dailyUsd)
62
+ throw new Error(`daily_usd cannot be above $${ceiling.dailyUsd}, the limit set when the server started.`);
63
+ if (!next.allow.every((pattern) => isCoveredBy(pattern, ceiling.allow))) {
64
+ throw new Error(`allow can only contain hosts from the startup allow list: ${ceiling.allow.join(", ")}.`);
65
+ }
66
+ return next;
67
+ }
68
+ function buildServer(agent, options) {
69
+ const server = new McpServer({ name: options.name, version: options.version });
70
+ server.registerTool("pay_url", {
71
+ title: "Fetch a URL, paying if needed",
72
+ description: "Fetches a URL on a host in the allow list. If the site answers 402 Payment Required with a Solverr invoice, pays it from the agent wallet within the spending limits, then fetches again. Returns the HTTP status, the payment receipt and the response body; a redirect is returned with its location instead of being followed. If the result has payment_unused true, invoice_id is paid but the site answered with an error and did not use the payment: calling pay_url again with the same method and URL sends that payment again instead of paying a new invoice. If an error says funds_sent is true, money left the wallet for invoice_id: follow the error message before calling pay_url for the same request again.",
73
+ inputSchema: z.object({
74
+ url: z.url({ protocol: /^https?$/ }).describe("The http or https URL to fetch."),
75
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"),
76
+ headers: z.record(z.string(), z.string()).optional().describe("Extra request headers."),
77
+ body: z.string().optional().describe("Request body for POST, PUT or PATCH."),
78
+ }),
79
+ }, async ({ url, method, headers, body }) => {
80
+ try {
81
+ const target = new URL(url);
82
+ const dispatcher = pinnedDispatcher(await assertFetchAllowed(target, agent.limits().allow));
83
+ let response;
84
+ let text;
85
+ let truncated;
86
+ try {
87
+ /** Redirects are returned, not followed, and connections go only to the checked addresses, so every URL the model reaches passes the checks above. */
88
+ response = await agent.fetch(target, { method, headers, body, redirect: "manual", dispatcher });
89
+ ({ body: text, truncated } = await readBody(response));
90
+ }
91
+ finally {
92
+ await dispatcher?.close();
93
+ }
94
+ const location = response.headers.get("location");
95
+ const unusedInvoice = response.status >= 400 ? agent.unusedInvoice(target, method) : undefined;
96
+ return json({
97
+ status: response.status,
98
+ ...(location ? { location } : {}),
99
+ content_type: response.headers.get("content-type") || null,
100
+ payment: readHeader(response.headers.get(PAYMENT_RESPONSE_HEADER), decodePaymentResponseHeader) ?? null,
101
+ ...(unusedInvoice ? { invoice_id: unusedInvoice, payment_unused: true } : {}),
102
+ body: text,
103
+ body_truncated: truncated,
104
+ });
105
+ }
106
+ catch (error) {
107
+ return failure(error);
108
+ }
109
+ });
110
+ server.registerTool("get_balances", { title: "Wallet balances", description: "Shows the agent wallet's balances of the coins it can pay with, on NEAR and Base." }, async () => {
111
+ try {
112
+ return json(await agent.balances());
113
+ }
114
+ catch (error) {
115
+ return failure(error);
116
+ }
117
+ });
118
+ server.registerTool("get_spending", { title: "Spending and limits", description: "Shows the spending limits, the amount spent in the last 24 hours, and recent payments." }, async () => {
119
+ try {
120
+ return json(await agent.spending());
121
+ }
122
+ catch (error) {
123
+ return failure(error);
124
+ }
125
+ });
126
+ server.registerTool("set_limits", {
127
+ title: "Change spending limits",
128
+ description: "Lowers the per-payment or daily USD limit, or narrows the list of hosts the agent may pay. Limits cannot be raised above the values the server was started with.",
129
+ inputSchema: z.object({
130
+ per_payment_usd: z.number().min(0).optional(),
131
+ daily_usd: z.number().min(0).optional(),
132
+ allow: z.array(z.string().min(1)).optional().describe("Hosts the agent may request and pay, such as api.example.com or *.example.com."),
133
+ }),
134
+ }, async ({ per_payment_usd, daily_usd, allow }) => {
135
+ try {
136
+ const current = agent.limits();
137
+ const requested = {
138
+ ...(per_payment_usd !== undefined ? { perPaymentUsd: per_payment_usd } : {}),
139
+ ...(daily_usd !== undefined ? { dailyUsd: daily_usd } : {}),
140
+ ...(allow !== undefined ? { allow } : {}),
141
+ };
142
+ return json({ limits: agent.setLimits(limitsWithinCeiling(current, requested, options.ceiling)) });
143
+ }
144
+ catch (error) {
145
+ return failure(error);
146
+ }
147
+ });
148
+ return server;
149
+ }
150
+ /** Serves the agent wallet as an MCP server over stdin and stdout. */
151
+ export function serveAgentMcp(agent, options) {
152
+ return serveStdio(() => buildServer(agent, options), { onerror: (error) => process.stderr.write(`[solverr-agent] ${errorSummary(error)}\n`) });
153
+ }
@@ -0,0 +1,28 @@
1
+ import type { WalletAsset } from "./assets.js";
2
+ import type { ChainWallet } from "./chains/chain-wallet.js";
3
+ import type { SolverrChallenge } from "./challenge.js";
4
+ import type { GatewayClient } from "./gateway.js";
5
+ import { type SpendingLimits } from "./limits.js";
6
+ import type { SpendLog } from "./spend-log.js";
7
+ interface PayerOptions {
8
+ wallets: readonly ChainWallet[];
9
+ assets: readonly WalletAsset[];
10
+ spendLog: SpendLog;
11
+ gateway: GatewayClient;
12
+ limits: () => SpendingLimits;
13
+ paymentTimeoutMs: number;
14
+ }
15
+ /** Pays `solverr` invoices one at a time: limits, coin choice, quote checks, transfer, and confirmation. */
16
+ export declare class Payer {
17
+ private readonly options;
18
+ private queue;
19
+ constructor(options: PayerOptions);
20
+ /** Pays the invoice in `challenge`, one payment at a time so limit checks never race.
21
+ An aborted `signal` stops the payment at any step before the transfer; once the transfer starts it is not stopped. */
22
+ pay(resourceUrl: URL, challenge: SolverrChallenge, signal?: AbortSignal): Promise<void>;
23
+ private payInvoice;
24
+ /** Mirrors `changes` on `entry` before saving, so the error path knows funds left even when the write fails. */
25
+ private record;
26
+ private transferAndConfirm;
27
+ }
28
+ export {};
package/dist/payer.js ADDED
@@ -0,0 +1,157 @@
1
+ import { SolverrApiError } from "solverr-x402";
2
+ import { formatUnits } from "viem";
3
+ import { rankPaymentOptions } from "./coin-selection.js";
4
+ import { MAX_QUOTE_ATTEMPTS, MAX_QUOTE_OVER_ESTIMATE, MIN_QUOTE_VALIDITY_MS, STATUS_POLL_INTERVAL_MS } from "./config.js";
5
+ import { PaymentError, errorSummary } from "./errors.js";
6
+ import { assertHostAllowed, enforceLimits } from "./limits.js";
7
+ import { sleep } from "./timeout.js";
8
+ /** Invoice states the agent's own quote ending sets, after which waiting cannot lead to `paid`. `expired` is not one:
9
+ a deposit sent near the deadline still moves the invoice to `detected` and then `paid`. */
10
+ const FAILED_INVOICE_STATES = new Set(["underpaid", "refunded", "failed"]);
11
+ /** Allowance for rounding in the USD comparison of a quote against its estimate. */
12
+ const QUOTE_TOLERANCE_USD = 0.01;
13
+ /** Pays `solverr` invoices one at a time: limits, coin choice, quote checks, transfer, and confirmation. */
14
+ export class Payer {
15
+ options;
16
+ queue = Promise.resolve();
17
+ constructor(options) {
18
+ this.options = options;
19
+ }
20
+ /** Pays the invoice in `challenge`, one payment at a time so limit checks never race.
21
+ An aborted `signal` stops the payment at any step before the transfer; once the transfer starts it is not stopped. */
22
+ pay(resourceUrl, challenge, signal) {
23
+ const run = () => this.payInvoice(resourceUrl, challenge, signal);
24
+ const result = this.queue.then(run, run);
25
+ this.queue = result.catch(() => undefined);
26
+ return result;
27
+ }
28
+ async payInvoice(resourceUrl, challenge, signal) {
29
+ const { extra } = challenge;
30
+ const { spendLog, gateway } = this.options;
31
+ const hostname = resourceUrl.hostname;
32
+ signal?.throwIfAborted();
33
+ assertHostAllowed(this.options.limits(), hostname);
34
+ if (extra.mode !== "live") {
35
+ throw new PaymentError("test_invoice", "This is a test invoice. Test invoices come from the Solverr demo store and cannot be paid on-chain.");
36
+ }
37
+ if (Date.parse(extra.expires_at) - Date.now() < MIN_QUOTE_VALIDITY_MS)
38
+ throw new PaymentError("invoice_expired", "The invoice expires too soon to pay safely.");
39
+ const tokens = await gateway.listTokens(challenge.tokensUrl);
40
+ const settlementToken = tokens.find((token) => token.asset_id === extra.settlement.asset_id);
41
+ if (!settlementToken?.price_usd)
42
+ throw new PaymentError("invalid_challenge", "The invoice's settlement coin has no price from the gateway.");
43
+ const settlementUsd = Number(extra.settlement.amount) * settlementToken.price_usd;
44
+ const amountUsd = extra.currency === "USD" ? Math.max(settlementUsd, Number(extra.price)) : settlementUsd;
45
+ if (!Number.isFinite(amountUsd) || amountUsd <= 0)
46
+ throw new PaymentError("invalid_challenge", "The invoice amount is not valid.");
47
+ const entry = await spendLog.reserve({ url: resourceUrl.toString(), host: hostname, invoice_id: extra.invoice_id, amount_usd: amountUsd, status: "reserved" }, (spentLast24hUsd) => enforceLimits(this.options.limits(), { hostname, amountUsd, spentLast24hUsd }));
48
+ try {
49
+ const { options, skipped } = await rankPaymentOptions({ wallets: this.options.wallets, assets: this.options.assets, tokens, settlement: extra.settlement, amountUsd });
50
+ if (options.length === 0) {
51
+ throw new PaymentError("no_funds", `No coin in this wallet covers about $${amountUsd.toFixed(2)}.${skipped.length ? ` Checked: ${skipped.join("; ")}.` : ""}`);
52
+ }
53
+ const refusals = [];
54
+ for (const option of options.slice(0, MAX_QUOTE_ATTEMPTS)) {
55
+ signal?.throwIfAborted();
56
+ let quote;
57
+ try {
58
+ quote = await gateway.requestQuote(extra.quote_url, option.asset.asset_id, option.wallet.address);
59
+ }
60
+ catch (error) {
61
+ if (error instanceof SolverrApiError && error.status === 422) {
62
+ refusals.push(`${option.asset.symbol} on ${option.asset.blockchain}: ${error.message}`);
63
+ continue;
64
+ }
65
+ throw error;
66
+ }
67
+ const { amount, quotedUsd } = verifyQuote(quote, option, amountUsd);
68
+ await this.record(entry, { amount_usd: quotedUsd, asset_id: option.asset.asset_id, blockchain: option.asset.blockchain, amount_base: amount.toString() }, (spentLast24hUsd) => enforceLimits(this.options.limits(), { hostname, amountUsd: quotedUsd, spentLast24hUsd }));
69
+ signal?.throwIfAborted();
70
+ return await this.transferAndConfirm(entry, option, quote, amount, challenge);
71
+ }
72
+ throw new PaymentError("quote_unavailable", `No quote was available. ${refusals.join(" ")}`.trim());
73
+ }
74
+ catch (error) {
75
+ const fundsSent = entry.status !== "reserved" || (error instanceof PaymentError && error.fundsSent);
76
+ if (entry.status === "reserved") {
77
+ /** A failed write leaves the entry reserved, which still counts toward the daily limit. */
78
+ await this.record(entry, { status: fundsSent ? "failed" : "aborted", error: errorSummary(error) }).catch(() => undefined);
79
+ }
80
+ throw fundsSent ? sentPaymentError(error, extra.invoice_id) : error;
81
+ }
82
+ }
83
+ /** Mirrors `changes` on `entry` before saving, so the error path knows funds left even when the write fails. */
84
+ async record(entry, changes, check) {
85
+ Object.assign(entry, changes);
86
+ await this.options.spendLog.update(entry.id, changes, check);
87
+ }
88
+ async transferAndConfirm(entry, option, quote, amount, challenge) {
89
+ const { gateway } = this.options;
90
+ const { extra } = challenge;
91
+ const transfer = await option.wallet.transfer(option.asset, quote.deposit_address, amount);
92
+ /** From here on the entry counts toward the daily limit whatever its saved status, so a failed write does not stop the payment. */
93
+ await this.record(entry, { status: "sent", tx_hash: transfer.hash }).catch(() => undefined);
94
+ try {
95
+ await gateway.reportDeposit(extra.deposit_url, transfer.hash, quote.id);
96
+ }
97
+ catch {
98
+ /** Reporting only speeds up detection; the gateway also watches the deposit address. */
99
+ }
100
+ const deadline = Date.now() + this.options.paymentTimeoutMs;
101
+ let lastStatus = "unknown";
102
+ while (Date.now() < deadline) {
103
+ try {
104
+ lastStatus = (await gateway.getStatus(extra.status_url)).status;
105
+ }
106
+ catch (error) {
107
+ if (!(error instanceof SolverrApiError))
108
+ throw error;
109
+ }
110
+ if (lastStatus === "paid") {
111
+ await this.record(entry, { status: "paid" }).catch(() => undefined);
112
+ return;
113
+ }
114
+ if (FAILED_INVOICE_STATES.has(lastStatus)) {
115
+ await this.record(entry, { status: "failed", error: `Invoice ${lastStatus}` }).catch(() => undefined);
116
+ throw new PaymentError("payment_failed", `Sent ${transfer.hash}, but the invoice is ${lastStatus}. Any refund goes to ${option.wallet.address}.`, true, extra.invoice_id);
117
+ }
118
+ await sleep(STATUS_POLL_INTERVAL_MS);
119
+ }
120
+ throw new PaymentError("payment_timeout", `Sent ${transfer.hash}, but invoice ${extra.invoice_id} was still "${lastStatus}" after ${Math.round(this.options.paymentTimeoutMs / 1000)} s. Check ${extra.status_url} before paying again.`, true, extra.invoice_id);
121
+ }
122
+ }
123
+ /** The error for a payment whose funds may have left the wallet, carrying the invoice ID so the caller never pays it twice. */
124
+ function sentPaymentError(error, invoiceId) {
125
+ if (error instanceof PaymentError && error.fundsSent)
126
+ return new PaymentError(error.code, error.message, true, invoiceId);
127
+ return new PaymentError("payment_unconfirmed", `Funds were sent for invoice ${invoiceId}, but the payment could not be confirmed: ${errorSummary(error)}`, true, invoiceId);
128
+ }
129
+ /** Checks a quote against the chosen coin, the wallet and the estimate before any funds move; returns the exact amount and its USD value. */
130
+ function verifyQuote(quote, option, estimateUsd) {
131
+ const mismatch = (reason) => new PaymentError("quote_mismatch", `The quote was refused: ${reason}.`);
132
+ if (quote.deposit_address?.startsWith("TEST-")) {
133
+ throw new PaymentError("test_invoice", "This invoice uses a test deposit address. Test invoices cannot be paid on-chain.");
134
+ }
135
+ if (quote.coin?.asset_id !== option.asset.asset_id)
136
+ throw mismatch("it is for a different coin");
137
+ if (quote.refund_address?.toLowerCase() !== option.wallet.address.toLowerCase())
138
+ throw mismatch("its refund address is not this wallet");
139
+ if (!option.wallet.isValidRecipient(quote.deposit_address))
140
+ throw mismatch(`the deposit address is not a valid ${option.asset.blockchain} address`);
141
+ if (quote.deposit_memo)
142
+ throw mismatch("it needs a memo, which this wallet cannot attach");
143
+ if (!/^\d{1,78}$/.test(quote.amount_in_base ?? ""))
144
+ throw mismatch("its amount is malformed");
145
+ const amount = BigInt(quote.amount_in_base);
146
+ if (amount <= 0n)
147
+ throw mismatch("its amount is zero");
148
+ if (amount > option.balance)
149
+ throw new PaymentError("no_funds", `The quote needs ${quote.amount_in} ${option.asset.symbol}, more than this wallet holds.`);
150
+ if (Date.parse(quote.deadline) - Date.now() < MIN_QUOTE_VALIDITY_MS)
151
+ throw mismatch("it expires too soon");
152
+ const quotedUsd = Number(formatUnits(amount, option.asset.decimals)) * option.priceUsd;
153
+ if (quotedUsd > estimateUsd * (1 + MAX_QUOTE_OVER_ESTIMATE) + QUOTE_TOLERANCE_USD) {
154
+ throw mismatch(`it costs about $${quotedUsd.toFixed(4)}, far above the expected $${estimateUsd.toFixed(4)}`);
155
+ }
156
+ return { amount, quotedUsd };
157
+ }
@@ -0,0 +1,38 @@
1
+ /** `reserved`: limits checked, nothing sent. `sent`: transfer broadcast. `paid`: invoice paid.
2
+ `failed`: funds were sent but the invoice was not paid. `aborted`: stopped before any transfer. */
3
+ export type SpendStatus = "reserved" | "sent" | "paid" | "failed" | "aborted";
4
+ export interface SpendEntry {
5
+ id: string;
6
+ created_at: string;
7
+ url: string;
8
+ host: string;
9
+ invoice_id: string;
10
+ amount_usd: number;
11
+ status: SpendStatus;
12
+ asset_id?: string;
13
+ blockchain?: string;
14
+ amount_base?: string;
15
+ tx_hash?: string;
16
+ error?: string;
17
+ }
18
+ export type SpendChanges = Partial<Omit<SpendEntry, "id" | "created_at">>;
19
+ /** Throws to refuse a write; receives the USD counted over the last 24 hours, excluding the entry being written. */
20
+ export type SpendCheck = (spentLast24hUsd: number) => void;
21
+ /** Local JSON record of payments that enforces the rolling 24-hour limit across restarts and processes.
22
+ Every write re-reads the file under an exclusive lock file, so agents sharing one log never overwrite each other. */
23
+ export declare class SpendLog {
24
+ readonly path: string;
25
+ constructor(path?: string);
26
+ /** Reads the file; a missing file is an empty log, a corrupt one is an error so limits are never reset silently. */
27
+ entries(): Promise<SpendEntry[]>;
28
+ /** USD counted toward the daily limit over the last 24 hours. */
29
+ spentLast24hUsd(): Promise<number>;
30
+ /** Runs `check` against the current total and adds the entry in one locked step, so two processes cannot both pass the limit. */
31
+ reserve(entry: Omit<SpendEntry, "id" | "created_at">, check: SpendCheck): Promise<SpendEntry>;
32
+ /** Applies `changes` to entry `id`, after `check` passes against the total of all other entries. */
33
+ update(id: string, changes: SpendChanges, check?: SpendCheck): Promise<SpendEntry>;
34
+ /** Holds `${path}.lock` (created with `wx`, holding a random token) while `run` reads and writes the log. */
35
+ private withLock;
36
+ /** Writes to a temporary file and renames it, so a crash never leaves a half-written log. */
37
+ private save;
38
+ }
@@ -0,0 +1,162 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { DAY_MS, SPEND_LOG_LOCK_RETRY_MS, SPEND_LOG_LOCK_TIMEOUT_MS, SPEND_LOG_RETENTION_MS, SPEND_LOG_STALE_LOCK_MS } from "./config.js";
6
+ import { sleep } from "./timeout.js";
7
+ const DEFAULT_SPEND_LOG_PATH = join(homedir(), ".solverr", "agent-spend-log.json");
8
+ /** Every status except `aborted` counts toward the daily limit, since funds may have left the wallet. */
9
+ const counts = (entry) => entry.status !== "aborted";
10
+ function spentLast24h(entries, exceptId) {
11
+ const since = Date.now() - DAY_MS;
12
+ return entries
13
+ .filter((entry) => entry.id !== exceptId && counts(entry) && Date.parse(entry.created_at) >= since)
14
+ .reduce((sum, entry) => sum + entry.amount_usd, 0);
15
+ }
16
+ /** On Windows a lock file that is being deleted refuses to open with EPERM, which means the same as EEXIST here. */
17
+ const isLockBusy = (error) => {
18
+ const code = error.code;
19
+ return code === "EEXIST" || (code === "EPERM" && process.platform === "win32");
20
+ };
21
+ /** Reads the lock file's token and whether it is older than the stale limit; `undefined` when there is no lock. */
22
+ async function readLock(lockPath) {
23
+ try {
24
+ const [{ mtimeMs }, token] = await Promise.all([stat(lockPath), readFile(lockPath, "utf8")]);
25
+ return { token, stale: Date.now() - mtimeMs >= SPEND_LOG_STALE_LOCK_MS };
26
+ }
27
+ catch (error) {
28
+ if (error.code === "ENOENT")
29
+ return undefined;
30
+ if (isLockBusy(error))
31
+ return { token: "", stale: false };
32
+ throw error;
33
+ }
34
+ }
35
+ /** Removes a stale lock file and returns whether it may now be free. Only the waiter that creates this token's takeover file
36
+ removes it, after reading the lock again, so a fresh lock another waiter took meanwhile is never deleted. */
37
+ async function removeStaleLock(lockPath) {
38
+ const lock = await readLock(lockPath);
39
+ if (!lock)
40
+ return true;
41
+ if (!lock.stale)
42
+ return false;
43
+ const claimPath = `${lockPath}.${/^[\w-]{1,64}$/.test(lock.token) ? lock.token : "unknown"}.takeover`;
44
+ let claim;
45
+ try {
46
+ claim = await open(claimPath, "wx", 0o600);
47
+ }
48
+ catch (error) {
49
+ if (isLockBusy(error))
50
+ return false;
51
+ throw error;
52
+ }
53
+ try {
54
+ const current = await readLock(lockPath);
55
+ if (current?.stale && current.token === lock.token)
56
+ await rm(lockPath, { force: true });
57
+ }
58
+ finally {
59
+ await claim.close();
60
+ await rm(claimPath, { force: true });
61
+ }
62
+ return true;
63
+ }
64
+ /** Local JSON record of payments that enforces the rolling 24-hour limit across restarts and processes.
65
+ Every write re-reads the file under an exclusive lock file, so agents sharing one log never overwrite each other. */
66
+ export class SpendLog {
67
+ path;
68
+ constructor(path = DEFAULT_SPEND_LOG_PATH) {
69
+ this.path = path;
70
+ }
71
+ /** Reads the file; a missing file is an empty log, a corrupt one is an error so limits are never reset silently. */
72
+ async entries() {
73
+ let text;
74
+ try {
75
+ text = await readFile(this.path, "utf8");
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "ENOENT")
79
+ throw error;
80
+ return [];
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(text);
85
+ }
86
+ catch {
87
+ parsed = null;
88
+ }
89
+ if (parsed?.version !== 1 || !Array.isArray(parsed.entries))
90
+ throw new Error(`Spend log ${this.path} is not a valid spend log file.`);
91
+ return parsed.entries;
92
+ }
93
+ /** USD counted toward the daily limit over the last 24 hours. */
94
+ async spentLast24hUsd() {
95
+ return spentLast24h(await this.entries());
96
+ }
97
+ /** Runs `check` against the current total and adds the entry in one locked step, so two processes cannot both pass the limit. */
98
+ async reserve(entry, check) {
99
+ return this.withLock(async () => {
100
+ const entries = await this.entries();
101
+ check(spentLast24h(entries));
102
+ const created = { id: randomUUID(), created_at: new Date().toISOString(), ...entry };
103
+ entries.push(created);
104
+ await this.save(entries);
105
+ return created;
106
+ });
107
+ }
108
+ /** Applies `changes` to entry `id`, after `check` passes against the total of all other entries. */
109
+ async update(id, changes, check) {
110
+ return this.withLock(async () => {
111
+ const entries = await this.entries();
112
+ const entry = entries.find((item) => item.id === id);
113
+ if (!entry)
114
+ throw new Error(`Spend log entry ${id} not found.`);
115
+ check?.(spentLast24h(entries, id));
116
+ Object.assign(entry, changes);
117
+ await this.save(entries);
118
+ return entry;
119
+ });
120
+ }
121
+ /** Holds `${path}.lock` (created with `wx`, holding a random token) while `run` reads and writes the log. */
122
+ async withLock(run) {
123
+ const lockPath = `${this.path}.lock`;
124
+ await mkdir(dirname(this.path), { recursive: true });
125
+ const deadline = Date.now() + SPEND_LOG_LOCK_TIMEOUT_MS;
126
+ const token = randomUUID();
127
+ let lock;
128
+ while (!lock) {
129
+ try {
130
+ lock = await open(lockPath, "wx", 0o600);
131
+ }
132
+ catch (error) {
133
+ if (!isLockBusy(error))
134
+ throw error;
135
+ if (await removeStaleLock(lockPath))
136
+ continue;
137
+ if (Date.now() >= deadline) {
138
+ throw new Error(`Spend log ${this.path} stayed locked for ${SPEND_LOG_LOCK_TIMEOUT_MS / 1000} s. If no other agent is running, delete ${lockPath}.`);
139
+ }
140
+ await sleep(SPEND_LOG_LOCK_RETRY_MS);
141
+ }
142
+ }
143
+ try {
144
+ await lock.writeFile(token, "utf8");
145
+ return await run();
146
+ }
147
+ finally {
148
+ await lock.close();
149
+ /** A lock taken over after going stale belongs to its new holder and is left in place. */
150
+ if ((await readLock(lockPath))?.token === token)
151
+ await rm(lockPath, { force: true });
152
+ }
153
+ }
154
+ /** Writes to a temporary file and renames it, so a crash never leaves a half-written log. */
155
+ async save(entries) {
156
+ const cutoff = Date.now() - SPEND_LOG_RETENTION_MS;
157
+ const file = { version: 1, entries: entries.filter((entry) => Date.parse(entry.created_at) >= cutoff) };
158
+ const temporary = `${this.path}.${process.pid}.tmp`;
159
+ await writeFile(temporary, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
160
+ await rename(temporary, this.path);
161
+ }
162
+ }
@@ -0,0 +1,7 @@
1
+ /** Error raised when an operation exceeds its time budget. */
2
+ export declare class TimeoutError extends Error {
3
+ constructor(label: string, ms: number);
4
+ }
5
+ /** Rejects with `TimeoutError` when `promise` does not settle within `ms`; the underlying work is not cancelled. */
6
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
7
+ export declare const sleep: (ms: number) => Promise<unknown>;
@@ -0,0 +1,16 @@
1
+ /** Error raised when an operation exceeds its time budget. */
2
+ export class TimeoutError extends Error {
3
+ constructor(label, ms) {
4
+ super(`${label} did not finish within ${Math.round(ms / 1000)} s.`);
5
+ this.name = "TimeoutError";
6
+ }
7
+ }
8
+ /** Rejects with `TimeoutError` when `promise` does not settle within `ms`; the underlying work is not cancelled. */
9
+ export function withTimeout(promise, ms, label) {
10
+ let timer;
11
+ const timeout = new Promise((_, reject) => {
12
+ timer = setTimeout(() => reject(new TimeoutError(label, ms)), ms);
13
+ });
14
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
15
+ }
16
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -0,0 +1,7 @@
1
+ import { Agent } from "undici";
2
+ /** Refuses a host off the allow list or resolving to any private address, unless the URL names that IP or `localhost` and it is allowed exactly.
3
+ Returns the checked public addresses for `pinnedDispatcher`; empty for an IP or `localhost` allowed exactly, which needs no pinning. */
4
+ export declare function assertFetchAllowed(url: URL, allow: readonly string[]): Promise<string[]>;
5
+ /** Dispatcher that connects only to `addresses`, so a DNS answer changed after `assertFetchAllowed` cannot reach a private network.
6
+ Undefined when there is nothing to pin; close it once the response body is read. */
7
+ export declare function pinnedDispatcher(addresses: readonly string[]): Agent | undefined;