@aibtc/mcp-server 1.65.0 → 1.66.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.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * AIBTC News Legion (news-gov-v5) chain reads.
3
+ *
4
+ * Everything here reads the deployed contracts rather than trusting constants:
5
+ * governance parameters, the timing mode (which clock the windows count on),
6
+ * and the sBTC token the treasury actually holds all come off chain and are
7
+ * cached per process. A doc that drifts from the contract is a doc; a read that
8
+ * drifts from the contract is a bug, so there is nothing to drift.
9
+ */
10
+ import { ClarityType, cvToJSON, deserializeCV, getAddressFromPrivateKey, principalCV, uintCV, } from "@stacks/transactions";
11
+ import { LEGION_GOV_CONTRACT, LEGION_NETWORK, LEGION_TREASURY_CONTRACT, ORDINALS_CONTENT_BASE, ORDINALS_INSCRIPTION_BASE, } from "../config/legion.js";
12
+ import { getHiroApi } from "./hiro-api.js";
13
+ import { getAccount } from "./x402.service.js";
14
+ // ---------------------------------------------------------------------------
15
+ // Clarity value → plain JS
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Deep-unwrap a ClarityValue into plain JS. `cvToValue` only unwraps the top
19
+ * level — nested tuples and optionals come back as `{type, value}` envelopes —
20
+ * so the legion's tuple-heavy views need this instead.
21
+ *
22
+ * uints/ints become strings to stay exact; callers narrow with `num()`.
23
+ */
24
+ export function simplifyCV(cv) {
25
+ switch (cv.type) {
26
+ case ClarityType.BoolTrue:
27
+ return true;
28
+ case ClarityType.BoolFalse:
29
+ return false;
30
+ case ClarityType.Int:
31
+ case ClarityType.UInt:
32
+ return cv.value.toString();
33
+ case ClarityType.Buffer:
34
+ return `0x${cv.value}`;
35
+ case ClarityType.StringASCII:
36
+ case ClarityType.StringUTF8:
37
+ case ClarityType.PrincipalStandard:
38
+ case ClarityType.PrincipalContract:
39
+ return cv.value;
40
+ case ClarityType.OptionalNone:
41
+ return null;
42
+ case ClarityType.OptionalSome:
43
+ case ClarityType.ResponseOk:
44
+ case ClarityType.ResponseErr:
45
+ return simplifyCV(cv.value);
46
+ case ClarityType.List:
47
+ return cv.value.map(simplifyCV);
48
+ case ClarityType.Tuple: {
49
+ const out = {};
50
+ for (const [key, value] of Object.entries(cv.value)) {
51
+ out[key] = simplifyCV(value);
52
+ }
53
+ return out;
54
+ }
55
+ default:
56
+ return cvToJSON(cv).value;
57
+ }
58
+ }
59
+ /** A uint field from a simplified tuple, as a JS number. */
60
+ export function num(value) {
61
+ return Number(value);
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // Contract reads
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Call a read-only function on a legion contract and return it deep-unwrapped.
68
+ * Always reads the legion's own chain, never the globally configured one.
69
+ */
70
+ export async function readLegionContract(contractId, functionName, functionArgs = []) {
71
+ const hiro = getHiroApi(LEGION_NETWORK);
72
+ const sender = contractId.split(".")[0];
73
+ const result = await hiro.callReadOnlyFunction(contractId, functionName, functionArgs, sender);
74
+ if (!result.okay || !result.result) {
75
+ throw new Error(`${contractId}::${functionName} failed: ${result.cause ?? "no result"}`);
76
+ }
77
+ const hex = result.result.startsWith("0x")
78
+ ? result.result.slice(2)
79
+ : result.result;
80
+ return simplifyCV(deserializeCV(Buffer.from(hex, "hex")));
81
+ }
82
+ export function readGov(functionName, functionArgs = []) {
83
+ return readLegionContract(LEGION_GOV_CONTRACT, functionName, functionArgs);
84
+ }
85
+ export function readTreasury(functionName, functionArgs = []) {
86
+ return readLegionContract(LEGION_TREASURY_CONTRACT, functionName, functionArgs);
87
+ }
88
+ let paramsCache = null;
89
+ let timingModeCache = null;
90
+ let tokenCache = null;
91
+ /** Every governance parameter, straight from `get-params`. */
92
+ export async function getParams() {
93
+ if (paramsCache)
94
+ return paramsCache;
95
+ const raw = (await readGov("get-params"));
96
+ paramsCache = {
97
+ votingQuorum: num(raw.votingQuorum),
98
+ votingThreshold: num(raw.votingThreshold),
99
+ vetoQuorum: num(raw.vetoQuorum),
100
+ minParticipants: num(raw.minParticipants),
101
+ minWeight: num(raw.minWeight),
102
+ minContribution: num(raw.minContribution),
103
+ drawBps: num(raw.drawBps),
104
+ votingDelay: num(raw.votingDelay),
105
+ voteWindow: num(raw.voteWindow),
106
+ vetoWindow: num(raw.vetoWindow),
107
+ concludeWindow: num(raw.concludeWindow),
108
+ proposeInterval: num(raw.proposeInterval),
109
+ };
110
+ return paramsCache;
111
+ }
112
+ /**
113
+ * Which clock the windows count on. `"TEST-STACKS-BLOCKS"` counts Stacks
114
+ * blocks; `"PROD-BURN"` counts Bitcoin burn blocks. Never assume — a window
115
+ * measured against the wrong tip is off by a factor of ten.
116
+ */
117
+ export async function getTimingMode() {
118
+ if (timingModeCache)
119
+ return timingModeCache;
120
+ timingModeCache = (await readGov("get-timing-mode"));
121
+ return timingModeCache;
122
+ }
123
+ /**
124
+ * The sBTC token the treasury actually moves, plus its fungible-token asset
125
+ * name — both needed to write an exact post-condition. Read from the treasury
126
+ * and its contract interface so a post-condition can never name the wrong asset.
127
+ */
128
+ export async function getLegionToken() {
129
+ if (tokenCache)
130
+ return tokenCache;
131
+ const contract = (await readTreasury("get-token"));
132
+ const iface = await getHiroApi(LEGION_NETWORK).getContractInterface(contract);
133
+ const assetName = iface.fungible_tokens?.[0]?.name;
134
+ if (!assetName) {
135
+ throw new Error(`${contract} declares no fungible token — cannot build a post-condition for it.`);
136
+ }
137
+ tokenCache = { contract, assetName };
138
+ return tokenCache;
139
+ }
140
+ /**
141
+ * The height the contract's windows are measured against, on the clock
142
+ * `get-timing-mode` names.
143
+ */
144
+ export async function getClockHeight() {
145
+ const [timingMode, info] = await Promise.all([
146
+ getTimingMode(),
147
+ getHiroApi(LEGION_NETWORK).getCoreApiInfo(),
148
+ ]);
149
+ const clock = timingMode === "PROD-BURN" ? "burn" : "stacks";
150
+ return {
151
+ height: clock === "burn" ? info.burn_block_height : info.stacks_tip_height,
152
+ clock,
153
+ timingMode,
154
+ };
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // The signing account, pinned to the legion's chain
158
+ // ---------------------------------------------------------------------------
159
+ /**
160
+ * The unlocked wallet, re-derived for the legion's network.
161
+ *
162
+ * The same private key yields a different address per chain, so a mainnet
163
+ * session still signs legion calls from its testnet address against the testnet
164
+ * node. This is the guard that makes a mainnet-configured server safe here.
165
+ */
166
+ export async function getLegionAccount() {
167
+ const base = await getAccount();
168
+ if (base.network === LEGION_NETWORK)
169
+ return base;
170
+ return {
171
+ ...base,
172
+ address: getAddressFromPrivateKey(base.privateKey, LEGION_NETWORK),
173
+ network: LEGION_NETWORK,
174
+ };
175
+ }
176
+ export async function getStory(proposalId) {
177
+ const raw = (await readGov("get-story", [uintCV(proposalId)]));
178
+ if (!raw)
179
+ return null;
180
+ return {
181
+ proposer: String(raw.proposer),
182
+ bond: num(raw.bond),
183
+ draw: num(raw.draw),
184
+ createdAt: num(raw.createdAt),
185
+ voteEnd: num(raw.voteEnd),
186
+ eligibleSnapshot: num(raw.eligibleSnapshot),
187
+ yesWeight: num(raw.yesWeight),
188
+ noWeight: num(raw.noWeight),
189
+ vetoWeight: num(raw.vetoWeight),
190
+ voterCount: num(raw.voterCount),
191
+ status: num(raw.status),
192
+ reason: String(raw.reason ?? ""),
193
+ };
194
+ }
195
+ export async function getStoryMeta(proposalId) {
196
+ const raw = (await readGov("get-story-meta", [uintCV(proposalId)]));
197
+ if (!raw)
198
+ return null;
199
+ return {
200
+ title: String(raw.title ?? ""),
201
+ description: String(raw.description ?? ""),
202
+ link: String(raw.link ?? ""),
203
+ };
204
+ }
205
+ /**
206
+ * `"none" | "pending" | "voting" | "veto" | "concludable" | "expired" |
207
+ * "passed" | "failed"` — the contract's own view of where a piece stands.
208
+ */
209
+ export async function getPhase(proposalId) {
210
+ return (await readGov("get-phase", [uintCV(proposalId)]));
211
+ }
212
+ export async function getLastProposalId() {
213
+ return num(await readGov("get-last-proposal-id"));
214
+ }
215
+ export async function getVoteRecord(proposalId, voter) {
216
+ const raw = (await readGov("get-vote-record", [
217
+ uintCV(proposalId),
218
+ principalCV(voter),
219
+ ]));
220
+ if (!raw)
221
+ return null;
222
+ return { support: Boolean(raw.support), weight: num(raw.weight) };
223
+ }
224
+ export async function getVetoRecord(proposalId, voter) {
225
+ const raw = (await readGov("get-veto-record", [
226
+ uintCV(proposalId),
227
+ principalCV(voter),
228
+ ]));
229
+ if (!raw)
230
+ return null;
231
+ return { weight: num(raw.weight) };
232
+ }
233
+ /** Every propose precondition folded into one read, with the individual gates. */
234
+ export async function getProposeStatus(who) {
235
+ const raw = (await readGov("propose-status", [principalCV(who)]));
236
+ return {
237
+ canPropose: Boolean(raw.canPropose),
238
+ eligible: Boolean(raw.eligible),
239
+ slotOpen: Boolean(raw.slotOpen),
240
+ noLiveProposal: Boolean(raw.noLiveProposal),
241
+ poolOk: Boolean(raw.poolOk),
242
+ nextProposeHeight: num(raw.nextProposeHeight),
243
+ lockOnPropose: num(raw.lockOnPropose),
244
+ draw: num(raw.draw),
245
+ freeWeight: num(raw.freeWeight),
246
+ };
247
+ }
248
+ /** Why a propose would revert right now, in the caller's words. */
249
+ export function proposeBlockers(status, minWeight) {
250
+ const blockers = [];
251
+ if (!status.eligible) {
252
+ blockers.push(`your weight is below minWeight (${minWeight}) — call legion_contribute first`);
253
+ }
254
+ if (!status.noLiveProposal) {
255
+ blockers.push("you already have a live proposal — one per principal until it concludes or lapses");
256
+ }
257
+ if (!status.slotOpen) {
258
+ blockers.push(`the contract-wide propose interval has not elapsed — next open at height ${status.nextProposeHeight}`);
259
+ }
260
+ if (!status.poolOk) {
261
+ blockers.push("the pool is empty or the draw would round to zero sats");
262
+ }
263
+ return blockers;
264
+ }
265
+ /** When each window opens and closes, on the clock the contract counts. */
266
+ export function buildTimeline(story, params, height, clock) {
267
+ const votingOpensAt = story.createdAt + params.votingDelay;
268
+ const vetoEnd = story.voteEnd + params.vetoWindow;
269
+ const concludeDeadline = vetoEnd + params.concludeWindow;
270
+ const nextBoundary = [votingOpensAt, story.voteEnd, vetoEnd, concludeDeadline].find((boundary) => boundary > height);
271
+ return {
272
+ createdAt: story.createdAt,
273
+ votingOpensAt,
274
+ voteEnd: story.voteEnd,
275
+ vetoEnd,
276
+ concludeDeadline,
277
+ currentHeight: height,
278
+ clock,
279
+ blocksUntilNextTransition: nextBoundary === undefined ? null : nextBoundary - height,
280
+ };
281
+ }
282
+ /**
283
+ * What `conclude` would decide if it ran at this height, following the
284
+ * contract's decision order exactly (integer division included, since Clarity
285
+ * floors every percentage).
286
+ *
287
+ * A story that is already terminal reports what the contract RECORDED, not a
288
+ * forecast. Forecasting a settled piece would be worse than useless: a passed
289
+ * proposal is necessarily past its conclude window, so the forecast branch
290
+ * would call every settled piece "expired".
291
+ */
292
+ export function predictOutcome(story, params, poolBalance, height) {
293
+ const cast = story.yesWeight + story.noWeight;
294
+ const eligible = story.eligibleSnapshot;
295
+ const quorumPct = eligible > 0 ? Math.floor((cast * 100) / eligible) : 0;
296
+ const yesPct = cast > 0 ? Math.floor((story.yesWeight * 100) / cast) : 0;
297
+ const vetoPct = eligible > 0 ? Math.floor((story.vetoWeight * 100) / eligible) : 0;
298
+ const participantsMet = story.voterCount >= params.minParticipants;
299
+ const quorumMet = eligible > 0 && participantsMet && quorumPct >= params.votingQuorum;
300
+ const thresholdMet = cast > 0 && yesPct >= params.votingThreshold;
301
+ const vetoed = eligible > 0 && vetoPct >= params.vetoQuorum;
302
+ const base = {
303
+ cast,
304
+ quorumPct,
305
+ yesPct,
306
+ vetoPct,
307
+ quorumMet,
308
+ thresholdMet,
309
+ vetoed,
310
+ participantsMet,
311
+ };
312
+ // Already terminal: report the record.
313
+ if (story.status !== 0) {
314
+ const settledOutcome = story.status === 1 ? "passed" : story.status === 3 ? "expired" : "failed";
315
+ return {
316
+ ...base,
317
+ settled: true,
318
+ outcome: settledOutcome,
319
+ reason: story.reason || settledOutcome,
320
+ payout: story.status === 1 ? story.draw : 0,
321
+ };
322
+ }
323
+ const concludeDeadline = story.voteEnd + params.vetoWindow + params.concludeWindow;
324
+ if (height >= concludeDeadline) {
325
+ return {
326
+ ...base,
327
+ settled: false,
328
+ outcome: "expired",
329
+ reason: "not-concluded — the conclude window closed with nobody calling it",
330
+ payout: 0,
331
+ };
332
+ }
333
+ if (vetoed) {
334
+ return { ...base, settled: false, outcome: "failed", reason: "vetoed", payout: 0 };
335
+ }
336
+ if (!quorumMet) {
337
+ return { ...base, settled: false, outcome: "failed", reason: "no-quorum", payout: 0 };
338
+ }
339
+ if (!thresholdMet) {
340
+ return { ...base, settled: false, outcome: "failed", reason: "voted-down", payout: 0 };
341
+ }
342
+ if (story.draw > poolBalance) {
343
+ return { ...base, settled: false, outcome: "failed", reason: "pool-short", payout: 0 };
344
+ }
345
+ return { ...base, settled: false, outcome: "passed", reason: "paid", payout: story.draw };
346
+ }
347
+ // ---------------------------------------------------------------------------
348
+ // Links and validation
349
+ // ---------------------------------------------------------------------------
350
+ /**
351
+ * Accept either a bare inscription id (`<txid>i0`) or a full URL, and return
352
+ * the link the contract should store.
353
+ *
354
+ * A bare id becomes the VIEWER url, not the content url: that is what every
355
+ * proposal already on chain uses, and it is what a voter wants from a click.
356
+ * The reader at legions.aibtc.news accepts either form and both bare ids.
357
+ */
358
+ export function normalizeOrdinalsLink(input) {
359
+ const trimmed = input.trim();
360
+ if (/^[0-9a-f]{64}i\d+$/i.test(trimmed)) {
361
+ return `${ORDINALS_INSCRIPTION_BASE}${trimmed.toLowerCase()}`;
362
+ }
363
+ if (/^https?:\/\//i.test(trimmed))
364
+ return trimmed;
365
+ throw new Error(`"${input}" is neither an inscription id (<64-hex-txid>i0) nor a URL. ` +
366
+ `Pass the inscription id from legion_inscribe_reveal, or a full https:// link.`);
367
+ }
368
+ /** The inscription id inside an ordinals link, when there is one. */
369
+ export function inscriptionIdFromLink(link) {
370
+ const match = link.match(/([0-9a-f]{64}i\d+)/i);
371
+ return match ? match[1].toLowerCase() : null;
372
+ }
373
+ /**
374
+ * The raw-bytes url for a stored link, so a voter can read the piece rather
375
+ * than just look at where it lives. Null when the link names no inscription.
376
+ */
377
+ export function contentUrlFromLink(link) {
378
+ const id = inscriptionIdFromLink(link);
379
+ return id ? `${ORDINALS_CONTENT_BASE}${id}` : null;
380
+ }
381
+ /**
382
+ * Clarity `string-ascii` holds one byte per character, so a smart quote or an
383
+ * em dash pasted into a headline is a revert, not a truncation. Catch it here
384
+ * with a message that names the character.
385
+ */
386
+ export function assertAscii(value, field, maxLength) {
387
+ const offender = [...value].find((char) => char.charCodeAt(0) > 127);
388
+ if (offender) {
389
+ throw new Error(`${field} must be ASCII — "${offender}" (U+${offender
390
+ .charCodeAt(0)
391
+ .toString(16)
392
+ .toUpperCase()
393
+ .padStart(4, "0")}) is not. Clarity string-ascii cannot hold it.`);
394
+ }
395
+ if (value.length > maxLength) {
396
+ throw new Error(`${field} is ${value.length} characters; the contract accepts at most ${maxLength}.`);
397
+ }
398
+ }
399
+ /** `(err uXXX)` from a failed read or a broadcast rejection, as a sentence. */
400
+ export function explainError(error, errors) {
401
+ const text = error instanceof Error ? error.message : String(error);
402
+ const match = text.match(/\bu(\d{3})\b/);
403
+ if (match) {
404
+ const explanation = errors[Number(match[1])];
405
+ if (explanation)
406
+ return `${text} — ${explanation}`;
407
+ }
408
+ return text;
409
+ }
410
+ //# sourceMappingURL=legion.service.js.map
@@ -9,6 +9,28 @@ export declare class X402RateLimitError extends Error {
9
9
  readonly retryAfterSeconds: number;
10
10
  constructor(message: string, retryAfterSeconds: number);
11
11
  }
12
+ /**
13
+ * How long a completed x402 request suppresses an identical repeat.
14
+ *
15
+ * This was 60s, which is shorter than a realistic agent retry: an agent that
16
+ * reports a failure to its operator and retries takes minutes, not seconds.
17
+ * In the incident behind #630 the two payments landed 362s apart — 6x the old
18
+ * window — so the guard never engaged and the endpoint was paid twice.
19
+ *
20
+ * The asymmetry favours a long window: a false dedup hit costs a wait (and
21
+ * still returns the prior txid so the caller can verify), while a miss costs a
22
+ * duplicate on-chain payment that cannot be reversed. Deliberate repeat
23
+ * purchases of the same endpoint with the same params must wait it out or vary
24
+ * a param; raise or lower via X402_DEDUP_TTL_SECONDS.
25
+ */
26
+ export declare const X402_DEDUP_TTL_MS: number;
27
+ /**
28
+ * Point the dedup cache at a different state file and drop what is in memory.
29
+ *
30
+ * Also the seam tests use to simulate a restart: re-pointing at the same file
31
+ * clears memory and forces the next read to come off disk.
32
+ */
33
+ export declare function setDedupStateFile(file: string): void;
12
34
  /**
13
35
  * Convert mnemonic to account
14
36
  */
@@ -168,14 +190,21 @@ export declare function probeEndpoint(options: {
168
190
  */
169
191
  export declare function generateDedupKey(method: string, url: string, params?: Record<string, string>, data?: Record<string, unknown>): string;
170
192
  /**
171
- * Check if a request was recently processed (within 60s)
193
+ * Check if a request was recently processed (within X402_DEDUP_TTL_MS).
194
+ *
195
+ * Loads persisted entries first so a payment broadcast before a server restart
196
+ * still suppresses the retry that follows it.
197
+ *
172
198
  * @returns txid if duplicate found, null otherwise
173
199
  */
174
- export declare function checkDedupCache(key: string): string | null;
200
+ export declare function checkDedupCache(key: string): Promise<string | null>;
175
201
  /**
176
- * Record a transaction in the dedup cache
202
+ * Record a transaction in the dedup cache and persist it.
203
+ *
204
+ * Awaits the write so the entry survives a crash immediately after a payment —
205
+ * the window where losing it costs a duplicate payment.
177
206
  */
178
- export declare function recordTransaction(key: string, txid: string): void;
207
+ export declare function recordTransaction(key: string, txid: string): Promise<void>;
179
208
  /**
180
209
  * Check if account has sufficient balance to pay for x402 endpoint.
181
210
  * @throws InsufficientBalanceError if balance is too low
@@ -10,6 +10,9 @@ import { formatStx, formatSbtc } from "../utils/formatting.js";
10
10
  import { getSbtcService } from "./sbtc.service.js";
11
11
  import { getHiroApi } from "./hiro-api.js";
12
12
  import { createHash } from "crypto";
13
+ import { promises as fs } from "fs";
14
+ import os from "os";
15
+ import path from "path";
13
16
  import { InsufficientBalanceError } from "../utils/errors.js";
14
17
  import { getContracts, parseContractId } from "../config/contracts.js";
15
18
  import { resolveDefaultFee } from "../utils/fee.js";
@@ -104,13 +107,167 @@ export class X402RateLimitError extends Error {
104
107
  this.name = "X402RateLimitError";
105
108
  }
106
109
  }
110
+ /**
111
+ * How long a completed x402 request suppresses an identical repeat.
112
+ *
113
+ * This was 60s, which is shorter than a realistic agent retry: an agent that
114
+ * reports a failure to its operator and retries takes minutes, not seconds.
115
+ * In the incident behind #630 the two payments landed 362s apart — 6x the old
116
+ * window — so the guard never engaged and the endpoint was paid twice.
117
+ *
118
+ * The asymmetry favours a long window: a false dedup hit costs a wait (and
119
+ * still returns the prior txid so the caller can verify), while a miss costs a
120
+ * duplicate on-chain payment that cannot be reversed. Deliberate repeat
121
+ * purchases of the same endpoint with the same params must wait it out or vary
122
+ * a param; raise or lower via X402_DEDUP_TTL_SECONDS.
123
+ */
124
+ export const X402_DEDUP_TTL_MS = parseSatsCap("X402_DEDUP_TTL_SECONDS", 900) * 1000; // 15 minutes
107
125
  // Transaction deduplication cache: {dedupKey -> {txid, timestamp}}
108
126
  const dedupCache = new Map();
127
+ /**
128
+ * Where the dedup cache is persisted.
129
+ *
130
+ * Memory alone left the guard open to the same double-pay it exists to stop: an
131
+ * MCP server restarts routinely (client reconnect, config change, crash), and a
132
+ * restart inside the TTL window dropped every entry. An agent retrying after one
133
+ * sailed past the guard and paid a second time — #630's outcome reached by a
134
+ * different route.
135
+ *
136
+ * Keys are SHA-256 digests (see generateDedupKey) and values are chain txids, so
137
+ * no request content reaches disk. The file is still written 0600 under a 0700
138
+ * directory to match the wallet store.
139
+ *
140
+ * Caveat: entries are keyed by request, not by payer. Switching wallets and
141
+ * repeating a request inside the window reports a hit for the other wallet's
142
+ * payment. That is the asymmetry the TTL already takes — a false hit costs a
143
+ * wait and still surfaces the prior txid to verify, while a miss costs an
144
+ * irreversible duplicate payment.
145
+ *
146
+ * Override the location with X402_DEDUP_STATE_FILE.
147
+ */
148
+ let dedupStateFile = process.env.X402_DEDUP_STATE_FILE ||
149
+ path.join(os.homedir(), ".aibtc", "x402-dedup.json");
150
+ let dedupLoad = null;
151
+ let dedupWriteLock = Promise.resolve();
152
+ /**
153
+ * Point the dedup cache at a different state file and drop what is in memory.
154
+ *
155
+ * Also the seam tests use to simulate a restart: re-pointing at the same file
156
+ * clears memory and forces the next read to come off disk.
157
+ */
158
+ export function setDedupStateFile(file) {
159
+ dedupStateFile = file;
160
+ dedupLoad = null;
161
+ dedupCache.clear();
162
+ }
163
+ function isFresh(entry, now) {
164
+ return now - entry.timestamp <= X402_DEDUP_TTL_MS;
165
+ }
166
+ /** Read persisted entries. A missing file is the normal first-run case. */
167
+ async function readDedupState() {
168
+ let raw;
169
+ try {
170
+ raw = await fs.readFile(dedupStateFile, "utf8");
171
+ }
172
+ catch (err) {
173
+ if (err.code !== "ENOENT") {
174
+ console.error(`[x402-dedup] Could not read ${dedupStateFile}: ${err instanceof Error ? err.message : String(err)}`);
175
+ }
176
+ return {};
177
+ }
178
+ try {
179
+ const parsed = JSON.parse(raw);
180
+ if (!parsed || typeof parsed !== "object")
181
+ return {};
182
+ return parsed;
183
+ }
184
+ catch {
185
+ // A corrupt file must not brick every x402 call. Report it and continue
186
+ // with the in-memory guard; the next write replaces the file.
187
+ console.error(`[x402-dedup] Ignoring corrupt state at ${dedupStateFile}`);
188
+ return {};
189
+ }
190
+ }
191
+ /**
192
+ * Load persisted entries into memory once per state file.
193
+ *
194
+ * Must be awaited before any dedup read: answering from a not-yet-loaded cache
195
+ * would report "no prior payment" for one that is sitting on disk, which is the
196
+ * exact miss this persistence closes.
197
+ */
198
+ async function ensureDedupLoaded() {
199
+ if (!dedupLoad) {
200
+ dedupLoad = (async () => {
201
+ const persisted = await readDedupState();
202
+ const now = Date.now();
203
+ for (const [key, entry] of Object.entries(persisted)) {
204
+ if (!entry ||
205
+ typeof entry.txid !== "string" ||
206
+ typeof entry.timestamp !== "number") {
207
+ continue;
208
+ }
209
+ if (!isFresh(entry, now))
210
+ continue;
211
+ // Anything recorded in this process is at least as fresh as disk.
212
+ const existing = dedupCache.get(key);
213
+ if (!existing || existing.timestamp < entry.timestamp) {
214
+ dedupCache.set(key, entry);
215
+ }
216
+ }
217
+ })();
218
+ }
219
+ return dedupLoad;
220
+ }
221
+ /**
222
+ * Merge memory over disk and write atomically, dropping expired entries.
223
+ *
224
+ * Re-reads inside the lock so a second server instance sharing the file does not
225
+ * lose the entries this one never saw. A failure here is logged rather than
226
+ * thrown: the caller records *after* funds have already moved, so rejecting
227
+ * would turn a recorded payment into a reported error while leaving the
228
+ * in-memory guard intact.
229
+ */
230
+ async function persistDedupCache() {
231
+ dedupWriteLock = dedupWriteLock
232
+ .then(async () => {
233
+ const onDisk = await readDedupState();
234
+ const now = Date.now();
235
+ const merged = {};
236
+ for (const [key, entry] of Object.entries(onDisk)) {
237
+ if (entry &&
238
+ typeof entry.txid === "string" &&
239
+ typeof entry.timestamp === "number" &&
240
+ isFresh(entry, now)) {
241
+ merged[key] = entry;
242
+ }
243
+ }
244
+ for (const [key, entry] of dedupCache) {
245
+ if (!isFresh(entry, now))
246
+ continue;
247
+ const existing = merged[key];
248
+ if (!existing || existing.timestamp <= entry.timestamp) {
249
+ merged[key] = entry;
250
+ }
251
+ }
252
+ await fs.mkdir(path.dirname(dedupStateFile), {
253
+ recursive: true,
254
+ mode: 0o700,
255
+ });
256
+ // pid-suffixed so concurrent servers do not clobber each other's temp.
257
+ const tmp = `${dedupStateFile}.${process.pid}.tmp`;
258
+ await fs.writeFile(tmp, JSON.stringify(merged), { mode: 0o600 });
259
+ await fs.rename(tmp, dedupStateFile);
260
+ })
261
+ .catch((err) => {
262
+ console.error(`[x402-dedup] Could not persist ${dedupStateFile}: ${err instanceof Error ? err.message : String(err)}`);
263
+ });
264
+ return dedupWriteLock;
265
+ }
109
266
  // Cleanup expired dedup entries every 5 minutes
110
267
  setInterval(() => {
111
268
  const now = Date.now();
112
269
  for (const [key, value] of dedupCache) {
113
- if (now - value.timestamp > 60000) {
270
+ if (!isFresh(value, now)) {
114
271
  dedupCache.delete(key);
115
272
  }
116
273
  }
@@ -845,26 +1002,36 @@ export function generateDedupKey(method, url, params, data) {
845
1002
  return createHash('sha256').update(payload).digest('hex');
846
1003
  }
847
1004
  /**
848
- * Check if a request was recently processed (within 60s)
1005
+ * Check if a request was recently processed (within X402_DEDUP_TTL_MS).
1006
+ *
1007
+ * Loads persisted entries first so a payment broadcast before a server restart
1008
+ * still suppresses the retry that follows it.
1009
+ *
849
1010
  * @returns txid if duplicate found, null otherwise
850
1011
  */
851
- export function checkDedupCache(key) {
1012
+ export async function checkDedupCache(key) {
1013
+ await ensureDedupLoaded();
852
1014
  const cached = dedupCache.get(key);
853
1015
  if (!cached) {
854
1016
  return null;
855
1017
  }
856
1018
  const now = Date.now();
857
- if (now - cached.timestamp > 60000) {
1019
+ if (!isFresh(cached, now)) {
858
1020
  dedupCache.delete(key);
859
1021
  return null;
860
1022
  }
861
1023
  return cached.txid;
862
1024
  }
863
1025
  /**
864
- * Record a transaction in the dedup cache
1026
+ * Record a transaction in the dedup cache and persist it.
1027
+ *
1028
+ * Awaits the write so the entry survives a crash immediately after a payment —
1029
+ * the window where losing it costs a duplicate payment.
865
1030
  */
866
- export function recordTransaction(key, txid) {
1031
+ export async function recordTransaction(key, txid) {
1032
+ await ensureDedupLoaded();
867
1033
  dedupCache.set(key, { txid, timestamp: Date.now() });
1034
+ await persistDedupCache();
868
1035
  }
869
1036
  /**
870
1037
  * Check if account has sufficient balance to pay for x402 endpoint.