@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.
- package/README.md +37 -9
- package/docs/QUICKSTART.es.md +14 -6
- package/docs/QUICKSTART.ja.md +8 -3
- package/docs/QUICKSTART.md +13 -5
- package/docs/QUICKSTART.zh.md +6 -4
- package/docs/README.es.md +19 -4
- package/docs/README.ja.md +18 -4
- package/docs/README.zh.md +18 -4
- package/llms.txt +23 -8
- package/package.json +7 -2
- package/scripts/dist/cli.js +1294 -553
- package/scripts/dist/create-order.js +209 -22
- package/scripts/dist/quote.js +107 -5
- package/scripts/dist/receipt.js +84 -13
- package/scripts/dist/resume.js +103 -18
- package/scripts/dist/send-evm.js +31852 -211
- package/scripts/dist/send-sol.js +468 -248
- package/scripts/dist/status.js +87 -13
|
@@ -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
|
|
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
|
-
|
|
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, {
|
|
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: {
|
package/scripts/dist/quote.js
CHANGED
|
@@ -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.`
|