@rozoai/checkout 0.1.19 → 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.
@@ -494,6 +494,8 @@ function createOrderRecordUnlocked(record) {
494
494
  amount: record.amount,
495
495
  amountUnit: record.amountUnit ?? null,
496
496
  expiresAt: record.expiresAt ?? null,
497
+ // Native coin orders only: the price lock the backend reported at create.
498
+ quoteExpiresAt: record.quoteExpiresAt ?? existing?.quoteExpiresAt ?? null,
497
499
  createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
498
500
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
499
501
  confirmation: existing?.confirmation ?? null,
@@ -515,7 +517,7 @@ function depositDigest(source) {
515
517
  });
516
518
  return crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
517
519
  }
518
- function recordConfirmation(rozoPaymentId, { source, invoiceAmount, tier }) {
520
+ function recordConfirmation(rozoPaymentId, { source, invoiceAmount, tier, quoteExpiresAt = null }) {
519
521
  return withLock(() => {
520
522
  const state = readState(rozoPaymentId);
521
523
  if (!state) {
@@ -528,7 +530,10 @@ function recordConfirmation(rozoPaymentId, { source, invoiceAmount, tier }) {
528
530
  confirmedAt: (/* @__PURE__ */ new Date()).toISOString(),
529
531
  depositDigest: depositDigest(source),
530
532
  invoiceAmount: invoiceAmount ?? state.invoiceAmount ?? null,
531
- tier: tier ?? null
533
+ tier: tier ?? null,
534
+ // The native price lock the human was shown. A send never goes out
535
+ // after it, even if the backend later reports a later one.
536
+ quoteExpiresAt: quoteExpiresAt ?? null
532
537
  }
533
538
  };
534
539
  writeAtomic(statePath(rozoPaymentId), next);
@@ -732,6 +737,7 @@ var CHAIN_NAMES = {
732
737
  56: "BNB Chain",
733
738
  137: "Polygon",
734
739
  8453: "Base",
740
+ 42161: "Arbitrum",
735
741
  900: "Solana",
736
742
  1500: "Stellar",
737
743
  lightning: "Bitcoin Lightning"
@@ -741,19 +747,31 @@ var CHAIN_FAMILY = {
741
747
  56: "evm",
742
748
  137: "evm",
743
749
  8453: "evm",
750
+ 42161: "evm",
744
751
  900: "solana",
745
752
  1500: "stellar",
746
753
  lightning: "lightning"
747
754
  };
748
755
  var SUPPORTED_SOURCES = [
749
- { chainId: "1", chain: "Ethereum", tokens: ["USDC", "USDT"] },
750
- { chainId: "56", chain: "BNB Chain", tokens: ["USDC", "USDT"] },
751
- { chainId: "137", chain: "Polygon", tokens: ["USDC", "USDT"] },
752
- { chainId: "900", chain: "Solana", tokens: ["USDC", "USDT"] },
753
- { chainId: "8453", chain: "Base", tokens: ["USDC"] },
756
+ { chainId: "1", chain: "Ethereum", tokens: ["USDC", "USDT", "ETH"] },
757
+ { chainId: "56", chain: "BNB Chain", tokens: ["USDC", "USDT", "BNB"] },
758
+ { chainId: "137", chain: "Polygon", tokens: ["USDC", "USDT", "POL"] },
759
+ { chainId: "900", chain: "Solana", tokens: ["USDC", "USDT", "SOL"] },
760
+ { chainId: "8453", chain: "Base", tokens: ["USDC", "ETH"] },
761
+ { chainId: "42161", chain: "Arbitrum", tokens: ["USDC", "USDT", "ETH"] },
754
762
  { chainId: "1500", chain: "Stellar", tokens: ["USDC"] },
755
763
  { chainId: "lightning", chain: "Bitcoin Lightning", tokens: ["BTC"] }
756
764
  ];
765
+ var NATIVE_COINS = Object.freeze({
766
+ "1:ETH": { chainId: "1", symbol: "ETH", decimals: 18 },
767
+ "8453:ETH": { chainId: "8453", symbol: "ETH", decimals: 18 },
768
+ "42161:ETH": { chainId: "42161", symbol: "ETH", decimals: 18 },
769
+ "56:BNB": { chainId: "56", symbol: "BNB", decimals: 18 },
770
+ "137:POL": { chainId: "137", symbol: "POL", decimals: 18 },
771
+ "900:SOL": { chainId: "900", symbol: "SOL", decimals: 9 }
772
+ });
773
+ var NATIVE_MAX_USD = 2e3;
774
+ var NATIVE_MIN_USD_FALLBACK = Object.freeze({ "1:ETH": 1 });
757
775
  var DECIMALS = {
758
776
  "1:USDC": 6,
759
777
  "1:USDT": 6,
@@ -763,10 +781,20 @@ var DECIMALS = {
763
781
  "137:USDC": 6,
764
782
  "137:USDT": 6,
765
783
  "8453:USDC": 6,
784
+ // Arbitrum One: native USDC and USDT0 are both 6-decimals.
785
+ "42161:USDC": 6,
786
+ "42161:USDT": 6,
766
787
  "900:USDC": 6,
767
788
  "900:USDT": 6,
768
789
  // Stellar classic assets carry 7 decimal places.
769
- "1500:USDC": 7
790
+ "1500:USDC": 7,
791
+ // Native coins: wei (18) on every EVM chain, lamports (9) on Solana.
792
+ "1:ETH": 18,
793
+ "8453:ETH": 18,
794
+ "42161:ETH": 18,
795
+ "56:BNB": 18,
796
+ "137:POL": 18,
797
+ "900:SOL": 9
770
798
  };
771
799
  var AmountError = class extends Error {
772
800
  constructor(code, message) {
@@ -868,6 +896,63 @@ function isSupportedSource(chainId, tokenSymbol) {
868
896
  const sym = String(tokenSymbol || "").trim().toUpperCase();
869
897
  return SUPPORTED_SOURCES.some((s) => s.chainId === cid && s.tokens.includes(sym));
870
898
  }
899
+ function isNativeSource(chainId, tokenSymbol) {
900
+ return Object.prototype.hasOwnProperty.call(NATIVE_COINS, decimalsKey(chainId, tokenSymbol));
901
+ }
902
+ function nativeMinUsdFor(chainId, tokenSymbol, quoteNativeMinUsd) {
903
+ const cid = String(chainId).trim();
904
+ const sym = String(tokenSymbol || "").trim().toUpperCase();
905
+ const fromQuote = quoteNativeMinUsd?.[cid]?.[sym];
906
+ const n = Number(fromQuote);
907
+ if (fromQuote !== void 0 && fromQuote !== null && Number.isFinite(n) && n > 0) return n;
908
+ return NATIVE_MIN_USD_FALLBACK[`${cid}:${sym}`] ?? null;
909
+ }
910
+ function nativeOfferFor({ chainId, tokenSymbol, supportedSources, nativeMinUsd, callerPaysUsd }) {
911
+ const cid = String(chainId).trim();
912
+ const sym = String(tokenSymbol || "").trim().toUpperCase();
913
+ const label = `${sym} on ${chainName(cid)}`;
914
+ const minUsd = nativeMinUsdFor(cid, sym, nativeMinUsd);
915
+ if (!isNativeSource(cid, sym)) {
916
+ return { ok: false, code: "NOT_NATIVE", reason: `${label} is not a native coin.`, minUsd: null };
917
+ }
918
+ const list = supportedSources && typeof supportedSources === "object" ? supportedSources[cid] : null;
919
+ if (!Array.isArray(list) || !list.map((t) => String(t).toUpperCase()).includes(sym)) {
920
+ return {
921
+ ok: false,
922
+ code: "NATIVE_NOT_OFFERED",
923
+ reason: `${label} is not offered for this invoice. Pay with USDC or USDT instead.`,
924
+ minUsd
925
+ };
926
+ }
927
+ const usd = Number(callerPaysUsd);
928
+ if (!Number.isFinite(usd) || usd <= 0) {
929
+ return {
930
+ ok: false,
931
+ code: "NATIVE_AMOUNT_UNKNOWN",
932
+ reason: `The quote carries no USD amount, so ${label} cannot be checked against the native limits. Pay with USDC or USDT instead.`,
933
+ minUsd
934
+ };
935
+ }
936
+ {
937
+ if (usd > NATIVE_MAX_USD) {
938
+ return {
939
+ ok: false,
940
+ code: "NATIVE_AMOUNT_TOO_HIGH",
941
+ reason: `Native coin orders are limited to $${NATIVE_MAX_USD} per invoice; this one is $${usd}. Pay with USDC or USDT instead.`,
942
+ minUsd
943
+ };
944
+ }
945
+ if (minUsd !== null && usd < minUsd) {
946
+ return {
947
+ ok: false,
948
+ code: "NATIVE_AMOUNT_TOO_LOW",
949
+ reason: `${label} needs an invoice of at least $${minUsd}; this one is $${usd}. Pay with another coin.`,
950
+ minUsd
951
+ };
952
+ }
953
+ }
954
+ return { ok: true, code: null, reason: null, minUsd };
955
+ }
871
956
 
872
957
  // scripts/src/lib/expiry.mjs
873
958
  var MINUTE = 6e4;
@@ -876,6 +961,7 @@ var MARGINS_MS = {
876
961
  56: 10 * MINUTE,
877
962
  137: 10 * MINUTE,
878
963
  8453: 10 * MINUTE,
964
+ 42161: 10 * MINUTE,
879
965
  900: 5 * MINUTE,
880
966
  1500: 10 * MINUTE,
881
967
  lightning: 10 * MINUTE
@@ -916,13 +1002,16 @@ function checkExpiry({
916
1002
  chainId,
917
1003
  intentExpiresAt,
918
1004
  coinbaseExpiry,
919
- bolt11ExpiresAt = void 0
1005
+ bolt11ExpiresAt = void 0,
1006
+ quoteExpiresAt = void 0
920
1007
  }) {
921
1008
  const marginMs = marginFor(chainId);
922
1009
  const intentMs = parseDeadline(intentExpiresAt);
923
1010
  const coinbaseMs = parseDeadline(coinbaseExpiry);
924
1011
  const bolt11Ms = bolt11ExpiresAt === void 0 ? void 0 : parseDeadline(bolt11ExpiresAt);
925
- const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null };
1012
+ const native = quoteExpiresAt !== void 0;
1013
+ const quoteMs = native ? parseDeadline(quoteExpiresAt) : null;
1014
+ const deadlines = { intentMs, coinbaseMs, bolt11Ms: bolt11Ms ?? null, quoteMs };
926
1015
  const base = {
927
1016
  marginMs,
928
1017
  effectiveDeadlineMs: null,
@@ -946,7 +1035,16 @@ function checkExpiry({
946
1035
  reason: "Coinbase preApprovalExpiry is missing or unparsable."
947
1036
  };
948
1037
  }
949
- const effective = Math.min(intentMs, coinbaseMs);
1038
+ if (native && quoteMs === null) {
1039
+ return {
1040
+ ...base,
1041
+ ok: false,
1042
+ code: "EXPIRY_UNPARSABLE",
1043
+ 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."
1044
+ };
1045
+ }
1046
+ const effective = native ? Math.min(intentMs, coinbaseMs, quoteMs) : Math.min(intentMs, coinbaseMs);
1047
+ const lockBinds = native && quoteMs <= Math.min(intentMs, coinbaseMs);
950
1048
  const msRemaining = effective - now;
951
1049
  const msOfSlack = msRemaining - marginMs;
952
1050
  const withDeadline = {
@@ -959,16 +1057,16 @@ function checkExpiry({
959
1057
  return {
960
1058
  ...withDeadline,
961
1059
  ok: false,
962
- code: "EXPIRED",
963
- reason: "The order or the Coinbase link has already expired."
1060
+ code: lockBinds ? "QUOTE_EXPIRED" : "EXPIRED",
1061
+ 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."
964
1062
  };
965
1063
  }
966
1064
  if (msOfSlack <= 0) {
967
1065
  return {
968
1066
  ...withDeadline,
969
1067
  ok: false,
970
- code: "EXPIRY_MARGIN",
971
- reason: `Only ${Math.floor(msRemaining / 1e3)}s left before the earliest deadline; this chain needs a ${Math.floor(marginMs / 6e4)} min safety margin.`
1068
+ code: lockBinds ? "QUOTE_EXPIRY_MARGIN" : "EXPIRY_MARGIN",
1069
+ 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.`
972
1070
  };
973
1071
  }
974
1072
  if (bolt11ExpiresAt !== void 0) {
@@ -992,6 +1090,44 @@ function checkExpiry({
992
1090
  }
993
1091
  return { ...withDeadline, ok: true, code: null, reason: null };
994
1092
  }
1093
+ function priceLockFor({ native, payment = null, others = [] }) {
1094
+ if (!native) return void 0;
1095
+ const candidates = [payment?.quoteExpiresAt, payment?.source?.quoteExpiresAt, ...others].map(parseDeadline).filter((ms) => ms !== null);
1096
+ if (!candidates.length) return null;
1097
+ return new Date(Math.min(...candidates)).toISOString();
1098
+ }
1099
+
1100
+ // scripts/src/lib/gas.mjs
1101
+ var EVM_FEE_COIN = Object.freeze({
1102
+ 1: { symbol: "ETH", network: "Ethereum" },
1103
+ 56: { symbol: "BNB", network: "BNB Chain" },
1104
+ 137: { symbol: "POL", network: "Polygon" },
1105
+ 8453: { symbol: "ETH", network: "Base" },
1106
+ 42161: { symbol: "ETH", network: "Arbitrum" }
1107
+ });
1108
+
1109
+ // scripts/src/lib/rpc.mjs
1110
+ var DEFAULT_RPC = Object.freeze({
1111
+ // eth.llamarpc.com and polygon-rpc.com stopped answering (checked
1112
+ // 2026-10-12: HTTP 525, and "API key disabled"); publicnode serves both.
1113
+ 1: "https://ethereum-rpc.publicnode.com",
1114
+ 56: "https://bsc-dataseed.binance.org",
1115
+ 137: "https://polygon-bor-rpc.publicnode.com",
1116
+ 8453: "https://mainnet.base.org",
1117
+ 42161: "https://arb1.arbitrum.io/rpc",
1118
+ 900: "https://api.mainnet-beta.solana.com"
1119
+ });
1120
+
1121
+ // scripts/src/lib/native.mjs
1122
+ function nativeDepositWarnings({ tokenSymbol, chainId }) {
1123
+ const coin = String(tokenSymbol || "").toUpperCase();
1124
+ return [
1125
+ `This is an exact-out ${coin} order: send exactly the amount shown, in one transfer. The coin price is locked only until the time shown; after that, do not send, start a new order.`,
1126
+ `The ${coin} amount includes an FX spread on top of the service fee.`,
1127
+ `Your wallet also needs some ${coin} on ${chainName(chainId)} for the network fee, on top of the amount shown.`,
1128
+ "Paying from an exchange: make sure the amount that ARRIVES is exactly the amount shown. Exchanges often subtract their withdrawal fee, and a short payment may need manual review."
1129
+ ];
1130
+ }
995
1131
 
996
1132
  // scripts/src/lib/guards.mjs
997
1133
  var UNPAID_STATUS = "payment_unpaid";
@@ -1435,6 +1571,28 @@ async function main(argv) {
1435
1571
  const quote = await quoteInvoice({ url: String(url) });
1436
1572
  const snapshot = snapshotFromQuote(quote);
1437
1573
  const quoteReceipt = quote?.quoteReceipt ?? null;
1574
+ const native = isNativeSource(chainId, tokenSymbol);
1575
+ if (native) {
1576
+ const offer = nativeOfferFor({
1577
+ chainId,
1578
+ tokenSymbol,
1579
+ supportedSources: quote?.supportedSources,
1580
+ nativeMinUsd: quote?.nativeMinUsd,
1581
+ callerPaysUsd: snapshot.callerPays ?? snapshot.original
1582
+ });
1583
+ if (!offer.ok) {
1584
+ emit(
1585
+ {
1586
+ success: false,
1587
+ step: "native-offer",
1588
+ error: { code: offer.code, message: offer.reason },
1589
+ linkId: snapshot.linkId ?? parsedLinkId,
1590
+ guidance: "Nothing was created. Choose USDC or USDT, or another coin the quote lists."
1591
+ },
1592
+ EXIT_ERROR
1593
+ );
1594
+ }
1595
+ }
1438
1596
  const created = await createInvoice({
1439
1597
  url: String(url),
1440
1598
  source: requested,
@@ -1521,6 +1679,17 @@ async function main(argv) {
1521
1679
  }
1522
1680
  const lightning = String(source.chainId) === "lightning";
1523
1681
  const bolt11 = source.lnInvoice ?? payment?.lnInvoice ?? null;
1682
+ let prior = null;
1683
+ try {
1684
+ prior = native ? readState(rozoPaymentId) : null;
1685
+ } catch {
1686
+ prior = null;
1687
+ }
1688
+ const quoteExpiresAt = priceLockFor({
1689
+ native,
1690
+ payment,
1691
+ others: [created?.quoteExpiresAt, prior?.quoteExpiresAt, prior?.confirmation?.quoteExpiresAt]
1692
+ });
1524
1693
  const expiry = checkExpiry({
1525
1694
  now: Date.now(),
1526
1695
  chainId: source.chainId ?? chainId,
@@ -1529,7 +1698,8 @@ async function main(argv) {
1529
1698
  // For Lightning the BOLT11's own validity is an extra gate. We only have a
1530
1699
  // separate expiry if the backend exposes one; otherwise the intent expiry
1531
1700
  // governs and we still require the 10-minute floor via the margin.
1532
- ...lightning ? { bolt11ExpiresAt: payment?.expiresAt } : {}
1701
+ ...lightning ? { bolt11ExpiresAt: payment?.expiresAt } : {},
1702
+ ...native ? { quoteExpiresAt } : {}
1533
1703
  });
1534
1704
  if (!expiry.ok) {
1535
1705
  emit(
@@ -1539,7 +1709,7 @@ async function main(argv) {
1539
1709
  error: { code: expiry.code, message: expiry.reason, details: expiry },
1540
1710
  linkId: created.linkId ?? parsedLinkId,
1541
1711
  rozoPaymentId,
1542
- guidance: "Not enough time remains to fund, bridge and settle safely. Ask the merchant for a fresh link and start over. Do not fund this order."
1712
+ guidance: String(expiry.code).startsWith("QUOTE_") ? "The coin price lock for this order has run out, or is about to. Do not fund it. An unfunded order costs nothing; once it has expired, run pay again for a fresh price." : "Not enough time remains to fund, bridge and settle safely. Ask the merchant for a fresh link and start over. Do not fund this order."
1543
1713
  },
1544
1714
  EXIT_ERROR
1545
1715
  );
@@ -1594,14 +1764,20 @@ async function main(argv) {
1594
1764
  receiverMemo: source.receiverMemo ?? null,
1595
1765
  amount: source.amount,
1596
1766
  amountUnit: source.amountUnit ?? null,
1597
- expiresAt: payment?.expiresAt ?? null
1767
+ expiresAt: payment?.expiresAt ?? null,
1768
+ ...native ? { quoteExpiresAt } : {}
1598
1769
  });
1599
1770
  const usd = created.original ?? snapshot.original;
1600
1771
  const family = chainFamily(source.chainId);
1601
1772
  const tier = confirmTier(usd);
1602
1773
  const depositInfo = guard.deposit;
1603
1774
  if (confirmed) {
1604
- recordConfirmation(rozoPaymentId, { source, invoiceAmount: usd, tier });
1775
+ recordConfirmation(rozoPaymentId, {
1776
+ source,
1777
+ invoiceAmount: usd,
1778
+ tier,
1779
+ ...native ? { quoteExpiresAt } : {}
1780
+ });
1605
1781
  }
1606
1782
  const memoRequirement = lightning ? "Lightning invoices carry their own routing data; there is no separate memo." : source.receiverMemo ? "This deposit REQUIRES the memo/tag below. Sending without it will very likely lose the funds." : family === "stellar" ? "A Stellar deposit always requires a memo." : "This deposit does not use a memo. Leave the memo field empty.";
1607
1783
  emit({
@@ -1644,7 +1820,12 @@ async function main(argv) {
1644
1820
  lnInvoice: bolt11 || null,
1645
1821
  payTo: depositInfo.payTo,
1646
1822
  expiresAt: payment?.expiresAt ?? null,
1647
- expiresIn: formatRemaining(expiry.msRemaining)
1823
+ expiresIn: formatRemaining(expiry.msRemaining),
1824
+ // Native coins are exact-out like Lightning: this exact coin amount,
1825
+ // at a price locked only until quoteExpiresAt.
1826
+ native,
1827
+ exactOut: native || lightning,
1828
+ quoteExpiresAt: native ? quoteExpiresAt : null
1648
1829
  } : null,
1649
1830
  depositWithheld: !confirmed,
1650
1831
  // Safe for prose / chat.
@@ -1657,11 +1838,16 @@ async function main(argv) {
1657
1838
  receiverMemoMasked: maskMemo(source.receiverMemo),
1658
1839
  hasMemo: Boolean(source.receiverMemo),
1659
1840
  memoType: source.receiverMemo ? STELLAR_MEMO_TYPE : null,
1660
- memoRequirement
1841
+ memoRequirement,
1842
+ native,
1843
+ priceLockedUntil: native ? quoteExpiresAt : null,
1844
+ // The bare decimal amount (not secret), for balance comparisons.
1845
+ coinAmount: isSatsUnit(source.amountUnit) ? null : String(source.amount ?? "")
1661
1846
  },
1662
1847
  expiry: {
1663
1848
  intentExpiresAt: payment?.expiresAt ?? null,
1664
1849
  coinbaseExpiry: snapshot?.coinbase?.preApprovalExpiry ?? null,
1850
+ quoteExpiresAt: native ? quoteExpiresAt : null,
1665
1851
  effectiveDeadlineIso: new Date(expiry.effectiveDeadlineMs).toISOString(),
1666
1852
  // A duration, not just a timestamp: this is what a payer actually needs.
1667
1853
  expiresIn: formatRemaining(expiry.msRemaining),
@@ -1677,7 +1863,8 @@ async function main(argv) {
1677
1863
  "Wrong token, wrong network, or wrong amount is usually unrecoverable.",
1678
1864
  memoRequirement,
1679
1865
  "Send exactly once. A second send to the same one-time address is not guaranteed to be credited.",
1680
- "The deposit amount can exceed the invoice: it includes the bridge and network fees."
1866
+ "The deposit amount can exceed the invoice: it includes the bridge and network fees.",
1867
+ ...native ? nativeDepositWarnings({ tokenSymbol: source.tokenSymbol, chainId: source.chainId }) : []
1681
1868
  ]
1682
1869
  },
1683
1870
  blacklist: {
@@ -330,19 +330,34 @@ var CHAIN_NAMES = {
330
330
  56: "BNB Chain",
331
331
  137: "Polygon",
332
332
  8453: "Base",
333
+ 42161: "Arbitrum",
333
334
  900: "Solana",
334
335
  1500: "Stellar",
335
336
  lightning: "Bitcoin Lightning"
336
337
  };
337
338
  var SUPPORTED_SOURCES = [
338
- { chainId: "1", chain: "Ethereum", tokens: ["USDC", "USDT"] },
339
- { chainId: "56", chain: "BNB Chain", tokens: ["USDC", "USDT"] },
340
- { chainId: "137", chain: "Polygon", tokens: ["USDC", "USDT"] },
341
- { chainId: "900", chain: "Solana", tokens: ["USDC", "USDT"] },
342
- { chainId: "8453", chain: "Base", tokens: ["USDC"] },
339
+ { chainId: "1", chain: "Ethereum", tokens: ["USDC", "USDT", "ETH"] },
340
+ { chainId: "56", chain: "BNB Chain", tokens: ["USDC", "USDT", "BNB"] },
341
+ { chainId: "137", chain: "Polygon", tokens: ["USDC", "USDT", "POL"] },
342
+ { chainId: "900", chain: "Solana", tokens: ["USDC", "USDT", "SOL"] },
343
+ { chainId: "8453", chain: "Base", tokens: ["USDC", "ETH"] },
344
+ { chainId: "42161", chain: "Arbitrum", tokens: ["USDC", "USDT", "ETH"] },
343
345
  { chainId: "1500", chain: "Stellar", tokens: ["USDC"] },
344
346
  { chainId: "lightning", chain: "Bitcoin Lightning", tokens: ["BTC"] }
345
347
  ];
348
+ var NATIVE_COINS = Object.freeze({
349
+ "1:ETH": { chainId: "1", symbol: "ETH", decimals: 18 },
350
+ "8453:ETH": { chainId: "8453", symbol: "ETH", decimals: 18 },
351
+ "42161:ETH": { chainId: "42161", symbol: "ETH", decimals: 18 },
352
+ "56:BNB": { chainId: "56", symbol: "BNB", decimals: 18 },
353
+ "137:POL": { chainId: "137", symbol: "POL", decimals: 18 },
354
+ "900:SOL": { chainId: "900", symbol: "SOL", decimals: 9 }
355
+ });
356
+ var NATIVE_MAX_USD = 2e3;
357
+ var NATIVE_MIN_USD_FALLBACK = Object.freeze({ "1:ETH": 1 });
358
+ function decimalsKey(chainId, tokenSymbol) {
359
+ return `${String(chainId).trim()}:${String(tokenSymbol || "").trim().toUpperCase()}`;
360
+ }
346
361
  function chainName(chainId) {
347
362
  return CHAIN_NAMES[String(chainId)] || CHAIN_NAMES[chainId] || `chain ${chainId}`;
348
363
  }
@@ -351,6 +366,63 @@ function isSupportedSource(chainId, tokenSymbol) {
351
366
  const sym = String(tokenSymbol || "").trim().toUpperCase();
352
367
  return SUPPORTED_SOURCES.some((s) => s.chainId === cid && s.tokens.includes(sym));
353
368
  }
369
+ function isNativeSource(chainId, tokenSymbol) {
370
+ return Object.prototype.hasOwnProperty.call(NATIVE_COINS, decimalsKey(chainId, tokenSymbol));
371
+ }
372
+ function nativeMinUsdFor(chainId, tokenSymbol, quoteNativeMinUsd) {
373
+ const cid = String(chainId).trim();
374
+ const sym = String(tokenSymbol || "").trim().toUpperCase();
375
+ const fromQuote = quoteNativeMinUsd?.[cid]?.[sym];
376
+ const n = Number(fromQuote);
377
+ if (fromQuote !== void 0 && fromQuote !== null && Number.isFinite(n) && n > 0) return n;
378
+ return NATIVE_MIN_USD_FALLBACK[`${cid}:${sym}`] ?? null;
379
+ }
380
+ function nativeOfferFor({ chainId, tokenSymbol, supportedSources, nativeMinUsd, callerPaysUsd }) {
381
+ const cid = String(chainId).trim();
382
+ const sym = String(tokenSymbol || "").trim().toUpperCase();
383
+ const label = `${sym} on ${chainName(cid)}`;
384
+ const minUsd = nativeMinUsdFor(cid, sym, nativeMinUsd);
385
+ if (!isNativeSource(cid, sym)) {
386
+ return { ok: false, code: "NOT_NATIVE", reason: `${label} is not a native coin.`, minUsd: null };
387
+ }
388
+ const list = supportedSources && typeof supportedSources === "object" ? supportedSources[cid] : null;
389
+ if (!Array.isArray(list) || !list.map((t) => String(t).toUpperCase()).includes(sym)) {
390
+ return {
391
+ ok: false,
392
+ code: "NATIVE_NOT_OFFERED",
393
+ reason: `${label} is not offered for this invoice. Pay with USDC or USDT instead.`,
394
+ minUsd
395
+ };
396
+ }
397
+ const usd = Number(callerPaysUsd);
398
+ if (!Number.isFinite(usd) || usd <= 0) {
399
+ return {
400
+ ok: false,
401
+ code: "NATIVE_AMOUNT_UNKNOWN",
402
+ reason: `The quote carries no USD amount, so ${label} cannot be checked against the native limits. Pay with USDC or USDT instead.`,
403
+ minUsd
404
+ };
405
+ }
406
+ {
407
+ if (usd > NATIVE_MAX_USD) {
408
+ return {
409
+ ok: false,
410
+ code: "NATIVE_AMOUNT_TOO_HIGH",
411
+ reason: `Native coin orders are limited to $${NATIVE_MAX_USD} per invoice; this one is $${usd}. Pay with USDC or USDT instead.`,
412
+ minUsd
413
+ };
414
+ }
415
+ if (minUsd !== null && usd < minUsd) {
416
+ return {
417
+ ok: false,
418
+ code: "NATIVE_AMOUNT_TOO_LOW",
419
+ reason: `${label} needs an invoice of at least $${minUsd}; this one is $${usd}. Pay with another coin.`,
420
+ minUsd
421
+ };
422
+ }
423
+ }
424
+ return { ok: true, code: null, reason: null, minUsd };
425
+ }
354
426
 
355
427
  // scripts/src/lib/expiry.mjs
356
428
  var MINUTE = 6e4;
@@ -359,6 +431,7 @@ var MARGINS_MS = {
359
431
  56: 10 * MINUTE,
360
432
  137: 10 * MINUTE,
361
433
  8453: 10 * MINUTE,
434
+ 42161: 10 * MINUTE,
362
435
  900: 5 * MINUTE,
363
436
  1500: 10 * MINUTE,
364
437
  lightning: 10 * MINUTE
@@ -459,6 +532,24 @@ async function main(argv) {
459
532
  const quote = await quoteInvoice({ url: String(url) });
460
533
  const snapshot = snapshotFromQuote(quote);
461
534
  const payability = derivePayable(snapshot, now);
535
+ const nativeOffered = Object.values(NATIVE_COINS).map((c) => ({
536
+ chainId: c.chainId,
537
+ tokenSymbol: c.symbol,
538
+ chain: chainName(c.chainId),
539
+ offer: nativeOfferFor({
540
+ chainId: c.chainId,
541
+ tokenSymbol: c.symbol,
542
+ supportedSources: quote?.supportedSources,
543
+ nativeMinUsd: quote?.nativeMinUsd,
544
+ callerPaysUsd: snapshot.callerPays ?? snapshot.original
545
+ })
546
+ })).filter((c) => c.offer.ok).map((c) => ({ chainId: c.chainId, tokenSymbol: c.tokenSymbol, chain: c.chain, minUsd: c.offer.minUsd }));
547
+ const nativeRequested = chosenSource && isNativeSource(chosenSource.chainId, chosenSource.tokenSymbol) ? nativeOfferFor({
548
+ ...chosenSource,
549
+ supportedSources: quote?.supportedSources,
550
+ nativeMinUsd: quote?.nativeMinUsd,
551
+ callerPaysUsd: snapshot.callerPays ?? snapshot.original
552
+ }) : null;
462
553
  const payload = {
463
554
  success: payability.payable,
464
555
  step: "quote",
@@ -477,6 +568,11 @@ async function main(argv) {
477
568
  coinbaseExpiryIso: payability.expiryMs ? new Date(payability.expiryMs).toISOString() : null,
478
569
  chosenSource,
479
570
  supportedSources: SUPPORTED_SOURCES,
571
+ // The router's own per-invoice list ({ chainId: [symbols] }), verbatim,
572
+ // and the native coins it makes payable for this invoice.
573
+ routerSupportedSources: quote?.supportedSources && typeof quote.supportedSources === "object" ? quote.supportedSources : null,
574
+ nativeOffered,
575
+ nativeNote: "Native coins are exact-out: the coin amount is fixed at order creation and its price is locked only until quoteExpiresAt. Up to $2,000 per invoice; native ETH on Ethereum needs $1.",
480
576
  quoteReceipt: quote?.quoteReceipt ?? null,
481
577
  quoteReceiptTtlSeconds: 60,
482
578
  quoteReceiptNote: "Short-lived (~60s). create-order.js takes its own fresh quote; do not carry this over.",
@@ -487,6 +583,12 @@ async function main(argv) {
487
583
  payload.error = { code: payability.code, message: payability.reason };
488
584
  emit(payload, EXIT_ERROR);
489
585
  }
586
+ if (nativeRequested && !nativeRequested.ok) {
587
+ payload.success = false;
588
+ payload.nextStep = null;
589
+ payload.error = { code: nativeRequested.code, message: nativeRequested.reason };
590
+ emit(payload, EXIT_ERROR);
591
+ }
490
592
  if (snapshot.callerPays && snapshot.original && normalizeDecimal(snapshot.callerPays) !== normalizeDecimal(snapshot.original)) {
491
593
  payload.warnings = [
492
594
  `callerPays (${snapshot.callerPays}) differs from the invoice amount (${snapshot.original}). This flow is supposed to charge the full invoice \u2014 do not proceed until that is explained.`