@rozoai/checkout 0.1.20 → 0.1.21

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.
@@ -357,10 +357,20 @@ var CHAIN_NAMES = {
357
357
  56: "BNB Chain",
358
358
  137: "Polygon",
359
359
  8453: "Base",
360
+ 42161: "Arbitrum",
360
361
  900: "Solana",
361
362
  1500: "Stellar",
362
363
  lightning: "Bitcoin Lightning"
363
364
  };
365
+ var NATIVE_COINS = Object.freeze({
366
+ "1:ETH": { chainId: "1", symbol: "ETH", decimals: 18 },
367
+ "8453:ETH": { chainId: "8453", symbol: "ETH", decimals: 18 },
368
+ "42161:ETH": { chainId: "42161", symbol: "ETH", decimals: 18 },
369
+ "56:BNB": { chainId: "56", symbol: "BNB", decimals: 18 },
370
+ "137:POL": { chainId: "137", symbol: "POL", decimals: 18 },
371
+ "900:SOL": { chainId: "900", symbol: "SOL", decimals: 9 }
372
+ });
373
+ var NATIVE_MIN_USD_FALLBACK = Object.freeze({ "1:ETH": 1 });
364
374
  var DECIMALS = {
365
375
  "1:USDC": 6,
366
376
  "1:USDT": 6,
@@ -370,10 +380,20 @@ var DECIMALS = {
370
380
  "137:USDC": 6,
371
381
  "137:USDT": 6,
372
382
  "8453:USDC": 6,
383
+ // Arbitrum One: native USDC and USDT0 are both 6-decimals.
384
+ "42161:USDC": 6,
385
+ "42161:USDT": 6,
373
386
  "900:USDC": 6,
374
387
  "900:USDT": 6,
375
388
  // Stellar classic assets carry 7 decimal places.
376
- "1500:USDC": 7
389
+ "1500:USDC": 7,
390
+ // Native coins: wei (18) on every EVM chain, lamports (9) on Solana.
391
+ "1:ETH": 18,
392
+ "8453:ETH": 18,
393
+ "42161:ETH": 18,
394
+ "56:BNB": 18,
395
+ "137:POL": 18,
396
+ "900:SOL": 9
377
397
  };
378
398
  var AmountError = class extends Error {
379
399
  constructor(code, message) {
@@ -466,8 +486,18 @@ function formatAmount(source) {
466
486
  function chainName(chainId) {
467
487
  return CHAIN_NAMES[String(chainId)] || CHAIN_NAMES[chainId] || `chain ${chainId}`;
468
488
  }
489
+ function isNativeSource(chainId, tokenSymbol) {
490
+ return Object.prototype.hasOwnProperty.call(NATIVE_COINS, decimalsKey(chainId, tokenSymbol));
491
+ }
469
492
 
470
493
  // scripts/src/lib/guards.mjs
494
+ var NATIVE_PROGRESS_STATUSES = /* @__PURE__ */ new Set([
495
+ "payment_payin_completed",
496
+ "payment_bridging",
497
+ "payment_payout_started",
498
+ "payment_payout_completed",
499
+ "payment_completed"
500
+ ]);
471
501
  function receiptSignal(source) {
472
502
  const raw = source?.amountReceived;
473
503
  if (raw === null || raw === void 0 || raw === "") {
@@ -597,7 +627,8 @@ function classifyStatus({
597
627
  if (!bitrefill && (routerStatus === "paid" || coinbase?.settled === true)) {
598
628
  return mk("settled", "Coinbase invoice settled by the funder wallet.", { terminal: true });
599
629
  }
600
- if (moneyDetected && receipt && receipt.state === "underpaid") {
630
+ const nativeAccepted = isNativeSource(source.chainId, source.tokenSymbol) && NATIVE_PROGRESS_STATUSES.has(payment?.status);
631
+ if (moneyDetected && receipt && receipt.state === "underpaid" && !nativeAccepted) {
601
632
  return mk(
602
633
  "underpaid",
603
634
  "Less arrived than the order requires. Do NOT send a top-up to the same address \u2014 escalate.",
@@ -678,6 +709,7 @@ var MARGINS_MS = {
678
709
  56: 10 * MINUTE,
679
710
  137: 10 * MINUTE,
680
711
  8453: 10 * MINUTE,
712
+ 42161: 10 * MINUTE,
681
713
  900: 5 * MINUTE,
682
714
  1500: 10 * MINUTE,
683
715
  lightning: 10 * MINUTE
@@ -718,13 +750,16 @@ function checkExpiry({
718
750
  chainId,
719
751
  intentExpiresAt,
720
752
  coinbaseExpiry,
721
- bolt11ExpiresAt = void 0
753
+ bolt11ExpiresAt = void 0,
754
+ quoteExpiresAt = void 0
722
755
  }) {
723
756
  const marginMs = marginFor(chainId);
724
757
  const intentMs = parseDeadline(intentExpiresAt);
725
758
  const coinbaseMs = parseDeadline(coinbaseExpiry);
726
759
  const bolt11Ms = bolt11ExpiresAt === void 0 ? void 0 : parseDeadline(bolt11ExpiresAt);
727
- const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null };
760
+ const native = quoteExpiresAt !== void 0;
761
+ const quoteMs = native ? parseDeadline(quoteExpiresAt) : null;
762
+ const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null, quoteMs };
728
763
  const base = {
729
764
  marginMs,
730
765
  effectiveDeadlineMs: null,
@@ -748,7 +783,16 @@ function checkExpiry({
748
783
  reason: "Coinbase preApprovalExpiry is missing or unparsable."
749
784
  };
750
785
  }
751
- const effective = Math.min(intentMs, coinbaseMs);
786
+ if (native && quoteMs === null) {
787
+ return {
788
+ ...base,
789
+ ok: false,
790
+ code: "EXPIRY_UNPARSABLE",
791
+ reason: "This native coin order carries no price lock (quoteExpiresAt), so the coin amount cannot be trusted to be honoured. Refusing to treat it as payable."
792
+ };
793
+ }
794
+ const effective = native ? Math.min(intentMs, coinbaseMs, quoteMs) : Math.min(intentMs, coinbaseMs);
795
+ const lockBinds = native && quoteMs <= Math.min(intentMs, coinbaseMs);
752
796
  const msRemaining = effective - now;
753
797
  const msOfSlack = msRemaining - marginMs;
754
798
  const withDeadline = {
@@ -761,16 +805,16 @@ function checkExpiry({
761
805
  return {
762
806
  ...withDeadline,
763
807
  ok: false,
764
- code: "EXPIRED",
765
- reason: "The order or the Coinbase link has already expired."
808
+ code: lockBinds ? "QUOTE_EXPIRED" : "EXPIRED",
809
+ reason: lockBinds ? "The coin price for this order was locked until quoteExpiresAt, and that has passed. A payment now would be valued at the price when it arrives and may fall short. Do not send it; let the order expire unfunded and start a new one." : "The order or the Coinbase link has already expired."
766
810
  };
767
811
  }
768
812
  if (msOfSlack <= 0) {
769
813
  return {
770
814
  ...withDeadline,
771
815
  ok: false,
772
- code: "EXPIRY_MARGIN",
773
- reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
816
+ code: lockBinds ? "QUOTE_EXPIRY_MARGIN" : "EXPIRY_MARGIN",
817
+ reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline${lockBinds ? " (the coin price lock)" : ""}; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
774
818
  };
775
819
  }
776
820
  if (bolt11ExpiresAt !== void 0) {
@@ -794,6 +838,12 @@ function checkExpiry({
794
838
  }
795
839
  return { ...withDeadline, ok: true, code: null, reason: null };
796
840
  }
841
+ function priceLockFor({ native, payment = null, others = [] }) {
842
+ if (!native) return void 0;
843
+ const candidates = [payment?.quoteExpiresAt, payment?.source?.quoteExpiresAt, ...others].map(parseDeadline).filter((ms) => ms !== null);
844
+ if (!candidates.length) return null;
845
+ return new Date(Math.min(...candidates)).toISOString();
846
+ }
797
847
 
798
848
  // scripts/src/lib/bitrefill.mjs
799
849
  var PROVIDER_BITREFILL = "bitrefill";
@@ -1075,7 +1125,7 @@ function localSendFor(id, read = readState) {
1075
1125
  if (!send || !send.status) return null;
1076
1126
  return { status: send.status, txHash: publicHash(send.txHash ?? null), claimedAt: send.claimedAt ?? null };
1077
1127
  }
1078
- function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, now = Date.now() }) {
1128
+ function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, quoteExpiresAt, now = Date.now() }) {
1079
1129
  if (provider === "bitrefill") {
1080
1130
  const deadline = bitrefillExpiry ?? null;
1081
1131
  const expiry2 = checkExpiry({
@@ -1095,7 +1145,9 @@ function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, no
1095
1145
  now,
1096
1146
  chainId,
1097
1147
  intentExpiresAt: payment?.expiresAt ?? status?.rozoPayment?.expiresAt,
1098
- coinbaseExpiry: status?.coinbase?.preApprovalExpiry
1148
+ coinbaseExpiry: status?.coinbase?.preApprovalExpiry,
1149
+ // Native orders: the price lock is a deadline too (undefined otherwise).
1150
+ ...quoteExpiresAt !== void 0 ? { quoteExpiresAt } : {}
1099
1151
  });
1100
1152
  return expiry.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry.msOfSlack / 6e4), deadlineMs: expiry.effectiveDeadlineMs } : { ok: false, code: expiry.code, reason: expiry.reason };
1101
1153
  }
@@ -1149,6 +1201,18 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1149
1201
  provider
1150
1202
  });
1151
1203
  const source = payment?.source || status?.rozoPayment?.source || {};
1204
+ const native = isNativeSource(source.chainId, source.tokenSymbol);
1205
+ let localState = null;
1206
+ try {
1207
+ localState = id && isRozoPaymentId(id) ? readState(id) : null;
1208
+ } catch {
1209
+ localState = null;
1210
+ }
1211
+ const quoteExpiresAt = priceLockFor({
1212
+ native,
1213
+ payment: payment || status?.rozoPayment || null,
1214
+ others: [localState?.confirmation?.quoteExpiresAt, localState?.quoteExpiresAt]
1215
+ });
1152
1216
  let bitrefillExpiry = null;
1153
1217
  let state = verdict.state;
1154
1218
  let terminal = verdict.terminal;
@@ -1208,10 +1272,17 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1208
1272
  const iso = provider === "bitrefill" ? bitrefillExpiry : payment?.expiresAt ?? status?.rozoPayment?.expiresAt ?? null;
1209
1273
  if (!iso) return { expiresAt: null, expiresIn: null, msRemaining: null };
1210
1274
  const ms = Date.parse(iso) - Date.now();
1275
+ const lockMs = quoteExpiresAt ? Date.parse(quoteExpiresAt) - Date.now() : NaN;
1211
1276
  return {
1212
1277
  expiresAt: iso,
1213
1278
  expiresIn: formatRemaining(ms),
1214
- msRemaining: Number.isFinite(ms) ? ms : null
1279
+ msRemaining: Number.isFinite(ms) ? ms : null,
1280
+ // Native orders: the order stays open after the price lock, but a
1281
+ // payment after it is valued at the arrival price. Pay before this.
1282
+ ...native ? {
1283
+ quoteExpiresAt: quoteExpiresAt ?? null,
1284
+ priceLockExpiresIn: Number.isFinite(lockMs) ? formatRemaining(lockMs) : null
1285
+ } : {}
1215
1286
  };
1216
1287
  })(),
1217
1288
  payout: {
@@ -1219,7 +1290,7 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1219
1290
  confirmedAt: payment?.destination?.confirmedAt ?? status?.rozoPayment?.destination?.confirmedAt ?? null
1220
1291
  },
1221
1292
  localSend: localSendFor(id),
1222
- sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry }) : null,
1293
+ sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry, quoteExpiresAt }) : null,
1223
1294
  errors: [statusError, paymentError].filter(Boolean)
1224
1295
  };
1225
1296
  }
@@ -379,6 +379,7 @@ var CHAIN_NAMES = {
379
379
  56: "BNB Chain",
380
380
  137: "Polygon",
381
381
  8453: "Base",
382
+ 42161: "Arbitrum",
382
383
  900: "Solana",
383
384
  1500: "Stellar",
384
385
  lightning: "Bitcoin Lightning"
@@ -388,10 +389,20 @@ var CHAIN_FAMILY = {
388
389
  56: "evm",
389
390
  137: "evm",
390
391
  8453: "evm",
392
+ 42161: "evm",
391
393
  900: "solana",
392
394
  1500: "stellar",
393
395
  lightning: "lightning"
394
396
  };
397
+ var NATIVE_COINS = Object.freeze({
398
+ "1:ETH": { chainId: "1", symbol: "ETH", decimals: 18 },
399
+ "8453:ETH": { chainId: "8453", symbol: "ETH", decimals: 18 },
400
+ "42161:ETH": { chainId: "42161", symbol: "ETH", decimals: 18 },
401
+ "56:BNB": { chainId: "56", symbol: "BNB", decimals: 18 },
402
+ "137:POL": { chainId: "137", symbol: "POL", decimals: 18 },
403
+ "900:SOL": { chainId: "900", symbol: "SOL", decimals: 9 }
404
+ });
405
+ var NATIVE_MIN_USD_FALLBACK = Object.freeze({ "1:ETH": 1 });
395
406
  var DECIMALS = {
396
407
  "1:USDC": 6,
397
408
  "1:USDT": 6,
@@ -401,10 +412,20 @@ var DECIMALS = {
401
412
  "137:USDC": 6,
402
413
  "137:USDT": 6,
403
414
  "8453:USDC": 6,
415
+ // Arbitrum One: native USDC and USDT0 are both 6-decimals.
416
+ "42161:USDC": 6,
417
+ "42161:USDT": 6,
404
418
  "900:USDC": 6,
405
419
  "900:USDT": 6,
406
420
  // Stellar classic assets carry 7 decimal places.
407
- "1500:USDC": 7
421
+ "1500:USDC": 7,
422
+ // Native coins: wei (18) on every EVM chain, lamports (9) on Solana.
423
+ "1:ETH": 18,
424
+ "8453:ETH": 18,
425
+ "42161:ETH": 18,
426
+ "56:BNB": 18,
427
+ "137:POL": 18,
428
+ "900:SOL": 9
408
429
  };
409
430
  var AmountError = class extends Error {
410
431
  constructor(code, message) {
@@ -501,9 +522,19 @@ var STELLAR_MEMO_TYPE = "MEMO_TEXT";
501
522
  function chainFamily(chainId) {
502
523
  return CHAIN_FAMILY[String(chainId)] || CHAIN_FAMILY[chainId] || null;
503
524
  }
525
+ function isNativeSource(chainId, tokenSymbol) {
526
+ return Object.prototype.hasOwnProperty.call(NATIVE_COINS, decimalsKey(chainId, tokenSymbol));
527
+ }
504
528
 
505
529
  // scripts/src/lib/guards.mjs
506
530
  var UNPAID_STATUS = "payment_unpaid";
531
+ var NATIVE_PROGRESS_STATUSES = /* @__PURE__ */ new Set([
532
+ "payment_payin_completed",
533
+ "payment_bridging",
534
+ "payment_payout_started",
535
+ "payment_payout_completed",
536
+ "payment_completed"
537
+ ]);
507
538
  function receiptSignal(source) {
508
539
  const raw = source?.amountReceived;
509
540
  if (raw === null || raw === void 0 || raw === "") {
@@ -750,7 +781,8 @@ function classifyStatus({
750
781
  if (!bitrefill && (routerStatus === "paid" || coinbase?.settled === true)) {
751
782
  return mk("settled", "Coinbase invoice settled by the funder wallet.", { terminal: true });
752
783
  }
753
- if (moneyDetected && receipt && receipt.state === "underpaid") {
784
+ const nativeAccepted = isNativeSource(source.chainId, source.tokenSymbol) && NATIVE_PROGRESS_STATUSES.has(payment?.status);
785
+ if (moneyDetected && receipt && receipt.state === "underpaid" && !nativeAccepted) {
754
786
  return mk(
755
787
  "underpaid",
756
788
  "Less arrived than the order requires. Do NOT send a top-up to the same address \u2014 escalate.",
@@ -954,6 +986,7 @@ var MARGINS_MS = {
954
986
  56: 10 * MINUTE,
955
987
  137: 10 * MINUTE,
956
988
  8453: 10 * MINUTE,
989
+ 42161: 10 * MINUTE,
957
990
  900: 5 * MINUTE,
958
991
  1500: 10 * MINUTE,
959
992
  lightning: 10 * MINUTE
@@ -994,13 +1027,16 @@ function checkExpiry({
994
1027
  chainId,
995
1028
  intentExpiresAt,
996
1029
  coinbaseExpiry,
997
- bolt11ExpiresAt = void 0
1030
+ bolt11ExpiresAt = void 0,
1031
+ quoteExpiresAt = void 0
998
1032
  }) {
999
1033
  const marginMs = marginFor(chainId);
1000
1034
  const intentMs = parseDeadline(intentExpiresAt);
1001
1035
  const coinbaseMs = parseDeadline(coinbaseExpiry);
1002
1036
  const bolt11Ms = bolt11ExpiresAt === void 0 ? void 0 : parseDeadline(bolt11ExpiresAt);
1003
- const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null };
1037
+ const native = quoteExpiresAt !== void 0;
1038
+ const quoteMs = native ? parseDeadline(quoteExpiresAt) : null;
1039
+ const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null, quoteMs };
1004
1040
  const base = {
1005
1041
  marginMs,
1006
1042
  effectiveDeadlineMs: null,
@@ -1024,7 +1060,16 @@ function checkExpiry({
1024
1060
  reason: "Coinbase preApprovalExpiry is missing or unparsable."
1025
1061
  };
1026
1062
  }
1027
- const effective = Math.min(intentMs, coinbaseMs);
1063
+ if (native && quoteMs === null) {
1064
+ return {
1065
+ ...base,
1066
+ ok: false,
1067
+ code: "EXPIRY_UNPARSABLE",
1068
+ reason: "This native coin order carries no price lock (quoteExpiresAt), so the coin amount cannot be trusted to be honoured. Refusing to treat it as payable."
1069
+ };
1070
+ }
1071
+ const effective = native ? Math.min(intentMs, coinbaseMs, quoteMs) : Math.min(intentMs, coinbaseMs);
1072
+ const lockBinds = native && quoteMs <= Math.min(intentMs, coinbaseMs);
1028
1073
  const msRemaining = effective - now;
1029
1074
  const msOfSlack = msRemaining - marginMs;
1030
1075
  const withDeadline = {
@@ -1037,16 +1082,16 @@ function checkExpiry({
1037
1082
  return {
1038
1083
  ...withDeadline,
1039
1084
  ok: false,
1040
- code: "EXPIRED",
1041
- reason: "The order or the Coinbase link has already expired."
1085
+ code: lockBinds ? "QUOTE_EXPIRED" : "EXPIRED",
1086
+ reason: lockBinds ? "The coin price for this order was locked until quoteExpiresAt, and that has passed. A payment now would be valued at the price when it arrives and may fall short. Do not send it; let the order expire unfunded and start a new one." : "The order or the Coinbase link has already expired."
1042
1087
  };
1043
1088
  }
1044
1089
  if (msOfSlack <= 0) {
1045
1090
  return {
1046
1091
  ...withDeadline,
1047
1092
  ok: false,
1048
- code: "EXPIRY_MARGIN",
1049
- reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
1093
+ code: lockBinds ? "QUOTE_EXPIRY_MARGIN" : "EXPIRY_MARGIN",
1094
+ reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline${lockBinds ? " (the coin price lock)" : ""}; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
1050
1095
  };
1051
1096
  }
1052
1097
  if (bolt11ExpiresAt !== void 0) {
@@ -1070,6 +1115,12 @@ function checkExpiry({
1070
1115
  }
1071
1116
  return { ...withDeadline, ok: true, code: null, reason: null };
1072
1117
  }
1118
+ function priceLockFor({ native, payment = null, others = [] }) {
1119
+ if (!native) return void 0;
1120
+ const candidates = [payment?.quoteExpiresAt, payment?.source?.quoteExpiresAt, ...others].map(parseDeadline).filter((ms) => ms !== null);
1121
+ if (!candidates.length) return null;
1122
+ return new Date(Math.min(...candidates)).toISOString();
1123
+ }
1073
1124
 
1074
1125
  // scripts/src/lib/bitrefill.mjs
1075
1126
  var PROVIDER_BITREFILL = "bitrefill";
@@ -1148,7 +1199,7 @@ function localSendFor(id, read = readState) {
1148
1199
  if (!send || !send.status) return null;
1149
1200
  return { status: send.status, txHash: publicHash(send.txHash ?? null), claimedAt: send.claimedAt ?? null };
1150
1201
  }
1151
- function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, now = Date.now() }) {
1202
+ function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, quoteExpiresAt, now = Date.now() }) {
1152
1203
  if (provider === "bitrefill") {
1153
1204
  const deadline = bitrefillExpiry ?? null;
1154
1205
  const expiry2 = checkExpiry({
@@ -1168,7 +1219,9 @@ function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, no
1168
1219
  now,
1169
1220
  chainId,
1170
1221
  intentExpiresAt: payment?.expiresAt ?? status?.rozoPayment?.expiresAt,
1171
- coinbaseExpiry: status?.coinbase?.preApprovalExpiry
1222
+ coinbaseExpiry: status?.coinbase?.preApprovalExpiry,
1223
+ // Native orders: the price lock is a deadline too (undefined otherwise).
1224
+ ...quoteExpiresAt !== void 0 ? { quoteExpiresAt } : {}
1172
1225
  });
1173
1226
  return expiry.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry.msOfSlack / 6e4), deadlineMs: expiry.effectiveDeadlineMs } : { ok: false, code: expiry.code, reason: expiry.reason };
1174
1227
  }
@@ -1222,6 +1275,18 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1222
1275
  provider
1223
1276
  });
1224
1277
  const source = payment?.source || status?.rozoPayment?.source || {};
1278
+ const native = isNativeSource(source.chainId, source.tokenSymbol);
1279
+ let localState = null;
1280
+ try {
1281
+ localState = id && isRozoPaymentId(id) ? readState(id) : null;
1282
+ } catch {
1283
+ localState = null;
1284
+ }
1285
+ const quoteExpiresAt = priceLockFor({
1286
+ native,
1287
+ payment: payment || status?.rozoPayment || null,
1288
+ others: [localState?.confirmation?.quoteExpiresAt, localState?.quoteExpiresAt]
1289
+ });
1225
1290
  let bitrefillExpiry = null;
1226
1291
  let state = verdict.state;
1227
1292
  let terminal = verdict.terminal;
@@ -1281,10 +1346,17 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1281
1346
  const iso = provider === "bitrefill" ? bitrefillExpiry : payment?.expiresAt ?? status?.rozoPayment?.expiresAt ?? null;
1282
1347
  if (!iso) return { expiresAt: null, expiresIn: null, msRemaining: null };
1283
1348
  const ms = Date.parse(iso) - Date.now();
1349
+ const lockMs = quoteExpiresAt ? Date.parse(quoteExpiresAt) - Date.now() : NaN;
1284
1350
  return {
1285
1351
  expiresAt: iso,
1286
1352
  expiresIn: formatRemaining(ms),
1287
- msRemaining: Number.isFinite(ms) ? ms : null
1353
+ msRemaining: Number.isFinite(ms) ? ms : null,
1354
+ // Native orders: the order stays open after the price lock, but a
1355
+ // payment after it is valued at the arrival price. Pay before this.
1356
+ ...native ? {
1357
+ quoteExpiresAt: quoteExpiresAt ?? null,
1358
+ priceLockExpiresIn: Number.isFinite(lockMs) ? formatRemaining(lockMs) : null
1359
+ } : {}
1288
1360
  };
1289
1361
  })(),
1290
1362
  payout: {
@@ -1292,7 +1364,7 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1292
1364
  confirmedAt: payment?.destination?.confirmedAt ?? status?.rozoPayment?.destination?.confirmedAt ?? null
1293
1365
  },
1294
1366
  localSend: localSendFor(id),
1295
- sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry }) : null,
1367
+ sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry, quoteExpiresAt }) : null,
1296
1368
  errors: [statusError, paymentError].filter(Boolean)
1297
1369
  };
1298
1370
  }
@@ -1451,11 +1523,14 @@ async function main(argv) {
1451
1523
  EXIT_ERROR
1452
1524
  );
1453
1525
  }
1526
+ const native = isNativeSource(source.chainId, source.tokenSymbol);
1527
+ const quoteExpiresAt = priceLockFor({ native, payment, others: [snap.expiry?.quoteExpiresAt] });
1454
1528
  const finalWindow = checkExpiry({
1455
1529
  now: Date.now(),
1456
1530
  chainId: source.chainId,
1457
1531
  intentExpiresAt: payment?.expiresAt ?? snap.sendWindow?.deadlineMs,
1458
- coinbaseExpiry: snap.sendWindow?.deadlineMs
1532
+ coinbaseExpiry: snap.sendWindow?.deadlineMs,
1533
+ ...native ? { quoteExpiresAt } : {}
1459
1534
  });
1460
1535
  if (!finalWindow.ok) {
1461
1536
  emit(
@@ -1496,7 +1571,7 @@ async function main(argv) {
1496
1571
  }
1497
1572
  }
1498
1573
  const expiresAt = payment?.expiresAt ?? snap.expiry?.expiresAt ?? null;
1499
- const msRemaining = expiresAt ? Date.parse(expiresAt) - Date.now() : NaN;
1574
+ const msRemaining = Number.isFinite(finalWindow.effectiveDeadlineMs) ? finalWindow.effectiveDeadlineMs - Date.now() : NaN;
1500
1575
  const expiresIn = Number.isFinite(msRemaining) ? formatRemaining(msRemaining) : null;
1501
1576
  const paymentLink = payment?.paymentLink ?? localPaymentLink(id);
1502
1577
  const bolt11 = source.lnInvoice ?? payment?.lnInvoice ?? null;
@@ -1528,7 +1603,10 @@ async function main(argv) {
1528
1603
  lnInvoice: bolt11 || null,
1529
1604
  payTo: guard.deposit.payTo,
1530
1605
  expiresAt,
1531
- expiresIn
1606
+ expiresIn,
1607
+ native,
1608
+ exactOut: native || lightning,
1609
+ quoteExpiresAt: native ? quoteExpiresAt : null
1532
1610
  } : null,
1533
1611
  depositWithheld: !confirmed,
1534
1612
  display: {
@@ -1539,9 +1617,16 @@ async function main(argv) {
1539
1617
  payToMasked: maskAddress(guard.deposit.payTo),
1540
1618
  receiverMemoMasked: maskMemo(source.receiverMemo),
1541
1619
  hasMemo: Boolean(source.receiverMemo),
1542
- memoType: source.receiverMemo ? STELLAR_MEMO_TYPE : null
1620
+ memoType: source.receiverMemo ? STELLAR_MEMO_TYPE : null,
1621
+ native,
1622
+ priceLockedUntil: native ? quoteExpiresAt : null
1623
+ },
1624
+ expiry: {
1625
+ expiresAt,
1626
+ expiresIn,
1627
+ ...native ? { quoteExpiresAt } : {},
1628
+ minutesOfSlack: snap.sendWindow?.minutesOfSlack ?? null
1543
1629
  },
1544
- expiry: { expiresAt, expiresIn, minutesOfSlack: snap.sendWindow?.minutesOfSlack ?? null },
1545
1630
  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."
1546
1631
  });
1547
1632
  }