@rozoai/checkout 0.1.15 → 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.
@@ -41,7 +41,20 @@ function redact(text) {
41
41
  );
42
42
  return s;
43
43
  }
44
+ var PublicHash = class {
45
+ constructor(value) {
46
+ this.value = value;
47
+ }
48
+ toJSON() {
49
+ return this.value;
50
+ }
51
+ };
52
+ var TX_HASH_VALUE = /^(0x[0-9a-fA-F]{64}|[1-9A-HJ-NP-Za-km-z]{86,88})$/;
53
+ function publicHash(value) {
54
+ return typeof value === "string" && TX_HASH_VALUE.test(value) ? new PublicHash(value) : value;
55
+ }
44
56
  function redactDeep(value) {
57
+ if (value instanceof PublicHash) return value.value;
45
58
  if (typeof value === "string") return redact(value);
46
59
  if (Array.isArray(value)) return value.map(redactDeep);
47
60
  if (value && typeof value === "object") {
@@ -194,6 +207,57 @@ function getJson(url, opts) {
194
207
  return request("GET", url, opts);
195
208
  }
196
209
 
210
+ // scripts/src/lib/state.mjs
211
+ import fs from "node:fs";
212
+ import path from "node:path";
213
+ import os from "node:os";
214
+ function stateRoot() {
215
+ return process.env.ROZO_CHECKOUT_STATE_DIR || path.join(os.homedir(), ".rozo-checkout", "state");
216
+ }
217
+ function statePath(rozoPaymentId) {
218
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(String(rozoPaymentId || ""))) {
219
+ throw new SkillError("BAD_ROZO_PAYMENT_ID", "Refusing to build a state path from that id.");
220
+ }
221
+ return path.join(stateRoot(), `${rozoPaymentId}.json`);
222
+ }
223
+ function readState(rozoPaymentId) {
224
+ const file = statePath(rozoPaymentId);
225
+ let raw;
226
+ try {
227
+ raw = fs.readFileSync(file, "utf8");
228
+ } catch (err) {
229
+ if (err.code === "ENOENT") return null;
230
+ throw new SkillError("STATE_UNREADABLE", `Cannot read local state: ${err.code}`);
231
+ }
232
+ try {
233
+ return JSON.parse(raw);
234
+ } catch {
235
+ throw new SkillError(
236
+ "STATE_CORRUPT",
237
+ "The local state file for this order is corrupt. Refusing to act; inspect it manually."
238
+ );
239
+ }
240
+ }
241
+ function findByLinkId(linkId) {
242
+ const dir = stateRoot();
243
+ let files = [];
244
+ try {
245
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
246
+ } catch {
247
+ return null;
248
+ }
249
+ let best = null;
250
+ for (const f of files) {
251
+ try {
252
+ const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
253
+ if (s?.linkId !== linkId) continue;
254
+ if (!best || String(s.createdAt) > String(best.createdAt)) best = s;
255
+ } catch {
256
+ }
257
+ }
258
+ return best;
259
+ }
260
+
197
261
  // scripts/src/lib/api.mjs
198
262
  var PKG_VERSION = (() => {
199
263
  const requireFrom = createRequire(import.meta.url);
@@ -352,6 +416,72 @@ function receiptSignal(source) {
352
416
  return { money: true, receipt: null, unparsable: true };
353
417
  }
354
418
  }
419
+ function checkPayable(statusResponse, now = Date.now()) {
420
+ const cb = statusResponse?.coinbase;
421
+ if (!cb) {
422
+ return {
423
+ ok: false,
424
+ code: "LINK_NO_LONGER_PAYABLE",
425
+ reason: "invoice-status returned no Coinbase state; cannot prove the link is still payable.",
426
+ derived: null
427
+ };
428
+ }
429
+ const protocolVersion = cb.protocolVersion ?? statusResponse?.protocolVersion ?? null;
430
+ const derived = {
431
+ protocolVersion,
432
+ status: cb.status ?? null,
433
+ settled: cb.settled ?? null,
434
+ usageCount: cb.usageCount ?? null,
435
+ maxUsage: cb.maxUsage ?? null,
436
+ preApprovalExpiry: cb.preApprovalExpiry ?? null
437
+ };
438
+ if (cb.settled === true) {
439
+ return {
440
+ ok: false,
441
+ code: "LINK_NO_LONGER_PAYABLE",
442
+ reason: "The Coinbase resource is already settled \u2014 someone has paid it.",
443
+ derived
444
+ };
445
+ }
446
+ if (protocolVersion === "v3") {
447
+ if (!cb.status) {
448
+ return {
449
+ ok: false,
450
+ code: "LINK_PAYABILITY_UNKNOWN",
451
+ reason: "The Payment Session response carries no status; cannot prove it is still payable.",
452
+ derived
453
+ };
454
+ }
455
+ if (cb.status !== "PAYMENT_SESSION_STATUS_CREATED") {
456
+ return {
457
+ ok: false,
458
+ code: "LINK_NO_LONGER_PAYABLE",
459
+ reason: `Payment Session status is ${cb.status}; only PAYMENT_SESSION_STATUS_CREATED is payable.`,
460
+ derived
461
+ };
462
+ }
463
+ return { ok: true, code: null, reason: null, derived };
464
+ }
465
+ const usage2 = Number(cb.usageCount);
466
+ const max = Number(cb.maxUsage);
467
+ if (cb.usageCount === null || cb.usageCount === void 0 || cb.maxUsage === null || cb.maxUsage === void 0 || !Number.isFinite(usage2) || !Number.isFinite(max)) {
468
+ return {
469
+ ok: false,
470
+ code: "LINK_PAYABILITY_UNKNOWN",
471
+ reason: "The payment link response is missing usageCount/maxUsage; cannot prove it has not already been used.",
472
+ derived
473
+ };
474
+ }
475
+ if (usage2 >= max) {
476
+ return {
477
+ ok: false,
478
+ code: "LINK_NO_LONGER_PAYABLE",
479
+ reason: `Payment link already used (${usage2}/${max}).`,
480
+ derived
481
+ };
482
+ }
483
+ return { ok: true, code: null, reason: null, derived };
484
+ }
355
485
  function classifyStatus({
356
486
  payment,
357
487
  routerState,
@@ -393,9 +523,6 @@ function classifyStatus({
393
523
  { escalate: true }
394
524
  );
395
525
  }
396
- if (!bitrefill && (routerStatus === "paid" || coinbase?.settled === true)) {
397
- return mk("settled", "Coinbase invoice settled by the funder wallet.", { terminal: true });
398
- }
399
526
  if (routerStatus === "failed_pay_invoice" || routerStatus === "failed_insufficient_balance") {
400
527
  return mk(
401
528
  "stuck_after_payment",
@@ -403,6 +530,9 @@ function classifyStatus({
403
530
  { terminal: false, escalate: true }
404
531
  );
405
532
  }
533
+ if (!bitrefill && (routerStatus === "paid" || coinbase?.settled === true)) {
534
+ return mk("settled", "Coinbase invoice settled by the funder wallet.", { terminal: true });
535
+ }
406
536
  if (moneyDetected && receipt && receipt.state === "underpaid") {
407
537
  return mk(
408
538
  "underpaid",
@@ -500,6 +630,10 @@ function formatRemaining(ms) {
500
630
  const m = totalMinutes % 60;
501
631
  return m ? `${h}h ${m}m` : `${h}h`;
502
632
  }
633
+ function marginFor(chainId) {
634
+ const key = String(chainId);
635
+ return MARGINS_MS[key] ?? MARGINS_MS[chainId] ?? DEFAULT_MARGIN_MS;
636
+ }
503
637
  function parseDeadline(value) {
504
638
  if (value === null || value === void 0 || value === "") return null;
505
639
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -513,56 +647,86 @@ function parseDeadline(value) {
513
647
  const t = Date.parse(s);
514
648
  return Number.isFinite(t) ? t : null;
515
649
  }
516
-
517
- // scripts/src/lib/state.mjs
518
- import fs from "node:fs";
519
- import path from "node:path";
520
- import os from "node:os";
521
- function stateRoot() {
522
- return process.env.ROZO_CHECKOUT_STATE_DIR || path.join(os.homedir(), ".rozo-checkout", "state");
523
- }
524
- function statePath(rozoPaymentId) {
525
- if (!/^[A-Za-z0-9-]{8,64}$/.test(String(rozoPaymentId || ""))) {
526
- throw new SkillError("BAD_ROZO_PAYMENT_ID", "Refusing to build a state path from that id.");
650
+ function checkExpiry({
651
+ now,
652
+ chainId,
653
+ intentExpiresAt,
654
+ coinbaseExpiry,
655
+ bolt11ExpiresAt = void 0
656
+ }) {
657
+ const marginMs = marginFor(chainId);
658
+ const intentMs = parseDeadline(intentExpiresAt);
659
+ const coinbaseMs = parseDeadline(coinbaseExpiry);
660
+ const bolt11Ms = bolt11ExpiresAt === void 0 ? void 0 : parseDeadline(bolt11ExpiresAt);
661
+ const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null };
662
+ const base = {
663
+ marginMs,
664
+ effectiveDeadlineMs: null,
665
+ msRemaining: null,
666
+ msOfSlack: null,
667
+ deadlines
668
+ };
669
+ if (intentMs === null) {
670
+ return {
671
+ ...base,
672
+ ok: false,
673
+ code: "EXPIRY_UNPARSABLE",
674
+ reason: "Intent expiresAt is missing or unparsable."
675
+ };
527
676
  }
528
- return path.join(stateRoot(), `${rozoPaymentId}.json`);
529
- }
530
- function readState(rozoPaymentId) {
531
- const file = statePath(rozoPaymentId);
532
- let raw;
533
- try {
534
- raw = fs.readFileSync(file, "utf8");
535
- } catch (err) {
536
- if (err.code === "ENOENT") return null;
537
- throw new SkillError("STATE_UNREADABLE", `Cannot read local state: ${err.code}`);
677
+ if (coinbaseMs === null) {
678
+ return {
679
+ ...base,
680
+ ok: false,
681
+ code: "EXPIRY_UNPARSABLE",
682
+ reason: "Coinbase preApprovalExpiry is missing or unparsable."
683
+ };
538
684
  }
539
- try {
540
- return JSON.parse(raw);
541
- } catch {
542
- throw new SkillError(
543
- "STATE_CORRUPT",
544
- "The local state file for this order is corrupt. Refusing to act; inspect it manually."
545
- );
685
+ const effective = Math.min(intentMs, coinbaseMs);
686
+ const msRemaining = effective - now;
687
+ const msOfSlack = msRemaining - marginMs;
688
+ const withDeadline = {
689
+ ...base,
690
+ effectiveDeadlineMs: effective,
691
+ msRemaining,
692
+ msOfSlack
693
+ };
694
+ if (msRemaining <= 0) {
695
+ return {
696
+ ...withDeadline,
697
+ ok: false,
698
+ code: "EXPIRED",
699
+ reason: "The order or the Coinbase link has already expired."
700
+ };
546
701
  }
547
- }
548
- function findByLinkId(linkId) {
549
- const dir = stateRoot();
550
- let files = [];
551
- try {
552
- files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
553
- } catch {
554
- return null;
702
+ if (msOfSlack <= 0) {
703
+ return {
704
+ ...withDeadline,
705
+ ok: false,
706
+ code: "EXPIRY_MARGIN",
707
+ reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
708
+ };
555
709
  }
556
- let best = null;
557
- for (const f of files) {
558
- try {
559
- const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
560
- if (s?.linkId !== linkId) continue;
561
- if (!best || String(s.createdAt) > String(best.createdAt)) best = s;
562
- } catch {
710
+ if (bolt11ExpiresAt !== void 0) {
711
+ if (bolt11Ms === null) {
712
+ return {
713
+ ...withDeadline,
714
+ ok: false,
715
+ code: "EXPIRY_UNPARSABLE",
716
+ reason: "BOLT11 invoice expiry is missing or unparsable."
717
+ };
718
+ }
719
+ const bolt11Remaining = bolt11Ms - now;
720
+ if (bolt11Remaining < BOLT11_MIN_VALIDITY_MS) {
721
+ return {
722
+ ...withDeadline,
723
+ ok: false,
724
+ code: "BOLT11_TOO_SHORT",
725
+ 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.`
726
+ };
563
727
  }
564
728
  }
565
- return best;
729
+ return { ...withDeadline, ok: true, code: null, reason: null };
566
730
  }
567
731
 
568
732
  // scripts/src/lib/bitrefill.mjs
@@ -586,6 +750,153 @@ function intentBitrefillExpiry(payment) {
586
750
  return payment?.metadata?.bitrefillExpiresAt ?? null;
587
751
  }
588
752
 
753
+ // scripts/src/lib/receipt.mjs
754
+ var SCHEMA_VERSION = 1;
755
+ var PAYMENT_OUTCOMES = Object.freeze([
756
+ "awaiting_payment",
757
+ "processing",
758
+ "settled",
759
+ "expired_unfunded",
760
+ "needs_attention",
761
+ "unknown"
762
+ ]);
763
+ var NEXT_ACTION_TYPES = Object.freeze([
764
+ "pay",
765
+ "check_status",
766
+ "choose_method",
767
+ "request_new_invoice",
768
+ "contact_support",
769
+ "none"
770
+ ]);
771
+ var STATE_TO_OUTCOME = Object.freeze({
772
+ awaiting_deposit: "awaiting_payment",
773
+ payin_detected: "processing",
774
+ payin_confirmed: "processing",
775
+ bridging: "processing",
776
+ paying_coinbase: "processing",
777
+ settled: "settled",
778
+ expired_unfunded: "expired_unfunded",
779
+ invoice_expired: "expired_unfunded",
780
+ deadline_unknown: "needs_attention",
781
+ underpaid: "needs_attention",
782
+ stuck_after_payment: "needs_attention",
783
+ unknown: "unknown"
784
+ });
785
+ function merchantSettlementProved(result) {
786
+ if (result?.provider === "bitrefill") return result?.state === "settled";
787
+ return result?.backend?.coinbaseSettled === true;
788
+ }
789
+ function hasLocalSend(result) {
790
+ return Boolean(result?.localSend && result.localSend.status);
791
+ }
792
+ function paymentOutcomeFor(result) {
793
+ if (!result) return "unknown";
794
+ if (result.escalate) return "needs_attention";
795
+ if (result.unknown) return "unknown";
796
+ let outcome = Object.prototype.hasOwnProperty.call(STATE_TO_OUTCOME, result.state) ? STATE_TO_OUTCOME[result.state] : "unknown";
797
+ if (outcome === "settled" && !merchantSettlementProved(result)) outcome = "processing";
798
+ if (outcome === "settled" && result.provider !== "bitrefill") {
799
+ const router = result.backend?.routerStatus ?? null;
800
+ if (router && router.startsWith("failed_")) return "needs_attention";
801
+ if (router && router !== "paid") outcome = "processing";
802
+ }
803
+ if (!result.authoritativeView && (outcome === "awaiting_payment" || outcome === "expired_unfunded")) {
804
+ return "unknown";
805
+ }
806
+ if ((result.moneyDetected || hasLocalSend(result)) && outcome === "expired_unfunded") {
807
+ return "needs_attention";
808
+ }
809
+ if (result.moneyDetected && outcome === "awaiting_payment") return "processing";
810
+ return outcome;
811
+ }
812
+ function nextActionFor(result, outcome = paymentOutcomeFor(result)) {
813
+ const locked = Boolean(result?.moneyDetected) || hasLocalSend(result);
814
+ const base = { canRetryQuery: true, canCreateOrder: false, canSend: false };
815
+ const bitrefill = result?.provider === "bitrefill";
816
+ switch (outcome) {
817
+ case "awaiting_payment":
818
+ if (locked) {
819
+ return {
820
+ ...base,
821
+ type: "check_status",
822
+ message: "A send was already recorded for this order. Do NOT pay again; poll status until the pay-in shows up."
823
+ };
824
+ }
825
+ if (!result?.sendWindow?.ok) {
826
+ const code = result?.sendWindow?.code ?? "SEND_WINDOW_UNKNOWN";
827
+ if (code === "LINK_NO_LONGER_PAYABLE") {
828
+ return {
829
+ ...base,
830
+ type: "request_new_invoice",
831
+ reason: code,
832
+ message: "The merchant link no longer accepts payment. Do NOT fund this order; get a new payment link."
833
+ };
834
+ }
835
+ if (code === "EXPIRED" || code === "EXPIRY_MARGIN") {
836
+ return {
837
+ ...base,
838
+ type: "check_status",
839
+ reason: code,
840
+ message: "Too little time is left to pay this order safely. Do NOT fund it. Once it shows expired_unfunded, run pay again on the same link for a new order."
841
+ };
842
+ }
843
+ return {
844
+ ...base,
845
+ type: "check_status",
846
+ reason: code,
847
+ message: "Whether this order can still be paid could not be proved. Do NOT fund it on a guess; retry."
848
+ };
849
+ }
850
+ return {
851
+ ...base,
852
+ type: "pay",
853
+ canSend: true,
854
+ message: "Send the exact amount from the order deposit block, once."
855
+ };
856
+ case "processing":
857
+ return {
858
+ ...base,
859
+ type: "check_status",
860
+ message: "Funds are moving. Do NOT pay again; poll status."
861
+ };
862
+ case "settled":
863
+ return { ...base, type: "none", canRetryQuery: false, message: "Nothing to do." };
864
+ case "expired_unfunded":
865
+ if (bitrefill) {
866
+ return {
867
+ ...base,
868
+ type: "request_new_invoice",
869
+ message: "Nothing was funded. Create a fresh Bitrefill invoice and pay that one."
870
+ };
871
+ }
872
+ return {
873
+ ...base,
874
+ type: "choose_method",
875
+ canCreateOrder: true,
876
+ message: "Nothing arrived for this order. If your own wallet shows no send to the old deposit address and no pending Lightning payment, run pay again on the same link: it creates a new order, and you may pick a different coin. Never fund the old deposit address."
877
+ };
878
+ case "needs_attention":
879
+ if (result?.state === "deadline_unknown" && !locked) {
880
+ return {
881
+ ...base,
882
+ type: "request_new_invoice",
883
+ message: "The invoice deadline is unknown here, so the order is not payable. Re-run pay with the invoice details, or create a fresh invoice."
884
+ };
885
+ }
886
+ return {
887
+ ...base,
888
+ type: "contact_support",
889
+ message: "Do NOT pay again and do NOT create a new order. Keep linkId, rozoPaymentId and every tx hash, and contact Rozo support."
890
+ };
891
+ default:
892
+ return {
893
+ ...base,
894
+ type: "check_status",
895
+ message: "The order state could not be established. This is not evidence that nothing was paid. Retry with the rozoPaymentId before doing anything else."
896
+ };
897
+ }
898
+ }
899
+
589
900
  // scripts/src/status.mjs
590
901
  var POLL_INTERVAL_MS = 1e4;
591
902
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -600,6 +911,42 @@ function resolveProvider(explicit, rozoPaymentId) {
600
911
  }
601
912
  return null;
602
913
  }
914
+ function localSendFor(id, read = readState) {
915
+ if (!id || !isRozoPaymentId(id)) return null;
916
+ let state;
917
+ try {
918
+ state = read(id);
919
+ } catch (err) {
920
+ return { status: "unreadable", txHash: null, claimedAt: null, error: err?.code ?? "STATE_UNREADABLE" };
921
+ }
922
+ const send = state?.send;
923
+ if (!send || !send.status) return null;
924
+ return { status: send.status, txHash: publicHash(send.txHash ?? null), claimedAt: send.claimedAt ?? null };
925
+ }
926
+ function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, now = Date.now() }) {
927
+ if (provider === "bitrefill") {
928
+ const deadline = bitrefillExpiry ?? null;
929
+ const expiry2 = checkExpiry({
930
+ now,
931
+ chainId,
932
+ intentExpiresAt: payment?.expiresAt ?? deadline,
933
+ coinbaseExpiry: deadline
934
+ });
935
+ return expiry2.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry2.msOfSlack / 6e4), deadlineMs: expiry2.effectiveDeadlineMs } : { ok: false, code: expiry2.code, reason: expiry2.reason };
936
+ }
937
+ if (!status) {
938
+ return { ok: false, code: "LINK_PAYABILITY_UNKNOWN", reason: "The Coinbase link state could not be read." };
939
+ }
940
+ const payable = checkPayable(status, now);
941
+ if (!payable.ok) return { ok: false, code: payable.code, reason: payable.reason };
942
+ const expiry = checkExpiry({
943
+ now,
944
+ chainId,
945
+ intentExpiresAt: payment?.expiresAt ?? status?.rozoPayment?.expiresAt,
946
+ coinbaseExpiry: status?.coinbase?.preApprovalExpiry
947
+ });
948
+ return expiry.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry.msOfSlack / 6e4), deadlineMs: expiry.effectiveDeadlineMs } : { ok: false, code: expiry.code, reason: expiry.reason };
949
+ }
603
950
  async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
604
951
  let provider = resolveProvider(explicitProvider, rozoPaymentId);
605
952
  let prefetched = null;
@@ -697,7 +1044,7 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
697
1044
  expected: source.amount ? formatAmount(source) : null,
698
1045
  received: source.amountReceived ?? null,
699
1046
  receipt: verdict.receipt,
700
- txHash: source.txHash ?? null,
1047
+ txHash: publicHash(source.txHash ?? null),
701
1048
  confirmedAt: source.confirmedAt ?? null,
702
1049
  senderAddressMasked: source.senderAddress ? maskAddress(source.senderAddress) : null,
703
1050
  chain: source.chainId ? chainName(source.chainId) : null
@@ -716,9 +1063,11 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
716
1063
  };
717
1064
  })(),
718
1065
  payout: {
719
- txHash: payment?.destination?.txHash ?? status?.rozoPayment?.destination?.txHash ?? null,
1066
+ txHash: publicHash(payment?.destination?.txHash ?? status?.rozoPayment?.destination?.txHash ?? null),
720
1067
  confirmedAt: payment?.destination?.confirmedAt ?? status?.rozoPayment?.destination?.confirmedAt ?? null
721
1068
  },
1069
+ localSend: localSendFor(id),
1070
+ sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry }) : null,
722
1071
  errors: [statusError, paymentError].filter(Boolean)
723
1072
  };
724
1073
  }
@@ -767,9 +1116,16 @@ async function main(argv) {
767
1116
  const guidance = statusGuidance(result);
768
1117
  const unresolved = watch && !result.terminal && !result.escalate && !result.unknown;
769
1118
  const failed = result.escalate || result.unknown || !result.authoritativeView;
1119
+ const paymentOutcome = paymentOutcomeFor(result);
770
1120
  emit(
771
1121
  {
1122
+ // `success` keeps its historical meaning (the query produced a clean
1123
+ // answer). It is NOT "the invoice is paid": read paymentOutcome.
772
1124
  success: !failed,
1125
+ schemaVersion: SCHEMA_VERSION,
1126
+ operationOk: !result.unknown,
1127
+ paymentOutcome,
1128
+ nextAction: nextActionFor(result, paymentOutcome),
773
1129
  step: "status",
774
1130
  ...result,
775
1131
  history,