@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.
- package/LICENSE +21 -0
- package/QUICKSTART.es.md +192 -0
- package/QUICKSTART.ja.md +187 -0
- package/QUICKSTART.md +188 -0
- package/QUICKSTART.zh.md +178 -0
- package/README.es.md +287 -0
- package/README.ja.md +265 -0
- package/README.md +270 -0
- package/README.zh.md +187 -0
- package/llms.txt +95 -0
- package/package.json +60 -0
- package/scripts/dist/blacklist.json +141 -0
- package/scripts/dist/cli.js +57390 -0
- package/scripts/dist/create-order.js +1332 -0
- package/scripts/dist/quote.js +396 -0
- package/scripts/dist/send-evm.js +22557 -0
- package/scripts/dist/send-sol.js +34695 -0
- package/scripts/dist/status.js +567 -0
|
@@ -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));
|