@rozoai/checkout 0.1.22 → 0.1.24

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.
@@ -301,9 +301,15 @@ var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpproute
301
301
  var CLIENT_LABEL = `rozo-checkout-cli/${PKG_VERSION}`;
302
302
  var ATTRIBUTION_CLIENT = `rozo-checkout-skill/${PKG_VERSION}`;
303
303
  var INTENTS_BASE = process.env.ROZO_CHECKOUT_INTENTS_BASE || "https://intentapiv4.rozo.ai/functions/v1/payment-api";
304
- async function quoteInvoice({ url, linkId }) {
304
+ async function quoteInvoice({ url, linkId, beta }) {
305
305
  const body = url ? { url } : { payment_id: linkId };
306
- return postJson(`${MPP_BASE}/quote-invoice`, body);
306
+ return postJson(withBeta(`${MPP_BASE}/quote-invoice`, beta), body);
307
+ }
308
+ function withBeta(url, beta) {
309
+ if (!beta) return url;
310
+ const u = new URL(url);
311
+ u.searchParams.set("beta", beta);
312
+ return u.toString();
307
313
  }
308
314
  function snapshotFromQuote(quote) {
309
315
  const cb = quote?.coinbasePayment || quote?.paymentLink || null;
@@ -333,7 +339,8 @@ var CHAIN_NAMES = {
333
339
  42161: "Arbitrum",
334
340
  900: "Solana",
335
341
  1500: "Stellar",
336
- lightning: "Bitcoin Lightning"
342
+ lightning: "Bitcoin Lightning",
343
+ 9133: "Zcash"
337
344
  };
338
345
  var SUPPORTED_SOURCES = [
339
346
  { chainId: "1", chain: "Ethereum", tokens: ["USDC", "USDT", "ETH"] },
@@ -343,7 +350,9 @@ var SUPPORTED_SOURCES = [
343
350
  { chainId: "8453", chain: "Base", tokens: ["USDC", "ETH"] },
344
351
  { chainId: "42161", chain: "Arbitrum", tokens: ["USDC", "USDT", "ETH"] },
345
352
  { chainId: "1500", chain: "Stellar", tokens: ["USDC"] },
346
- { chainId: "lightning", chain: "Bitcoin Lightning", tokens: ["BTC"] }
353
+ { chainId: "lightning", chain: "Bitcoin Lightning", tokens: ["BTC"] },
354
+ // ZEC (Beta): shielded orders only, offered per invoice by the router (see lib/zec.mjs).
355
+ { chainId: "9133", chain: "Zcash", tokens: ["ZEC"] }
347
356
  ];
348
357
  var NATIVE_COINS = Object.freeze({
349
358
  "1:ETH": { chainId: "1", symbol: "ETH", decimals: 18 },
@@ -353,7 +362,7 @@ var NATIVE_COINS = Object.freeze({
353
362
  "137:POL": { chainId: "137", symbol: "POL", decimals: 18 },
354
363
  "900:SOL": { chainId: "900", symbol: "SOL", decimals: 9 }
355
364
  });
356
- var NATIVE_MAX_USD = 2e3;
365
+ var NATIVE_MAX_USD = 3500;
357
366
  var NATIVE_MIN_USD_FALLBACK = Object.freeze({ "1:ETH": 1 });
358
367
  function decimalsKey(chainId, tokenSymbol) {
359
368
  return `${String(chainId).trim()}:${String(tokenSymbol || "").trim().toUpperCase()}`;
@@ -434,7 +443,9 @@ var MARGINS_MS = {
434
443
  42161: 10 * MINUTE,
435
444
  900: 5 * MINUTE,
436
445
  1500: 10 * MINUTE,
437
- lightning: 10 * MINUTE
446
+ lightning: 10 * MINUTE,
447
+ // Zcash: 75 s blocks, 1 to 2 confirmations, then the hub advances the merchant.
448
+ 9133: 10 * MINUTE
438
449
  };
439
450
  var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
440
451
  var DEFAULT_MARGIN_MS = 10 * MINUTE;
@@ -454,6 +465,69 @@ function parseDeadline(value) {
454
465
  return Number.isFinite(t) ? t : null;
455
466
  }
456
467
 
468
+ // scripts/src/lib/zec.mjs
469
+ var ZEC_CHAIN_ID = "9133";
470
+ var ZEC_SYMBOL = "ZEC";
471
+ var ZEC_SOURCE_META_KEY = "ZEC@9133";
472
+ var ZEC_BETA_PARAM = "zec,zec_shielded";
473
+ var ZEC_SHIELDED_MAX_USD = 1200;
474
+ var ZEC_MIN_USD = 5;
475
+ var ZEC_TWO_CONF_FROM_USD = 110;
476
+ function isZecSource(chainId, tokenSymbol) {
477
+ return String(chainId ?? "").trim() === ZEC_CHAIN_ID && String(tokenSymbol ?? "").trim().toUpperCase() === ZEC_SYMBOL;
478
+ }
479
+ function zecRequiredConfirmations(usd) {
480
+ const n = Number(usd);
481
+ return Number.isFinite(n) && n >= ZEC_TWO_CONF_FROM_USD ? 2 : 1;
482
+ }
483
+ function zecShieldedOfferFor({ supportedSources, sourceMeta, callerPaysUsd }) {
484
+ const list = supportedSources && typeof supportedSources === "object" ? supportedSources[ZEC_CHAIN_ID] : null;
485
+ if (!Array.isArray(list) || !list.map((t) => String(t).toUpperCase()).includes(ZEC_SYMBOL)) {
486
+ return {
487
+ ok: false,
488
+ code: "ZEC_NOT_OFFERED",
489
+ reason: "ZEC is not offered for this invoice. Pay with USDC or USDT instead.",
490
+ requiredConfirmations: null
491
+ };
492
+ }
493
+ const meta = sourceMeta && typeof sourceMeta === "object" ? sourceMeta[ZEC_SOURCE_META_KEY] : null;
494
+ const available = meta && typeof meta === "object" && (meta.shieldedAvailable === true || meta.shielded_available === true);
495
+ if (!available) {
496
+ return {
497
+ ok: false,
498
+ code: "ZEC_SHIELDED_NOT_AVAILABLE",
499
+ reason: "Paying from a shielded balance is not available for this invoice right now.",
500
+ requiredConfirmations: null
501
+ };
502
+ }
503
+ const usd = Number(callerPaysUsd);
504
+ if (!Number.isFinite(usd) || usd <= 0) {
505
+ return {
506
+ ok: false,
507
+ code: "ZEC_AMOUNT_UNKNOWN",
508
+ reason: "The quote carries no USD amount, so the shielded ZEC limits cannot be checked. Pay with USDC or USDT instead.",
509
+ requiredConfirmations: null
510
+ };
511
+ }
512
+ if (usd > ZEC_SHIELDED_MAX_USD) {
513
+ return {
514
+ ok: false,
515
+ code: "ZEC_AMOUNT_TOO_HIGH",
516
+ reason: `Shielded ZEC is limited to $${ZEC_SHIELDED_MAX_USD} per payment during the beta; this invoice is $${usd}. Pay with USDC or USDT instead.`,
517
+ requiredConfirmations: null
518
+ };
519
+ }
520
+ if (usd < ZEC_MIN_USD) {
521
+ return {
522
+ ok: false,
523
+ code: "ZEC_AMOUNT_TOO_LOW",
524
+ reason: `ZEC payments need an invoice of at least $${ZEC_MIN_USD}; this one is $${usd}. Pay with another coin.`,
525
+ requiredConfirmations: null
526
+ };
527
+ }
528
+ return { ok: true, code: null, reason: null, requiredConfirmations: zecRequiredConfirmations(usd) };
529
+ }
530
+
457
531
  // scripts/src/lib/guards.mjs
458
532
  function normalizeDecimal(v) {
459
533
  if (v === null || v === void 0) return null;
@@ -529,7 +603,7 @@ async function main(argv) {
529
603
  chosenSource = { chainId, tokenSymbol };
530
604
  }
531
605
  const now = Date.now();
532
- const quote = await quoteInvoice({ url: String(url) });
606
+ const quote = await quoteInvoice({ url: String(url), beta: ZEC_BETA_PARAM });
533
607
  const snapshot = snapshotFromQuote(quote);
534
608
  const payability = derivePayable(snapshot, now);
535
609
  const nativeOffered = Object.values(NATIVE_COINS).map((c) => ({
@@ -544,6 +618,20 @@ async function main(argv) {
544
618
  callerPaysUsd: snapshot.callerPays ?? snapshot.original
545
619
  })
546
620
  })).filter((c) => c.offer.ok).map((c) => ({ chainId: c.chainId, tokenSymbol: c.tokenSymbol, chain: c.chain, minUsd: c.offer.minUsd }));
621
+ const zecOffer = zecShieldedOfferFor({
622
+ supportedSources: quote?.supportedSources,
623
+ sourceMeta: quote?.sourceMeta,
624
+ callerPaysUsd: snapshot.callerPays ?? snapshot.original
625
+ });
626
+ const zecShielded = {
627
+ offered: zecOffer.ok,
628
+ code: zecOffer.code,
629
+ reason: zecOffer.reason,
630
+ requiredConfirmations: zecOffer.requiredConfirmations,
631
+ maxUsd: ZEC_SHIELDED_MAX_USD,
632
+ note: "ZEC (Beta) is paid from a shielded wallet, exact amount in one transaction; the CLI shows the address, amount and payment URI and never sends. Underpayments are not refunded automatically."
633
+ };
634
+ const zecRequested = chosenSource && isZecSource(chosenSource.chainId, chosenSource.tokenSymbol) ? zecOffer : null;
547
635
  const nativeRequested = chosenSource && isNativeSource(chosenSource.chainId, chosenSource.tokenSymbol) ? nativeOfferFor({
548
636
  ...chosenSource,
549
637
  supportedSources: quote?.supportedSources,
@@ -572,7 +660,8 @@ async function main(argv) {
572
660
  // and the native coins it makes payable for this invoice.
573
661
  routerSupportedSources: quote?.supportedSources && typeof quote.supportedSources === "object" ? quote.supportedSources : null,
574
662
  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.",
663
+ zecShielded,
664
+ nativeNote: "Native coins are exact-out: the coin amount is fixed at order creation and its price is locked only until quoteExpiresAt. Up to $3,500 per invoice; native ETH on Ethereum needs $1.",
576
665
  quoteReceipt: quote?.quoteReceipt ?? null,
577
666
  quoteReceiptTtlSeconds: 60,
578
667
  quoteReceiptNote: "Short-lived (~60s). create-order.js takes its own fresh quote; do not carry this over.",
@@ -589,6 +678,13 @@ async function main(argv) {
589
678
  payload.error = { code: nativeRequested.code, message: nativeRequested.reason };
590
679
  emit(payload, EXIT_ERROR);
591
680
  }
681
+ if (zecRequested && !zecRequested.ok) {
682
+ payload.success = false;
683
+ payload.nextStep = null;
684
+ payload.error = { code: zecRequested.code, message: zecRequested.reason };
685
+ emit(payload, EXIT_ERROR);
686
+ }
687
+ if (zecRequested) payload.nextStep = "create-order.js --url <url> --chain 9133 --token ZEC --shielded";
592
688
  if (snapshot.callerPays && snapshot.original && normalizeDecimal(snapshot.callerPays) !== normalizeDecimal(snapshot.original)) {
593
689
  payload.warnings = [
594
690
  `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.`
@@ -360,7 +360,8 @@ var CHAIN_NAMES = {
360
360
  42161: "Arbitrum",
361
361
  900: "Solana",
362
362
  1500: "Stellar",
363
- lightning: "Bitcoin Lightning"
363
+ lightning: "Bitcoin Lightning",
364
+ 9133: "Zcash"
364
365
  };
365
366
  var NATIVE_COINS = Object.freeze({
366
367
  "1:ETH": { chainId: "1", symbol: "ETH", decimals: 18 },
@@ -393,7 +394,9 @@ var DECIMALS = {
393
394
  "42161:ETH": 18,
394
395
  "56:BNB": 18,
395
396
  "137:POL": 18,
396
- "900:SOL": 9
397
+ "900:SOL": 9,
398
+ // Zcash: zatoshi.
399
+ "9133:ZEC": 8
397
400
  };
398
401
  var AmountError = class extends Error {
399
402
  constructor(code, message) {
@@ -490,6 +493,32 @@ function isNativeSource(chainId, tokenSymbol) {
490
493
  return Object.prototype.hasOwnProperty.call(NATIVE_COINS, decimalsKey(chainId, tokenSymbol));
491
494
  }
492
495
 
496
+ // scripts/src/lib/zec.mjs
497
+ var ZEC_CHAIN_ID = "9133";
498
+ var ZEC_SYMBOL = "ZEC";
499
+ var ZEC_PAYIN_STATES = /* @__PURE__ */ new Set(["awaiting", "seen", "confirming", "eligible", "expired", "reorged"]);
500
+ function isZecSource(chainId, tokenSymbol) {
501
+ return String(chainId ?? "").trim() === ZEC_CHAIN_ID && String(tokenSymbol ?? "").trim().toUpperCase() === ZEC_SYMBOL;
502
+ }
503
+ function zecStatusOf(source) {
504
+ const z = source?.zec;
505
+ if (!z || typeof z !== "object") return null;
506
+ const receive = z.receive === "shielded" || z.receive === "transparent" ? z.receive : null;
507
+ const payinState = ZEC_PAYIN_STATES.has(z.payinState) ? z.payinState : null;
508
+ const confirmations = Number.isInteger(z.confirmations) && z.confirmations >= 0 ? z.confirmations : null;
509
+ const requiredConfirmations = z.requiredConfirmations === 1 || z.requiredConfirmations === 2 ? z.requiredConfirmations : null;
510
+ const quoteExpiresAt = typeof z.quoteExpiresAt === "string" && z.quoteExpiresAt ? z.quoteExpiresAt : null;
511
+ const receivedZat = typeof z.receivedZat === "string" && /^\d+$/.test(z.receivedZat) ? z.receivedZat : null;
512
+ return { receive, payinState, confirmations, requiredConfirmations, quoteExpiresAt, receivedZat };
513
+ }
514
+ function zecMoneySeen(zec) {
515
+ if (!zec) return false;
516
+ if (zec.payinState === "seen" || zec.payinState === "confirming" || zec.payinState === "eligible") return true;
517
+ if (zec.receivedZat !== null && zec.receivedZat !== "0") return true;
518
+ if (zec.payinState === null) return true;
519
+ return false;
520
+ }
521
+
493
522
  // scripts/src/lib/guards.mjs
494
523
  var NATIVE_PROGRESS_STATUSES = /* @__PURE__ */ new Set([
495
524
  "payment_payin_completed",
@@ -712,7 +741,9 @@ var MARGINS_MS = {
712
741
  42161: 10 * MINUTE,
713
742
  900: 5 * MINUTE,
714
743
  1500: 10 * MINUTE,
715
- lightning: 10 * MINUTE
744
+ lightning: 10 * MINUTE,
745
+ // Zcash: 75 s blocks, 1 to 2 confirmations, then the hub advances the merchant.
746
+ 9133: 10 * MINUTE
716
747
  };
717
748
  var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
718
749
  var DEFAULT_MARGIN_MS = 10 * MINUTE;
@@ -1202,13 +1233,15 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1202
1233
  });
1203
1234
  const source = payment?.source || status?.rozoPayment?.source || {};
1204
1235
  const native = isNativeSource(source.chainId, source.tokenSymbol);
1236
+ const isZec = isZecSource(source.chainId, source.tokenSymbol);
1237
+ const zec = isZec ? zecStatusOf(source) : null;
1205
1238
  let localState = null;
1206
1239
  try {
1207
1240
  localState = id && isRozoPaymentId(id) ? readState(id) : null;
1208
1241
  } catch {
1209
1242
  localState = null;
1210
1243
  }
1211
- const quoteExpiresAt = priceLockFor({
1244
+ const quoteExpiresAt = isZec ? zec?.quoteExpiresAt ?? null : priceLockFor({
1212
1245
  native,
1213
1246
  payment: payment || status?.rozoPayment || null,
1214
1247
  others: [localState?.confirmation?.quoteExpiresAt, localState?.quoteExpiresAt]
@@ -1217,6 +1250,17 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1217
1250
  let state = verdict.state;
1218
1251
  let terminal = verdict.terminal;
1219
1252
  let detail = verdict.detail;
1253
+ const zecMoney = Boolean(zec && zecMoneySeen(zec));
1254
+ let escalate = verdict.escalate;
1255
+ if (zecMoney && state === "expired_unfunded") {
1256
+ state = "stuck_after_payment";
1257
+ terminal = false;
1258
+ escalate = true;
1259
+ detail = "The order expired but the custody rail reports a ZEC pay-in. Do not pay again; escalate for manual reconciliation.";
1260
+ } else if (zecMoney && state === "awaiting_deposit") {
1261
+ state = "payin_detected";
1262
+ detail = zec.payinState === "seen" || zec.payinState === "confirming" ? `Shielded pay-in ${zec.payinState}: ${zec.confirmations ?? 0} of ${zec.requiredConfirmations ?? "?"} confirmations.` : `The custody rail reports a ZEC pay-in (${zec.payinState ?? "state unreadable"}). Do not pay again.`;
1263
+ }
1220
1264
  if (provider === "bitrefill") {
1221
1265
  let local = null;
1222
1266
  try {
@@ -1246,9 +1290,9 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1246
1290
  linkId: linkId || status?.pl_id || null,
1247
1291
  state,
1248
1292
  unknown: verdict.unknown,
1249
- moneyDetected: verdict.moneyDetected,
1293
+ moneyDetected: verdict.moneyDetected || zecMoney,
1250
1294
  terminal,
1251
- escalate: verdict.escalate,
1295
+ escalate,
1252
1296
  detail,
1253
1297
  backend: {
1254
1298
  paymentStatus: payment?.status ?? status?.rozoPayment?.status ?? null,
@@ -1263,7 +1307,9 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1263
1307
  txHash: publicHash(source.txHash ?? null),
1264
1308
  confirmedAt: source.confirmedAt ?? null,
1265
1309
  senderAddressMasked: source.senderAddress ? maskAddress(source.senderAddress) : null,
1266
- chain: source.chainId ? chainName(source.chainId) : null
1310
+ chain: source.chainId ? chainName(source.chainId) : null,
1311
+ // Shielded ZEC only: the custody rail's own view of the pay-in.
1312
+ ...zec ? { zec } : {}
1267
1313
  },
1268
1314
  expiry: (() => {
1269
1315
  if (provider === "bitrefill" && !bitrefillExpiry) {
@@ -1279,7 +1325,7 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1279
1325
  msRemaining: Number.isFinite(ms) ? ms : null,
1280
1326
  // Native orders: the order stays open after the price lock, but a
1281
1327
  // payment after it is valued at the arrival price. Pay before this.
1282
- ...native ? {
1328
+ ...native || isZec ? {
1283
1329
  quoteExpiresAt: quoteExpiresAt ?? null,
1284
1330
  priceLockExpiresIn: Number.isFinite(lockMs) ? formatRemaining(lockMs) : null
1285
1331
  } : {}
@@ -1290,7 +1336,8 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1290
1336
  confirmedAt: payment?.destination?.confirmedAt ?? status?.rozoPayment?.destination?.confirmedAt ?? null
1291
1337
  },
1292
1338
  localSend: localSendFor(id),
1293
- sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry, quoteExpiresAt }) : null,
1339
+ // Never a send window once the custody rail has seen a ZEC pay-in.
1340
+ sendWindow: state === "awaiting_deposit" && !zecMoney ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry, quoteExpiresAt }) : null,
1294
1341
  errors: [statusError, paymentError].filter(Boolean)
1295
1342
  };
1296
1343
  }
@@ -382,7 +382,8 @@ var CHAIN_NAMES = {
382
382
  42161: "Arbitrum",
383
383
  900: "Solana",
384
384
  1500: "Stellar",
385
- lightning: "Bitcoin Lightning"
385
+ lightning: "Bitcoin Lightning",
386
+ 9133: "Zcash"
386
387
  };
387
388
  var CHAIN_FAMILY = {
388
389
  1: "evm",
@@ -392,7 +393,9 @@ var CHAIN_FAMILY = {
392
393
  42161: "evm",
393
394
  900: "solana",
394
395
  1500: "stellar",
395
- lightning: "lightning"
396
+ lightning: "lightning",
397
+ // Zcash: the CLI creates shielded orders only (pool Unified Address); it never signs.
398
+ 9133: "zcash"
396
399
  };
397
400
  var NATIVE_COINS = Object.freeze({
398
401
  "1:ETH": { chainId: "1", symbol: "ETH", decimals: 18 },
@@ -425,7 +428,9 @@ var DECIMALS = {
425
428
  "42161:ETH": 18,
426
429
  "56:BNB": 18,
427
430
  "137:POL": 18,
428
- "900:SOL": 9
431
+ "900:SOL": 9,
432
+ // Zcash: zatoshi.
433
+ "9133:ZEC": 8
429
434
  };
430
435
  var AmountError = class extends Error {
431
436
  constructor(code, message) {
@@ -526,6 +531,96 @@ function isNativeSource(chainId, tokenSymbol) {
526
531
  return Object.prototype.hasOwnProperty.call(NATIVE_COINS, decimalsKey(chainId, tokenSymbol));
527
532
  }
528
533
 
534
+ // scripts/src/lib/zec.mjs
535
+ var ZEC_CHAIN_ID = "9133";
536
+ var ZEC_SYMBOL = "ZEC";
537
+ var ZEC_PAYIN_STATES = /* @__PURE__ */ new Set(["awaiting", "seen", "confirming", "eligible", "expired", "reorged"]);
538
+ var UA_RE = /^u1[02-9ac-hj-np-z]{100,400}$/;
539
+ function isZecSource(chainId, tokenSymbol) {
540
+ return String(chainId ?? "").trim() === ZEC_CHAIN_ID && String(tokenSymbol ?? "").trim().toUpperCase() === ZEC_SYMBOL;
541
+ }
542
+ function isUnifiedAddress(value) {
543
+ return typeof value === "string" && UA_RE.test(value.trim());
544
+ }
545
+ function zecStatusOf(source) {
546
+ const z = source?.zec;
547
+ if (!z || typeof z !== "object") return null;
548
+ const receive = z.receive === "shielded" || z.receive === "transparent" ? z.receive : null;
549
+ const payinState = ZEC_PAYIN_STATES.has(z.payinState) ? z.payinState : null;
550
+ const confirmations = Number.isInteger(z.confirmations) && z.confirmations >= 0 ? z.confirmations : null;
551
+ const requiredConfirmations = z.requiredConfirmations === 1 || z.requiredConfirmations === 2 ? z.requiredConfirmations : null;
552
+ const quoteExpiresAt = typeof z.quoteExpiresAt === "string" && z.quoteExpiresAt ? z.quoteExpiresAt : null;
553
+ const receivedZat = typeof z.receivedZat === "string" && /^\d+$/.test(z.receivedZat) ? z.receivedZat : null;
554
+ return { receive, payinState, confirmations, requiredConfirmations, quoteExpiresAt, receivedZat };
555
+ }
556
+ function zecMoneySeen(zec) {
557
+ if (!zec) return false;
558
+ if (zec.payinState === "seen" || zec.payinState === "confirming" || zec.payinState === "eligible") return true;
559
+ if (zec.receivedZat !== null && zec.receivedZat !== "0") return true;
560
+ if (zec.payinState === null) return true;
561
+ return false;
562
+ }
563
+ function parsePaymentUri(uri) {
564
+ if (typeof uri !== "string") return null;
565
+ const m = /^zcash:([0-9a-z]+)\?([^#]*)$/i.exec(uri.trim());
566
+ if (!m) return null;
567
+ const address = m[1];
568
+ const out = { address, amount: null, memo: null };
569
+ const seen = /* @__PURE__ */ new Set();
570
+ for (const part of m[2].split("&")) {
571
+ if (!part) return null;
572
+ const eq = part.indexOf("=");
573
+ if (eq <= 0) return null;
574
+ const key = part.slice(0, eq);
575
+ const value = part.slice(eq + 1);
576
+ if (seen.has(key)) return null;
577
+ seen.add(key);
578
+ if (key === "amount") {
579
+ if (!/^\d+(\.\d{1,8})?$/.test(value)) return null;
580
+ out.amount = value;
581
+ } else if (key === "memo") {
582
+ if (!/^[A-Za-z0-9_-]{1,684}$/.test(value)) return null;
583
+ out.memo = value;
584
+ } else {
585
+ return null;
586
+ }
587
+ }
588
+ if (out.amount === null) return null;
589
+ return out;
590
+ }
591
+ function sameDecimal(a, b) {
592
+ const norm = (v) => {
593
+ const s = String(v ?? "").trim();
594
+ if (!/^\d+(\.\d+)?$/.test(s)) return null;
595
+ const [w, f = ""] = s.split(".");
596
+ const frac = f.replace(/0+$/, "");
597
+ return `${w.replace(/^0+(?=\d)/, "")}${frac ? `.${frac}` : ""}`;
598
+ };
599
+ const na = norm(a);
600
+ return na !== null && na === norm(b);
601
+ }
602
+ function validateShieldedDeposit(source) {
603
+ const address = typeof source?.receiverAddress === "string" ? source.receiverAddress.trim() : "";
604
+ if (!isUnifiedAddress(address)) {
605
+ return { ok: false, code: "ZEC_ADDRESS_INVALID", reason: "The order did not return a mainnet Unified Address (u1...). Refusing to show it as payable." };
606
+ }
607
+ const uri = typeof source?.paymentUri === "string" ? source.paymentUri.trim() : "";
608
+ if (!uri) {
609
+ return { ok: false, code: "ZEC_PAYMENT_URI_MISSING", reason: "The order carries no payment URI yet. Nothing is payable until it appears." };
610
+ }
611
+ const parsed = parsePaymentUri(uri);
612
+ if (!parsed) {
613
+ return { ok: false, code: "ZEC_PAYMENT_URI_INVALID", reason: "The payment URI is not a single-payment ZIP-321 URI. Refusing to show it." };
614
+ }
615
+ if (parsed.address !== address) {
616
+ return { ok: false, code: "ZEC_PAYMENT_URI_MISMATCH", reason: "The payment URI names a different address than the order. Refusing to show it." };
617
+ }
618
+ if (!sameDecimal(parsed.amount, source?.amount)) {
619
+ return { ok: false, code: "ZEC_PAYMENT_URI_MISMATCH", reason: "The payment URI amount differs from the order amount. Refusing to show it." };
620
+ }
621
+ return { ok: true, code: null, reason: null, address, paymentUri: uri, memo: parsed.memo };
622
+ }
623
+
529
624
  // scripts/src/lib/guards.mjs
530
625
  var UNPAID_STATUS = "payment_unpaid";
531
626
  var NATIVE_PROGRESS_STATUSES = /* @__PURE__ */ new Set([
@@ -586,6 +681,11 @@ function validateDepositInstructions(source) {
586
681
  reason: "The live payments response returned no deposit address."
587
682
  };
588
683
  }
684
+ if (family === "zcash" && zecStatusOf(source)?.receive === "shielded") {
685
+ const zec = validateShieldedDeposit(source);
686
+ if (!zec.ok) return { ok: false, code: zec.code, reason: zec.reason };
687
+ return { ok: true, code: null, reason: null, family, amountAtomic, payTo: zec.address, memo: null, paymentUri: zec.paymentUri };
688
+ }
589
689
  if (family === "stellar" && !memo) {
590
690
  return {
591
691
  ok: false,
@@ -618,13 +718,14 @@ function reuseGuard({ payment, requested, reused = false }) {
618
718
  const hasTx = source.txHash !== null && source.txHash !== void 0 && source.txHash !== "";
619
719
  const signal = receiptSignal(source);
620
720
  const hasConfirm = source.confirmedAt !== null && source.confirmedAt !== void 0 && source.confirmedAt !== "";
621
- if (hasTx || signal.money || hasConfirm) {
721
+ const zecSeen = isZecSource(source.chainId, source.tokenSymbol) && zecMoneySeen(zecStatusOf(source));
722
+ if (hasTx || signal.money || hasConfirm || zecSeen) {
622
723
  return {
623
724
  ok: false,
624
725
  code: "ORDER_ALREADY_FUNDED",
625
- reason: signal.unparsable ? "This order reports an amountReceived that cannot be read. It is treated as funded until a human confirms otherwise \u2014 do NOT pay again." : "This Coinbase link already has a funded Rozo order (it may have been paid elsewhere). Do NOT pay again \u2014 escalate for manual reconciliation.",
726
+ reason: signal.unparsable ? "This order reports an amountReceived that cannot be read. It is treated as funded until a human confirms otherwise \u2014 do NOT pay again." : zecSeen && !hasTx && !signal.money && !hasConfirm ? "A shielded ZEC payment to this order has already been seen on the network. Do NOT pay again; watch it with status instead." : "This Coinbase link already has a funded Rozo order (it may have been paid elsewhere). Do NOT pay again \u2014 escalate for manual reconciliation.",
626
727
  moneyDetected: true,
627
- evidence: { ...evidence, receipt: signal.receipt, receiptUnparsable: signal.unparsable }
728
+ evidence: { ...evidence, receipt: signal.receipt, receiptUnparsable: signal.unparsable, ...zecSeen ? { zec: zecStatusOf(source) } : {} }
628
729
  };
629
730
  }
630
731
  if (payment?.status !== UNPAID_STATUS) {
@@ -652,6 +753,28 @@ function reuseGuard({ payment, requested, reused = false }) {
652
753
  evidence
653
754
  };
654
755
  }
756
+ if (isZecSource(gotChain, gotToken)) {
757
+ const zec = zecStatusOf(source);
758
+ const wantReceive = requested?.shielded === true ? "shielded" : "transparent";
759
+ if (!zec || zec.receive !== wantReceive) {
760
+ return {
761
+ ok: false,
762
+ code: "ZEC_RECEIVE_MODE_MISMATCH",
763
+ reason: `This link already has a ZEC order that is paid ${zec?.receive ?? "in an unknown way"}, but you asked for ${wantReceive}. Nothing new was created. Pay that order the way it was made, or wait for it to expire unfunded.`,
764
+ moneyDetected: false,
765
+ evidence: { ...evidence, zec }
766
+ };
767
+ }
768
+ if (zec.payinState === "expired" || zec.payinState === "reorged") {
769
+ return {
770
+ ok: false,
771
+ code: "ZEC_QUOTE_EXPIRED",
772
+ reason: `The ZEC price lock on this order has ${zec.payinState === "reorged" ? "been disturbed by a reorg" : "expired"} with nothing received. Do not pay it; re-run pay to get a fresh order.`,
773
+ moneyDetected: false,
774
+ evidence: { ...evidence, zec }
775
+ };
776
+ }
777
+ }
655
778
  const deposit = validateDepositInstructions(source);
656
779
  if (!deposit.ok) {
657
780
  return {
@@ -989,7 +1112,9 @@ var MARGINS_MS = {
989
1112
  42161: 10 * MINUTE,
990
1113
  900: 5 * MINUTE,
991
1114
  1500: 10 * MINUTE,
992
- lightning: 10 * MINUTE
1115
+ lightning: 10 * MINUTE,
1116
+ // Zcash: 75 s blocks, 1 to 2 confirmations, then the hub advances the merchant.
1117
+ 9133: 10 * MINUTE
993
1118
  };
994
1119
  var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
995
1120
  var DEFAULT_MARGIN_MS = 10 * MINUTE;
@@ -1276,13 +1401,15 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1276
1401
  });
1277
1402
  const source = payment?.source || status?.rozoPayment?.source || {};
1278
1403
  const native = isNativeSource(source.chainId, source.tokenSymbol);
1404
+ const isZec = isZecSource(source.chainId, source.tokenSymbol);
1405
+ const zec = isZec ? zecStatusOf(source) : null;
1279
1406
  let localState = null;
1280
1407
  try {
1281
1408
  localState = id && isRozoPaymentId(id) ? readState(id) : null;
1282
1409
  } catch {
1283
1410
  localState = null;
1284
1411
  }
1285
- const quoteExpiresAt = priceLockFor({
1412
+ const quoteExpiresAt = isZec ? zec?.quoteExpiresAt ?? null : priceLockFor({
1286
1413
  native,
1287
1414
  payment: payment || status?.rozoPayment || null,
1288
1415
  others: [localState?.confirmation?.quoteExpiresAt, localState?.quoteExpiresAt]
@@ -1291,6 +1418,17 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1291
1418
  let state = verdict.state;
1292
1419
  let terminal = verdict.terminal;
1293
1420
  let detail = verdict.detail;
1421
+ const zecMoney = Boolean(zec && zecMoneySeen(zec));
1422
+ let escalate = verdict.escalate;
1423
+ if (zecMoney && state === "expired_unfunded") {
1424
+ state = "stuck_after_payment";
1425
+ terminal = false;
1426
+ escalate = true;
1427
+ detail = "The order expired but the custody rail reports a ZEC pay-in. Do not pay again; escalate for manual reconciliation.";
1428
+ } else if (zecMoney && state === "awaiting_deposit") {
1429
+ state = "payin_detected";
1430
+ detail = zec.payinState === "seen" || zec.payinState === "confirming" ? `Shielded pay-in ${zec.payinState}: ${zec.confirmations ?? 0} of ${zec.requiredConfirmations ?? "?"} confirmations.` : `The custody rail reports a ZEC pay-in (${zec.payinState ?? "state unreadable"}). Do not pay again.`;
1431
+ }
1294
1432
  if (provider === "bitrefill") {
1295
1433
  let local = null;
1296
1434
  try {
@@ -1320,9 +1458,9 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1320
1458
  linkId: linkId || status?.pl_id || null,
1321
1459
  state,
1322
1460
  unknown: verdict.unknown,
1323
- moneyDetected: verdict.moneyDetected,
1461
+ moneyDetected: verdict.moneyDetected || zecMoney,
1324
1462
  terminal,
1325
- escalate: verdict.escalate,
1463
+ escalate,
1326
1464
  detail,
1327
1465
  backend: {
1328
1466
  paymentStatus: payment?.status ?? status?.rozoPayment?.status ?? null,
@@ -1337,7 +1475,9 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1337
1475
  txHash: publicHash(source.txHash ?? null),
1338
1476
  confirmedAt: source.confirmedAt ?? null,
1339
1477
  senderAddressMasked: source.senderAddress ? maskAddress(source.senderAddress) : null,
1340
- chain: source.chainId ? chainName(source.chainId) : null
1478
+ chain: source.chainId ? chainName(source.chainId) : null,
1479
+ // Shielded ZEC only: the custody rail's own view of the pay-in.
1480
+ ...zec ? { zec } : {}
1341
1481
  },
1342
1482
  expiry: (() => {
1343
1483
  if (provider === "bitrefill" && !bitrefillExpiry) {
@@ -1353,7 +1493,7 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1353
1493
  msRemaining: Number.isFinite(ms) ? ms : null,
1354
1494
  // Native orders: the order stays open after the price lock, but a
1355
1495
  // payment after it is valued at the arrival price. Pay before this.
1356
- ...native ? {
1496
+ ...native || isZec ? {
1357
1497
  quoteExpiresAt: quoteExpiresAt ?? null,
1358
1498
  priceLockExpiresIn: Number.isFinite(lockMs) ? formatRemaining(lockMs) : null
1359
1499
  } : {}
@@ -1364,7 +1504,8 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
1364
1504
  confirmedAt: payment?.destination?.confirmedAt ?? status?.rozoPayment?.destination?.confirmedAt ?? null
1365
1505
  },
1366
1506
  localSend: localSendFor(id),
1367
- sendWindow: state === "awaiting_deposit" ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry, quoteExpiresAt }) : null,
1507
+ // Never a send window once the custody rail has seen a ZEC pay-in.
1508
+ sendWindow: state === "awaiting_deposit" && !zecMoney ? sendWindowFor({ provider, chainId: source.chainId, payment, status, bitrefillExpiry, quoteExpiresAt }) : null,
1368
1509
  errors: [statusError, paymentError].filter(Boolean)
1369
1510
  };
1370
1511
  }
@@ -1506,7 +1647,12 @@ async function main(argv) {
1506
1647
  }
1507
1648
  const payment = await getPayment(id);
1508
1649
  const source = payment?.source || {};
1509
- const guard = reuseGuard({ payment, requested: { chainId: source.chainId, tokenSymbol: source.tokenSymbol }, reused: true });
1650
+ const resumedShielded = isZecSource(source.chainId, source.tokenSymbol) && zecStatusOf(source)?.receive === "shielded";
1651
+ const guard = reuseGuard({
1652
+ payment,
1653
+ requested: { chainId: source.chainId, tokenSymbol: source.tokenSymbol, ...resumedShielded ? { shielded: true } : {} },
1654
+ reused: true
1655
+ });
1510
1656
  if (!guard.ok) {
1511
1657
  emit(
1512
1658
  {
@@ -1524,13 +1670,16 @@ async function main(argv) {
1524
1670
  );
1525
1671
  }
1526
1672
  const native = isNativeSource(source.chainId, source.tokenSymbol);
1527
- const quoteExpiresAt = priceLockFor({ native, payment, others: [snap.expiry?.quoteExpiresAt] });
1673
+ const isZec = isZecSource(source.chainId, source.tokenSymbol);
1674
+ const zecStatus = isZec ? zecStatusOf(source) : null;
1675
+ const zec = Boolean(zecStatus && zecStatus.receive === "shielded");
1676
+ const quoteExpiresAt = isZec ? zecStatus?.quoteExpiresAt ?? null : priceLockFor({ native, payment, others: [snap.expiry?.quoteExpiresAt] });
1528
1677
  const finalWindow = checkExpiry({
1529
1678
  now: Date.now(),
1530
1679
  chainId: source.chainId,
1531
1680
  intentExpiresAt: payment?.expiresAt ?? snap.sendWindow?.deadlineMs,
1532
1681
  coinbaseExpiry: snap.sendWindow?.deadlineMs,
1533
- ...native ? { quoteExpiresAt } : {}
1682
+ ...native || isZec ? { quoteExpiresAt } : {}
1534
1683
  });
1535
1684
  if (!finalWindow.ok) {
1536
1685
  emit(
@@ -1605,8 +1754,14 @@ async function main(argv) {
1605
1754
  expiresAt,
1606
1755
  expiresIn,
1607
1756
  native,
1608
- exactOut: native || lightning,
1609
- quoteExpiresAt: native ? quoteExpiresAt : null
1757
+ exactOut: native || lightning || zec,
1758
+ quoteExpiresAt: native || zec ? quoteExpiresAt : null,
1759
+ ...zec ? {
1760
+ shielded: true,
1761
+ paymentUri: guard.deposit.paymentUri,
1762
+ requiredConfirmations: zecStatus.requiredConfirmations,
1763
+ payinState: zecStatus.payinState
1764
+ } : {}
1610
1765
  } : null,
1611
1766
  depositWithheld: !confirmed,
1612
1767
  display: {
@@ -1619,12 +1774,13 @@ async function main(argv) {
1619
1774
  hasMemo: Boolean(source.receiverMemo),
1620
1775
  memoType: source.receiverMemo ? STELLAR_MEMO_TYPE : null,
1621
1776
  native,
1622
- priceLockedUntil: native ? quoteExpiresAt : null
1777
+ priceLockedUntil: native || zec ? quoteExpiresAt : null,
1778
+ ...zec ? { shielded: true } : {}
1623
1779
  },
1624
1780
  expiry: {
1625
1781
  expiresAt,
1626
1782
  expiresIn,
1627
- ...native ? { quoteExpiresAt } : {},
1783
+ ...native || zec ? { quoteExpiresAt } : {},
1628
1784
  minutesOfSlack: snap.sendWindow?.minutesOfSlack ?? null
1629
1785
  },
1630
1786
  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."