@rozoai/checkout 0.1.11 → 0.1.12

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.
@@ -193,7 +193,7 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
193
193
  }
194
194
  }
195
195
  if (!res.ok) {
196
- const code = json?.code || json?.error?.code || (typeof json?.error === "string" ? null : null) || `HTTP_${res.status}`;
196
+ 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}`;
197
197
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
198
198
  throw new SkillError(code, redact(String(message)), {
199
199
  httpStatus: res.status,
@@ -1058,12 +1058,23 @@ function readState(rozoPaymentId) {
1058
1058
  function createOrderRecord(record) {
1059
1059
  return withLock(() => createOrderRecordUnlocked(record));
1060
1060
  }
1061
+ function mergeBitrefill(prev, next) {
1062
+ if (!next) return prev ?? null;
1063
+ if (!prev?.expiresAt || !next.expiresAt) return { ...next, expiresAt: next.expiresAt ?? prev?.expiresAt ?? null };
1064
+ const a = Date.parse(prev.expiresAt);
1065
+ const b = Date.parse(next.expiresAt);
1066
+ return { ...next, expiresAt: Number.isFinite(a) && (!Number.isFinite(b) || a < b) ? prev.expiresAt : next.expiresAt };
1067
+ }
1061
1068
  function createOrderRecordUnlocked(record) {
1062
1069
  const { rozoPaymentId } = record;
1063
1070
  const existing = readState(rozoPaymentId);
1064
1071
  const next = {
1065
1072
  version: 1,
1066
1073
  rozoPaymentId,
1074
+ // 'coinbase' (default, historical records) or 'bitrefill'. Drives which
1075
+ // payability checks presend and status run.
1076
+ provider: record.provider ?? existing?.provider ?? "coinbase",
1077
+ bitrefill: mergeBitrefill(existing?.bitrefill, record.bitrefill),
1067
1078
  linkId: record.linkId,
1068
1079
  paymentLink: record.paymentLink ?? null,
1069
1080
  merchant: record.merchant ?? null,
@@ -173,7 +173,7 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
173
173
  }
174
174
  }
175
175
  if (!res.ok) {
176
- const code = json?.code || json?.error?.code || (typeof json?.error === "string" ? null : null) || `HTTP_${res.status}`;
176
+ 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}`;
177
177
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
178
178
  throw new SkillError(code, redact(String(message)), {
179
179
  httpStatus: res.status,
@@ -21857,7 +21857,7 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
21857
21857
  }
21858
21858
  }
21859
21859
  if (!res.ok) {
21860
- const code = json?.code || json?.error?.code || (typeof json?.error === "string" ? null : null) || `HTTP_${res.status}`;
21860
+ 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}`;
21861
21861
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
21862
21862
  throw new SkillError(code, redact(String(message)), {
21863
21863
  httpStatus: res.status,
@@ -22045,6 +22045,15 @@ function reuseGuard({ payment, requested, reused = false }) {
22045
22045
  }
22046
22046
  return { ok: true, code: null, reason: null, moneyDetected: false, evidence, deposit };
22047
22047
  }
22048
+ function normalizeDecimal(v) {
22049
+ if (v === null || v === void 0) return null;
22050
+ const s = String(v).trim();
22051
+ if (!/^\d+(\.\d+)?$/.test(s)) return s;
22052
+ const [w, f = ""] = s.split(".");
22053
+ const frac = f.replace(/0+$/, "");
22054
+ const whole = w.replace(/^0+(?=\d)/, "");
22055
+ return frac ? `${whole}.${frac}` : whole;
22056
+ }
22048
22057
  function checkPayable(statusResponse, now = Date.now()) {
22049
22058
  const cb = statusResponse?.coinbase;
22050
22059
  if (!cb) {
@@ -22540,7 +22549,67 @@ function assertPaymentLimit(invoiceUsd) {
22540
22549
  return { usd, limitUsd: MAX_PAYMENT_USD };
22541
22550
  }
22542
22551
 
22552
+ // scripts/src/lib/bitrefill.mjs
22553
+ var BITREFILL_DESTINATION = Object.freeze({ chainId: "8453", tokenSymbol: "USDC" });
22554
+ var BITREFILL_MIN_EXPIRY_MS = 5 * 60 * 1e3;
22555
+ var BITREFILL_MIN_PAY_WINDOW_MS = 2 * 60 * 1e3;
22556
+ var BITREFILL_MAX_EXPIRY_AHEAD_MS = 30 * 60 * 1e3;
22557
+ function checkBitrefillExpiry(expiresAt, now = Date.now(), minMs = BITREFILL_MIN_PAY_WINDOW_MS) {
22558
+ const ms = parseDeadline(expiresAt);
22559
+ const base = { marginMs: minMs, deadlines: { invoiceMs: ms } };
22560
+ if (ms === null) {
22561
+ return { ...base, ok: false, code: "EXPIRY_UNPARSABLE", reason: "The Bitrefill invoice expiry is missing or unreadable.", effectiveDeadlineMs: null, msRemaining: null, msOfSlack: null };
22562
+ }
22563
+ const msRemaining = ms - now;
22564
+ const out = { ...base, effectiveDeadlineMs: ms, msRemaining, msOfSlack: msRemaining - minMs };
22565
+ if (msRemaining <= 0) return { ...out, ok: false, code: "EXPIRED", reason: "The Bitrefill invoice has expired." };
22566
+ if (msRemaining < minMs) {
22567
+ return {
22568
+ ...out,
22569
+ ok: false,
22570
+ code: "INVOICE_EXPIRING",
22571
+ reason: `Only ${Math.floor(msRemaining / 1e3)}s left on the Bitrefill invoice; at least ${Math.round(minMs / 6e4)} min is required. Create a fresh invoice.`
22572
+ };
22573
+ }
22574
+ return { ...out, ok: true, code: null, reason: null };
22575
+ }
22576
+ var sameAddress = (a, b) => typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase();
22577
+ function verifyBitrefillDestination({ requested, payment }) {
22578
+ const dest = payment?.destination || {};
22579
+ const drift = [];
22580
+ if (String(dest.chainId ?? "") !== BITREFILL_DESTINATION.chainId) {
22581
+ drift.push({ field: "destination.chainId", requested: "8453", live: dest.chainId ?? null });
22582
+ }
22583
+ if (String(dest.tokenSymbol ?? "").toUpperCase() !== BITREFILL_DESTINATION.tokenSymbol) {
22584
+ drift.push({ field: "destination.tokenSymbol", requested: "USDC", live: dest.tokenSymbol ?? null });
22585
+ }
22586
+ if (normalizeDecimal(dest.amount) !== normalizeDecimal(requested.amount)) {
22587
+ drift.push({ field: "destination.amount", requested: requested.amount, live: dest.amount ?? null });
22588
+ }
22589
+ const liveAddress = dest.receiverAddress ?? dest.address ?? null;
22590
+ if (!sameAddress(liveAddress, requested.address)) {
22591
+ drift.push({ field: "destination.address", requested: requested.address, live: liveAddress });
22592
+ }
22593
+ return drift.length ? {
22594
+ ok: false,
22595
+ code: "BITREFILL_ECHO_MISMATCH",
22596
+ reason: "The live order does not deliver to the Bitrefill invoice. Refusing to continue.",
22597
+ drift,
22598
+ addressVerified: false
22599
+ } : { ok: true, code: null, reason: null, drift, addressVerified: true };
22600
+ }
22601
+
22543
22602
  // scripts/src/lib/presend.mjs
22603
+ function assertBitrefillLive(state, payment) {
22604
+ if (!state.bitrefill) {
22605
+ throw new SkillError("BITREFILL_ECHO_MISMATCH", "This Bitrefill order has no recorded invoice. Refusing to send.");
22606
+ }
22607
+ const dest = verifyBitrefillDestination({ requested: state.bitrefill, payment });
22608
+ if (!dest.ok) throw new SkillError(dest.code, dest.reason, { drift: dest.drift });
22609
+ const expiry = checkBitrefillExpiry(state.bitrefill.expiresAt, Date.now());
22610
+ if (!expiry.ok) throw new SkillError(expiry.code, expiry.reason, expiry);
22611
+ return expiry;
22612
+ }
22544
22613
  async function preflight({
22545
22614
  rozoPaymentId,
22546
22615
  expectFamily,
@@ -22626,16 +22695,22 @@ async function preflight({
22626
22695
  if ((source.receiverMemo ?? null) !== (state.receiverMemo ?? null)) {
22627
22696
  throw new SkillError("DEPOSIT_CHANGED", "The live deposit memo differs from the recorded one.");
22628
22697
  }
22629
- const statusNow = await invoiceStatus({ linkId: state.linkId });
22630
- const expiry = checkExpiry({
22631
- now: Date.now(),
22632
- chainId: source.chainId,
22633
- intentExpiresAt: payment?.expiresAt,
22634
- coinbaseExpiry: statusNow?.coinbase?.preApprovalExpiry
22635
- });
22636
- if (!expiry.ok) throw new SkillError(expiry.code, expiry.reason, expiry);
22637
- const payable = checkPayable(statusNow, Date.now());
22638
- if (!payable.ok) throw new SkillError(payable.code, payable.reason, payable.derived);
22698
+ let statusNow = null;
22699
+ let expiry;
22700
+ if (state.provider === "bitrefill") {
22701
+ expiry = assertBitrefillLive(state, payment);
22702
+ } else {
22703
+ statusNow = await invoiceStatus({ linkId: state.linkId });
22704
+ expiry = checkExpiry({
22705
+ now: Date.now(),
22706
+ chainId: source.chainId,
22707
+ intentExpiresAt: payment?.expiresAt,
22708
+ coinbaseExpiry: statusNow?.coinbase?.preApprovalExpiry
22709
+ });
22710
+ if (!expiry.ok) throw new SkillError(expiry.code, expiry.reason, expiry);
22711
+ const payable = checkPayable(statusNow, Date.now());
22712
+ if (!payable.ok) throw new SkillError(payable.code, payable.reason, payable.derived);
22713
+ }
22639
22714
  assertNotBlacklisted(
22640
22715
  [
22641
22716
  { address: source.receiverAddress, family, role: "deposit address" },
@@ -22654,7 +22729,17 @@ async function preflight({
22654
22729
  statusNow
22655
22730
  };
22656
22731
  }
22657
- async function finalPayabilityCheck({ linkId, chainId, intentExpiresAt }) {
22732
+ async function finalPayabilityCheck({ linkId, chainId, intentExpiresAt, state = null, rozoPaymentId = null }) {
22733
+ if (state?.provider === "bitrefill") {
22734
+ const id = rozoPaymentId ?? state.rozoPaymentId;
22735
+ const payment = await getPayment(id);
22736
+ const guard = reuseGuard({ payment, requested: state.source });
22737
+ if (!guard.ok) {
22738
+ throw new SkillError(guard.code, guard.reason, { ...guard.evidence, moneyDetected: guard.moneyDetected });
22739
+ }
22740
+ const expiry2 = assertBitrefillLive(state, payment);
22741
+ return { statusNow: null, payable: { ok: true }, expiry: expiry2 };
22742
+ }
22658
22743
  const statusNow = await invoiceStatus({ linkId });
22659
22744
  const payable = checkPayable(statusNow, Date.now());
22660
22745
  if (!payable.ok) throw new SkillError(payable.code, payable.reason, payable.derived);
@@ -23018,7 +23103,9 @@ async function main(argv) {
23018
23103
  await finalPayabilityCheck({
23019
23104
  linkId: state.linkId,
23020
23105
  chainId: source.chainId,
23021
- intentExpiresAt: payment?.expiresAt
23106
+ intentExpiresAt: payment?.expiresAt,
23107
+ state,
23108
+ rozoPaymentId
23022
23109
  });
23023
23110
  claimSend(
23024
23111
  rozoPaymentId,
@@ -33043,7 +33043,7 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
33043
33043
  }
33044
33044
  }
33045
33045
  if (!res.ok) {
33046
- const code = json?.code || json?.error?.code || (typeof json?.error === "string" ? null : null) || `HTTP_${res.status}`;
33046
+ 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}`;
33047
33047
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
33048
33048
  throw new SkillError(code, redact(String(message)), {
33049
33049
  httpStatus: res.status,
@@ -33231,6 +33231,15 @@ function reuseGuard({ payment, requested, reused = false }) {
33231
33231
  }
33232
33232
  return { ok: true, code: null, reason: null, moneyDetected: false, evidence, deposit };
33233
33233
  }
33234
+ function normalizeDecimal(v) {
33235
+ if (v === null || v === void 0) return null;
33236
+ const s = String(v).trim();
33237
+ if (!/^\d+(\.\d+)?$/.test(s)) return s;
33238
+ const [w, f = ""] = s.split(".");
33239
+ const frac = f.replace(/0+$/, "");
33240
+ const whole = w.replace(/^0+(?=\d)/, "");
33241
+ return frac ? `${whole}.${frac}` : whole;
33242
+ }
33234
33243
  function checkPayable(statusResponse, now = Date.now()) {
33235
33244
  const cb = statusResponse?.coinbase;
33236
33245
  if (!cb) {
@@ -33726,7 +33735,67 @@ function assertPaymentLimit(invoiceUsd) {
33726
33735
  return { usd, limitUsd: MAX_PAYMENT_USD };
33727
33736
  }
33728
33737
 
33738
+ // scripts/src/lib/bitrefill.mjs
33739
+ var BITREFILL_DESTINATION = Object.freeze({ chainId: "8453", tokenSymbol: "USDC" });
33740
+ var BITREFILL_MIN_EXPIRY_MS = 5 * 60 * 1e3;
33741
+ var BITREFILL_MIN_PAY_WINDOW_MS = 2 * 60 * 1e3;
33742
+ var BITREFILL_MAX_EXPIRY_AHEAD_MS = 30 * 60 * 1e3;
33743
+ function checkBitrefillExpiry(expiresAt, now = Date.now(), minMs = BITREFILL_MIN_PAY_WINDOW_MS) {
33744
+ const ms = parseDeadline(expiresAt);
33745
+ const base = { marginMs: minMs, deadlines: { invoiceMs: ms } };
33746
+ if (ms === null) {
33747
+ return { ...base, ok: false, code: "EXPIRY_UNPARSABLE", reason: "The Bitrefill invoice expiry is missing or unreadable.", effectiveDeadlineMs: null, msRemaining: null, msOfSlack: null };
33748
+ }
33749
+ const msRemaining = ms - now;
33750
+ const out = { ...base, effectiveDeadlineMs: ms, msRemaining, msOfSlack: msRemaining - minMs };
33751
+ if (msRemaining <= 0) return { ...out, ok: false, code: "EXPIRED", reason: "The Bitrefill invoice has expired." };
33752
+ if (msRemaining < minMs) {
33753
+ return {
33754
+ ...out,
33755
+ ok: false,
33756
+ code: "INVOICE_EXPIRING",
33757
+ reason: `Only ${Math.floor(msRemaining / 1e3)}s left on the Bitrefill invoice; at least ${Math.round(minMs / 6e4)} min is required. Create a fresh invoice.`
33758
+ };
33759
+ }
33760
+ return { ...out, ok: true, code: null, reason: null };
33761
+ }
33762
+ var sameAddress = (a, b) => typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase();
33763
+ function verifyBitrefillDestination({ requested, payment }) {
33764
+ const dest = payment?.destination || {};
33765
+ const drift = [];
33766
+ if (String(dest.chainId ?? "") !== BITREFILL_DESTINATION.chainId) {
33767
+ drift.push({ field: "destination.chainId", requested: "8453", live: dest.chainId ?? null });
33768
+ }
33769
+ if (String(dest.tokenSymbol ?? "").toUpperCase() !== BITREFILL_DESTINATION.tokenSymbol) {
33770
+ drift.push({ field: "destination.tokenSymbol", requested: "USDC", live: dest.tokenSymbol ?? null });
33771
+ }
33772
+ if (normalizeDecimal(dest.amount) !== normalizeDecimal(requested.amount)) {
33773
+ drift.push({ field: "destination.amount", requested: requested.amount, live: dest.amount ?? null });
33774
+ }
33775
+ const liveAddress = dest.receiverAddress ?? dest.address ?? null;
33776
+ if (!sameAddress(liveAddress, requested.address)) {
33777
+ drift.push({ field: "destination.address", requested: requested.address, live: liveAddress });
33778
+ }
33779
+ return drift.length ? {
33780
+ ok: false,
33781
+ code: "BITREFILL_ECHO_MISMATCH",
33782
+ reason: "The live order does not deliver to the Bitrefill invoice. Refusing to continue.",
33783
+ drift,
33784
+ addressVerified: false
33785
+ } : { ok: true, code: null, reason: null, drift, addressVerified: true };
33786
+ }
33787
+
33729
33788
  // scripts/src/lib/presend.mjs
33789
+ function assertBitrefillLive(state, payment) {
33790
+ if (!state.bitrefill) {
33791
+ throw new SkillError("BITREFILL_ECHO_MISMATCH", "This Bitrefill order has no recorded invoice. Refusing to send.");
33792
+ }
33793
+ const dest = verifyBitrefillDestination({ requested: state.bitrefill, payment });
33794
+ if (!dest.ok) throw new SkillError(dest.code, dest.reason, { drift: dest.drift });
33795
+ const expiry = checkBitrefillExpiry(state.bitrefill.expiresAt, Date.now());
33796
+ if (!expiry.ok) throw new SkillError(expiry.code, expiry.reason, expiry);
33797
+ return expiry;
33798
+ }
33730
33799
  async function preflight({
33731
33800
  rozoPaymentId,
33732
33801
  expectFamily,
@@ -33812,16 +33881,22 @@ async function preflight({
33812
33881
  if ((source.receiverMemo ?? null) !== (state.receiverMemo ?? null)) {
33813
33882
  throw new SkillError("DEPOSIT_CHANGED", "The live deposit memo differs from the recorded one.");
33814
33883
  }
33815
- const statusNow = await invoiceStatus({ linkId: state.linkId });
33816
- const expiry = checkExpiry({
33817
- now: Date.now(),
33818
- chainId: source.chainId,
33819
- intentExpiresAt: payment?.expiresAt,
33820
- coinbaseExpiry: statusNow?.coinbase?.preApprovalExpiry
33821
- });
33822
- if (!expiry.ok) throw new SkillError(expiry.code, expiry.reason, expiry);
33823
- const payable = checkPayable(statusNow, Date.now());
33824
- if (!payable.ok) throw new SkillError(payable.code, payable.reason, payable.derived);
33884
+ let statusNow = null;
33885
+ let expiry;
33886
+ if (state.provider === "bitrefill") {
33887
+ expiry = assertBitrefillLive(state, payment);
33888
+ } else {
33889
+ statusNow = await invoiceStatus({ linkId: state.linkId });
33890
+ expiry = checkExpiry({
33891
+ now: Date.now(),
33892
+ chainId: source.chainId,
33893
+ intentExpiresAt: payment?.expiresAt,
33894
+ coinbaseExpiry: statusNow?.coinbase?.preApprovalExpiry
33895
+ });
33896
+ if (!expiry.ok) throw new SkillError(expiry.code, expiry.reason, expiry);
33897
+ const payable = checkPayable(statusNow, Date.now());
33898
+ if (!payable.ok) throw new SkillError(payable.code, payable.reason, payable.derived);
33899
+ }
33825
33900
  assertNotBlacklisted(
33826
33901
  [
33827
33902
  { address: source.receiverAddress, family, role: "deposit address" },
@@ -33840,7 +33915,17 @@ async function preflight({
33840
33915
  statusNow
33841
33916
  };
33842
33917
  }
33843
- async function finalPayabilityCheck({ linkId, chainId, intentExpiresAt }) {
33918
+ async function finalPayabilityCheck({ linkId, chainId, intentExpiresAt, state = null, rozoPaymentId = null }) {
33919
+ if (state?.provider === "bitrefill") {
33920
+ const id = rozoPaymentId ?? state.rozoPaymentId;
33921
+ const payment = await getPayment(id);
33922
+ const guard = reuseGuard({ payment, requested: state.source });
33923
+ if (!guard.ok) {
33924
+ throw new SkillError(guard.code, guard.reason, { ...guard.evidence, moneyDetected: guard.moneyDetected });
33925
+ }
33926
+ const expiry2 = assertBitrefillLive(state, payment);
33927
+ return { statusNow: null, payable: { ok: true }, expiry: expiry2 };
33928
+ }
33844
33929
  const statusNow = await invoiceStatus({ linkId });
33845
33930
  const payable = checkPayable(statusNow, Date.now());
33846
33931
  if (!payable.ok) throw new SkillError(payable.code, payable.reason, payable.derived);
@@ -34109,7 +34194,9 @@ async function main(argv) {
34109
34194
  await finalPayabilityCheck({
34110
34195
  linkId: state.linkId,
34111
34196
  chainId: source.chainId,
34112
- intentExpiresAt: payment?.expiresAt
34197
+ intentExpiresAt: payment?.expiresAt,
34198
+ state,
34199
+ rozoPaymentId
34113
34200
  });
34114
34201
  claimSend(
34115
34202
  rozoPaymentId,
@@ -156,7 +156,7 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
156
156
  }
157
157
  }
158
158
  if (!res.ok) {
159
- const code = json?.code || json?.error?.code || (typeof json?.error === "string" ? null : null) || `HTTP_${res.status}`;
159
+ 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}`;
160
160
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
161
161
  throw new SkillError(code, redact(String(message)), {
162
162
  httpStatus: res.status,
@@ -342,7 +342,15 @@ function receiptSignal(source) {
342
342
  return { money: true, receipt: null, unparsable: true };
343
343
  }
344
344
  }
345
- function classifyStatus({ payment, routerState, coinbase, now = Date.now(), viewsFailed = false }) {
345
+ function classifyStatus({
346
+ payment,
347
+ routerState,
348
+ coinbase,
349
+ now = Date.now(),
350
+ viewsFailed = false,
351
+ provider = "coinbase"
352
+ }) {
353
+ const bitrefill = provider === "bitrefill";
346
354
  const source = payment?.source || {};
347
355
  const hasTx = Boolean(source.txHash);
348
356
  const confirmed = Boolean(source.confirmedAt);
@@ -375,7 +383,7 @@ function classifyStatus({ payment, routerState, coinbase, now = Date.now(), view
375
383
  { escalate: true }
376
384
  );
377
385
  }
378
- if (routerStatus === "paid" || coinbase?.settled === true) {
386
+ if (!bitrefill && (routerStatus === "paid" || coinbase?.settled === true)) {
379
387
  return mk("settled", "Coinbase invoice settled by the funder wallet.", { terminal: true });
380
388
  }
381
389
  if (routerStatus === "failed_pay_invoice" || routerStatus === "failed_insufficient_balance") {
@@ -397,6 +405,17 @@ function classifyStatus({ payment, routerState, coinbase, now = Date.now(), view
397
405
  escalate: true
398
406
  });
399
407
  }
408
+ if (bitrefill) {
409
+ const payoutTx = Boolean(payment?.destination?.txHash);
410
+ if (payment?.status === "payment_payout_completed" || payment?.status === "payment_completed" && payoutTx) {
411
+ return mk("settled", "USDC delivered to the Bitrefill invoice address on Base.", {
412
+ terminal: true
413
+ });
414
+ }
415
+ if (payment?.status === "payment_completed") {
416
+ return mk("bridging", "Payment completed but no payout transaction is visible yet. Keep polling.");
417
+ }
418
+ }
400
419
  switch (payment?.status) {
401
420
  case "payment_unpaid": {
402
421
  if (moneyDetected) {
@@ -471,6 +490,19 @@ function formatRemaining(ms) {
471
490
  const m = totalMinutes % 60;
472
491
  return m ? `${h}h ${m}m` : `${h}h`;
473
492
  }
493
+ function parseDeadline(value) {
494
+ if (value === null || value === void 0 || value === "") return null;
495
+ if (typeof value === "number" && Number.isFinite(value)) {
496
+ return value < 1e12 ? Math.round(value * 1e3) : Math.round(value);
497
+ }
498
+ const s = String(value).trim();
499
+ if (/^\d+$/.test(s)) {
500
+ const n = Number(s);
501
+ return n < 1e12 ? n * 1e3 : n;
502
+ }
503
+ const t = Date.parse(s);
504
+ return Number.isFinite(t) ? t : null;
505
+ }
474
506
 
475
507
  // scripts/src/lib/state.mjs
476
508
  import fs from "node:fs";
@@ -479,6 +511,30 @@ import os from "node:os";
479
511
  function stateRoot() {
480
512
  return process.env.ROZO_CHECKOUT_STATE_DIR || path.join(os.homedir(), ".rozo-checkout", "state");
481
513
  }
514
+ function statePath(rozoPaymentId) {
515
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(String(rozoPaymentId || ""))) {
516
+ throw new SkillError("BAD_ROZO_PAYMENT_ID", "Refusing to build a state path from that id.");
517
+ }
518
+ return path.join(stateRoot(), `${rozoPaymentId}.json`);
519
+ }
520
+ function readState(rozoPaymentId) {
521
+ const file = statePath(rozoPaymentId);
522
+ let raw;
523
+ try {
524
+ raw = fs.readFileSync(file, "utf8");
525
+ } catch (err) {
526
+ if (err.code === "ENOENT") return null;
527
+ throw new SkillError("STATE_UNREADABLE", `Cannot read local state: ${err.code}`);
528
+ }
529
+ try {
530
+ return JSON.parse(raw);
531
+ } catch {
532
+ throw new SkillError(
533
+ "STATE_CORRUPT",
534
+ "The local state file for this order is corrupt. Refusing to act; inspect it manually."
535
+ );
536
+ }
537
+ }
482
538
  function findByLinkId(linkId) {
483
539
  const dir = stateRoot();
484
540
  let files = [];
@@ -499,16 +555,61 @@ function findByLinkId(linkId) {
499
555
  return best;
500
556
  }
501
557
 
558
+ // scripts/src/lib/bitrefill.mjs
559
+ var PROVIDER_BITREFILL = "bitrefill";
560
+ var BITREFILL_DESTINATION = Object.freeze({ chainId: "8453", tokenSymbol: "USDC" });
561
+ var BITREFILL_MIN_EXPIRY_MS = 5 * 60 * 1e3;
562
+ var BITREFILL_MIN_PAY_WINDOW_MS = 2 * 60 * 1e3;
563
+ var BITREFILL_MAX_EXPIRY_AHEAD_MS = 30 * 60 * 1e3;
564
+ function providerFromPayment(payment) {
565
+ if (!payment) return null;
566
+ if (String(payment.orderId ?? payment.order_id ?? "").startsWith("bitrefill_")) return PROVIDER_BITREFILL;
567
+ const meta = payment.metadata?.provider;
568
+ if (meta) return String(meta).toLowerCase();
569
+ return null;
570
+ }
571
+ function earliestExpiry(...values) {
572
+ const ms = values.map((v) => parseDeadline(v)).filter((v) => v !== null);
573
+ return ms.length ? new Date(Math.min(...ms)).toISOString() : null;
574
+ }
575
+ function intentBitrefillExpiry(payment) {
576
+ return payment?.metadata?.bitrefillExpiresAt ?? null;
577
+ }
578
+
502
579
  // scripts/src/status.mjs
503
580
  var POLL_INTERVAL_MS = 1e4;
504
581
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
505
- async function snapshot({ rozoPaymentId, linkId }) {
582
+ function resolveProvider(explicit, rozoPaymentId) {
583
+ if (explicit) return String(explicit).toLowerCase();
584
+ if (rozoPaymentId && isRozoPaymentId(rozoPaymentId)) {
585
+ try {
586
+ return readState(rozoPaymentId)?.provider ?? null;
587
+ } catch {
588
+ return null;
589
+ }
590
+ }
591
+ return null;
592
+ }
593
+ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
594
+ let provider = resolveProvider(explicitProvider, rozoPaymentId);
595
+ let prefetched = null;
596
+ if (!provider && rozoPaymentId && isRozoPaymentId(rozoPaymentId)) {
597
+ try {
598
+ prefetched = await getPayment(rozoPaymentId);
599
+ provider = providerFromPayment(prefetched);
600
+ } catch {
601
+ prefetched = null;
602
+ }
603
+ }
604
+ provider = provider === "bitrefill" ? "bitrefill" : "coinbase";
506
605
  let status = null;
507
606
  let statusError = null;
508
- try {
509
- status = await invoiceStatus({ linkId, rozoPaymentId });
510
- } catch (err) {
511
- statusError = { code: err.code, message: err.message };
607
+ if (provider !== "bitrefill") {
608
+ try {
609
+ status = await invoiceStatus({ linkId, rozoPaymentId });
610
+ } catch (err) {
611
+ statusError = { code: err.code, message: err.message };
612
+ }
512
613
  }
513
614
  let id = rozoPaymentId || status?.rozo_payment_id || null;
514
615
  let idSource = rozoPaymentId ? "argument" : status?.rozo_payment_id ? "invoice-status" : null;
@@ -521,33 +622,61 @@ async function snapshot({ rozoPaymentId, linkId }) {
521
622
  }
522
623
  let payment = null;
523
624
  let paymentError = null;
524
- if (id && isRozoPaymentId(id)) {
625
+ if (prefetched && id === rozoPaymentId) {
626
+ payment = prefetched;
627
+ } else if (id && isRozoPaymentId(id)) {
525
628
  try {
526
629
  payment = await getPayment(id);
527
630
  } catch (err) {
528
631
  paymentError = { code: err.code, message: err.message };
529
632
  }
530
633
  }
531
- const viewsFailed = Boolean(statusError) && !payment;
634
+ const viewsFailed = provider === "bitrefill" ? !payment : Boolean(statusError) && !payment;
532
635
  const verdict = classifyStatus({
533
636
  payment: payment || status?.rozoPayment || {},
534
637
  routerState: status?.routerState,
535
638
  coinbase: status?.coinbase,
536
- viewsFailed
639
+ viewsFailed,
640
+ provider
537
641
  });
538
642
  const source = payment?.source || status?.rozoPayment?.source || {};
643
+ let bitrefillExpiry = null;
644
+ let state = verdict.state;
645
+ let terminal = verdict.terminal;
646
+ let detail = verdict.detail;
647
+ if (provider === "bitrefill") {
648
+ let local = null;
649
+ try {
650
+ local = id && isRozoPaymentId(id) ? readState(id) : null;
651
+ } catch {
652
+ local = null;
653
+ }
654
+ bitrefillExpiry = earliestExpiry(local?.bitrefill?.expiresAt, intentBitrefillExpiry(payment));
655
+ if (!verdict.moneyDetected && ["awaiting_deposit", "expired_unfunded"].includes(verdict.state)) {
656
+ const ms = bitrefillExpiry ? Date.parse(bitrefillExpiry) : NaN;
657
+ if (Number.isFinite(ms) && ms <= Date.now()) {
658
+ state = "invoice_expired";
659
+ terminal = true;
660
+ detail = "The Bitrefill invoice has expired. Do NOT fund this order; create a fresh invoice.";
661
+ } else if (!bitrefillExpiry && verdict.state === "awaiting_deposit") {
662
+ state = "deadline_unknown";
663
+ 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.";
664
+ }
665
+ }
666
+ }
539
667
  return {
668
+ provider,
540
669
  rozoPaymentId: id,
541
670
  rozoPaymentIdSource: idSource,
542
671
  // Without the authoritative payment object we are reading a partial view.
543
672
  authoritativeView: Boolean(payment),
544
673
  linkId: linkId || status?.pl_id || null,
545
- state: verdict.state,
674
+ state,
546
675
  unknown: verdict.unknown,
547
676
  moneyDetected: verdict.moneyDetected,
548
- terminal: verdict.terminal,
677
+ terminal,
549
678
  escalate: verdict.escalate,
550
- detail: verdict.detail,
679
+ detail,
551
680
  backend: {
552
681
  paymentStatus: payment?.status ?? status?.rozoPayment?.status ?? null,
553
682
  routerStatus: verdict.routerStatus,
@@ -564,7 +693,10 @@ async function snapshot({ rozoPaymentId, linkId }) {
564
693
  chain: source.chainId ? chainName(source.chainId) : null
565
694
  },
566
695
  expiry: (() => {
567
- const iso = payment?.expiresAt ?? status?.rozoPayment?.expiresAt ?? null;
696
+ if (provider === "bitrefill" && !bitrefillExpiry) {
697
+ return { expiresAt: null, expiresIn: null, msRemaining: null, deadlineUnknown: true };
698
+ }
699
+ const iso = provider === "bitrefill" ? bitrefillExpiry : payment?.expiresAt ?? status?.rozoPayment?.expiresAt ?? null;
568
700
  if (!iso) return { expiresAt: null, expiresIn: null, msRemaining: null };
569
701
  const ms = Date.parse(iso) - Date.now();
570
702
  return {
@@ -584,21 +716,28 @@ async function main(argv) {
584
716
  const args = parseArgs(argv);
585
717
  const rozoPaymentId = args["rozo-payment-id"] || (isRozoPaymentId(args._[0]) ? args._[0] : null);
586
718
  const linkId = args["link-id"] || (!rozoPaymentId ? args._[0] : null);
719
+ const provider = args.provider ? String(args.provider) : null;
720
+ if (provider && provider !== "bitrefill" && provider !== "coinbase") {
721
+ usage("--provider must be coinbase or bitrefill");
722
+ }
723
+ if (provider === "bitrefill" && !rozoPaymentId) {
724
+ usage("A Bitrefill order is tracked by --rozo-payment-id <uuid>.");
725
+ }
587
726
  if (!rozoPaymentId && !linkId) {
588
727
  usage("Required: --rozo-payment-id <uuid> and/or --link-id <pl_* | paymentSession_*>");
589
728
  }
590
729
  const watch = Boolean(args.watch);
591
730
  const timeoutMs = Math.max(0, Number(args.timeout ?? 600) * 1e3);
592
731
  const deadline = Date.now() + timeoutMs;
593
- let result = await snapshot({ rozoPaymentId, linkId });
732
+ let result = await snapshot({ rozoPaymentId, linkId, provider });
594
733
  const history = [{ at: (/* @__PURE__ */ new Date()).toISOString(), state: result.state }];
595
734
  while (watch && !result.terminal && !result.escalate && !result.unknown && Date.now() < deadline) {
596
735
  await sleep(POLL_INTERVAL_MS);
597
- const next = await snapshot({ rozoPaymentId: result.rozoPaymentId || rozoPaymentId, linkId });
736
+ const next = await snapshot({ rozoPaymentId: result.rozoPaymentId || rozoPaymentId, linkId, provider });
598
737
  if (next.state !== result.state) history.push({ at: (/* @__PURE__ */ new Date()).toISOString(), state: next.state });
599
738
  result = next;
600
739
  }
601
- const guidance = result.escalate ? "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." : result.unknown ? "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." : !result.authoritativeView ? "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." : result.state === "expired_unfunded" ? "Nothing was funded, so nothing was lost. Start a fresh order with: rozo-checkout pay <coinbase-link> --with <coin> (or create-order.js --url <link> --chain <id> --token <SYMBOL>)" : result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
740
+ const guidance = result.escalate ? "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." : result.unknown ? "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." : !result.authoritativeView ? "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." : (result.state === "expired_unfunded" || result.state === "invoice_expired") && result.provider === "bitrefill" ? "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>" : result.state === "expired_unfunded" ? "Nothing was funded, so nothing was lost. Start a fresh order with: rozo-checkout pay <coinbase-link> --with <coin> (or create-order.js --url <link> --chain <id> --token <SYMBOL>)" : result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
602
741
  const unresolved = watch && !result.terminal && !result.escalate && !result.unknown;
603
742
  const failed = result.escalate || result.unknown || !result.authoritativeView;
604
743
  emit(