@rozoai/checkout 0.1.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,1332 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __rozoCreateRequire } from 'node:module';
3
+ const require = __rozoCreateRequire(import.meta.url);
4
+
5
+ // scripts/src/lib/output.mjs
6
+ var EXIT_OK = 0;
7
+ var EXIT_ERROR = 1;
8
+ var EXIT_USAGE = 2;
9
+ function redact(text) {
10
+ if (text === null || text === void 0) return text;
11
+ let s = typeof text === "string" ? text : String(text);
12
+ s = s.replace(/0x[0-9a-fA-F]{64}/g, "0x<redacted>");
13
+ s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
14
+ s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
15
+ s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
16
+ s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (_m, scheme, rest) => {
17
+ const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
18
+ const host = withoutUserinfo.split(/[/?#]/)[0];
19
+ const hadMore = withoutUserinfo.length > host.length;
20
+ return `${scheme}://${host}${hadMore ? "/<redacted>" : ""}`;
21
+ });
22
+ s = s.replace(/\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 <redacted>");
23
+ s = s.replace(
24
+ /\b(api[-_]?key|apikey|access[-_]?token|auth[-_]?token|secret|token|password|passwd|pwd)\b(\s*[:=]\s*)("?)[A-Za-z0-9._~+/=-]{6,}\3/gi,
25
+ "$1$2<redacted>"
26
+ );
27
+ return s;
28
+ }
29
+ function redactDeep(value) {
30
+ if (typeof value === "string") return redact(value);
31
+ if (Array.isArray(value)) return value.map(redactDeep);
32
+ if (value && typeof value === "object") {
33
+ const out = {};
34
+ for (const [k, v] of Object.entries(value)) {
35
+ if (/priv(ate)?[-_]?key|secret|mnemonic|seed/i.test(k)) {
36
+ out[k] = "<redacted>";
37
+ continue;
38
+ }
39
+ out[k] = redactDeep(v);
40
+ }
41
+ return out;
42
+ }
43
+ return value;
44
+ }
45
+ var capturing = false;
46
+ var EmitSignal = class extends Error {
47
+ constructor(payload, exitCode) {
48
+ super("emit");
49
+ this.payload = payload;
50
+ this.exitCode = exitCode;
51
+ }
52
+ };
53
+ function formatFailure(err, fallbackCode = "RUNTIME_ERROR") {
54
+ const code = err && err.code || fallbackCode;
55
+ const message = redact(err && err.message || String(err));
56
+ const payload = { success: false, error: { code, message } };
57
+ if (err && err.details) payload.error.details = redactDeep(err.details);
58
+ return payload;
59
+ }
60
+ function emit(payload, exitCode = EXIT_OK) {
61
+ const redacted = redactDeep(payload);
62
+ if (capturing) throw new EmitSignal(redacted, exitCode);
63
+ process.stdout.write(JSON.stringify(redacted, null, 2) + "\n");
64
+ process.exit(exitCode);
65
+ }
66
+ function fail(err, fallbackCode = "RUNTIME_ERROR", exitCode = EXIT_ERROR) {
67
+ const payload = formatFailure(err, fallbackCode);
68
+ if (capturing) throw new EmitSignal(payload, exitCode);
69
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
70
+ process.exit(exitCode);
71
+ }
72
+ function usage(message) {
73
+ fail({ code: "USAGE", message }, "USAGE", EXIT_USAGE);
74
+ }
75
+ function parseArgs(argv) {
76
+ const out = { _: [] };
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const a = argv[i];
79
+ if (a.startsWith("--")) {
80
+ const key = a.slice(2);
81
+ const next = argv[i + 1];
82
+ if (next === void 0 || next.startsWith("--")) {
83
+ out[key] = true;
84
+ } else {
85
+ out[key] = next;
86
+ i++;
87
+ }
88
+ } else {
89
+ out._.push(a);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ var SkillError = class extends Error {
95
+ constructor(code, message, details) {
96
+ super(message);
97
+ this.code = code;
98
+ if (details) this.details = details;
99
+ }
100
+ };
101
+
102
+ // scripts/src/lib/ids.mjs
103
+ var IdError = class extends Error {
104
+ constructor(code, message) {
105
+ super(message);
106
+ this.code = code;
107
+ }
108
+ };
109
+ var PL_RE = /(pl_[0-9a-zA-Z]+)/;
110
+ var SESSION_RE = /(paymentSession_[A-Za-z0-9_-]+)/;
111
+ var UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
112
+ function extractLinkId(urlOrId) {
113
+ if (typeof urlOrId !== "string" || !urlOrId.trim()) {
114
+ throw new IdError("BAD_LINK", "A Coinbase payment link URL or id is required.");
115
+ }
116
+ const s = urlOrId.trim();
117
+ const session = s.match(SESSION_RE);
118
+ if (session) return { linkId: session[1], kind: "payment_session" };
119
+ const link = s.match(PL_RE);
120
+ if (link) return { linkId: link[1], kind: "payment_link" };
121
+ if (/commerce\.coinbase\.com\/pay\//.test(s)) {
122
+ throw new IdError(
123
+ "LEGACY_COMMERCE_URL",
124
+ "commerce.coinbase.com/pay/<uuid> uses the legacy protocol; this skill does not handle it."
125
+ );
126
+ }
127
+ throw new IdError(
128
+ "BAD_LINK",
129
+ "Could not find a `pl_*` or `paymentSession_*` id in the supplied value."
130
+ );
131
+ }
132
+ function assertRozoPaymentId(value) {
133
+ const s = String(value || "").trim();
134
+ if (!UUID_RE.test(s)) {
135
+ throw new IdError("BAD_ROZO_PAYMENT_ID", "rozoPaymentId must be a UUID.");
136
+ }
137
+ return s;
138
+ }
139
+ function maskAddress(address) {
140
+ const s = String(address ?? "").trim();
141
+ if (!s) return "(none)";
142
+ if (s.length <= 12) return s;
143
+ return `${s.slice(0, 6)}...${s.slice(-4)}`;
144
+ }
145
+ function maskMemo(memo) {
146
+ const s = String(memo ?? "").trim();
147
+ if (!s) return null;
148
+ if (s.length <= 8) return s;
149
+ return `${s.slice(0, 4)}...${s.slice(-3)}`;
150
+ }
151
+
152
+ // scripts/src/lib/http.mjs
153
+ var DEFAULT_TIMEOUT_MS = 2e4;
154
+ var USER_AGENT = "rozo-checkout-skill/1.0";
155
+ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
156
+ const controller = new AbortController();
157
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
158
+ let res;
159
+ try {
160
+ res = await fetch(url, {
161
+ method,
162
+ headers: {
163
+ accept: "application/json",
164
+ "user-agent": USER_AGENT,
165
+ ...body !== void 0 ? { "content-type": "application/json" } : {}
166
+ },
167
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
168
+ signal: controller.signal
169
+ });
170
+ } catch (err) {
171
+ throw new SkillError(
172
+ err?.name === "AbortError" ? "HTTP_TIMEOUT" : "HTTP_UNREACHABLE",
173
+ `${method} request failed: ${redact(err?.message || String(err))}`,
174
+ { url: redactUrl(url) }
175
+ );
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ const text = await res.text();
180
+ let json = null;
181
+ if (text) {
182
+ try {
183
+ json = JSON.parse(text);
184
+ } catch {
185
+ json = null;
186
+ }
187
+ }
188
+ if (!res.ok) {
189
+ const code = json?.code || json?.error?.code || (typeof json?.error === "string" ? null : null) || `HTTP_${res.status}`;
190
+ const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
191
+ throw new SkillError(code, redact(String(message)), {
192
+ httpStatus: res.status,
193
+ url: redactUrl(url),
194
+ body: json ?? redact(text).slice(0, 800)
195
+ });
196
+ }
197
+ if (json === null) {
198
+ throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
199
+ url: redactUrl(url),
200
+ snippet: redact(text).slice(0, 400)
201
+ });
202
+ }
203
+ return json;
204
+ }
205
+ function redactUrl(url) {
206
+ try {
207
+ const u = new URL(url);
208
+ return `${u.origin}${u.pathname}`;
209
+ } catch {
210
+ return String(url);
211
+ }
212
+ }
213
+ function getJson(url, opts) {
214
+ return request("GET", url, opts);
215
+ }
216
+ function postJson(url, body, opts) {
217
+ return request("POST", url, { ...opts, body });
218
+ }
219
+
220
+ // scripts/src/lib/api.mjs
221
+ var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpprouter.dev/v1/services/rozo-agent-api";
222
+ var INTENTS_BASE = process.env.ROZO_CHECKOUT_INTENTS_BASE || "https://intentapiv4.rozo.ai/functions/v1/payment-api";
223
+ async function quoteInvoice({ url, linkId }) {
224
+ const body = url ? { url } : { payment_id: linkId };
225
+ return postJson(`${MPP_BASE}/quote-invoice`, body);
226
+ }
227
+ async function createInvoice({ url, linkId, source, quoteReceipt }) {
228
+ const body = {
229
+ ...url ? { url } : { payment_id: linkId },
230
+ source: { chainId: String(source.chainId), tokenSymbol: source.tokenSymbol },
231
+ ...quoteReceipt ? { quoteReceipt } : {}
232
+ };
233
+ return postJson(`${MPP_BASE}/create-invoice`, body);
234
+ }
235
+ async function invoiceStatus({ linkId, rozoPaymentId }) {
236
+ const qs = new URLSearchParams();
237
+ if (linkId) qs.set("payment_id", linkId);
238
+ if (rozoPaymentId) qs.set("rozo_payment_id", rozoPaymentId);
239
+ if (![...qs.keys()].length) {
240
+ throw new SkillError("USAGE", "invoiceStatus needs linkId or rozoPaymentId.");
241
+ }
242
+ return getJson(`${MPP_BASE}/invoice-status?${qs.toString()}`);
243
+ }
244
+ async function getPayment(rozoPaymentId) {
245
+ return getJson(`${INTENTS_BASE}/payments/${encodeURIComponent(rozoPaymentId)}`);
246
+ }
247
+ function snapshotFromQuote(quote) {
248
+ const cb = quote?.coinbasePayment || quote?.paymentLink || null;
249
+ return {
250
+ linkId: quote?.linkId ?? quote?.paymentId ?? null,
251
+ protocolVersion: quote?.protocolVersion ?? null,
252
+ merchant: quote?.merchant ?? null,
253
+ original: quote?.invoice?.amount ?? null,
254
+ callerPays: quote?.quote?.callerPays ?? null,
255
+ fiat: quote?.invoice?.fiat ?? null,
256
+ coinbase: cb ? {
257
+ id: cb.id ?? null,
258
+ status: cb.status ?? null,
259
+ usageCount: cb.usageCount ?? null,
260
+ maxUsage: cb.maxUsage ?? null,
261
+ preApprovalExpiry: cb.preApprovalExpiry ?? cb.expiresAt ?? null
262
+ } : null
263
+ };
264
+ }
265
+
266
+ // scripts/src/lib/amounts.mjs
267
+ var CHAIN_NAMES = {
268
+ 1: "Ethereum",
269
+ 56: "BNB Chain",
270
+ 137: "Polygon",
271
+ 8453: "Base",
272
+ 900: "Solana",
273
+ 1500: "Stellar",
274
+ lightning: "Bitcoin Lightning"
275
+ };
276
+ var CHAIN_FAMILY = {
277
+ 1: "evm",
278
+ 56: "evm",
279
+ 137: "evm",
280
+ 8453: "evm",
281
+ 900: "solana",
282
+ 1500: "stellar",
283
+ lightning: "lightning"
284
+ };
285
+ var SUPPORTED_SOURCES = [
286
+ { chainId: "1", chain: "Ethereum", tokens: ["USDC", "USDT"] },
287
+ { chainId: "56", chain: "BNB Chain", tokens: ["USDC", "USDT"] },
288
+ { chainId: "137", chain: "Polygon", tokens: ["USDC", "USDT"] },
289
+ { chainId: "900", chain: "Solana", tokens: ["USDC", "USDT"] },
290
+ { chainId: "8453", chain: "Base", tokens: ["USDC"] },
291
+ { chainId: "1500", chain: "Stellar", tokens: ["USDC"] },
292
+ { chainId: "lightning", chain: "Bitcoin Lightning", tokens: ["BTC"] }
293
+ ];
294
+ var DECIMALS = {
295
+ "1:USDC": 6,
296
+ "1:USDT": 6,
297
+ // BNB Chain: BEP-20 USDT and USDC are both 18-decimals.
298
+ "56:USDC": 18,
299
+ "56:USDT": 18,
300
+ "137:USDC": 6,
301
+ "137:USDT": 6,
302
+ "8453:USDC": 6,
303
+ "900:USDC": 6,
304
+ "900:USDT": 6,
305
+ // Stellar classic assets carry 7 decimal places.
306
+ "1500:USDC": 7
307
+ };
308
+ var AmountError = class extends Error {
309
+ constructor(code, message) {
310
+ super(message);
311
+ this.code = code;
312
+ }
313
+ };
314
+ function decimalsKey(chainId, tokenSymbol) {
315
+ return `${String(chainId).trim()}:${String(tokenSymbol || "").trim().toUpperCase()}`;
316
+ }
317
+ function decimalsFor(chainId, tokenSymbol) {
318
+ const key = decimalsKey(chainId, tokenSymbol);
319
+ const d = DECIMALS[key];
320
+ if (d === void 0) {
321
+ throw new AmountError(
322
+ "UNKNOWN_DECIMALS",
323
+ `No decimals known for ${key}; refusing to guess.`
324
+ );
325
+ }
326
+ return d;
327
+ }
328
+ function isSatsUnit(amountUnit) {
329
+ return String(amountUnit || "").trim().toLowerCase() === "sats";
330
+ }
331
+ function toAtomic(decimalString, decimals) {
332
+ if (typeof decimalString === "number") decimalString = String(decimalString);
333
+ if (typeof decimalString !== "string") {
334
+ throw new AmountError("BAD_AMOUNT", "Amount must be a string or number.");
335
+ }
336
+ const s = decimalString.trim();
337
+ if (!/^\d+(\.\d+)?$/.test(s)) {
338
+ throw new AmountError("BAD_AMOUNT", `Not a plain decimal amount: ${s}`);
339
+ }
340
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
341
+ throw new AmountError("BAD_DECIMALS", `Invalid decimals: ${decimals}`);
342
+ }
343
+ const [whole, frac = ""] = s.split(".");
344
+ if (frac.length > decimals) {
345
+ const excess = frac.slice(decimals);
346
+ if (/[^0]/.test(excess)) {
347
+ throw new AmountError(
348
+ "AMOUNT_PRECISION",
349
+ `Amount ${s} has more precision than ${decimals} decimals allow.`
350
+ );
351
+ }
352
+ }
353
+ const padded = (frac + "0".repeat(decimals)).slice(0, decimals);
354
+ return BigInt(whole + (decimals > 0 ? padded : ""));
355
+ }
356
+ function satsToAtomic(value) {
357
+ const s = typeof value === "number" ? String(value) : String(value ?? "").trim();
358
+ if (!/^\d+$/.test(s)) {
359
+ throw new AmountError("BAD_SATS", `Lightning amount must be integer sats: ${s}`);
360
+ }
361
+ return BigInt(s);
362
+ }
363
+ function sourceAtomic(source, field) {
364
+ const raw = source?.[field];
365
+ if (raw === null || raw === void 0 || raw === "") return null;
366
+ if (isSatsUnit(source?.amountUnit)) return satsToAtomic(raw);
367
+ return toAtomic(raw, decimalsFor(source?.chainId, source?.tokenSymbol));
368
+ }
369
+ function comparePayment(source) {
370
+ const expected = sourceAtomic(source, "amount");
371
+ if (expected === null) {
372
+ throw new AmountError("MISSING_EXPECTED_AMOUNT", "source.amount is missing.");
373
+ }
374
+ const received = sourceAtomic(source, "amountReceived");
375
+ if (received === null || received === 0n) {
376
+ return {
377
+ state: "none",
378
+ expectedAtomic: expected.toString(),
379
+ receivedAtomic: (received ?? 0n).toString(),
380
+ deltaAtomic: (0n - expected).toString()
381
+ };
382
+ }
383
+ const delta = received - expected;
384
+ return {
385
+ state: delta === 0n ? "exact" : delta < 0n ? "underpaid" : "overpaid",
386
+ expectedAtomic: expected.toString(),
387
+ receivedAtomic: received.toString(),
388
+ deltaAtomic: delta.toString()
389
+ };
390
+ }
391
+ function formatAmount(source) {
392
+ const amount = source?.amount;
393
+ if (isSatsUnit(source?.amountUnit)) return `${amount} sats`;
394
+ return `${amount} ${source?.tokenSymbol ?? ""}`.trim();
395
+ }
396
+ function chainName(chainId) {
397
+ return CHAIN_NAMES[String(chainId)] || CHAIN_NAMES[chainId] || `chain ${chainId}`;
398
+ }
399
+ function chainFamily(chainId) {
400
+ return CHAIN_FAMILY[String(chainId)] || CHAIN_FAMILY[chainId] || null;
401
+ }
402
+ function isSupportedSource(chainId, tokenSymbol) {
403
+ const cid = String(chainId).trim();
404
+ const sym = String(tokenSymbol || "").trim().toUpperCase();
405
+ return SUPPORTED_SOURCES.some((s) => s.chainId === cid && s.tokens.includes(sym));
406
+ }
407
+
408
+ // scripts/src/lib/expiry.mjs
409
+ var MINUTE = 6e4;
410
+ var MARGINS_MS = {
411
+ 1: 10 * MINUTE,
412
+ 56: 10 * MINUTE,
413
+ 137: 10 * MINUTE,
414
+ 8453: 10 * MINUTE,
415
+ 900: 5 * MINUTE,
416
+ 1500: 10 * MINUTE,
417
+ lightning: 10 * MINUTE
418
+ };
419
+ var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
420
+ var DEFAULT_MARGIN_MS = 10 * MINUTE;
421
+ function marginFor(chainId) {
422
+ const key = String(chainId);
423
+ return MARGINS_MS[key] ?? MARGINS_MS[chainId] ?? DEFAULT_MARGIN_MS;
424
+ }
425
+ function parseDeadline(value) {
426
+ if (value === null || value === void 0 || value === "") return null;
427
+ if (typeof value === "number" && Number.isFinite(value)) {
428
+ return value < 1e12 ? Math.round(value * 1e3) : Math.round(value);
429
+ }
430
+ const s = String(value).trim();
431
+ if (/^\d+$/.test(s)) {
432
+ const n = Number(s);
433
+ return n < 1e12 ? n * 1e3 : n;
434
+ }
435
+ const t = Date.parse(s);
436
+ return Number.isFinite(t) ? t : null;
437
+ }
438
+ function checkExpiry({
439
+ now,
440
+ chainId,
441
+ intentExpiresAt,
442
+ coinbaseExpiry,
443
+ bolt11ExpiresAt = void 0
444
+ }) {
445
+ const marginMs = marginFor(chainId);
446
+ const intentMs = parseDeadline(intentExpiresAt);
447
+ const coinbaseMs = parseDeadline(coinbaseExpiry);
448
+ const bolt11Ms = bolt11ExpiresAt === void 0 ? void 0 : parseDeadline(bolt11ExpiresAt);
449
+ const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null };
450
+ const base = {
451
+ marginMs,
452
+ effectiveDeadlineMs: null,
453
+ msRemaining: null,
454
+ msOfSlack: null,
455
+ deadlines
456
+ };
457
+ if (intentMs === null) {
458
+ return {
459
+ ...base,
460
+ ok: false,
461
+ code: "EXPIRY_UNPARSABLE",
462
+ reason: "Intent expiresAt is missing or unparsable."
463
+ };
464
+ }
465
+ if (coinbaseMs === null) {
466
+ return {
467
+ ...base,
468
+ ok: false,
469
+ code: "EXPIRY_UNPARSABLE",
470
+ reason: "Coinbase preApprovalExpiry is missing or unparsable."
471
+ };
472
+ }
473
+ const effective = Math.min(intentMs, coinbaseMs);
474
+ const msRemaining = effective - now;
475
+ const msOfSlack = msRemaining - marginMs;
476
+ const withDeadline = {
477
+ ...base,
478
+ effectiveDeadlineMs: effective,
479
+ msRemaining,
480
+ msOfSlack
481
+ };
482
+ if (msRemaining <= 0) {
483
+ return {
484
+ ...withDeadline,
485
+ ok: false,
486
+ code: "EXPIRED",
487
+ reason: "The order or the Coinbase link has already expired."
488
+ };
489
+ }
490
+ if (msOfSlack <= 0) {
491
+ return {
492
+ ...withDeadline,
493
+ ok: false,
494
+ code: "EXPIRY_MARGIN",
495
+ reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
496
+ };
497
+ }
498
+ if (bolt11ExpiresAt !== void 0) {
499
+ if (bolt11Ms === null) {
500
+ return {
501
+ ...withDeadline,
502
+ ok: false,
503
+ code: "EXPIRY_UNPARSABLE",
504
+ reason: "BOLT11 invoice expiry is missing or unparsable."
505
+ };
506
+ }
507
+ const bolt11Remaining = bolt11Ms - now;
508
+ if (bolt11Remaining < BOLT11_MIN_VALIDITY_MS) {
509
+ return {
510
+ ...withDeadline,
511
+ ok: false,
512
+ code: "BOLT11_TOO_SHORT",
513
+ reason: `BOLT11 invoice has ${Math.max(0, Math.floor(bolt11Remaining / 1e3))}s of validity left; at least 10 min is required. Request a fresh invoice.`
514
+ };
515
+ }
516
+ }
517
+ return { ...withDeadline, ok: true, code: null, reason: null };
518
+ }
519
+
520
+ // scripts/src/lib/guards.mjs
521
+ var UNPAID_STATUS = "payment_unpaid";
522
+ function receiptSignal(source) {
523
+ const raw = source?.amountReceived;
524
+ if (raw === null || raw === void 0 || raw === "") {
525
+ return { money: false, receipt: null, unparsable: false };
526
+ }
527
+ try {
528
+ const receipt = comparePayment(source);
529
+ return { money: receipt.state !== "none", receipt, unparsable: false };
530
+ } catch {
531
+ return { money: true, receipt: null, unparsable: true };
532
+ }
533
+ }
534
+ function validateDepositInstructions(source) {
535
+ const family = chainFamily(source?.chainId);
536
+ const address = typeof source?.receiverAddress === "string" ? source.receiverAddress.trim() : "";
537
+ const memo = typeof source?.receiverMemo === "string" ? source.receiverMemo.trim() : "";
538
+ const bolt11 = typeof source?.lnInvoice === "string" && source.lnInvoice.trim() ? source.lnInvoice.trim() : "";
539
+ let amountAtomic;
540
+ try {
541
+ amountAtomic = sourceAtomic(source, "amount");
542
+ } catch (err) {
543
+ return {
544
+ ok: false,
545
+ code: "DEPOSIT_INCOMPLETE",
546
+ reason: `The deposit amount is unusable: ${err.message}`
547
+ };
548
+ }
549
+ if (amountAtomic === null || amountAtomic <= 0n) {
550
+ return {
551
+ ok: false,
552
+ code: "DEPOSIT_INCOMPLETE",
553
+ reason: "The order carries no positive deposit amount."
554
+ };
555
+ }
556
+ if (family === "lightning") {
557
+ if (!bolt11) {
558
+ return {
559
+ ok: false,
560
+ code: "DEPOSIT_INCOMPLETE",
561
+ reason: "The Lightning order has no BOLT11 invoice yet (the swap may still be being created). Nothing is payable until it appears."
562
+ };
563
+ }
564
+ return { ok: true, code: null, reason: null, family, amountAtomic, payTo: bolt11, memo: null };
565
+ }
566
+ if (!address) {
567
+ return {
568
+ ok: false,
569
+ code: "DEPOSIT_NOT_LIVE",
570
+ reason: "The live payments response returned no deposit address."
571
+ };
572
+ }
573
+ if (family === "stellar" && !memo) {
574
+ return {
575
+ ok: false,
576
+ code: "DEPOSIT_MEMO_REQUIRED",
577
+ reason: "A Stellar deposit requires a memo, and this order did not supply one. Sending without it would very likely lose the funds. Refusing to display it as payable."
578
+ };
579
+ }
580
+ return {
581
+ ok: true,
582
+ code: null,
583
+ reason: null,
584
+ family,
585
+ amountAtomic,
586
+ payTo: address,
587
+ memo: memo || null
588
+ };
589
+ }
590
+ function reuseGuard({ payment, requested, reused = false }) {
591
+ const source = payment?.source || {};
592
+ const evidence = {
593
+ reused: Boolean(reused),
594
+ status: payment?.status ?? null,
595
+ txHash: source.txHash ?? null,
596
+ amountReceived: source.amountReceived ?? null,
597
+ confirmedAt: source.confirmedAt ?? null,
598
+ chainId: source.chainId ?? null,
599
+ tokenSymbol: source.tokenSymbol ?? null,
600
+ senderAddress: source.senderAddress ?? null
601
+ };
602
+ const hasTx = source.txHash !== null && source.txHash !== void 0 && source.txHash !== "";
603
+ const signal = receiptSignal(source);
604
+ const hasConfirm = source.confirmedAt !== null && source.confirmedAt !== void 0 && source.confirmedAt !== "";
605
+ if (hasTx || signal.money || hasConfirm) {
606
+ return {
607
+ ok: false,
608
+ code: "ORDER_ALREADY_FUNDED",
609
+ reason: signal.unparsable ? "This order reports an amountReceived that cannot be read. It is treated as funded until a human confirms otherwise \u2014 do NOT pay again." : "This Coinbase link already has a funded Rozo order (it may have been paid elsewhere). Do NOT pay again \u2014 escalate for manual reconciliation.",
610
+ moneyDetected: true,
611
+ evidence: { ...evidence, receipt: signal.receipt, receiptUnparsable: signal.unparsable }
612
+ };
613
+ }
614
+ if (payment?.status !== UNPAID_STATUS) {
615
+ const terminal = ["payment_expired", "payment_bounced", "payment_refunded"].includes(
616
+ payment?.status
617
+ );
618
+ return {
619
+ ok: false,
620
+ code: terminal ? "ORDER_NOT_PAYABLE" : "ORDER_ALREADY_FUNDED",
621
+ reason: `Existing order status is "${payment?.status ?? "unknown"}", not "${UNPAID_STATUS}".`,
622
+ moneyDetected: !terminal,
623
+ evidence
624
+ };
625
+ }
626
+ const wantChain = String(requested?.chainId ?? "").trim();
627
+ const wantToken = String(requested?.tokenSymbol ?? "").trim().toUpperCase();
628
+ const gotChain = String(source.chainId ?? "").trim();
629
+ const gotToken = String(source.tokenSymbol ?? "").trim().toUpperCase();
630
+ if (!gotChain || !gotToken || gotChain !== wantChain || gotToken !== wantToken) {
631
+ return {
632
+ ok: false,
633
+ code: "REUSED_SOURCE_MISMATCH",
634
+ reason: `The order expects ${gotToken || "?"} on chain ${gotChain || "?"}, but you chose ${wantToken} on chain ${wantChain}. Paying the wrong asset or network is usually unrecoverable.`,
635
+ moneyDetected: false,
636
+ evidence
637
+ };
638
+ }
639
+ const deposit = validateDepositInstructions(source);
640
+ if (!deposit.ok) {
641
+ return {
642
+ ok: false,
643
+ code: deposit.code,
644
+ reason: deposit.reason,
645
+ moneyDetected: false,
646
+ evidence
647
+ };
648
+ }
649
+ return { ok: true, code: null, reason: null, moneyDetected: false, evidence, deposit };
650
+ }
651
+ function verifyCreateAgainstQuote({ snapshot, created, requested }) {
652
+ const drift = [];
653
+ const require2 = (field, a, b, normalize = defaultNormalize) => {
654
+ const na = normalize(a);
655
+ const nb = normalize(b);
656
+ if (na === null) {
657
+ drift.push({ field, quoted: null, created: nb, note: "missing in quote" });
658
+ return;
659
+ }
660
+ if (nb === null) {
661
+ drift.push({ field, quoted: na, created: null, note: "missing in create response" });
662
+ return;
663
+ }
664
+ if (na !== nb) drift.push({ field, quoted: na, created: nb });
665
+ };
666
+ require2("linkId", snapshot?.linkId, created?.linkId);
667
+ require2("merchant", snapshot?.merchant, created?.merchant, normalizeMerchant);
668
+ require2("original", snapshot?.original, created?.original, normalizeDecimal);
669
+ require2("callerPays", snapshot?.callerPays, created?.callerPays, normalizeDecimal);
670
+ const wantChain = String(requested?.chainId ?? "").trim();
671
+ const wantToken = String(requested?.tokenSymbol ?? "").trim().toUpperCase();
672
+ const gotChain = String(created?.source?.chainId ?? "").trim();
673
+ const gotToken = String(created?.source?.tokenSymbol ?? "").trim().toUpperCase();
674
+ if (!gotChain || gotChain !== wantChain) {
675
+ drift.push({ field: "source.chainId", quoted: wantChain, created: gotChain || null });
676
+ }
677
+ if (!gotToken || gotToken !== wantToken) {
678
+ drift.push({ field: "source.tokenSymbol", quoted: wantToken, created: gotToken || null });
679
+ }
680
+ if (drift.length) {
681
+ return {
682
+ ok: false,
683
+ code: "CREATE_DRIFT",
684
+ reason: "The created order does not match what was quoted. Refusing to continue.",
685
+ drift
686
+ };
687
+ }
688
+ const disc = created?.discount;
689
+ if (disc !== void 0 && disc !== null && normalizeDecimal(disc) !== "0") {
690
+ return {
691
+ ok: false,
692
+ code: "NO_DISCOUNT_VIOLATION",
693
+ reason: `Server reported a discount of "${disc}"; this flow must charge the full invoice.`,
694
+ drift: [{ field: "discount", quoted: "0", created: String(disc) }]
695
+ };
696
+ }
697
+ if (created?.callerPays !== void 0 && created?.original !== void 0 && normalizeDecimal(created.callerPays) !== normalizeDecimal(created.original)) {
698
+ return {
699
+ ok: false,
700
+ code: "NO_DISCOUNT_VIOLATION",
701
+ reason: `callerPays (${created.callerPays}) differs from the invoice amount (${created.original}).`,
702
+ drift: [
703
+ { field: "callerPays", quoted: String(created.original), created: String(created.callerPays) }
704
+ ]
705
+ };
706
+ }
707
+ return { ok: true, code: null, reason: null, drift: [] };
708
+ }
709
+ function defaultNormalize(v) {
710
+ if (v === null || v === void 0) return null;
711
+ const s = String(v).trim();
712
+ return s === "" ? null : s;
713
+ }
714
+ function normalizeMerchant(v) {
715
+ if (v === null || v === void 0) return null;
716
+ if (typeof v === "object") {
717
+ const name = v.name ?? v.merchantName ?? null;
718
+ return name ? String(name).trim() || null : null;
719
+ }
720
+ const s = String(v).trim();
721
+ return s === "" ? null : s;
722
+ }
723
+ function normalizeDecimal(v) {
724
+ if (v === null || v === void 0) return null;
725
+ const s = String(v).trim();
726
+ if (!/^\d+(\.\d+)?$/.test(s)) return s;
727
+ const [w, f = ""] = s.split(".");
728
+ const frac = f.replace(/0+$/, "");
729
+ const whole = w.replace(/^0+(?=\d)/, "");
730
+ return frac ? `${whole}.${frac}` : whole;
731
+ }
732
+ function checkPayable(statusResponse, now = Date.now()) {
733
+ const cb = statusResponse?.coinbase;
734
+ if (!cb) {
735
+ return {
736
+ ok: false,
737
+ code: "LINK_NO_LONGER_PAYABLE",
738
+ reason: "invoice-status returned no Coinbase state; cannot prove the link is still payable.",
739
+ derived: null
740
+ };
741
+ }
742
+ const protocolVersion = cb.protocolVersion ?? statusResponse?.protocolVersion ?? null;
743
+ const derived = {
744
+ protocolVersion,
745
+ status: cb.status ?? null,
746
+ settled: cb.settled ?? null,
747
+ usageCount: cb.usageCount ?? null,
748
+ maxUsage: cb.maxUsage ?? null,
749
+ preApprovalExpiry: cb.preApprovalExpiry ?? null
750
+ };
751
+ if (cb.settled === true) {
752
+ return {
753
+ ok: false,
754
+ code: "LINK_NO_LONGER_PAYABLE",
755
+ reason: "The Coinbase resource is already settled \u2014 someone has paid it.",
756
+ derived
757
+ };
758
+ }
759
+ if (protocolVersion === "v3") {
760
+ if (!cb.status) {
761
+ return {
762
+ ok: false,
763
+ code: "LINK_PAYABILITY_UNKNOWN",
764
+ reason: "The Payment Session response carries no status; cannot prove it is still payable.",
765
+ derived
766
+ };
767
+ }
768
+ if (cb.status !== "PAYMENT_SESSION_STATUS_CREATED") {
769
+ return {
770
+ ok: false,
771
+ code: "LINK_NO_LONGER_PAYABLE",
772
+ reason: `Payment Session status is ${cb.status}; only PAYMENT_SESSION_STATUS_CREATED is payable.`,
773
+ derived
774
+ };
775
+ }
776
+ return { ok: true, code: null, reason: null, derived };
777
+ }
778
+ const usage2 = Number(cb.usageCount);
779
+ const max = Number(cb.maxUsage);
780
+ if (cb.usageCount === null || cb.usageCount === void 0 || cb.maxUsage === null || cb.maxUsage === void 0 || !Number.isFinite(usage2) || !Number.isFinite(max)) {
781
+ return {
782
+ ok: false,
783
+ code: "LINK_PAYABILITY_UNKNOWN",
784
+ reason: "The payment link response is missing usageCount/maxUsage; cannot prove it has not already been used.",
785
+ derived
786
+ };
787
+ }
788
+ if (usage2 >= max) {
789
+ return {
790
+ ok: false,
791
+ code: "LINK_NO_LONGER_PAYABLE",
792
+ reason: `Payment link already used (${usage2}/${max}).`,
793
+ derived
794
+ };
795
+ }
796
+ return { ok: true, code: null, reason: null, derived };
797
+ }
798
+
799
+ // scripts/src/lib/blacklist.mjs
800
+ import fs from "node:fs";
801
+ import path from "node:path";
802
+ import crypto from "node:crypto";
803
+ import { fileURLToPath } from "node:url";
804
+ var BlacklistError = class extends Error {
805
+ constructor(code, message) {
806
+ super(message);
807
+ this.code = code;
808
+ }
809
+ };
810
+ function normalizeAddress(address, family) {
811
+ if (typeof address !== "string") return null;
812
+ const trimmed = address.trim();
813
+ if (!trimmed) return null;
814
+ if (family === "evm" || /^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
815
+ return trimmed.toLowerCase();
816
+ }
817
+ return trimmed;
818
+ }
819
+ function parseBlacklist(doc) {
820
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
821
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist document is not an object.");
822
+ }
823
+ const { provenance, entries } = doc;
824
+ if (!provenance || typeof provenance !== "object") {
825
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist provenance header is missing.");
826
+ }
827
+ if (!Array.isArray(entries) || entries.length === 0) {
828
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist is empty or not an array.");
829
+ }
830
+ const addresses = [];
831
+ for (const e of entries) {
832
+ if (!e || typeof e !== "object" || typeof e.address !== "string" || !e.address.trim()) {
833
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist entry has no address.");
834
+ }
835
+ addresses.push(e.address);
836
+ }
837
+ const digest = crypto.createHash("sha256").update(JSON.stringify(addresses), "utf8").digest("hex");
838
+ if (typeof provenance.addressesSha256 !== "string" || !provenance.addressesSha256) {
839
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist provenance digest is missing.");
840
+ }
841
+ if (digest !== provenance.addressesSha256) {
842
+ throw new BlacklistError(
843
+ "BLACKLIST_UNAVAILABLE",
844
+ "Blacklist digest mismatch \u2014 the vendored address list was modified without re-signing."
845
+ );
846
+ }
847
+ if (provenance.addressCount !== void 0 && provenance.addressCount !== entries.length) {
848
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist addressCount does not match entries.");
849
+ }
850
+ const index = /* @__PURE__ */ new Map();
851
+ for (const e of entries) {
852
+ const key = normalizeAddress(e.address, e.family);
853
+ if (key) index.set(key, e);
854
+ const lower = e.address.trim().toLowerCase();
855
+ if (/^0x[0-9a-f]{40}$/.test(lower)) index.set(lower, e);
856
+ }
857
+ return { entries, index, provenance, digest };
858
+ }
859
+ function candidatePaths(moduleUrl) {
860
+ const here = path.dirname(fileURLToPath(moduleUrl));
861
+ return [
862
+ path.join(here, "blacklist.json"),
863
+ path.join(here, "..", "src", "lib", "blacklist.json"),
864
+ path.join(here, "..", "..", "src", "lib", "blacklist.json")
865
+ ];
866
+ }
867
+ var cached = null;
868
+ function loadBlacklist(explicitPath) {
869
+ if (!explicitPath && cached) return cached;
870
+ const paths = explicitPath ? [explicitPath] : candidatePaths(import.meta.url);
871
+ let lastErr = null;
872
+ for (const p of paths) {
873
+ let raw;
874
+ try {
875
+ raw = fs.readFileSync(p, "utf8");
876
+ } catch (err) {
877
+ lastErr = err;
878
+ continue;
879
+ }
880
+ let doc;
881
+ try {
882
+ doc = JSON.parse(raw);
883
+ } catch {
884
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", `Blacklist file is not valid JSON: ${p}`);
885
+ }
886
+ const parsed = parseBlacklist(doc);
887
+ parsed.sourceFile = p;
888
+ if (!explicitPath) cached = parsed;
889
+ return parsed;
890
+ }
891
+ throw new BlacklistError(
892
+ "BLACKLIST_UNAVAILABLE",
893
+ `Blacklist file not found (looked in ${paths.length} locations). Refusing to proceed.` + (lastErr ? ` Last error: ${lastErr.code || lastErr.message}` : "")
894
+ );
895
+ }
896
+ function checkAddress(address, family, blacklist) {
897
+ const bl = blacklist || loadBlacklist();
898
+ const normalized = normalizeAddress(address, family);
899
+ if (!normalized) {
900
+ throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Cannot check an empty address.");
901
+ }
902
+ const entry = bl.index.get(normalized) || bl.index.get(normalized.toLowerCase()) || null;
903
+ return { hit: Boolean(entry), entry, normalized };
904
+ }
905
+ function assertNotBlacklisted(targets, blacklist) {
906
+ const bl = blacklist || loadBlacklist();
907
+ for (const t of targets) {
908
+ if (!t || !t.address) continue;
909
+ const { hit, entry } = checkAddress(t.address, t.family, bl);
910
+ if (hit) {
911
+ const err = new BlacklistError(
912
+ "BLACKLIST_HIT",
913
+ `${t.role || "address"} is on the compromised-wallet blacklist (reported ${entry.reportedOn}: ${entry.note}). Refusing.`
914
+ );
915
+ err.role = t.role || "address";
916
+ throw err;
917
+ }
918
+ }
919
+ return true;
920
+ }
921
+
922
+ // scripts/src/lib/state.mjs
923
+ import fs2 from "node:fs";
924
+ import path2 from "node:path";
925
+ import os from "node:os";
926
+ import crypto2 from "node:crypto";
927
+ var LOCK_STALE_MS = 6e4;
928
+ var LOCK_WAIT_MS = 1e4;
929
+ var LOCK_POLL_MS = 25;
930
+ function lockPath() {
931
+ return path2.join(stateRoot(), ".send.lock");
932
+ }
933
+ function sleepSync(ms) {
934
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
935
+ }
936
+ function tryAcquire(file) {
937
+ try {
938
+ const fd = fs2.openSync(file, "wx", 384);
939
+ fs2.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }));
940
+ fs2.closeSync(fd);
941
+ return true;
942
+ } catch (err) {
943
+ if (err.code !== "EEXIST") throw err;
944
+ return false;
945
+ }
946
+ }
947
+ function withLock(fn) {
948
+ const file = lockPath();
949
+ fs2.mkdirSync(path2.dirname(file), { recursive: true, mode: 448 });
950
+ const deadline = Date.now() + LOCK_WAIT_MS;
951
+ for (; ; ) {
952
+ if (tryAcquire(file)) break;
953
+ try {
954
+ const age = Date.now() - fs2.statSync(file).mtimeMs;
955
+ if (age > LOCK_STALE_MS) {
956
+ const stolen = `${file}.stale.${crypto2.randomBytes(4).toString("hex")}`;
957
+ try {
958
+ fs2.renameSync(file, stolen);
959
+ fs2.unlinkSync(stolen);
960
+ } catch {
961
+ }
962
+ continue;
963
+ }
964
+ } catch (err) {
965
+ if (err.code !== "ENOENT") throw err;
966
+ continue;
967
+ }
968
+ if (Date.now() > deadline) {
969
+ throw new SkillError(
970
+ "LOCK_TIMEOUT",
971
+ "Another rozo-checkout process is holding the send lock. Refusing to proceed rather than risk a concurrent second send."
972
+ );
973
+ }
974
+ sleepSync(LOCK_POLL_MS);
975
+ }
976
+ try {
977
+ return fn();
978
+ } finally {
979
+ try {
980
+ fs2.unlinkSync(file);
981
+ } catch {
982
+ }
983
+ }
984
+ }
985
+ function stateRoot() {
986
+ return process.env.ROZO_CHECKOUT_STATE_DIR || path2.join(os.homedir(), ".rozo-checkout", "state");
987
+ }
988
+ function statePath(rozoPaymentId) {
989
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(String(rozoPaymentId || ""))) {
990
+ throw new SkillError("BAD_ROZO_PAYMENT_ID", "Refusing to build a state path from that id.");
991
+ }
992
+ return path2.join(stateRoot(), `${rozoPaymentId}.json`);
993
+ }
994
+ function writeAtomic(file, data) {
995
+ const dir = path2.dirname(file);
996
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
997
+ const tmp = path2.join(dir, `.${path2.basename(file)}.${crypto2.randomBytes(6).toString("hex")}.tmp`);
998
+ const fd = fs2.openSync(tmp, "wx", 384);
999
+ try {
1000
+ fs2.writeFileSync(fd, JSON.stringify(data, null, 2) + "\n", "utf8");
1001
+ fs2.fsyncSync(fd);
1002
+ } finally {
1003
+ fs2.closeSync(fd);
1004
+ }
1005
+ fs2.renameSync(tmp, file);
1006
+ }
1007
+ function readState(rozoPaymentId) {
1008
+ const file = statePath(rozoPaymentId);
1009
+ let raw;
1010
+ try {
1011
+ raw = fs2.readFileSync(file, "utf8");
1012
+ } catch (err) {
1013
+ if (err.code === "ENOENT") return null;
1014
+ throw new SkillError("STATE_UNREADABLE", `Cannot read local state: ${err.code}`);
1015
+ }
1016
+ try {
1017
+ return JSON.parse(raw);
1018
+ } catch {
1019
+ throw new SkillError(
1020
+ "STATE_CORRUPT",
1021
+ "The local state file for this order is corrupt. Refusing to act; inspect it manually."
1022
+ );
1023
+ }
1024
+ }
1025
+ function createOrderRecord(record) {
1026
+ return withLock(() => createOrderRecordUnlocked(record));
1027
+ }
1028
+ function createOrderRecordUnlocked(record) {
1029
+ const { rozoPaymentId } = record;
1030
+ const existing = readState(rozoPaymentId);
1031
+ const next = {
1032
+ version: 1,
1033
+ rozoPaymentId,
1034
+ linkId: record.linkId,
1035
+ paymentLink: record.paymentLink ?? null,
1036
+ merchant: record.merchant ?? null,
1037
+ invoiceAmount: record.invoiceAmount ?? null,
1038
+ source: record.source,
1039
+ receiverAddress: record.receiverAddress,
1040
+ receiverMemo: record.receiverMemo ?? null,
1041
+ amount: record.amount,
1042
+ amountUnit: record.amountUnit ?? null,
1043
+ expiresAt: record.expiresAt ?? null,
1044
+ createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1045
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1046
+ confirmation: existing?.confirmation ?? null,
1047
+ send: existing?.send ?? null
1048
+ };
1049
+ writeAtomic(statePath(rozoPaymentId), next);
1050
+ return next;
1051
+ }
1052
+ function depositDigest(source) {
1053
+ const canonical = JSON.stringify({
1054
+ chainId: String(source?.chainId ?? ""),
1055
+ tokenSymbol: String(source?.tokenSymbol ?? "").toUpperCase(),
1056
+ tokenAddress: String(source?.tokenAddress ?? ""),
1057
+ receiverAddress: String(source?.receiverAddress ?? ""),
1058
+ receiverMemo: source?.receiverMemo ?? null,
1059
+ amount: String(source?.amount ?? ""),
1060
+ amountUnit: source?.amountUnit ?? null,
1061
+ lnInvoice: source?.lnInvoice ?? null
1062
+ });
1063
+ return crypto2.createHash("sha256").update(canonical, "utf8").digest("hex");
1064
+ }
1065
+ function recordConfirmation(rozoPaymentId, { source, invoiceAmount, tier }) {
1066
+ return withLock(() => {
1067
+ const state = readState(rozoPaymentId);
1068
+ if (!state) {
1069
+ throw new SkillError("NO_ORDER_STATE", "Cannot confirm an order with no local record.");
1070
+ }
1071
+ const next = {
1072
+ ...state,
1073
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1074
+ confirmation: {
1075
+ confirmedAt: (/* @__PURE__ */ new Date()).toISOString(),
1076
+ depositDigest: depositDigest(source),
1077
+ invoiceAmount: invoiceAmount ?? state.invoiceAmount ?? null,
1078
+ tier: tier ?? null
1079
+ }
1080
+ };
1081
+ writeAtomic(statePath(rozoPaymentId), next);
1082
+ return next;
1083
+ });
1084
+ }
1085
+
1086
+ // scripts/src/create-order.mjs
1087
+ function confirmTier(usdAmount) {
1088
+ const usd = Number(usdAmount);
1089
+ if (!Number.isFinite(usd)) return "explicit";
1090
+ if (usd <= 1) return "silent";
1091
+ if (usd <= 10) return "one-line";
1092
+ return "explicit";
1093
+ }
1094
+ async function main(argv) {
1095
+ const args = parseArgs(argv);
1096
+ const url = args.url || args._[0];
1097
+ if (!url || url === true) usage('Required: --url "<coinbase payment link url or id>"');
1098
+ const chainId = String(args.chain ?? "").trim();
1099
+ const tokenSymbol = String(args.token ?? "").trim().toUpperCase();
1100
+ if (!chainId || !tokenSymbol) {
1101
+ usage("Required: --chain <chainId> --token <SYMBOL>. Run quote.js to see supported sources.");
1102
+ }
1103
+ if (!isSupportedSource(chainId, tokenSymbol)) {
1104
+ throw new SkillError(
1105
+ "UNSUPPORTED_SOURCE",
1106
+ `${tokenSymbol} on ${chainName(chainId)} is not a supported source.`,
1107
+ { supported: SUPPORTED_SOURCES }
1108
+ );
1109
+ }
1110
+ const requested = { chainId, tokenSymbol };
1111
+ const confirmed = Boolean(args.confirm);
1112
+ let blacklist;
1113
+ try {
1114
+ blacklist = loadBlacklist();
1115
+ } catch (err) {
1116
+ throw new SkillError(
1117
+ "BLACKLIST_UNAVAILABLE",
1118
+ `Compromised-address list unusable: ${err.message} Refusing to proceed.`
1119
+ );
1120
+ }
1121
+ const { linkId: parsedLinkId } = extractLinkId(String(url));
1122
+ const quote = await quoteInvoice({ url: String(url) });
1123
+ const snapshot = snapshotFromQuote(quote);
1124
+ const quoteReceipt = quote?.quoteReceipt ?? null;
1125
+ const created = await createInvoice({
1126
+ url: String(url),
1127
+ source: requested,
1128
+ quoteReceipt
1129
+ });
1130
+ if (!created?.rozoPaymentId) {
1131
+ throw new SkillError("CREATE_FAILED", "create-invoice returned no rozoPaymentId.", {
1132
+ response: created
1133
+ });
1134
+ }
1135
+ const rozoPaymentId = assertRozoPaymentId(created.rozoPaymentId);
1136
+ const verify = verifyCreateAgainstQuote({ snapshot, created, requested });
1137
+ if (!verify.ok) {
1138
+ emit(
1139
+ {
1140
+ success: false,
1141
+ step: "verify-create",
1142
+ error: { code: verify.code, message: verify.reason, details: { drift: verify.drift } },
1143
+ linkId: created.linkId ?? parsedLinkId,
1144
+ rozoPaymentId,
1145
+ paymentLink: created.paymentLink ?? null,
1146
+ guidance: "The order exists but was NOT validated. Do not fund it. Let it expire unfunded."
1147
+ },
1148
+ EXIT_ERROR
1149
+ );
1150
+ }
1151
+ const payment = await getPayment(rozoPaymentId);
1152
+ const source = payment?.source || {};
1153
+ const guard = reuseGuard({ payment, requested, reused: created.reused });
1154
+ if (!guard.ok) {
1155
+ emit(
1156
+ {
1157
+ success: false,
1158
+ step: "reuse-guard",
1159
+ error: { code: guard.code, message: guard.reason, details: guard.evidence },
1160
+ linkId: created.linkId ?? parsedLinkId,
1161
+ rozoPaymentId,
1162
+ paymentLink: created.paymentLink ?? null,
1163
+ moneyDetected: guard.moneyDetected,
1164
+ guidance: guard.moneyDetected ? "MONEY DETECTED. Do not pay again, do not retry into a new order. Preserve every id and tx hash above and escalate to the operator for manual reconciliation." : "Abort this run. Nothing was funded."
1165
+ },
1166
+ EXIT_ERROR
1167
+ );
1168
+ }
1169
+ const lightning = String(source.chainId) === "lightning";
1170
+ const bolt11 = source.lnInvoice ?? payment?.lnInvoice ?? null;
1171
+ const expiry = checkExpiry({
1172
+ now: Date.now(),
1173
+ chainId: source.chainId ?? chainId,
1174
+ intentExpiresAt: payment?.expiresAt,
1175
+ coinbaseExpiry: snapshot?.coinbase?.preApprovalExpiry,
1176
+ // For Lightning the BOLT11's own validity is an extra gate. We only have a
1177
+ // separate expiry if the backend exposes one; otherwise the intent expiry
1178
+ // governs and we still require the 10-minute floor via the margin.
1179
+ ...lightning ? { bolt11ExpiresAt: payment?.expiresAt } : {}
1180
+ });
1181
+ if (!expiry.ok) {
1182
+ emit(
1183
+ {
1184
+ success: false,
1185
+ step: "expiry-guard",
1186
+ error: { code: expiry.code, message: expiry.reason, details: expiry },
1187
+ linkId: created.linkId ?? parsedLinkId,
1188
+ rozoPaymentId,
1189
+ guidance: "Not enough time remains to fund, bridge and settle safely. Ask the merchant for a fresh link and start over. Do not fund this order."
1190
+ },
1191
+ EXIT_ERROR
1192
+ );
1193
+ }
1194
+ try {
1195
+ assertNotBlacklisted(
1196
+ [
1197
+ {
1198
+ address: source.receiverAddress,
1199
+ family: chainFamily(source.chainId),
1200
+ role: "deposit address"
1201
+ }
1202
+ ],
1203
+ blacklist
1204
+ );
1205
+ } catch (err) {
1206
+ emit(
1207
+ {
1208
+ success: false,
1209
+ step: "blacklist",
1210
+ error: { code: err.code, message: err.message },
1211
+ linkId: created.linkId ?? parsedLinkId,
1212
+ rozoPaymentId,
1213
+ guidance: "Do NOT send anything. Report this to the operator immediately."
1214
+ },
1215
+ EXIT_ERROR
1216
+ );
1217
+ }
1218
+ const statusNow = await invoiceStatus({ linkId: created.linkId ?? parsedLinkId });
1219
+ const payable = checkPayable(statusNow, Date.now());
1220
+ if (!payable.ok) {
1221
+ emit(
1222
+ {
1223
+ success: false,
1224
+ step: "payability-revalidation",
1225
+ error: { code: payable.code, message: payable.reason, details: payable.derived },
1226
+ linkId: created.linkId ?? parsedLinkId,
1227
+ rozoPaymentId,
1228
+ guidance: "The Coinbase resource stopped being payable between quote and now (someone else may have paid it). Do NOT fund this order."
1229
+ },
1230
+ EXIT_ERROR
1231
+ );
1232
+ }
1233
+ createOrderRecord({
1234
+ rozoPaymentId,
1235
+ linkId: created.linkId ?? parsedLinkId,
1236
+ paymentLink: created.paymentLink ?? null,
1237
+ merchant: created.merchant ?? snapshot.merchant,
1238
+ invoiceAmount: created.original ?? snapshot.original,
1239
+ source: { chainId: source.chainId, tokenSymbol: source.tokenSymbol },
1240
+ receiverAddress: source.receiverAddress,
1241
+ receiverMemo: source.receiverMemo ?? null,
1242
+ amount: source.amount,
1243
+ amountUnit: source.amountUnit ?? null,
1244
+ expiresAt: payment?.expiresAt ?? null
1245
+ });
1246
+ const usd = created.original ?? snapshot.original;
1247
+ const family = chainFamily(source.chainId);
1248
+ const tier = confirmTier(usd);
1249
+ const depositInfo = guard.deposit;
1250
+ if (confirmed) {
1251
+ recordConfirmation(rozoPaymentId, { source, invoiceAmount: usd, tier });
1252
+ }
1253
+ const memoRequirement = lightning ? "Lightning invoices carry their own routing data; there is no separate memo." : source.receiverMemo ? "This deposit REQUIRES the memo/tag below. Sending without it will very likely lose the funds." : family === "stellar" ? "A Stellar deposit always requires a memo." : "This deposit does not use a memo. Leave the memo field empty.";
1254
+ emit({
1255
+ success: true,
1256
+ step: "create-order",
1257
+ confirmed,
1258
+ reused: Boolean(created.reused),
1259
+ reusedNote: created.reused ? "An existing unfunded order for this link was reused; it passed the funded-check above." : null,
1260
+ linkId: created.linkId ?? parsedLinkId,
1261
+ rozoPaymentId,
1262
+ paymentLink: created.paymentLink ?? null,
1263
+ merchant: normalizeMerchant(created.merchant ?? snapshot.merchant),
1264
+ invoice: { amount: usd, currency: snapshot?.fiat?.currency ?? "USD" },
1265
+ callerPays: created.callerPays ?? snapshot.callerPays,
1266
+ discount: created.discount ?? "0",
1267
+ // Machine-readable, copy-pastable — and WITHHELD until --confirm. This is
1268
+ // the only place the full address, memo and BOLT11 ever appear.
1269
+ deposit: confirmed ? {
1270
+ chainId: source.chainId,
1271
+ chain: chainName(source.chainId),
1272
+ tokenSymbol: source.tokenSymbol,
1273
+ tokenAddress: source.tokenAddress || null,
1274
+ // Lightning has no deposit address: the BOLT11 IS the instruction.
1275
+ receiverAddress: lightning ? null : source.receiverAddress,
1276
+ receiverMemo: source.receiverMemo ?? null,
1277
+ amount: source.amount,
1278
+ amountUnit: source.amountUnit ?? null,
1279
+ isSats: isSatsUnit(source.amountUnit),
1280
+ lnInvoice: bolt11 || null,
1281
+ payTo: depositInfo.payTo,
1282
+ expiresAt: payment?.expiresAt ?? null
1283
+ } : null,
1284
+ depositWithheld: !confirmed,
1285
+ // Safe for prose / chat.
1286
+ display: {
1287
+ chain: chainName(source.chainId),
1288
+ token: source.tokenSymbol,
1289
+ amount: formatAmount(source),
1290
+ isSats: isSatsUnit(source.amountUnit),
1291
+ payToMasked: maskAddress(depositInfo.payTo),
1292
+ receiverMemoMasked: maskMemo(source.receiverMemo),
1293
+ hasMemo: Boolean(source.receiverMemo),
1294
+ memoRequirement
1295
+ },
1296
+ expiry: {
1297
+ intentExpiresAt: payment?.expiresAt ?? null,
1298
+ coinbaseExpiry: snapshot?.coinbase?.preApprovalExpiry ?? null,
1299
+ effectiveDeadlineIso: new Date(expiry.effectiveDeadlineMs).toISOString(),
1300
+ marginMinutes: Math.round(expiry.marginMs / 6e4),
1301
+ minutesOfSlack: Math.floor(expiry.msOfSlack / 6e4)
1302
+ },
1303
+ confirmation: {
1304
+ required: tier,
1305
+ satisfied: confirmed,
1306
+ note: confirmed ? "Confirmation recorded. The send scripts will verify it against the live deposit data." : "BINDING CONFIRMATION POINT. Present chain, token, exact amount, the masked address, the memo requirement, both expiries and the reused flag, and get an explicit yes. Then re-run this command with --confirm to release the full deposit details.",
1307
+ warnings: [
1308
+ "Wrong token, wrong network, or wrong amount is usually unrecoverable.",
1309
+ memoRequirement,
1310
+ "Send exactly once. A second send to the same one-time address is not guaranteed to be credited.",
1311
+ "The deposit amount can exceed the invoice: it includes the bridge and network fees."
1312
+ ]
1313
+ },
1314
+ blacklist: {
1315
+ checked: true,
1316
+ addressesInList: blacklist.entries.length,
1317
+ digest: blacklist.provenance.addressesSha256
1318
+ },
1319
+ nextStep: confirmed ? {
1320
+ modeA: "Give the user the `deposit` block, then poll: status.js --rozo-payment-id <id>",
1321
+ modeB: family === "evm" ? `send-evm.js --rozo-payment-id ${rozoPaymentId} --send (requires ROZO_CHECKOUT_EVM_KEY)` : family === "solana" ? `send-sol.js --rozo-payment-id ${rozoPaymentId} --send (requires ROZO_CHECKOUT_SOL_KEY)` : "not available for this chain \u2014 pay from a wallet (Mode A)"
1322
+ } : {
1323
+ confirm: "Re-run this exact command with --confirm once the user has said yes."
1324
+ }
1325
+ });
1326
+ }
1327
+ async function run(argv = process.argv.slice(2)) {
1328
+ return main(argv);
1329
+ }
1330
+
1331
+ // scripts/src/bin/create-order.mjs
1332
+ run().catch((err) => fail(err));