@rozoai/checkout 0.1.16 → 0.1.17
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/README.md +36 -0
- package/package.json +1 -1
- package/scripts/dist/cli.js +503 -83
- package/scripts/dist/create-order.js +321 -186
- package/scripts/dist/receipt.js +53 -53
- package/scripts/dist/resume.js +1486 -0
- package/scripts/dist/send-evm.js +202 -202
- package/scripts/dist/send-sol.js +202 -202
- package/scripts/dist/status.js +53 -53
|
@@ -0,0 +1,1486 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __rozoCreateRequire } from 'node:module';
|
|
3
|
+
import { fileURLToPath as __rozoFileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname as __rozoDirname } from 'node:path';
|
|
5
|
+
const require = __rozoCreateRequire(import.meta.url);
|
|
6
|
+
const __filename = __rozoFileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = __rozoDirname(__filename);
|
|
8
|
+
|
|
9
|
+
// scripts/src/lib/support.mjs
|
|
10
|
+
var SUPPORT = Object.freeze({
|
|
11
|
+
email: "hi@rozo.ai",
|
|
12
|
+
x: "https://x.com/ROZOai",
|
|
13
|
+
discord: "https://discord.gg/EfWejgTbuU"
|
|
14
|
+
});
|
|
15
|
+
var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
|
|
16
|
+
|
|
17
|
+
// scripts/src/lib/output.mjs
|
|
18
|
+
var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
|
|
19
|
+
var EXIT_OK = 0;
|
|
20
|
+
var EXIT_ERROR = 1;
|
|
21
|
+
var EXIT_USAGE = 2;
|
|
22
|
+
function redact(text) {
|
|
23
|
+
if (text === null || text === void 0) return text;
|
|
24
|
+
let s = typeof text === "string" ? text : String(text);
|
|
25
|
+
s = s.replace(/0x[0-9a-fA-F]{64}/g, "0x<redacted>");
|
|
26
|
+
s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
|
|
27
|
+
s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
|
|
28
|
+
s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
|
|
29
|
+
s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (m, scheme, rest) => {
|
|
30
|
+
if (PUBLIC_SUPPORT_URLS.has(m.replace(/[.,;:!?)]+$/, ""))) return m;
|
|
31
|
+
const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
|
|
32
|
+
const host = withoutUserinfo.split(/[/?#]/)[0];
|
|
33
|
+
const hadMore = withoutUserinfo.length > host.length;
|
|
34
|
+
return `${scheme}://${host}${hadMore ? "/<redacted>" : ""}`;
|
|
35
|
+
});
|
|
36
|
+
s = s.replace(/\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 <redacted>");
|
|
37
|
+
s = s.replace(
|
|
38
|
+
/\b(api[-_]?key|apikey|access[-_]?token|auth[-_]?token|secret|token|password|passwd|pwd)\b(\s*[:=]\s*)("?)[A-Za-z0-9._~+/=-]{6,}\3/gi,
|
|
39
|
+
"$1$2<redacted>"
|
|
40
|
+
);
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
var PublicHash = class {
|
|
44
|
+
constructor(value) {
|
|
45
|
+
this.value = value;
|
|
46
|
+
}
|
|
47
|
+
toJSON() {
|
|
48
|
+
return this.value;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var TX_HASH_VALUE = /^(0x[0-9a-fA-F]{64}|[1-9A-HJ-NP-Za-km-z]{86,88})$/;
|
|
52
|
+
function publicHash(value) {
|
|
53
|
+
return typeof value === "string" && TX_HASH_VALUE.test(value) ? new PublicHash(value) : value;
|
|
54
|
+
}
|
|
55
|
+
var PUBLIC_PAY_PAGE_HOSTS = /* @__PURE__ */ new Set(["invoice.rozo.ai", "checkout.rozo.ai"]);
|
|
56
|
+
var PAY_PAGE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
57
|
+
function publicPayPage(value) {
|
|
58
|
+
if (typeof value !== "string") return value;
|
|
59
|
+
let u;
|
|
60
|
+
try {
|
|
61
|
+
u = new URL(value);
|
|
62
|
+
} catch {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
if (u.protocol !== "https:" || u.username || u.password || u.port || !PUBLIC_PAY_PAGE_HOSTS.has(u.hostname)) return value;
|
|
66
|
+
if (u.pathname !== "/checkout") return value;
|
|
67
|
+
const id = u.searchParams.get("id");
|
|
68
|
+
if (!id || !PAY_PAGE_ID.test(id)) return value;
|
|
69
|
+
return new PublicHash(`https://${u.hostname}/checkout?id=${id.toLowerCase()}`);
|
|
70
|
+
}
|
|
71
|
+
function redactDeep(value) {
|
|
72
|
+
if (value instanceof PublicHash) return value.value;
|
|
73
|
+
if (typeof value === "string") return redact(value);
|
|
74
|
+
if (Array.isArray(value)) return value.map(redactDeep);
|
|
75
|
+
if (value && typeof value === "object") {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [k, v] of Object.entries(value)) {
|
|
78
|
+
if (/priv(ate)?[-_]?key|secret|mnemonic|seed/i.test(k)) {
|
|
79
|
+
out[k] = "<redacted>";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out[k] = redactDeep(v);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
var capturing = false;
|
|
89
|
+
var EmitSignal = class extends Error {
|
|
90
|
+
constructor(payload, exitCode) {
|
|
91
|
+
super("emit");
|
|
92
|
+
this.payload = payload;
|
|
93
|
+
this.exitCode = exitCode;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
function formatFailure(err, fallbackCode = "RUNTIME_ERROR") {
|
|
97
|
+
const code = err && err.code || fallbackCode;
|
|
98
|
+
const message = redact(err && err.message || String(err));
|
|
99
|
+
const payload = { success: false, error: { code, message } };
|
|
100
|
+
if (err && err.details) payload.error.details = redactDeep(err.details);
|
|
101
|
+
return payload;
|
|
102
|
+
}
|
|
103
|
+
function emit(payload, exitCode = EXIT_OK) {
|
|
104
|
+
const redacted = redactDeep(payload);
|
|
105
|
+
if (capturing) throw new EmitSignal(redacted, exitCode);
|
|
106
|
+
process.stdout.write(JSON.stringify(redacted, null, 2) + "\n");
|
|
107
|
+
process.exit(exitCode);
|
|
108
|
+
}
|
|
109
|
+
function fail(err, fallbackCode = "RUNTIME_ERROR", exitCode = EXIT_ERROR) {
|
|
110
|
+
const payload = formatFailure(err, fallbackCode);
|
|
111
|
+
if (capturing) throw new EmitSignal(payload, exitCode);
|
|
112
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
113
|
+
process.exit(exitCode);
|
|
114
|
+
}
|
|
115
|
+
function usage(message) {
|
|
116
|
+
fail({ code: "USAGE", message }, "USAGE", EXIT_USAGE);
|
|
117
|
+
}
|
|
118
|
+
function parseArgs(argv) {
|
|
119
|
+
const out = { _: [] };
|
|
120
|
+
for (let i = 0; i < argv.length; i++) {
|
|
121
|
+
const a = argv[i];
|
|
122
|
+
if (a.startsWith("--")) {
|
|
123
|
+
const key = a.slice(2);
|
|
124
|
+
const next = argv[i + 1];
|
|
125
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
126
|
+
out[key] = true;
|
|
127
|
+
} else {
|
|
128
|
+
out[key] = next;
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
out._.push(a);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
var SkillError = class extends Error {
|
|
138
|
+
constructor(code, message, details) {
|
|
139
|
+
super(message);
|
|
140
|
+
this.code = code;
|
|
141
|
+
if (details) this.details = details;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// scripts/src/lib/ids.mjs
|
|
146
|
+
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}$/;
|
|
147
|
+
function isRozoPaymentId(value) {
|
|
148
|
+
return UUID_RE.test(String(value || "").trim());
|
|
149
|
+
}
|
|
150
|
+
function maskAddress(address) {
|
|
151
|
+
const s = String(address ?? "").trim();
|
|
152
|
+
if (!s) return "(none)";
|
|
153
|
+
if (s.length <= 12) return s;
|
|
154
|
+
return `${s.slice(0, 6)}...${s.slice(-4)}`;
|
|
155
|
+
}
|
|
156
|
+
function maskMemo(memo) {
|
|
157
|
+
const s = String(memo ?? "").trim();
|
|
158
|
+
if (!s) return null;
|
|
159
|
+
if (s.length <= 8) return s;
|
|
160
|
+
return `${s.slice(0, 4)}...${s.slice(-3)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// scripts/src/lib/api.mjs
|
|
164
|
+
import { createRequire } from "node:module";
|
|
165
|
+
|
|
166
|
+
// scripts/src/lib/http.mjs
|
|
167
|
+
var DEFAULT_TIMEOUT_MS = 2e4;
|
|
168
|
+
var USER_AGENT = "rozo-checkout-skill/1.0";
|
|
169
|
+
async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
172
|
+
let res;
|
|
173
|
+
try {
|
|
174
|
+
res = await fetch(url, {
|
|
175
|
+
method,
|
|
176
|
+
headers: {
|
|
177
|
+
accept: "application/json",
|
|
178
|
+
"user-agent": USER_AGENT,
|
|
179
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
180
|
+
},
|
|
181
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
182
|
+
signal: controller.signal
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new SkillError(
|
|
186
|
+
err?.name === "AbortError" ? "HTTP_TIMEOUT" : "HTTP_UNREACHABLE",
|
|
187
|
+
`${method} request failed: ${redact(err?.message || String(err))}`,
|
|
188
|
+
{ url: redactUrl(url) }
|
|
189
|
+
);
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
}
|
|
193
|
+
const text = await res.text();
|
|
194
|
+
let json = null;
|
|
195
|
+
if (text) {
|
|
196
|
+
try {
|
|
197
|
+
json = JSON.parse(text);
|
|
198
|
+
} catch {
|
|
199
|
+
json = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const code = json?.code || json?.error?.code || (typeof json?.error === "string" && /^[A-Z][A-Z0-9_]+$/.test(json.error) ? json.error : null) || `HTTP_${res.status}`;
|
|
204
|
+
const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
|
|
205
|
+
throw new SkillError(code, redact(String(message)), {
|
|
206
|
+
httpStatus: res.status,
|
|
207
|
+
url: redactUrl(url),
|
|
208
|
+
body: json ?? redact(text).slice(0, 800)
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (json === null) {
|
|
212
|
+
throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
|
|
213
|
+
url: redactUrl(url),
|
|
214
|
+
snippet: redact(text).slice(0, 400)
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return json;
|
|
218
|
+
}
|
|
219
|
+
function redactUrl(url) {
|
|
220
|
+
try {
|
|
221
|
+
const u = new URL(url);
|
|
222
|
+
return `${u.origin}${u.pathname}`;
|
|
223
|
+
} catch {
|
|
224
|
+
return String(url);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function getJson(url, opts) {
|
|
228
|
+
return request("GET", url, opts);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// scripts/src/lib/state.mjs
|
|
232
|
+
import fs from "node:fs";
|
|
233
|
+
import path from "node:path";
|
|
234
|
+
import os from "node:os";
|
|
235
|
+
function stateRoot() {
|
|
236
|
+
return process.env.ROZO_CHECKOUT_STATE_DIR || path.join(os.homedir(), ".rozo-checkout", "state");
|
|
237
|
+
}
|
|
238
|
+
function statePath(rozoPaymentId) {
|
|
239
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(String(rozoPaymentId || ""))) {
|
|
240
|
+
throw new SkillError("BAD_ROZO_PAYMENT_ID", "Refusing to build a state path from that id.");
|
|
241
|
+
}
|
|
242
|
+
return path.join(stateRoot(), `${rozoPaymentId}.json`);
|
|
243
|
+
}
|
|
244
|
+
function readState(rozoPaymentId) {
|
|
245
|
+
const file = statePath(rozoPaymentId);
|
|
246
|
+
let raw;
|
|
247
|
+
try {
|
|
248
|
+
raw = fs.readFileSync(file, "utf8");
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (err.code === "ENOENT") return null;
|
|
251
|
+
throw new SkillError("STATE_UNREADABLE", `Cannot read local state: ${err.code}`);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(raw);
|
|
255
|
+
} catch {
|
|
256
|
+
throw new SkillError(
|
|
257
|
+
"STATE_CORRUPT",
|
|
258
|
+
"The local state file for this order is corrupt. Refusing to act; inspect it manually."
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function findByLinkId(linkId) {
|
|
263
|
+
const dir = stateRoot();
|
|
264
|
+
let files = [];
|
|
265
|
+
try {
|
|
266
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
let best = null;
|
|
271
|
+
for (const f of files) {
|
|
272
|
+
try {
|
|
273
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
274
|
+
if (s?.linkId !== linkId) continue;
|
|
275
|
+
if (!best || String(s.createdAt) > String(best.createdAt)) best = s;
|
|
276
|
+
} catch {
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return best;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// scripts/src/lib/api.mjs
|
|
283
|
+
var PKG_VERSION = (() => {
|
|
284
|
+
const requireFrom = createRequire(import.meta.url);
|
|
285
|
+
for (const candidate of ["../../package.json", "../../../package.json"]) {
|
|
286
|
+
try {
|
|
287
|
+
const pkg = requireFrom(candidate);
|
|
288
|
+
if (pkg?.name === "@rozoai/checkout" && pkg.version) return pkg.version;
|
|
289
|
+
} catch {
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return "0.0.0";
|
|
293
|
+
})();
|
|
294
|
+
var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpprouter.dev/v1/services/rozo-agent-api";
|
|
295
|
+
var CLIENT_LABEL = `rozo-checkout-cli/${PKG_VERSION}`;
|
|
296
|
+
var ATTRIBUTION_CLIENT = `rozo-checkout-skill/${PKG_VERSION}`;
|
|
297
|
+
var INTENTS_BASE = process.env.ROZO_CHECKOUT_INTENTS_BASE || "https://intentapiv4.rozo.ai/functions/v1/payment-api";
|
|
298
|
+
async function invoiceStatus({ linkId, rozoPaymentId }) {
|
|
299
|
+
const qs = new URLSearchParams();
|
|
300
|
+
if (linkId) qs.set("payment_id", linkId);
|
|
301
|
+
if (rozoPaymentId) qs.set("rozo_payment_id", rozoPaymentId);
|
|
302
|
+
if (![...qs.keys()].length) {
|
|
303
|
+
throw new SkillError("USAGE", "invoiceStatus needs linkId or rozoPaymentId.");
|
|
304
|
+
}
|
|
305
|
+
return getJson(`${MPP_BASE}/invoice-status?${qs.toString()}`);
|
|
306
|
+
}
|
|
307
|
+
async function getPayment(rozoPaymentId) {
|
|
308
|
+
return getJson(`${INTENTS_BASE}/payments/${encodeURIComponent(rozoPaymentId)}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// scripts/src/lib/amounts.mjs
|
|
312
|
+
var CHAIN_NAMES = {
|
|
313
|
+
1: "Ethereum",
|
|
314
|
+
56: "BNB Chain",
|
|
315
|
+
137: "Polygon",
|
|
316
|
+
8453: "Base",
|
|
317
|
+
900: "Solana",
|
|
318
|
+
1500: "Stellar",
|
|
319
|
+
lightning: "Bitcoin Lightning"
|
|
320
|
+
};
|
|
321
|
+
var CHAIN_FAMILY = {
|
|
322
|
+
1: "evm",
|
|
323
|
+
56: "evm",
|
|
324
|
+
137: "evm",
|
|
325
|
+
8453: "evm",
|
|
326
|
+
900: "solana",
|
|
327
|
+
1500: "stellar",
|
|
328
|
+
lightning: "lightning"
|
|
329
|
+
};
|
|
330
|
+
var DECIMALS = {
|
|
331
|
+
"1:USDC": 6,
|
|
332
|
+
"1:USDT": 6,
|
|
333
|
+
// BNB Chain: BEP-20 USDT and USDC are both 18-decimals.
|
|
334
|
+
"56:USDC": 18,
|
|
335
|
+
"56:USDT": 18,
|
|
336
|
+
"137:USDC": 6,
|
|
337
|
+
"137:USDT": 6,
|
|
338
|
+
"8453:USDC": 6,
|
|
339
|
+
"900:USDC": 6,
|
|
340
|
+
"900:USDT": 6,
|
|
341
|
+
// Stellar classic assets carry 7 decimal places.
|
|
342
|
+
"1500:USDC": 7
|
|
343
|
+
};
|
|
344
|
+
var AmountError = class extends Error {
|
|
345
|
+
constructor(code, message) {
|
|
346
|
+
super(message);
|
|
347
|
+
this.code = code;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
function decimalsKey(chainId, tokenSymbol) {
|
|
351
|
+
return `${String(chainId).trim()}:${String(tokenSymbol || "").trim().toUpperCase()}`;
|
|
352
|
+
}
|
|
353
|
+
function decimalsFor(chainId, tokenSymbol) {
|
|
354
|
+
const key = decimalsKey(chainId, tokenSymbol);
|
|
355
|
+
const d = DECIMALS[key];
|
|
356
|
+
if (d === void 0) {
|
|
357
|
+
throw new AmountError(
|
|
358
|
+
"UNKNOWN_DECIMALS",
|
|
359
|
+
`No decimals known for ${key}; refusing to guess.`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return d;
|
|
363
|
+
}
|
|
364
|
+
function isSatsUnit(amountUnit) {
|
|
365
|
+
return String(amountUnit || "").trim().toLowerCase() === "sats";
|
|
366
|
+
}
|
|
367
|
+
function toAtomic(decimalString, decimals) {
|
|
368
|
+
if (typeof decimalString === "number") decimalString = String(decimalString);
|
|
369
|
+
if (typeof decimalString !== "string") {
|
|
370
|
+
throw new AmountError("BAD_AMOUNT", "Amount must be a string or number.");
|
|
371
|
+
}
|
|
372
|
+
const s = decimalString.trim();
|
|
373
|
+
if (!/^\d+(\.\d+)?$/.test(s)) {
|
|
374
|
+
throw new AmountError("BAD_AMOUNT", `Not a plain decimal amount: ${s}`);
|
|
375
|
+
}
|
|
376
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
|
|
377
|
+
throw new AmountError("BAD_DECIMALS", `Invalid decimals: ${decimals}`);
|
|
378
|
+
}
|
|
379
|
+
const [whole, frac = ""] = s.split(".");
|
|
380
|
+
if (frac.length > decimals) {
|
|
381
|
+
const excess = frac.slice(decimals);
|
|
382
|
+
if (/[^0]/.test(excess)) {
|
|
383
|
+
throw new AmountError(
|
|
384
|
+
"AMOUNT_PRECISION",
|
|
385
|
+
`Amount ${s} has more precision than ${decimals} decimals allow.`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const padded = (frac + "0".repeat(decimals)).slice(0, decimals);
|
|
390
|
+
return BigInt(whole + (decimals > 0 ? padded : ""));
|
|
391
|
+
}
|
|
392
|
+
function satsToAtomic(value) {
|
|
393
|
+
const s = typeof value === "number" ? String(value) : String(value ?? "").trim();
|
|
394
|
+
if (!/^\d+$/.test(s)) {
|
|
395
|
+
throw new AmountError("BAD_SATS", `Lightning amount must be integer sats: ${s}`);
|
|
396
|
+
}
|
|
397
|
+
return BigInt(s);
|
|
398
|
+
}
|
|
399
|
+
function sourceAtomic(source, field) {
|
|
400
|
+
const raw = source?.[field];
|
|
401
|
+
if (raw === null || raw === void 0 || raw === "") return null;
|
|
402
|
+
if (isSatsUnit(source?.amountUnit)) return satsToAtomic(raw);
|
|
403
|
+
return toAtomic(raw, decimalsFor(source?.chainId, source?.tokenSymbol));
|
|
404
|
+
}
|
|
405
|
+
function comparePayment(source) {
|
|
406
|
+
const expected = sourceAtomic(source, "amount");
|
|
407
|
+
if (expected === null) {
|
|
408
|
+
throw new AmountError("MISSING_EXPECTED_AMOUNT", "source.amount is missing.");
|
|
409
|
+
}
|
|
410
|
+
const received = sourceAtomic(source, "amountReceived");
|
|
411
|
+
if (received === null || received === 0n) {
|
|
412
|
+
return {
|
|
413
|
+
state: "none",
|
|
414
|
+
expectedAtomic: expected.toString(),
|
|
415
|
+
receivedAtomic: (received ?? 0n).toString(),
|
|
416
|
+
deltaAtomic: (0n - expected).toString()
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const delta = received - expected;
|
|
420
|
+
return {
|
|
421
|
+
state: delta === 0n ? "exact" : delta < 0n ? "underpaid" : "overpaid",
|
|
422
|
+
expectedAtomic: expected.toString(),
|
|
423
|
+
receivedAtomic: received.toString(),
|
|
424
|
+
deltaAtomic: delta.toString()
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function formatAmount(source) {
|
|
428
|
+
const amount = source?.amount;
|
|
429
|
+
if (isSatsUnit(source?.amountUnit)) return `${amount} sats`;
|
|
430
|
+
return `${amount} ${source?.tokenSymbol ?? ""}`.trim();
|
|
431
|
+
}
|
|
432
|
+
function chainName(chainId) {
|
|
433
|
+
return CHAIN_NAMES[String(chainId)] || CHAIN_NAMES[chainId] || `chain ${chainId}`;
|
|
434
|
+
}
|
|
435
|
+
var STELLAR_MEMO_TYPE = "MEMO_TEXT";
|
|
436
|
+
function chainFamily(chainId) {
|
|
437
|
+
return CHAIN_FAMILY[String(chainId)] || CHAIN_FAMILY[chainId] || null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// scripts/src/lib/guards.mjs
|
|
441
|
+
var UNPAID_STATUS = "payment_unpaid";
|
|
442
|
+
function receiptSignal(source) {
|
|
443
|
+
const raw = source?.amountReceived;
|
|
444
|
+
if (raw === null || raw === void 0 || raw === "") {
|
|
445
|
+
return { money: false, receipt: null, unparsable: false };
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
const receipt = comparePayment(source);
|
|
449
|
+
return { money: receipt.state !== "none", receipt, unparsable: false };
|
|
450
|
+
} catch {
|
|
451
|
+
return { money: true, receipt: null, unparsable: true };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function validateDepositInstructions(source) {
|
|
455
|
+
const family = chainFamily(source?.chainId);
|
|
456
|
+
const address = typeof source?.receiverAddress === "string" ? source.receiverAddress.trim() : "";
|
|
457
|
+
const memo = typeof source?.receiverMemo === "string" ? source.receiverMemo.trim() : "";
|
|
458
|
+
const bolt11 = typeof source?.lnInvoice === "string" && source.lnInvoice.trim() ? source.lnInvoice.trim() : "";
|
|
459
|
+
let amountAtomic;
|
|
460
|
+
try {
|
|
461
|
+
amountAtomic = sourceAtomic(source, "amount");
|
|
462
|
+
} catch (err) {
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
code: "DEPOSIT_INCOMPLETE",
|
|
466
|
+
reason: `The deposit amount is unusable: ${err.message}`
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (amountAtomic === null || amountAtomic <= 0n) {
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
code: "DEPOSIT_INCOMPLETE",
|
|
473
|
+
reason: "The order carries no positive deposit amount."
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (family === "lightning") {
|
|
477
|
+
if (!bolt11) {
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
code: "DEPOSIT_INCOMPLETE",
|
|
481
|
+
reason: "The Lightning order has no BOLT11 invoice yet (the swap may still be being created). Nothing is payable until it appears."
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return { ok: true, code: null, reason: null, family, amountAtomic, payTo: bolt11, memo: null };
|
|
485
|
+
}
|
|
486
|
+
if (!address) {
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
code: "DEPOSIT_NOT_LIVE",
|
|
490
|
+
reason: "The live payments response returned no deposit address."
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (family === "stellar" && !memo) {
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
code: "DEPOSIT_MEMO_REQUIRED",
|
|
497
|
+
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."
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
ok: true,
|
|
502
|
+
code: null,
|
|
503
|
+
reason: null,
|
|
504
|
+
family,
|
|
505
|
+
amountAtomic,
|
|
506
|
+
payTo: address,
|
|
507
|
+
memo: memo || null
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function reuseGuard({ payment, requested, reused = false }) {
|
|
511
|
+
const source = payment?.source || {};
|
|
512
|
+
const evidence = {
|
|
513
|
+
reused: Boolean(reused),
|
|
514
|
+
status: payment?.status ?? null,
|
|
515
|
+
txHash: source.txHash ?? null,
|
|
516
|
+
amountReceived: source.amountReceived ?? null,
|
|
517
|
+
confirmedAt: source.confirmedAt ?? null,
|
|
518
|
+
chainId: source.chainId ?? null,
|
|
519
|
+
tokenSymbol: source.tokenSymbol ?? null,
|
|
520
|
+
senderAddress: source.senderAddress ?? null
|
|
521
|
+
};
|
|
522
|
+
const hasTx = source.txHash !== null && source.txHash !== void 0 && source.txHash !== "";
|
|
523
|
+
const signal = receiptSignal(source);
|
|
524
|
+
const hasConfirm = source.confirmedAt !== null && source.confirmedAt !== void 0 && source.confirmedAt !== "";
|
|
525
|
+
if (hasTx || signal.money || hasConfirm) {
|
|
526
|
+
return {
|
|
527
|
+
ok: false,
|
|
528
|
+
code: "ORDER_ALREADY_FUNDED",
|
|
529
|
+
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.",
|
|
530
|
+
moneyDetected: true,
|
|
531
|
+
evidence: { ...evidence, receipt: signal.receipt, receiptUnparsable: signal.unparsable }
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (payment?.status !== UNPAID_STATUS) {
|
|
535
|
+
const terminal = ["payment_expired", "payment_bounced", "payment_refunded"].includes(
|
|
536
|
+
payment?.status
|
|
537
|
+
);
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
code: terminal ? "ORDER_NOT_PAYABLE" : "ORDER_ALREADY_FUNDED",
|
|
541
|
+
reason: `Existing order status is "${payment?.status ?? "unknown"}", not "${UNPAID_STATUS}".`,
|
|
542
|
+
moneyDetected: !terminal,
|
|
543
|
+
evidence
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const wantChain = String(requested?.chainId ?? "").trim();
|
|
547
|
+
const wantToken = String(requested?.tokenSymbol ?? "").trim().toUpperCase();
|
|
548
|
+
const gotChain = String(source.chainId ?? "").trim();
|
|
549
|
+
const gotToken = String(source.tokenSymbol ?? "").trim().toUpperCase();
|
|
550
|
+
if (!gotChain || !gotToken || gotChain !== wantChain || gotToken !== wantToken) {
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
code: "REUSED_SOURCE_MISMATCH",
|
|
554
|
+
reason: `The order expects ${gotToken || "?"} on chain ${gotChain || "?"}, but you chose ${wantToken} on chain ${wantChain}. Paying the wrong asset or network is usually unrecoverable.`,
|
|
555
|
+
moneyDetected: false,
|
|
556
|
+
evidence
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const deposit = validateDepositInstructions(source);
|
|
560
|
+
if (!deposit.ok) {
|
|
561
|
+
return {
|
|
562
|
+
ok: false,
|
|
563
|
+
code: deposit.code,
|
|
564
|
+
reason: deposit.reason,
|
|
565
|
+
moneyDetected: false,
|
|
566
|
+
evidence
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
return { ok: true, code: null, reason: null, moneyDetected: false, evidence, deposit };
|
|
570
|
+
}
|
|
571
|
+
function checkPayable(statusResponse, now = Date.now()) {
|
|
572
|
+
const cb = statusResponse?.coinbase;
|
|
573
|
+
if (!cb) {
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
code: "LINK_NO_LONGER_PAYABLE",
|
|
577
|
+
reason: "invoice-status returned no Coinbase state; cannot prove the link is still payable.",
|
|
578
|
+
derived: null
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const protocolVersion = cb.protocolVersion ?? statusResponse?.protocolVersion ?? null;
|
|
582
|
+
const derived = {
|
|
583
|
+
protocolVersion,
|
|
584
|
+
status: cb.status ?? null,
|
|
585
|
+
settled: cb.settled ?? null,
|
|
586
|
+
usageCount: cb.usageCount ?? null,
|
|
587
|
+
maxUsage: cb.maxUsage ?? null,
|
|
588
|
+
preApprovalExpiry: cb.preApprovalExpiry ?? null
|
|
589
|
+
};
|
|
590
|
+
if (cb.settled === true) {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
code: "LINK_NO_LONGER_PAYABLE",
|
|
594
|
+
reason: "The Coinbase resource is already settled \u2014 someone has paid it.",
|
|
595
|
+
derived
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (protocolVersion === "v3") {
|
|
599
|
+
if (!cb.status) {
|
|
600
|
+
return {
|
|
601
|
+
ok: false,
|
|
602
|
+
code: "LINK_PAYABILITY_UNKNOWN",
|
|
603
|
+
reason: "The Payment Session response carries no status; cannot prove it is still payable.",
|
|
604
|
+
derived
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
if (cb.status !== "PAYMENT_SESSION_STATUS_CREATED") {
|
|
608
|
+
return {
|
|
609
|
+
ok: false,
|
|
610
|
+
code: "LINK_NO_LONGER_PAYABLE",
|
|
611
|
+
reason: `Payment Session status is ${cb.status}; only PAYMENT_SESSION_STATUS_CREATED is payable.`,
|
|
612
|
+
derived
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return { ok: true, code: null, reason: null, derived };
|
|
616
|
+
}
|
|
617
|
+
const usage2 = Number(cb.usageCount);
|
|
618
|
+
const max = Number(cb.maxUsage);
|
|
619
|
+
if (cb.usageCount === null || cb.usageCount === void 0 || cb.maxUsage === null || cb.maxUsage === void 0 || !Number.isFinite(usage2) || !Number.isFinite(max)) {
|
|
620
|
+
return {
|
|
621
|
+
ok: false,
|
|
622
|
+
code: "LINK_PAYABILITY_UNKNOWN",
|
|
623
|
+
reason: "The payment link response is missing usageCount/maxUsage; cannot prove it has not already been used.",
|
|
624
|
+
derived
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (usage2 >= max) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
code: "LINK_NO_LONGER_PAYABLE",
|
|
631
|
+
reason: `Payment link already used (${usage2}/${max}).`,
|
|
632
|
+
derived
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
return { ok: true, code: null, reason: null, derived };
|
|
636
|
+
}
|
|
637
|
+
function classifyStatus({
|
|
638
|
+
payment,
|
|
639
|
+
routerState,
|
|
640
|
+
coinbase,
|
|
641
|
+
now = Date.now(),
|
|
642
|
+
viewsFailed = false,
|
|
643
|
+
provider = "coinbase"
|
|
644
|
+
}) {
|
|
645
|
+
const bitrefill = provider === "bitrefill";
|
|
646
|
+
const source = payment?.source || {};
|
|
647
|
+
const hasTx = Boolean(source.txHash);
|
|
648
|
+
const confirmed = Boolean(source.confirmedAt);
|
|
649
|
+
const signal = receiptSignal(source);
|
|
650
|
+
const receipt = signal.receipt;
|
|
651
|
+
const moneyDetected = hasTx || confirmed || signal.money;
|
|
652
|
+
const routerStatus = routerState?.status ?? null;
|
|
653
|
+
const mk = (state, detail, opts = {}) => ({
|
|
654
|
+
state,
|
|
655
|
+
moneyDetected: Boolean(moneyDetected),
|
|
656
|
+
terminal: Boolean(opts.terminal),
|
|
657
|
+
escalate: Boolean(opts.escalate),
|
|
658
|
+
unknown: Boolean(opts.unknown),
|
|
659
|
+
detail,
|
|
660
|
+
receipt,
|
|
661
|
+
receiptUnparsable: signal.unparsable,
|
|
662
|
+
routerStatus
|
|
663
|
+
});
|
|
664
|
+
if (viewsFailed || !payment?.status && !routerStatus && !coinbase) {
|
|
665
|
+
return mk(
|
|
666
|
+
"unknown",
|
|
667
|
+
"Could not read the order state from the backend. This is NOT evidence that nothing has been paid \u2014 do not act on it.",
|
|
668
|
+
{ unknown: true }
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
if (signal.unparsable) {
|
|
672
|
+
return mk(
|
|
673
|
+
"stuck_after_payment",
|
|
674
|
+
"The order reports an amountReceived that cannot be read. Treating it as funded until a human confirms otherwise.",
|
|
675
|
+
{ escalate: true }
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if (routerStatus === "failed_pay_invoice" || routerStatus === "failed_insufficient_balance") {
|
|
679
|
+
return mk(
|
|
680
|
+
"stuck_after_payment",
|
|
681
|
+
`Fulfillment failed (${routerStatus}) after the pay-in. Do not pay again \u2014 escalate for manual reconciliation.`,
|
|
682
|
+
{ terminal: false, escalate: true }
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (!bitrefill && (routerStatus === "paid" || coinbase?.settled === true)) {
|
|
686
|
+
return mk("settled", "Coinbase invoice settled by the funder wallet.", { terminal: true });
|
|
687
|
+
}
|
|
688
|
+
if (moneyDetected && receipt && receipt.state === "underpaid") {
|
|
689
|
+
return mk(
|
|
690
|
+
"underpaid",
|
|
691
|
+
"Less arrived than the order requires. Do NOT send a top-up to the same address \u2014 escalate.",
|
|
692
|
+
{ escalate: true }
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (moneyDetected && receipt && receipt.state === "overpaid") {
|
|
696
|
+
return mk("payin_detected", "More arrived than required; escalate for operator follow-up.", {
|
|
697
|
+
escalate: true
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
if (bitrefill) {
|
|
701
|
+
const payoutTx = Boolean(payment?.destination?.txHash);
|
|
702
|
+
if (payment?.status === "payment_payout_completed" || payment?.status === "payment_completed" && payoutTx) {
|
|
703
|
+
return mk("settled", "USDC delivered to the Bitrefill invoice address on Base.", {
|
|
704
|
+
terminal: true
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (payment?.status === "payment_completed") {
|
|
708
|
+
return mk("bridging", "Payment completed but no payout transaction is visible yet. Keep polling.");
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
switch (payment?.status) {
|
|
712
|
+
case "payment_unpaid": {
|
|
713
|
+
if (moneyDetected) {
|
|
714
|
+
return mk("payin_detected", "Pay-in seen on chain, waiting for confirmations.");
|
|
715
|
+
}
|
|
716
|
+
const exp = payment?.expiresAt ? Date.parse(payment.expiresAt) : NaN;
|
|
717
|
+
if (Number.isFinite(exp) && exp < now) {
|
|
718
|
+
return mk("expired_unfunded", "The order expired before any funds arrived. This order can no longer be paid.", {
|
|
719
|
+
terminal: true
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
return mk("awaiting_deposit", "Waiting for the deposit.");
|
|
723
|
+
}
|
|
724
|
+
case "payment_started":
|
|
725
|
+
return mk("payin_detected", "Pay-in seen, waiting for confirmations.");
|
|
726
|
+
case "payment_payin_completed":
|
|
727
|
+
return mk("payin_confirmed", "Pay-in confirmed; fulfillment can start.");
|
|
728
|
+
case "payment_bridging":
|
|
729
|
+
case "payment_payout_started":
|
|
730
|
+
return mk("bridging", "Bridging the pay-in toward the funder.");
|
|
731
|
+
case "payment_payout_completed":
|
|
732
|
+
return mk(
|
|
733
|
+
routerStatus === "paying" ? "paying_coinbase" : "bridging",
|
|
734
|
+
routerStatus === "paying" ? "Funder is paying the Coinbase invoice." : "Payout landed; waiting on Coinbase settlement."
|
|
735
|
+
);
|
|
736
|
+
case "payment_completed":
|
|
737
|
+
return mk(
|
|
738
|
+
"paying_coinbase",
|
|
739
|
+
"The bridge leg completed, but Coinbase settlement is not yet confirmed. Keep polling."
|
|
740
|
+
);
|
|
741
|
+
case "payment_expired":
|
|
742
|
+
return moneyDetected ? mk("stuck_after_payment", "Order expired AFTER funds arrived \u2014 escalate immediately.", {
|
|
743
|
+
escalate: true
|
|
744
|
+
}) : mk("expired_unfunded", "Order expired unfunded. This order can no longer be paid.", { terminal: true });
|
|
745
|
+
case "payment_bounced":
|
|
746
|
+
case "payment_refunded":
|
|
747
|
+
return mk("stuck_after_payment", `Order ended as ${payment.status} \u2014 escalate.`, {
|
|
748
|
+
escalate: true
|
|
749
|
+
});
|
|
750
|
+
default:
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
if (routerStatus === "payin_seen") return mk("payin_confirmed", "Router saw the pay-in.");
|
|
754
|
+
if (routerStatus === "paying") return mk("paying_coinbase", "Funder is paying Coinbase.");
|
|
755
|
+
return mk(
|
|
756
|
+
moneyDetected ? "stuck_after_payment" : "unknown",
|
|
757
|
+
`Unrecognized backend status "${payment?.status ?? "unknown"}"; not assuming anything about it.`,
|
|
758
|
+
{ escalate: Boolean(moneyDetected), unknown: !moneyDetected }
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// scripts/src/lib/blacklist.mjs
|
|
763
|
+
import fs2 from "node:fs";
|
|
764
|
+
import path2 from "node:path";
|
|
765
|
+
import crypto from "node:crypto";
|
|
766
|
+
import { fileURLToPath } from "node:url";
|
|
767
|
+
var BlacklistError = class extends Error {
|
|
768
|
+
constructor(code, message) {
|
|
769
|
+
super(message);
|
|
770
|
+
this.code = code;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
function normalizeAddress(address, family) {
|
|
774
|
+
if (typeof address !== "string") return null;
|
|
775
|
+
const trimmed = address.trim();
|
|
776
|
+
if (!trimmed) return null;
|
|
777
|
+
if (family === "evm" || /^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
|
|
778
|
+
return trimmed.toLowerCase();
|
|
779
|
+
}
|
|
780
|
+
return trimmed;
|
|
781
|
+
}
|
|
782
|
+
function parseBlacklist(doc) {
|
|
783
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
784
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist document is not an object.");
|
|
785
|
+
}
|
|
786
|
+
const { provenance, entries } = doc;
|
|
787
|
+
if (!provenance || typeof provenance !== "object") {
|
|
788
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist provenance header is missing.");
|
|
789
|
+
}
|
|
790
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
791
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist is empty or not an array.");
|
|
792
|
+
}
|
|
793
|
+
const addresses = [];
|
|
794
|
+
for (const e of entries) {
|
|
795
|
+
if (!e || typeof e !== "object" || typeof e.address !== "string" || !e.address.trim()) {
|
|
796
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist entry has no address.");
|
|
797
|
+
}
|
|
798
|
+
addresses.push(e.address);
|
|
799
|
+
}
|
|
800
|
+
const digest = crypto.createHash("sha256").update(JSON.stringify(addresses), "utf8").digest("hex");
|
|
801
|
+
if (typeof provenance.addressesSha256 !== "string" || !provenance.addressesSha256) {
|
|
802
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist provenance digest is missing.");
|
|
803
|
+
}
|
|
804
|
+
if (digest !== provenance.addressesSha256) {
|
|
805
|
+
throw new BlacklistError(
|
|
806
|
+
"BLACKLIST_UNAVAILABLE",
|
|
807
|
+
"Blacklist digest mismatch \u2014 the vendored address list was modified without re-signing."
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (provenance.addressCount !== void 0 && provenance.addressCount !== entries.length) {
|
|
811
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Blacklist addressCount does not match entries.");
|
|
812
|
+
}
|
|
813
|
+
const index = /* @__PURE__ */ new Map();
|
|
814
|
+
for (const e of entries) {
|
|
815
|
+
const key = normalizeAddress(e.address, e.family);
|
|
816
|
+
if (key) index.set(key, e);
|
|
817
|
+
const lower = e.address.trim().toLowerCase();
|
|
818
|
+
if (/^0x[0-9a-f]{40}$/.test(lower)) index.set(lower, e);
|
|
819
|
+
}
|
|
820
|
+
return { entries, index, provenance, digest };
|
|
821
|
+
}
|
|
822
|
+
function candidatePaths(moduleUrl) {
|
|
823
|
+
const here = path2.dirname(fileURLToPath(moduleUrl));
|
|
824
|
+
return [
|
|
825
|
+
path2.join(here, "blacklist.json"),
|
|
826
|
+
path2.join(here, "..", "src", "lib", "blacklist.json"),
|
|
827
|
+
path2.join(here, "..", "..", "src", "lib", "blacklist.json")
|
|
828
|
+
];
|
|
829
|
+
}
|
|
830
|
+
var cached = null;
|
|
831
|
+
function loadBlacklist(explicitPath) {
|
|
832
|
+
if (!explicitPath && cached) return cached;
|
|
833
|
+
const paths = explicitPath ? [explicitPath] : candidatePaths(import.meta.url);
|
|
834
|
+
let lastErr = null;
|
|
835
|
+
for (const p of paths) {
|
|
836
|
+
let raw;
|
|
837
|
+
try {
|
|
838
|
+
raw = fs2.readFileSync(p, "utf8");
|
|
839
|
+
} catch (err) {
|
|
840
|
+
lastErr = err;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
let doc;
|
|
844
|
+
try {
|
|
845
|
+
doc = JSON.parse(raw);
|
|
846
|
+
} catch {
|
|
847
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", `Blacklist file is not valid JSON: ${p}`);
|
|
848
|
+
}
|
|
849
|
+
const parsed = parseBlacklist(doc);
|
|
850
|
+
parsed.sourceFile = p;
|
|
851
|
+
if (!explicitPath) cached = parsed;
|
|
852
|
+
return parsed;
|
|
853
|
+
}
|
|
854
|
+
throw new BlacklistError(
|
|
855
|
+
"BLACKLIST_UNAVAILABLE",
|
|
856
|
+
`Blacklist file not found (looked in ${paths.length} locations). Refusing to proceed.` + (lastErr ? ` Last error: ${lastErr.code || lastErr.message}` : "")
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
function checkAddress(address, family, blacklist) {
|
|
860
|
+
const bl = blacklist || loadBlacklist();
|
|
861
|
+
const normalized = normalizeAddress(address, family);
|
|
862
|
+
if (!normalized) {
|
|
863
|
+
throw new BlacklistError("BLACKLIST_UNAVAILABLE", "Cannot check an empty address.");
|
|
864
|
+
}
|
|
865
|
+
const entry = bl.index.get(normalized) || bl.index.get(normalized.toLowerCase()) || null;
|
|
866
|
+
return { hit: Boolean(entry), entry, normalized };
|
|
867
|
+
}
|
|
868
|
+
function assertNotBlacklisted(targets, blacklist) {
|
|
869
|
+
const bl = blacklist || loadBlacklist();
|
|
870
|
+
for (const t of targets) {
|
|
871
|
+
if (!t || !t.address) continue;
|
|
872
|
+
const { hit, entry } = checkAddress(t.address, t.family, bl);
|
|
873
|
+
if (hit) {
|
|
874
|
+
const err = new BlacklistError(
|
|
875
|
+
"BLACKLIST_HIT",
|
|
876
|
+
`${t.role || "address"} is on the compromised-wallet blacklist (reported ${entry.reportedOn}: ${entry.note}). Refusing.`
|
|
877
|
+
);
|
|
878
|
+
err.role = t.role || "address";
|
|
879
|
+
throw err;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// scripts/src/lib/expiry.mjs
|
|
886
|
+
var MINUTE = 6e4;
|
|
887
|
+
var MARGINS_MS = {
|
|
888
|
+
1: 10 * MINUTE,
|
|
889
|
+
56: 10 * MINUTE,
|
|
890
|
+
137: 10 * MINUTE,
|
|
891
|
+
8453: 10 * MINUTE,
|
|
892
|
+
900: 5 * MINUTE,
|
|
893
|
+
1500: 10 * MINUTE,
|
|
894
|
+
lightning: 10 * MINUTE
|
|
895
|
+
};
|
|
896
|
+
var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
|
|
897
|
+
var DEFAULT_MARGIN_MS = 10 * MINUTE;
|
|
898
|
+
function formatRemaining(ms) {
|
|
899
|
+
if (!Number.isFinite(ms)) return "unknown";
|
|
900
|
+
if (ms <= 0) return "expired";
|
|
901
|
+
const totalMinutes = Math.floor(ms / 6e4);
|
|
902
|
+
if (totalMinutes < 1) return `${Math.floor(ms / 1e3)}s`;
|
|
903
|
+
if (totalMinutes < 60) return `${totalMinutes}m`;
|
|
904
|
+
const h = Math.floor(totalMinutes / 60);
|
|
905
|
+
const m = totalMinutes % 60;
|
|
906
|
+
return m ? `${h}h ${m}m` : `${h}h`;
|
|
907
|
+
}
|
|
908
|
+
function marginFor(chainId) {
|
|
909
|
+
const key = String(chainId);
|
|
910
|
+
return MARGINS_MS[key] ?? MARGINS_MS[chainId] ?? DEFAULT_MARGIN_MS;
|
|
911
|
+
}
|
|
912
|
+
function parseDeadline(value) {
|
|
913
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
914
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
915
|
+
return value < 1e12 ? Math.round(value * 1e3) : Math.round(value);
|
|
916
|
+
}
|
|
917
|
+
const s = String(value).trim();
|
|
918
|
+
if (/^\d+$/.test(s)) {
|
|
919
|
+
const n = Number(s);
|
|
920
|
+
return n < 1e12 ? n * 1e3 : n;
|
|
921
|
+
}
|
|
922
|
+
const t = Date.parse(s);
|
|
923
|
+
return Number.isFinite(t) ? t : null;
|
|
924
|
+
}
|
|
925
|
+
function checkExpiry({
|
|
926
|
+
now,
|
|
927
|
+
chainId,
|
|
928
|
+
intentExpiresAt,
|
|
929
|
+
coinbaseExpiry,
|
|
930
|
+
bolt11ExpiresAt = void 0
|
|
931
|
+
}) {
|
|
932
|
+
const marginMs = marginFor(chainId);
|
|
933
|
+
const intentMs = parseDeadline(intentExpiresAt);
|
|
934
|
+
const coinbaseMs = parseDeadline(coinbaseExpiry);
|
|
935
|
+
const bolt11Ms = bolt11ExpiresAt === void 0 ? void 0 : parseDeadline(bolt11ExpiresAt);
|
|
936
|
+
const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null };
|
|
937
|
+
const base = {
|
|
938
|
+
marginMs,
|
|
939
|
+
effectiveDeadlineMs: null,
|
|
940
|
+
msRemaining: null,
|
|
941
|
+
msOfSlack: null,
|
|
942
|
+
deadlines
|
|
943
|
+
};
|
|
944
|
+
if (intentMs === null) {
|
|
945
|
+
return {
|
|
946
|
+
...base,
|
|
947
|
+
ok: false,
|
|
948
|
+
code: "EXPIRY_UNPARSABLE",
|
|
949
|
+
reason: "Intent expiresAt is missing or unparsable."
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
if (coinbaseMs === null) {
|
|
953
|
+
return {
|
|
954
|
+
...base,
|
|
955
|
+
ok: false,
|
|
956
|
+
code: "EXPIRY_UNPARSABLE",
|
|
957
|
+
reason: "Coinbase preApprovalExpiry is missing or unparsable."
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
const effective = Math.min(intentMs, coinbaseMs);
|
|
961
|
+
const msRemaining = effective - now;
|
|
962
|
+
const msOfSlack = msRemaining - marginMs;
|
|
963
|
+
const withDeadline = {
|
|
964
|
+
...base,
|
|
965
|
+
effectiveDeadlineMs: effective,
|
|
966
|
+
msRemaining,
|
|
967
|
+
msOfSlack
|
|
968
|
+
};
|
|
969
|
+
if (msRemaining <= 0) {
|
|
970
|
+
return {
|
|
971
|
+
...withDeadline,
|
|
972
|
+
ok: false,
|
|
973
|
+
code: "EXPIRED",
|
|
974
|
+
reason: "The order or the Coinbase link has already expired."
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
if (msOfSlack <= 0) {
|
|
978
|
+
return {
|
|
979
|
+
...withDeadline,
|
|
980
|
+
ok: false,
|
|
981
|
+
code: "EXPIRY_MARGIN",
|
|
982
|
+
reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
if (bolt11ExpiresAt !== void 0) {
|
|
986
|
+
if (bolt11Ms === null) {
|
|
987
|
+
return {
|
|
988
|
+
...withDeadline,
|
|
989
|
+
ok: false,
|
|
990
|
+
code: "EXPIRY_UNPARSABLE",
|
|
991
|
+
reason: "BOLT11 invoice expiry is missing or unparsable."
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const bolt11Remaining = bolt11Ms - now;
|
|
995
|
+
if (bolt11Remaining < BOLT11_MIN_VALIDITY_MS) {
|
|
996
|
+
return {
|
|
997
|
+
...withDeadline,
|
|
998
|
+
ok: false,
|
|
999
|
+
code: "BOLT11_TOO_SHORT",
|
|
1000
|
+
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.`
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return { ...withDeadline, ok: true, code: null, reason: null };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// scripts/src/lib/bitrefill.mjs
|
|
1008
|
+
var PROVIDER_BITREFILL = "bitrefill";
|
|
1009
|
+
var BITREFILL_DESTINATION = Object.freeze({ chainId: "8453", tokenSymbol: "USDC" });
|
|
1010
|
+
var BITREFILL_MIN_EXPIRY_MS = 5 * 60 * 1e3;
|
|
1011
|
+
var BITREFILL_MIN_PAY_WINDOW_MS = 2 * 60 * 1e3;
|
|
1012
|
+
var BITREFILL_MAX_EXPIRY_AHEAD_MS = 30 * 60 * 1e3;
|
|
1013
|
+
function providerFromPayment(payment) {
|
|
1014
|
+
if (!payment) return null;
|
|
1015
|
+
if (String(payment.orderId ?? payment.order_id ?? "").startsWith("bitrefill_")) return PROVIDER_BITREFILL;
|
|
1016
|
+
const meta = payment.metadata?.provider;
|
|
1017
|
+
if (meta) return String(meta).toLowerCase();
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
function earliestExpiry(...values) {
|
|
1021
|
+
const ms = values.map((v) => parseDeadline(v)).filter((v) => v !== null);
|
|
1022
|
+
return ms.length ? new Date(Math.min(...ms)).toISOString() : null;
|
|
1023
|
+
}
|
|
1024
|
+
function intentBitrefillExpiry(payment) {
|
|
1025
|
+
return payment?.metadata?.bitrefillExpiresAt ?? null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// scripts/src/lib/receipt.mjs
|
|
1029
|
+
var PAYMENT_OUTCOMES = Object.freeze([
|
|
1030
|
+
"awaiting_payment",
|
|
1031
|
+
"processing",
|
|
1032
|
+
"settled",
|
|
1033
|
+
"expired_unfunded",
|
|
1034
|
+
"needs_attention",
|
|
1035
|
+
"unknown"
|
|
1036
|
+
]);
|
|
1037
|
+
var NEXT_ACTION_TYPES = Object.freeze([
|
|
1038
|
+
"pay",
|
|
1039
|
+
"check_status",
|
|
1040
|
+
"choose_method",
|
|
1041
|
+
"request_new_invoice",
|
|
1042
|
+
"contact_support",
|
|
1043
|
+
"none"
|
|
1044
|
+
]);
|
|
1045
|
+
var STATE_TO_OUTCOME = Object.freeze({
|
|
1046
|
+
awaiting_deposit: "awaiting_payment",
|
|
1047
|
+
payin_detected: "processing",
|
|
1048
|
+
payin_confirmed: "processing",
|
|
1049
|
+
bridging: "processing",
|
|
1050
|
+
paying_coinbase: "processing",
|
|
1051
|
+
settled: "settled",
|
|
1052
|
+
expired_unfunded: "expired_unfunded",
|
|
1053
|
+
invoice_expired: "expired_unfunded",
|
|
1054
|
+
deadline_unknown: "needs_attention",
|
|
1055
|
+
underpaid: "needs_attention",
|
|
1056
|
+
stuck_after_payment: "needs_attention",
|
|
1057
|
+
unknown: "unknown"
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// scripts/src/status.mjs
|
|
1061
|
+
function resolveProvider(explicit, rozoPaymentId) {
|
|
1062
|
+
if (explicit) return String(explicit).toLowerCase();
|
|
1063
|
+
if (rozoPaymentId && isRozoPaymentId(rozoPaymentId)) {
|
|
1064
|
+
try {
|
|
1065
|
+
return readState(rozoPaymentId)?.provider ?? null;
|
|
1066
|
+
} catch {
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
function localSendFor(id, read = readState) {
|
|
1073
|
+
if (!id || !isRozoPaymentId(id)) return null;
|
|
1074
|
+
let state;
|
|
1075
|
+
try {
|
|
1076
|
+
state = read(id);
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
return { status: "unreadable", txHash: null, claimedAt: null, error: err?.code ?? "STATE_UNREADABLE" };
|
|
1079
|
+
}
|
|
1080
|
+
const send = state?.send;
|
|
1081
|
+
if (!send || !send.status) return null;
|
|
1082
|
+
return { status: send.status, txHash: publicHash(send.txHash ?? null), claimedAt: send.claimedAt ?? null };
|
|
1083
|
+
}
|
|
1084
|
+
function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, now = Date.now() }) {
|
|
1085
|
+
if (provider === "bitrefill") {
|
|
1086
|
+
const deadline = bitrefillExpiry ?? null;
|
|
1087
|
+
const expiry2 = checkExpiry({
|
|
1088
|
+
now,
|
|
1089
|
+
chainId,
|
|
1090
|
+
intentExpiresAt: payment?.expiresAt ?? deadline,
|
|
1091
|
+
coinbaseExpiry: deadline
|
|
1092
|
+
});
|
|
1093
|
+
return expiry2.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry2.msOfSlack / 6e4), deadlineMs: expiry2.effectiveDeadlineMs } : { ok: false, code: expiry2.code, reason: expiry2.reason };
|
|
1094
|
+
}
|
|
1095
|
+
if (!status) {
|
|
1096
|
+
return { ok: false, code: "LINK_PAYABILITY_UNKNOWN", reason: "The Coinbase link state could not be read." };
|
|
1097
|
+
}
|
|
1098
|
+
const payable = checkPayable(status, now);
|
|
1099
|
+
if (!payable.ok) return { ok: false, code: payable.code, reason: payable.reason };
|
|
1100
|
+
const expiry = checkExpiry({
|
|
1101
|
+
now,
|
|
1102
|
+
chainId,
|
|
1103
|
+
intentExpiresAt: payment?.expiresAt ?? status?.rozoPayment?.expiresAt,
|
|
1104
|
+
coinbaseExpiry: status?.coinbase?.preApprovalExpiry
|
|
1105
|
+
});
|
|
1106
|
+
return expiry.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry.msOfSlack / 6e4), deadlineMs: expiry.effectiveDeadlineMs } : { ok: false, code: expiry.code, reason: expiry.reason };
|
|
1107
|
+
}
|
|
1108
|
+
async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
|
|
1109
|
+
let provider = resolveProvider(explicitProvider, rozoPaymentId);
|
|
1110
|
+
let prefetched = null;
|
|
1111
|
+
if (!provider && rozoPaymentId && isRozoPaymentId(rozoPaymentId)) {
|
|
1112
|
+
try {
|
|
1113
|
+
prefetched = await getPayment(rozoPaymentId);
|
|
1114
|
+
provider = providerFromPayment(prefetched);
|
|
1115
|
+
} catch {
|
|
1116
|
+
prefetched = null;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
provider = provider === "bitrefill" ? "bitrefill" : "coinbase";
|
|
1120
|
+
let status = null;
|
|
1121
|
+
let statusError = null;
|
|
1122
|
+
if (provider !== "bitrefill") {
|
|
1123
|
+
try {
|
|
1124
|
+
status = await invoiceStatus({ linkId, rozoPaymentId });
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
statusError = { code: err.code, message: err.message };
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
let id = rozoPaymentId || status?.rozo_payment_id || null;
|
|
1130
|
+
let idSource = rozoPaymentId ? "argument" : status?.rozo_payment_id ? "invoice-status" : null;
|
|
1131
|
+
if (!id && linkId) {
|
|
1132
|
+
const local = findByLinkId(linkId);
|
|
1133
|
+
if (local) {
|
|
1134
|
+
id = local.rozoPaymentId;
|
|
1135
|
+
idSource = "local state";
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
let payment = null;
|
|
1139
|
+
let paymentError = null;
|
|
1140
|
+
if (prefetched && id === rozoPaymentId) {
|
|
1141
|
+
payment = prefetched;
|
|
1142
|
+
} else if (id && isRozoPaymentId(id)) {
|
|
1143
|
+
try {
|
|
1144
|
+
payment = await getPayment(id);
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
paymentError = { code: err.code, message: err.message };
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const viewsFailed = provider === "bitrefill" ? !payment : Boolean(statusError) && !payment;
|
|
1150
|
+
const verdict = classifyStatus({
|
|
1151
|
+
payment: payment || status?.rozoPayment || {},
|
|
1152
|
+
routerState: status?.routerState,
|
|
1153
|
+
coinbase: status?.coinbase,
|
|
1154
|
+
viewsFailed,
|
|
1155
|
+
provider
|
|
1156
|
+
});
|
|
1157
|
+
const source = payment?.source || status?.rozoPayment?.source || {};
|
|
1158
|
+
let bitrefillExpiry = null;
|
|
1159
|
+
let state = verdict.state;
|
|
1160
|
+
let terminal = verdict.terminal;
|
|
1161
|
+
let detail = verdict.detail;
|
|
1162
|
+
if (provider === "bitrefill") {
|
|
1163
|
+
let local = null;
|
|
1164
|
+
try {
|
|
1165
|
+
local = id && isRozoPaymentId(id) ? readState(id) : null;
|
|
1166
|
+
} catch {
|
|
1167
|
+
local = null;
|
|
1168
|
+
}
|
|
1169
|
+
bitrefillExpiry = earliestExpiry(local?.bitrefill?.expiresAt, intentBitrefillExpiry(payment));
|
|
1170
|
+
if (!verdict.moneyDetected && ["awaiting_deposit", "expired_unfunded"].includes(verdict.state)) {
|
|
1171
|
+
const ms = bitrefillExpiry ? Date.parse(bitrefillExpiry) : NaN;
|
|
1172
|
+
if (Number.isFinite(ms) && ms <= Date.now()) {
|
|
1173
|
+
state = "invoice_expired";
|
|
1174
|
+
terminal = true;
|
|
1175
|
+
detail = "The Bitrefill invoice has expired. Do NOT fund this order; create a fresh invoice.";
|
|
1176
|
+
} else if (!bitrefillExpiry && verdict.state === "awaiting_deposit") {
|
|
1177
|
+
state = "deadline_unknown";
|
|
1178
|
+
detail = "The Bitrefill invoice deadline is unknown on this machine, so this order is NOT shown as payable. Re-run pay with the invoice details, or create a fresh invoice.";
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return {
|
|
1183
|
+
provider,
|
|
1184
|
+
rozoPaymentId: id,
|
|
1185
|
+
rozoPaymentIdSource: idSource,
|
|
1186
|
+
// Without the authoritative payment object we are reading a partial view.
|
|
1187
|
+
authoritativeView: Boolean(payment),
|
|
1188
|
+
linkId: linkId || status?.pl_id || null,
|
|
1189
|
+
state,
|
|
1190
|
+
unknown: verdict.unknown,
|
|
1191
|
+
moneyDetected: verdict.moneyDetected,
|
|
1192
|
+
terminal,
|
|
1193
|
+
escalate: verdict.escalate,
|
|
1194
|
+
detail,
|
|
1195
|
+
backend: {
|
|
1196
|
+
paymentStatus: payment?.status ?? status?.rozoPayment?.status ?? null,
|
|
1197
|
+
routerStatus: verdict.routerStatus,
|
|
1198
|
+
coinbaseSettled: status?.coinbase?.settled ?? null,
|
|
1199
|
+
coinbaseStatus: status?.coinbase?.status ?? null
|
|
1200
|
+
},
|
|
1201
|
+
payin: {
|
|
1202
|
+
expected: source.amount ? formatAmount(source) : null,
|
|
1203
|
+
received: source.amountReceived ?? null,
|
|
1204
|
+
receipt: verdict.receipt,
|
|
1205
|
+
txHash: publicHash(source.txHash ?? null),
|
|
1206
|
+
confirmedAt: source.confirmedAt ?? null,
|
|
1207
|
+
senderAddressMasked: source.senderAddress ? maskAddress(source.senderAddress) : null,
|
|
1208
|
+
chain: source.chainId ? chainName(source.chainId) : null
|
|
1209
|
+
},
|
|
1210
|
+
expiry: (() => {
|
|
1211
|
+
if (provider === "bitrefill" && !bitrefillExpiry) {
|
|
1212
|
+
return { expiresAt: null, expiresIn: null, msRemaining: null, deadlineUnknown: true };
|
|
1213
|
+
}
|
|
1214
|
+
const iso = provider === "bitrefill" ? bitrefillExpiry : payment?.expiresAt ?? status?.rozoPayment?.expiresAt ?? null;
|
|
1215
|
+
if (!iso) return { expiresAt: null, expiresIn: null, msRemaining: null };
|
|
1216
|
+
const ms = Date.parse(iso) - Date.now();
|
|
1217
|
+
return {
|
|
1218
|
+
expiresAt: iso,
|
|
1219
|
+
expiresIn: formatRemaining(ms),
|
|
1220
|
+
msRemaining: Number.isFinite(ms) ? ms : null
|
|
1221
|
+
};
|
|
1222
|
+
})(),
|
|
1223
|
+
payout: {
|
|
1224
|
+
txHash: publicHash(payment?.destination?.txHash ?? status?.rozoPayment?.destination?.txHash ?? null),
|
|
1225
|
+
confirmedAt: payment?.destination?.confirmedAt ?? status?.rozoPayment?.destination?.confirmedAt ?? null
|
|
1226
|
+
},
|
|
1227
|
+
localSend: localSendFor(id),
|
|
1228
|
+
sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry }) : null,
|
|
1229
|
+
errors: [statusError, paymentError].filter(Boolean)
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
var EXPIRED_UNFUNDED_GUIDANCE = "This order expired before any funds arrived. First check your own wallet: if anything was sent to the old deposit address, or a Lightning payment is still pending, do not pay again; contact support with the linkId and rozoPaymentId (the router cannot see a pending Lightning payment). If nothing was sent, re-run the same pay on the same link with the same --with: it creates a new order for this link. If pay answers PAYMENT_EXPIRED with retryable: true, the old order cannot be verified yet; wait a few minutes and retry. If it answers LINK_USED_OR_EXPIRED, or PAYMENT_EXPIRED with confirmed: true, this link cannot be paid any more; get a new payment link from the merchant (OpenRouter). If it answers ORDER_ALREADY_ACTIVE, a payment was found: do not pay again; contact support.";
|
|
1233
|
+
function statusGuidance(result) {
|
|
1234
|
+
if (result.escalate) {
|
|
1235
|
+
return "MONEY DETECTED and the order is not on a healthy path. Do NOT pay again and do NOT create a new order for this link. Preserve linkId, rozoPaymentId and every tx hash, then escalate to the operator for manual reconciliation.";
|
|
1236
|
+
}
|
|
1237
|
+
if (result.unknown) {
|
|
1238
|
+
return "The order state could not be established. This is NOT evidence that nothing was paid \u2014 do not create a new order and do not send again on the strength of it. Retry, or pass --rozo-payment-id so the authoritative pay-in view can be read.";
|
|
1239
|
+
}
|
|
1240
|
+
if (!result.authoritativeView) {
|
|
1241
|
+
return "Only the fulfilment view was readable; the pay-in view is unavailable, so the money-detected rule cannot be enforced. Pass --rozo-payment-id for a complete answer.";
|
|
1242
|
+
}
|
|
1243
|
+
if ((result.state === "expired_unfunded" || result.state === "invoice_expired") && result.provider === "bitrefill") {
|
|
1244
|
+
return "Nothing was funded, so nothing was lost. Create a fresh Bitrefill invoice and run rozo-checkout pay --bitrefill-invoice <id> --to <0x\u2026> --amount <USDC> --with <coin>";
|
|
1245
|
+
}
|
|
1246
|
+
if (result.state === "expired_unfunded") return EXPIRED_UNFUNDED_GUIDANCE;
|
|
1247
|
+
return result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// scripts/src/resume.mjs
|
|
1251
|
+
var PAID_STATES = /* @__PURE__ */ new Set([
|
|
1252
|
+
"payin_detected",
|
|
1253
|
+
"payin_confirmed",
|
|
1254
|
+
"bridging",
|
|
1255
|
+
"paying_coinbase",
|
|
1256
|
+
"settled"
|
|
1257
|
+
]);
|
|
1258
|
+
var EXPIRED_STATES = /* @__PURE__ */ new Set(["expired_unfunded", "invoice_expired"]);
|
|
1259
|
+
function localPaymentLink(id) {
|
|
1260
|
+
try {
|
|
1261
|
+
return readState(id)?.paymentLink ?? null;
|
|
1262
|
+
} catch {
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
function resumeVerdict(snap) {
|
|
1267
|
+
const base = {
|
|
1268
|
+
rozoPaymentId: snap.rozoPaymentId,
|
|
1269
|
+
linkId: snap.linkId ?? null,
|
|
1270
|
+
provider: snap.provider,
|
|
1271
|
+
state: snap.state,
|
|
1272
|
+
moneyDetected: Boolean(snap.moneyDetected)
|
|
1273
|
+
};
|
|
1274
|
+
if (snap.escalate) {
|
|
1275
|
+
return { ...base, resumable: false, outcome: "needs_attention", code: "ORDER_NEEDS_ATTENTION", exitCode: EXIT_ERROR, message: statusGuidance(snap) };
|
|
1276
|
+
}
|
|
1277
|
+
if (snap.unknown || !snap.authoritativeView) {
|
|
1278
|
+
return { ...base, resumable: false, outcome: "unknown", code: "ORDER_STATE_UNKNOWN", exitCode: EXIT_ERROR, message: statusGuidance(snap) };
|
|
1279
|
+
}
|
|
1280
|
+
if (snap.moneyDetected || PAID_STATES.has(snap.state)) {
|
|
1281
|
+
const done = snap.state === "settled";
|
|
1282
|
+
return {
|
|
1283
|
+
...base,
|
|
1284
|
+
resumable: false,
|
|
1285
|
+
outcome: done ? "paid" : "paid_in_progress",
|
|
1286
|
+
code: "ORDER_ALREADY_PAID",
|
|
1287
|
+
exitCode: EXIT_OK,
|
|
1288
|
+
message: done ? "This order is already paid and settled. Nothing to resume. Do not pay again." : `A payment for this order was already detected (state: ${snap.state}). Do NOT pay again. Follow it with: rozo-checkout status ${snap.rozoPaymentId} --watch`
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
if (EXPIRED_STATES.has(snap.state)) {
|
|
1292
|
+
return {
|
|
1293
|
+
...base,
|
|
1294
|
+
resumable: false,
|
|
1295
|
+
outcome: "expired",
|
|
1296
|
+
code: "ORDER_EXPIRED",
|
|
1297
|
+
exitCode: EXIT_ERROR,
|
|
1298
|
+
message: snap.provider === "bitrefill" ? "This order expired before any funds arrived. Nothing was lost. Create a fresh Bitrefill invoice and run pay again." : EXPIRED_UNFUNDED_GUIDANCE
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
if (snap.localSend) {
|
|
1302
|
+
return {
|
|
1303
|
+
...base,
|
|
1304
|
+
resumable: false,
|
|
1305
|
+
outcome: "sent_locally",
|
|
1306
|
+
code: "ALREADY_SENT",
|
|
1307
|
+
exitCode: EXIT_ERROR,
|
|
1308
|
+
message: `This machine already recorded a send for this order (${snap.localSend.status}). Do NOT pay again. Check it with: rozo-checkout status ${snap.rozoPaymentId}`
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
if (snap.state !== "awaiting_deposit") {
|
|
1312
|
+
return {
|
|
1313
|
+
...base,
|
|
1314
|
+
resumable: false,
|
|
1315
|
+
outcome: "not_payable",
|
|
1316
|
+
code: "ORDER_NOT_RESUMABLE",
|
|
1317
|
+
exitCode: EXIT_ERROR,
|
|
1318
|
+
message: `This order is in state "${snap.state}" and cannot be paid now. ${statusGuidance(snap)}`
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
if (!snap.sendWindow?.ok) {
|
|
1322
|
+
return {
|
|
1323
|
+
...base,
|
|
1324
|
+
resumable: false,
|
|
1325
|
+
outcome: "expired",
|
|
1326
|
+
code: snap.sendWindow?.code ?? "SEND_WINDOW_CLOSED",
|
|
1327
|
+
exitCode: EXIT_ERROR,
|
|
1328
|
+
message: `${snap.sendWindow?.reason ?? "Not enough time is left to pay this order safely."} Do not fund it. Let it expire unfunded, then create a new order for the same link.`
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
return { ...base, resumable: true, outcome: "awaiting_deposit", code: null, exitCode: EXIT_OK, message: null };
|
|
1332
|
+
}
|
|
1333
|
+
async function main(argv) {
|
|
1334
|
+
const args = parseArgs(argv);
|
|
1335
|
+
const rozoPaymentId = args["rozo-payment-id"] || args._[0];
|
|
1336
|
+
if (!rozoPaymentId || rozoPaymentId === true) usage("Required: <rozoPaymentId> (the order id printed by pay)");
|
|
1337
|
+
if (!isRozoPaymentId(String(rozoPaymentId))) {
|
|
1338
|
+
usage("resume takes the order id (rozoPaymentId, a UUID) printed by pay, not a payment link.");
|
|
1339
|
+
}
|
|
1340
|
+
const id = String(rozoPaymentId);
|
|
1341
|
+
const confirmed = Boolean(args.confirm);
|
|
1342
|
+
let blacklist;
|
|
1343
|
+
try {
|
|
1344
|
+
blacklist = loadBlacklist();
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
throw new SkillError("BLACKLIST_UNAVAILABLE", `Compromised-address list unusable: ${err.message} Refusing to proceed.`);
|
|
1347
|
+
}
|
|
1348
|
+
const snap = await snapshot({ rozoPaymentId: id });
|
|
1349
|
+
const verdict = resumeVerdict(snap);
|
|
1350
|
+
if (!verdict.resumable) {
|
|
1351
|
+
emit(
|
|
1352
|
+
{
|
|
1353
|
+
success: verdict.exitCode === EXIT_OK,
|
|
1354
|
+
step: "resume",
|
|
1355
|
+
resumable: false,
|
|
1356
|
+
outcome: verdict.outcome,
|
|
1357
|
+
rozoPaymentId: id,
|
|
1358
|
+
linkId: verdict.linkId,
|
|
1359
|
+
state: verdict.state,
|
|
1360
|
+
moneyDetected: verdict.moneyDetected,
|
|
1361
|
+
...verdict.exitCode === EXIT_OK ? { message: verdict.message } : { error: { code: verdict.code, message: verdict.message } },
|
|
1362
|
+
payin: snap.payin,
|
|
1363
|
+
...verdict.exitCode === EXIT_OK ? {} : { support: SUPPORT }
|
|
1364
|
+
},
|
|
1365
|
+
verdict.exitCode
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
const payment = await getPayment(id);
|
|
1369
|
+
const source = payment?.source || {};
|
|
1370
|
+
const guard = reuseGuard({ payment, requested: { chainId: source.chainId, tokenSymbol: source.tokenSymbol }, reused: true });
|
|
1371
|
+
if (!guard.ok) {
|
|
1372
|
+
emit(
|
|
1373
|
+
{
|
|
1374
|
+
success: false,
|
|
1375
|
+
step: "resume",
|
|
1376
|
+
resumable: false,
|
|
1377
|
+
outcome: guard.moneyDetected ? "paid_in_progress" : "not_payable",
|
|
1378
|
+
rozoPaymentId: id,
|
|
1379
|
+
moneyDetected: guard.moneyDetected,
|
|
1380
|
+
error: { code: guard.code, message: guard.reason },
|
|
1381
|
+
guidance: guard.moneyDetected ? "A payment for this order was detected. Do NOT pay again." : "This order cannot be paid as it stands. Do not fund it.",
|
|
1382
|
+
support: SUPPORT
|
|
1383
|
+
},
|
|
1384
|
+
EXIT_ERROR
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
const finalWindow = checkExpiry({
|
|
1388
|
+
now: Date.now(),
|
|
1389
|
+
chainId: source.chainId,
|
|
1390
|
+
intentExpiresAt: payment?.expiresAt ?? snap.sendWindow?.deadlineMs,
|
|
1391
|
+
coinbaseExpiry: snap.sendWindow?.deadlineMs
|
|
1392
|
+
});
|
|
1393
|
+
if (!finalWindow.ok) {
|
|
1394
|
+
emit(
|
|
1395
|
+
{
|
|
1396
|
+
success: false,
|
|
1397
|
+
step: "resume",
|
|
1398
|
+
resumable: false,
|
|
1399
|
+
outcome: "expired",
|
|
1400
|
+
rozoPaymentId: id,
|
|
1401
|
+
moneyDetected: false,
|
|
1402
|
+
error: { code: finalWindow.code, message: finalWindow.reason },
|
|
1403
|
+
guidance: "Not enough time is left to pay this order safely. Do not fund it; let it expire unfunded and create a new order.",
|
|
1404
|
+
support: SUPPORT
|
|
1405
|
+
},
|
|
1406
|
+
EXIT_ERROR
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
const lightning = String(source.chainId) === "lightning";
|
|
1410
|
+
if (!lightning) {
|
|
1411
|
+
try {
|
|
1412
|
+
assertNotBlacklisted(
|
|
1413
|
+
[{ address: source.receiverAddress, family: chainFamily(source.chainId), role: "deposit address" }],
|
|
1414
|
+
blacklist
|
|
1415
|
+
);
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
emit(
|
|
1418
|
+
{
|
|
1419
|
+
success: false,
|
|
1420
|
+
step: "resume",
|
|
1421
|
+
resumable: false,
|
|
1422
|
+
rozoPaymentId: id,
|
|
1423
|
+
error: { code: err.code, message: err.message },
|
|
1424
|
+
guidance: "Do NOT send anything. Report this to the operator immediately.",
|
|
1425
|
+
support: SUPPORT
|
|
1426
|
+
},
|
|
1427
|
+
EXIT_ERROR
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const expiresAt = payment?.expiresAt ?? snap.expiry?.expiresAt ?? null;
|
|
1432
|
+
const msRemaining = expiresAt ? Date.parse(expiresAt) - Date.now() : NaN;
|
|
1433
|
+
const expiresIn = Number.isFinite(msRemaining) ? formatRemaining(msRemaining) : null;
|
|
1434
|
+
const paymentLink = payment?.paymentLink ?? localPaymentLink(id);
|
|
1435
|
+
const bolt11 = source.lnInvoice ?? payment?.lnInvoice ?? null;
|
|
1436
|
+
emit({
|
|
1437
|
+
success: true,
|
|
1438
|
+
step: "resume",
|
|
1439
|
+
resumable: true,
|
|
1440
|
+
outcome: "awaiting_deposit",
|
|
1441
|
+
confirmed,
|
|
1442
|
+
rozoPaymentId: id,
|
|
1443
|
+
linkId: snap.linkId ?? payment?.orderId ?? null,
|
|
1444
|
+
provider: snap.provider,
|
|
1445
|
+
state: snap.state,
|
|
1446
|
+
// Hosted pay page for this order: a human can open it and pay from there.
|
|
1447
|
+
// Shown in full only for Rozo's own pay-page hosts; anything else is
|
|
1448
|
+
// reduced to its host by the normal output redaction.
|
|
1449
|
+
paymentLink: publicPayPage(paymentLink),
|
|
1450
|
+
deposit: confirmed ? {
|
|
1451
|
+
chainId: source.chainId,
|
|
1452
|
+
chain: chainName(source.chainId),
|
|
1453
|
+
tokenSymbol: source.tokenSymbol,
|
|
1454
|
+
tokenAddress: source.tokenAddress || null,
|
|
1455
|
+
receiverAddress: lightning ? null : source.receiverAddress,
|
|
1456
|
+
receiverMemo: source.receiverMemo ?? null,
|
|
1457
|
+
receiverMemoType: source.receiverMemo ? STELLAR_MEMO_TYPE : null,
|
|
1458
|
+
amount: source.amount,
|
|
1459
|
+
amountUnit: source.amountUnit ?? null,
|
|
1460
|
+
isSats: isSatsUnit(source.amountUnit),
|
|
1461
|
+
lnInvoice: bolt11 || null,
|
|
1462
|
+
payTo: guard.deposit.payTo,
|
|
1463
|
+
expiresAt,
|
|
1464
|
+
expiresIn
|
|
1465
|
+
} : null,
|
|
1466
|
+
depositWithheld: !confirmed,
|
|
1467
|
+
display: {
|
|
1468
|
+
chain: chainName(source.chainId),
|
|
1469
|
+
token: source.tokenSymbol,
|
|
1470
|
+
amount: formatAmount(source),
|
|
1471
|
+
isSats: isSatsUnit(source.amountUnit),
|
|
1472
|
+
payToMasked: maskAddress(guard.deposit.payTo),
|
|
1473
|
+
receiverMemoMasked: maskMemo(source.receiverMemo),
|
|
1474
|
+
hasMemo: Boolean(source.receiverMemo),
|
|
1475
|
+
memoType: source.receiverMemo ? STELLAR_MEMO_TYPE : null
|
|
1476
|
+
},
|
|
1477
|
+
expiry: { expiresAt, expiresIn, minutesOfSlack: snap.sendWindow?.minutesOfSlack ?? null },
|
|
1478
|
+
note: confirmed ? "Send exactly once, exactly this amount, on this chain. Then: rozo-checkout status <id> --watch" : "Deposit address withheld. Confirm the chain, token and amount with the payer, then re-run with --confirm, or open paymentLink."
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
async function run(argv = process.argv.slice(2)) {
|
|
1482
|
+
return main(argv);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// scripts/src/bin/resume.mjs
|
|
1486
|
+
run().catch((err) => fail(err));
|