@rozoai/checkout 0.1.12 → 0.1.15

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 CHANGED
@@ -116,6 +116,12 @@ the optional `--send` flag signs locally: on Solana it uses the
116
116
  encrypted JSON keystore whose passphrase is prompted. A raw key in the
117
117
  environment (or a gitignored `.env`) still works for unattended automation.
118
118
 
119
+ Distributing this CLI? `--utm-source <label>` (or `ROZO_CHECKOUT_UTM_SOURCE`) is an optional channel label for reporting; no identity, no privilege.
120
+
121
+ Want ROZO to be able to reach you if a payment needs attention? Add the optional `--email you@example.com` to `pay`.
122
+
123
+ Need help? Email hi@rozo.ai, or reach us on [X](https://x.com/ROZOai) or [Discord](https://discord.gg/EfWejgTbuU).
124
+
119
125
  <details>
120
126
  <summary><b>Set up a local wallet for <code>--send</code></b> — .env template and per-wallet export steps</summary>
121
127
 
@@ -366,6 +372,104 @@ keypair or an encrypted keystore, and supports EVM chains and Solana only —
366
372
  Stellar and Lightning are Mode A only.
367
373
  </details>
368
374
 
375
+ ## Paying many invoices (batch / resellers)
376
+
377
+ Each OpenRouter top-up is its own Coinbase link, so paying many is a loop: one
378
+ `pay` per link, one logged result per link. Install once
379
+ (`npm i -g @rozoai/checkout`) so each run skips the npx download, run the links
380
+ one at a time, and pay each deposit right after it is created.
381
+
382
+ ```bash
383
+ #!/usr/bin/env bash
384
+ # links.txt: one Coinbase link per line (payment-links/pl_* or payment-sessions/paymentSession_*)
385
+ mkdir -p runs
386
+ while IFS= read -r link; do
387
+ link="${link%$'\r'}"; link="${link%%\?*}"
388
+ [ -n "$link" ] || continue
389
+ id="${link##*/}"
390
+ rozo-checkout pay "$link" --with btc-lightning --yes --json --no-watch \
391
+ --email you@example.com </dev/null > "runs/$id.json"
392
+ code=$?
393
+ row=$(jq -r --arg id "$id" --arg code "$code" '[$id, $code,
394
+ (.order.rozoPaymentId // .error.details.body.rozoPaymentId // "-"),
395
+ (if .success then "pending: pay before " + (.order.expiry.effectiveDeadlineIso // "?")
396
+ else (.error.code // .send.error.code // "FAILED") end)] | @tsv' \
397
+ "runs/$id.json" 2>/dev/null) || row="$id $code - NO_OUTPUT"
398
+ printf '%s\n' "$row" >> runs/batch.tsv
399
+ done < links.txt
400
+ ```
401
+
402
+ - `--yes` is required when stdin is not a terminal, `--json` prints exactly one
403
+ JSON object, and `--no-watch` returns as soon as the deposit details exist.
404
+ Without `--no-watch`, `pay --json` first waits for settlement (up to
405
+ `--timeout`, default 900 s) and prints nothing until then, invoice included.
406
+ - Without `--send` (the loop above), a successful `pay --no-watch` means the
407
+ order and its deposit details exist. **No money has moved yet**, which is
408
+ why the log says `pending`. With `--send`, the CLI may already have
409
+ broadcast your payment, so never pay that deposit by hand as well. A link is
410
+ paid only when `status` later reports `settled`.
411
+ - `order.rozoPaymentId` names the order and `order.deposit` says what to pay.
412
+ Lightning: `deposit.lnInvoice` (the BOLT11) for `deposit.amount` sats. Other
413
+ coins: `deposit.receiverAddress` and `deposit.amount`, plus
414
+ `deposit.receiverMemo` on Stellar. `--send` cannot pay Lightning or Stellar.
415
+ - The deadline to watch is `order.expiry.effectiveDeadlineIso`, not
416
+ `deposit.expiresAt`: it is the earlier of our order expiry and the Coinbase
417
+ link expiry. Stop paying at least `order.expiry.marginMinutes` before it,
418
+ since settlement needs that much time.
419
+ - If time has passed since you created the order, do not pay saved deposit
420
+ details blindly. Re-run the same `pay` command (same `--with`): while the
421
+ order is still payable it re-checks the link and the deadline and hands back
422
+ the same order. Pay only if that succeeds.
423
+
424
+ Check settlement by `rozoPaymentId` (a link alone is only resolved from this
425
+ machine's own records in `~/.rozo-checkout/state`):
426
+
427
+ ```bash
428
+ rozo-checkout status <rozoPaymentId> --json | jq -r '.state, .escalate'
429
+ rozo-checkout status <rozoPaymentId> --watch --timeout 900 --json # poll every 10 s
430
+ ```
431
+
432
+ Exit 0 means the check itself ran cleanly, not that the invoice settled:
433
+ `expired_unfunded` also exits 0. Always read `state`.
434
+
435
+ **Retrying without paying twice.** Running `pay` again on the same link does
436
+ not create a second order while its order is open: you get the same order
437
+ back, and once money has arrived `pay` refuses with `ORDER_ALREADY_ACTIVE` or
438
+ `ORDER_ALREADY_FUNDED`. Only after an order expired with nothing received does
439
+ `pay` on the same link create a new order. The risk is paying a deposit twice, so before acting on
440
+ a link again, run `status` on its `rozoPaymentId`:
441
+
442
+ | `status` result | What to do |
443
+ | --- | --- |
444
+ | `escalate: true` (includes `underpaid`, `stuck_after_payment`; exit 1) | Do not pay again. Contact support with the link, `rozoPaymentId` and any tx hash or payment preimage. |
445
+ | `state: settled` | Done. Skip this link. |
446
+ | `state: awaiting_deposit` | Still open, but a payment you just sent may not be detected yet. First check your wallet: if a send or Lightning payment to this deposit exists or is pending, wait and poll. Only if nothing was sent, re-run the same `pay` to re-check it, then pay the deposit once. |
447
+ | `state: payin_detected`, `payin_confirmed`, `bridging`, `paying_coinbase` | Money is in flight. Wait and poll; do not pay again. |
448
+ | `state: expired_unfunded` | Nothing has arrived and this order is dead. **First check your own wallet**: if you sent anything to the old deposit, or a Lightning payment is still pending, do not pay again; contact support with the link and `rozoPaymentId` (we cannot see a pending Lightning payment). If nothing was sent, re-run the same `pay` on the same link with the same `--with`: it creates a new order. If that answers `PAYMENT_EXPIRED`, see below. |
449
+ | `state: unknown` (exit 1) | The backend could not be read. Not proof that nothing was paid: retry `status`, not `pay`. |
450
+
451
+ - Exit codes: `0` ok, `1` refused or failed (read `error.code`), `2` usage
452
+ error (nothing was created), `3` the watch window ended before a final
453
+ state. Exit 3 means money may still be in flight: keep polling, do not pay
454
+ again.
455
+ - Use the same `--with` coin on every run for a link. A different coin can be
456
+ refused with `REUSED_SOURCE_MISMATCH` or change the open order's deposit.
457
+ - `LINK_USED_OR_EXPIRED` or `LINK_NO_LONGER_PAYABLE`: the Coinbase link itself
458
+ is already paid or expired. Check your OpenRouter balance and get a new link
459
+ if it was not credited.
460
+ - `PAYMENT_EXPIRED`: an earlier order for this link expired. Read
461
+ `error.details.body`: with `retryable: true` (`confirmed: false`) the old
462
+ order cannot be proved unfunded yet, so wait a few minutes and retry the same
463
+ `pay`; with `confirmed: true` the link has used all its re-orders, so get a
464
+ new link from OpenRouter.
465
+ - `RATE_LIMITED`: order creation is capped per IP per hour (currently 30
466
+ requests, and each `pay` makes two), so plan on about 15 links per hour from
467
+ one IP. Wait for the next hour and resume with the links not yet `settled`.
468
+ Need more? Email us.
469
+
470
+ Questions about a batch? Email hi@rozo.ai, or reach us on
471
+ [X](https://x.com/ROZOai) or [Discord](https://discord.gg/EfWejgTbuU).
472
+
369
473
  ## Three rules worth knowing
370
474
 
371
475
  - **The deposit address is one-time.** Never reuse one from an older order, a
@@ -394,6 +498,22 @@ The full list of what this refuses to do, and why, is in
394
498
 
395
499
  ## Changelog
396
500
 
501
+ - **0.1.15**: corrected guidance after an order expires unpaid. First check
502
+ your own wallet that nothing was sent to the old deposit and no Lightning
503
+ payment is pending; if nothing was sent, re-run the same `pay` on the same
504
+ link (the router opens a new order once it has verified the old one is
505
+ unfunded). `PAYMENT_EXPIRED` with `retryable: true` means try again in a few
506
+ minutes; `LINK_USED_OR_EXPIRED` or `PAYMENT_EXPIRED` with `confirmed: true`
507
+ means get a new link from the merchant. New README section on paying many
508
+ invoices (batch / resellers).
509
+ - **0.1.14**: optional `--email <addr>` on `pay` (and on the create-order
510
+ scripts) stores a contact email with the order so ROZO can reach you if the
511
+ payment needs attention. Never required; an invalid address is rejected
512
+ before any order is created. Support channels (hi@rozo.ai, X, Discord) are
513
+ shown at order creation and whenever an order is not clean.
514
+ - **0.1.13**: optional `--utm-source <label>` / `ROZO_CHECKOUT_UTM_SOURCE`
515
+ adds `attribution.utm_source` to created orders so distribution channels can
516
+ be measured. Invalid values are dropped, never failing an order.
397
517
  - **0.1.12**: pay Bitrefill invoices. `pay --bitrefill-invoice <id> --to <0x…>
398
518
  --amount <USDC> --expires-at <ISO>` creates a Rozo exactOut order through
399
519
  mpprouter that delivers the exact USDC on Base straight to the Bitrefill
@@ -330,7 +330,7 @@ Leer `error.code`. Los tres con los que es más probable toparse:
330
330
  | `error.code` | Qué ocurrió | Qué hacer |
331
331
  |---|---|---|
332
332
  | `LINK_NO_LONGER_PAYABLE` | alguien ya pagó el enlace, o expiró | pedir al comercio un enlace nuevo; no pagar nada |
333
- | `EXPIRY_MARGIN` | queda muy poco tiempo para fondear, puentear y liquidar de forma segura | dejar que la orden expire y volver a empezar desde el paso 1 |
333
+ | `EXPIRY_MARGIN` | queda muy poco tiempo para fondear, puentear y liquidar de forma segura | no fondear esta orden; dejar que expire y volver a empezar desde el paso 1 con el mismo enlace (se crea una orden nueva cuando se comprueba que la anterior no recibió fondos). Si responde `LINK_USED_OR_EXPIRED`, o `PAYMENT_EXPIRED` con `confirmed: true`, pedir al comercio un enlace nuevo |
334
334
  | `ALREADY_SENT` | ya hay un envío registrado para esta orden | **no** enviar de nuevo; ejecutar `status.js` y revisar primero la cadena |
335
335
 
336
336
  **Si algo de dinero ya salió de la billetera, nunca pagar de nuevo.** Conservar
@@ -306,7 +306,7 @@ node scripts/dist/status.js --rozo-payment-id <rozoPaymentId> --watch --timeout
306
306
  | `error.code` | 何が起きたか | どうするか |
307
307
  |---|---|---|
308
308
  | `LINK_NO_LONGER_PAYABLE` | そのリンクは既に誰かが支払ったか、期限切れです | 加盟店に新しいリンクを依頼してください。何も支払わないこと |
309
- | `EXPIRY_MARGIN` | 入金・ブリッジ・決済完了を安全に行うには残り時間が足りません | その注文は期限切れにさせ、ステップ 1 からやり直してください |
309
+ | `EXPIRY_MARGIN` | 入金・ブリッジ・決済完了を安全に行うには残り時間が足りません | この注文には入金しないでください。期限切れになるのを待ち、同じリンクでステップ 1 からやり直してください(旧注文が未入金と確認されると新しい注文が作られます)。`LINK_USED_OR_EXPIRED`、または `confirmed: true` の `PAYMENT_EXPIRED` が返った場合は、加盟店に新しいリンクを依頼してください |
310
310
  | `ALREADY_SENT` | この注文に対する送金が既に記録されています | 再送信しては**いけません**。まず `status.js` を実行し、チェーンを確認してください |
311
311
 
312
312
  **ウォレットから既に資金が出ている場合は、決して二重に支払わないでください。**
@@ -322,7 +322,7 @@ Read `error.code`. The three you are most likely to hit:
322
322
  | `error.code` | What happened | What to do |
323
323
  |---|---|---|
324
324
  | `LINK_NO_LONGER_PAYABLE` | someone already paid the link, or it expired | ask the merchant for a fresh link; do not pay anything |
325
- | `EXPIRY_MARGIN` | too little time left to fund, bridge and settle safely | let the order expire, then start again from step 1 |
325
+ | `EXPIRY_MARGIN` | too little time left to fund, bridge and settle safely | do not fund this order; let it expire, then re-run from step 1 on the same link (a new order is created once the old one is proved unfunded). If that answers `LINK_USED_OR_EXPIRED` or `PAYMENT_EXPIRED` with `confirmed: true`, ask the merchant for a fresh link |
326
326
  | `ALREADY_SENT` | a send is already recorded for this order | do **not** send again; run `status.js` and check the chain first |
327
327
 
328
328
  **If any money has already left your wallet, never pay again.** Keep the
@@ -281,7 +281,7 @@ node scripts/dist/status.js --rozo-payment-id <rozoPaymentId> --watch --timeout
281
281
  | `error.code` | 发生了什么 | 该怎么办 |
282
282
  |---|---|---|
283
283
  | `LINK_NO_LONGER_PAYABLE` | 这个链接已经被别人付过,或者已过期 | 找商家要一个新链接;不要付任何钱 |
284
- | `EXPIRY_MARGIN` | 剩余时间太短,不足以安全完成充值、跨桥和结清 | 让订单过期,然后从第 1 步重新开始 |
284
+ | `EXPIRY_MARGIN` | 剩余时间太短,不足以安全完成充值、跨桥和结清 | 不要给这个订单充值;等它过期后,用同一个链接从第 1 步重新开始(确认旧订单未收到付款后会生成新订单)。如果返回 `LINK_USED_OR_EXPIRED`,或 `PAYMENT_EXPIRED` 且 `confirmed: true`,就找商家要一个新链接 |
285
285
  | `ALREADY_SENT` | 这个订单已经记录过一次发送 | **不要**再发一次;先跑 `status.js` 并去链上核对 |
286
286
 
287
287
  **只要已经有钱离开了你的钱包,就绝不要再付一次。** 保留好 `linkId`、
package/llms.txt CHANGED
@@ -25,6 +25,10 @@ calls with every safety rule already enforced:
25
25
  usdc-ethereum, usdt-polygon, usdc-polygon, usdc-base, usdc-stellar, btc-lightning
26
26
  (or raw: --chain 900 --token USDT).
27
27
 
28
+ Optional: --email <addr> stores a contact email with the order so ROZO can reach
29
+ the user if the payment needs attention. Ask the user once if they are present;
30
+ never required, proceed without it if they decline or no one is there.
31
+
28
32
  DEFAULT PATH NEEDS NO KEY: `pay` prints a deposit address for the user to pay from
29
33
  any wallet. No private key, no environment variable, no configuration. Only the
30
34
  optional --send flag signs locally. Its key comes from the first source that
@@ -117,6 +121,8 @@ private key (EVM and Solana only); every other path is keyless.
117
121
  -d '{"url":"https://payments.coinbase.com/payment-links/pl_XXXX",
118
122
  "source":{"chainId":"900","tokenSymbol":"USDT"}}'
119
123
  -> { ok, reused, linkId, rozoPaymentId, paymentLink, expiresAt, source, ... }
124
+ Optional "email":"you@example.com" in the body stores a contact email with a
125
+ newly created order (a malformed value returns 400 INVALID_EMAIL).
120
126
  WARNING: if an unexpired order already exists for this link it is REUSED as-is,
121
127
  even with a different chain/token than you asked for. Always verify the returned
122
128
  source matches your request; abort on mismatch.
@@ -157,9 +163,24 @@ private key (EVM and Solana only); every other path is keyless.
157
163
  - After any on-chain payment is detected, never retry into a new order and never
158
164
  label the payment a normal failure — keep the tx hash and rozoPaymentId, keep
159
165
  polling, and escalate if it stays stuck.
166
+ - An order that expired unfunded does not spend its link. First check your wallet: if
167
+ anything was sent to the old deposit, or a Lightning payment is still pending, do not
168
+ pay again; contact support with linkId + rozoPaymentId (the router cannot see a
169
+ pending Lightning payment). If nothing was sent, create-invoice on the SAME link again:
170
+ the router makes a new order once every earlier order is proved unfunded.
171
+ 409 PAYMENT_EXPIRED retryable:true (confirmed:false) = not verifiable yet, wait a few
172
+ minutes and retry. 409 PAYMENT_EXPIRED confirmed:true or LINK_USED_OR_EXPIRED = ask
173
+ the merchant for a new link. 409 ORDER_ALREADY_ACTIVE = funds seen, do not pay again.
160
174
 
161
175
  ## Errors
162
176
 
163
177
  409 = link already used · 410 = link expired · UNSUPPORTED_SOURCE = the response's
164
178
  "supported" object lists valid (chainId, tokenSymbol) pairs (Lightning is supported
165
- but omitted from that list) · RATE_LIMITED = per-IP creation cap, retry later.
179
+ but omitted from that list) · RATE_LIMITED = per-IP creation cap, retry later ·
180
+ INVALID_EMAIL = the optional email is malformed; fix it or omit it.
181
+
182
+ ## Support
183
+
184
+ If an order is stuck or anything goes wrong: email hi@rozo.ai, X
185
+ https://x.com/ROZOai, Discord https://discord.gg/EfWejgTbuU. Include linkId,
186
+ rozoPaymentId and any tx hash.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rozoai/checkout",
3
- "version": "0.1.12",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "description": "Pay OpenRouter, or any Coinbase invoice, with the crypto you already hold: USDT/USDC on Solana, BNB Chain, Ethereum or Polygon, USDC on Base or Stellar, or BTC over Lightning. No Coinbase account.",
6
6
  "license": "MIT",
@@ -41445,7 +41445,47 @@ process.on("warning", (warning) => {
41445
41445
  import readline2 from "node:readline";
41446
41446
  import { createRequire as createRequire2 } from "node:module";
41447
41447
 
41448
+ // scripts/src/lib/support.mjs
41449
+ var SUPPORT = Object.freeze({
41450
+ email: "hi@rozo.ai",
41451
+ x: "https://x.com/ROZOai",
41452
+ discord: "https://discord.gg/EfWejgTbuU"
41453
+ });
41454
+ var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
41455
+ var CONTACT_EMAIL_MAX_LENGTH = 254;
41456
+ var EMAIL_RE = /^[a-z0-9][^\s@]*@[^\s@.]+(\.[^\s@.]+)+$/;
41457
+ function normalizeContactEmail(raw) {
41458
+ if (raw === void 0 || raw === null) return null;
41459
+ if (typeof raw !== "string") return void 0;
41460
+ const email = raw.trim().toLowerCase();
41461
+ if (email.length === 0) return null;
41462
+ if (email.length > CONTACT_EMAIL_MAX_LENGTH) return void 0;
41463
+ if (/[\x00-\x1f\x7f]/.test(email) || !EMAIL_RE.test(email)) return void 0;
41464
+ return email;
41465
+ }
41466
+ function contactEmailAttached(email, reused) {
41467
+ return Boolean(email) && !reused;
41468
+ }
41469
+ function maskEmail(email) {
41470
+ const at = String(email).lastIndexOf("@");
41471
+ if (at <= 0) return "***";
41472
+ return `${email[0]}***${email.slice(at)}`;
41473
+ }
41474
+ function contactEmailFromArgs(args) {
41475
+ if (args.email === void 0) return null;
41476
+ const email = args.email === true ? void 0 : normalizeContactEmail(String(args.email));
41477
+ if (email === void 0) {
41478
+ const err = new Error(
41479
+ "--email must be a valid email address, for example name@example.com. It is optional: leave it out to continue without one."
41480
+ );
41481
+ err.code = "INVALID_EMAIL";
41482
+ throw err;
41483
+ }
41484
+ return email;
41485
+ }
41486
+
41448
41487
  // scripts/src/lib/output.mjs
41488
+ var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
41449
41489
  var EXIT_OK = 0;
41450
41490
  var EXIT_ERROR = 1;
41451
41491
  var EXIT_USAGE = 2;
@@ -41457,7 +41497,8 @@ function redact(text) {
41457
41497
  s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
41458
41498
  s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
41459
41499
  s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
41460
- s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (_m, scheme, rest) => {
41500
+ s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (m, scheme, rest) => {
41501
+ if (PUBLIC_SUPPORT_URLS.has(m.replace(/[.,;:!?)]+$/, ""))) return m;
41461
41502
  const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
41462
41503
  const host = withoutUserinfo.split(/[/?#]/)[0];
41463
41504
  const hadMore = withoutUserinfo.length > host.length;
@@ -41704,6 +41745,14 @@ function isSupportedSource(chainId, tokenSymbol) {
41704
41745
  return SUPPORTED_SOURCES.some((s) => s.chainId === cid && s.tokens.includes(sym));
41705
41746
  }
41706
41747
 
41748
+ // scripts/src/lib/utm.mjs
41749
+ function normalizeUtmSource(value) {
41750
+ if (typeof value !== "string") return null;
41751
+ const s = value.trim().toLowerCase();
41752
+ if (!s || s.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(s)) return null;
41753
+ return s;
41754
+ }
41755
+
41707
41756
  // scripts/src/lib/cli-args.mjs
41708
41757
  var COMMANDS = ["pay", "quote", "status", "help", "version"];
41709
41758
  var CHAIN_ALIASES = {
@@ -41834,7 +41883,11 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
41834
41883
  "to",
41835
41884
  "amount",
41836
41885
  "expires-at",
41837
- "provider"
41886
+ "provider",
41887
+ // Optional channel label for reporting (pay, quote)
41888
+ "utm-source",
41889
+ // Optional contact email so ROZO can reach the payer (pay)
41890
+ "email"
41838
41891
  ]);
41839
41892
  function resolveFromPreset(value) {
41840
41893
  try {
@@ -41903,9 +41956,19 @@ function parseCliArgs(argv) {
41903
41956
  }
41904
41957
  const target = positional[1];
41905
41958
  const json = Boolean(flags.json);
41959
+ let utmSource;
41960
+ if (flags["utm-source"] !== void 0 && (command === "pay" || command === "quote")) {
41961
+ utmSource = normalizeUtmSource(String(flags["utm-source"]));
41962
+ if (!utmSource) {
41963
+ throw new CliError(
41964
+ "BAD_VALUE",
41965
+ '--utm-source must be 1-100 characters of a-z, 0-9, ".", "_" or "-", starting with a letter or digit.'
41966
+ );
41967
+ }
41968
+ }
41906
41969
  if (command === "quote") {
41907
41970
  if (!target) throw new CliError("MISSING_TARGET", "Usage: rozo-checkout quote <coinbase-link>");
41908
- return { command, target, json };
41971
+ return { command, target, json, ...utmSource ? { utmSource } : {} };
41909
41972
  }
41910
41973
  if (command === "status") {
41911
41974
  if (!target) {
@@ -41981,6 +42044,16 @@ function parseCliArgs(argv) {
41981
42044
  } else {
41982
42045
  source = null;
41983
42046
  }
42047
+ let email = null;
42048
+ if (flags.email !== void 0) {
42049
+ email = normalizeContactEmail(String(flags.email));
42050
+ if (email === void 0) {
42051
+ throw new CliError(
42052
+ "INVALID_EMAIL",
42053
+ "--email must be a valid email address, for example name@example.com. It is optional: leave it out to continue without one."
42054
+ );
42055
+ }
42056
+ }
41984
42057
  const timeout = flags.timeout === void 0 ? 900 : Number(flags.timeout);
41985
42058
  if (!Number.isFinite(timeout) || timeout < 0) {
41986
42059
  throw new CliError("BAD_VALUE", "--timeout must be a non-negative number of seconds.");
@@ -42000,7 +42073,9 @@ function parseCliArgs(argv) {
42000
42073
  payer: flags.payer,
42001
42074
  fresh: flags.fresh === true,
42002
42075
  keyfile: flags.keyfile,
42003
- envFile: flags["env-file"]
42076
+ envFile: flags["env-file"],
42077
+ ...utmSource ? { utmSource } : {},
42078
+ ...email ? { email } : {}
42004
42079
  };
42005
42080
  }
42006
42081
  var HELP = `rozo-checkout \u2014 pay a Coinbase Payment Link or a Bitrefill invoice
@@ -42050,6 +42125,10 @@ OPTIONS
42050
42125
  gets signed.
42051
42126
  --fresh ignore the remembered wallet address and coin for this run
42052
42127
  --rpc <url> override the RPC endpoint for --send
42128
+ --utm-source <s> optional channel label for reporting; no identity, no
42129
+ privilege. Overrides ROZO_CHECKOUT_UTM_SOURCE.
42130
+ --email <addr> optional. A contact email stored with the order so ROZO can
42131
+ reach you if the payment needs attention. Never required.
42053
42132
  --help, -h this text
42054
42133
  --version, -v print the version
42055
42134
 
@@ -42057,11 +42136,15 @@ EXAMPLES
42057
42136
  npx @rozoai/checkout pay https://payments.coinbase.com/payment-links/pl_01...
42058
42137
  npx @rozoai/checkout pay https://payments.coinbase.com/payment-links/pl_01... --with usdt-solana
42059
42138
  npx @rozoai/checkout pay pl_01... --with btc-lightning
42139
+ npx @rozoai/checkout pay pl_01... --with usdt-solana --email you@example.com
42060
42140
  npx @rozoai/checkout pay --bitrefill-invoice 8f3k2 --to 0xAbC\u2026123 --amount 7.90 --expires-at 2026-10-01T12:00:00Z --with usdc-stellar
42061
42141
  npx @rozoai/checkout status 11111111-2222-4333-8444-555555555555
42062
42142
 
42063
42143
  Creating an order moves no money; an unfunded order simply expires. Nothing is
42064
- paid until you confirm, and --send is required before anything is signed.`;
42144
+ paid until you confirm, and --send is required before anything is signed.
42145
+
42146
+ SUPPORT
42147
+ Email hi@rozo.ai, X https://x.com/ROZOai, Discord https://discord.gg/EfWejgTbuU`;
42065
42148
 
42066
42149
  // scripts/src/lib/wallet-check.mjs
42067
42150
  var INTENT_API_BASE = process.env.ROZO_CHECKOUT_INTENT_API || "https://intentapi.rozo.ai";
@@ -54489,22 +54572,30 @@ var PKG_VERSION = (() => {
54489
54572
  var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpprouter.dev/v1/services/rozo-agent-api";
54490
54573
  var CLIENT_LABEL = `rozo-checkout-cli/${PKG_VERSION}`;
54491
54574
  var ATTRIBUTION_CLIENT = `rozo-checkout-skill/${PKG_VERSION}`;
54575
+ function buildAttribution({ utmSource } = {}) {
54576
+ const raw = utmSource !== void 0 ? utmSource : process.env.ROZO_CHECKOUT_UTM_SOURCE;
54577
+ const utm_source = normalizeUtmSource(raw);
54578
+ return { client: ATTRIBUTION_CLIENT, ...utm_source ? { utm_source } : {} };
54579
+ }
54492
54580
  var INTENTS_BASE = process.env.ROZO_CHECKOUT_INTENTS_BASE || "https://intentapiv4.rozo.ai/functions/v1/payment-api";
54493
54581
  async function quoteInvoice({ url, linkId }) {
54494
54582
  const body = url ? { url } : { payment_id: linkId };
54495
54583
  return postJson(`${MPP_BASE}/quote-invoice`, body);
54496
54584
  }
54497
- async function createInvoice({ url, linkId, source, quoteReceipt }) {
54585
+ async function createInvoice({ url, linkId, source, quoteReceipt, utmSource, email }) {
54498
54586
  const body = {
54499
54587
  ...url ? { url } : { payment_id: linkId },
54500
54588
  source: { chainId: String(source.chainId), tokenSymbol: source.tokenSymbol },
54501
54589
  ...quoteReceipt ? { quoteReceipt } : {},
54502
54590
  client: CLIENT_LABEL,
54503
- attribution: { client: ATTRIBUTION_CLIENT }
54591
+ attribution: buildAttribution({ utmSource }),
54592
+ // Optional payer contact email (validated by the caller). Omitted, never
54593
+ // sent empty, when the user gave none.
54594
+ ...email ? { email } : {}
54504
54595
  };
54505
54596
  return postJson(`${MPP_BASE}/create-invoice`, body);
54506
54597
  }
54507
- async function createBitrefillInvoice({ invoice, source }) {
54598
+ async function createBitrefillInvoice({ invoice, source, utmSource, email }) {
54508
54599
  const body = {
54509
54600
  provider: "bitrefill",
54510
54601
  bitrefill: {
@@ -54515,7 +54606,8 @@ async function createBitrefillInvoice({ invoice, source }) {
54515
54606
  },
54516
54607
  source: { chainId: String(source.chainId), tokenSymbol: source.tokenSymbol },
54517
54608
  client: CLIENT_LABEL,
54518
- attribution: { client: ATTRIBUTION_CLIENT }
54609
+ attribution: buildAttribution({ utmSource }),
54610
+ ...email ? { email } : {}
54519
54611
  };
54520
54612
  return postJson(`${MPP_BASE}/create-invoice`, body);
54521
54613
  }
@@ -54909,7 +55001,7 @@ function classifyStatus({
54909
55001
  }
54910
55002
  const exp = payment?.expiresAt ? Date.parse(payment.expiresAt) : NaN;
54911
55003
  if (Number.isFinite(exp) && exp < now) {
54912
- return mk("expired_unfunded", "The order expired before any funds arrived. Safe to retry.", {
55004
+ return mk("expired_unfunded", "The order expired before any funds arrived. This order can no longer be paid.", {
54913
55005
  terminal: true
54914
55006
  });
54915
55007
  }
@@ -54935,7 +55027,7 @@ function classifyStatus({
54935
55027
  case "payment_expired":
54936
55028
  return moneyDetected ? mk("stuck_after_payment", "Order expired AFTER funds arrived \u2014 escalate immediately.", {
54937
55029
  escalate: true
54938
- }) : mk("expired_unfunded", "Order expired unfunded. Safe to retry.", { terminal: true });
55030
+ }) : mk("expired_unfunded", "Order expired unfunded. This order can no longer be paid.", { terminal: true });
54939
55031
  case "payment_bounced":
54940
55032
  case "payment_refunded":
54941
55033
  return mk("stuck_after_payment", `Order ended as ${payment.status} \u2014 escalate.`, {
@@ -55085,6 +55177,7 @@ async function main2(argv) {
55085
55177
  }
55086
55178
  const requested = { chainId, tokenSymbol };
55087
55179
  const confirmed = Boolean(args.confirm);
55180
+ const email = contactEmailFromArgs(args);
55088
55181
  let blacklist;
55089
55182
  try {
55090
55183
  blacklist = loadBlacklist();
@@ -55101,7 +55194,9 @@ async function main2(argv) {
55101
55194
  const created = await createInvoice({
55102
55195
  url: String(url),
55103
55196
  source: requested,
55104
- quoteReceipt
55197
+ quoteReceipt,
55198
+ utmSource: typeof args["utm-source"] === "string" ? args["utm-source"] : void 0,
55199
+ email
55105
55200
  });
55106
55201
  if (!created?.rozoPaymentId) {
55107
55202
  throw new SkillError("CREATE_FAILED", "create-invoice returned no rozoPaymentId.", {
@@ -55258,6 +55353,13 @@ async function main2(argv) {
55258
55353
  success: true,
55259
55354
  step: "create-order",
55260
55355
  confirmed,
55356
+ // True only when an email went out with a NEW order. A reused order keeps
55357
+ // whatever email it was first created with (the router never rebinds), so
55358
+ // reporting true there would be a false promise. The address itself is
55359
+ // never echoed. Note the --confirm run always sees the order as reused;
55360
+ // the CLI carries the first run's value forward.
55361
+ contactEmailProvided: contactEmailAttached(email, created.reused),
55362
+ support: SUPPORT,
55261
55363
  reused: Boolean(created.reused),
55262
55364
  reusedNote: created.reused ? `An existing unpaid order for this link was reused (${rozoPaymentId}), valid for another ${formatRemaining(expiry.msRemaining)}. Nothing new was created.` : null,
55263
55365
  orderCost: "Creating an order moves no money. An order you never fund simply expires and costs nothing.",
@@ -55532,6 +55634,7 @@ async function main3(argv) {
55532
55634
  }
55533
55635
  const requested = { chainId, tokenSymbol };
55534
55636
  const confirmed = Boolean(args.confirm);
55637
+ const email = contactEmailFromArgs(args);
55535
55638
  const rawInvoice = {
55536
55639
  invoiceId: args["invoice-id"],
55537
55640
  address: args.to,
@@ -55552,7 +55655,12 @@ async function main3(argv) {
55552
55655
  let rozoPaymentId;
55553
55656
  let resumed = false;
55554
55657
  try {
55555
- created = await createBitrefillInvoice({ invoice, source: requested });
55658
+ created = await createBitrefillInvoice({
55659
+ invoice,
55660
+ source: requested,
55661
+ utmSource: typeof args["utm-source"] === "string" ? args["utm-source"] : void 0,
55662
+ email
55663
+ });
55556
55664
  } catch (err) {
55557
55665
  const existing = duplicateInvoicePaymentId(err);
55558
55666
  if (!existing) throw explainRouterError(err);
@@ -55655,6 +55763,9 @@ async function main3(argv) {
55655
55763
  step: "create-order",
55656
55764
  provider: PROVIDER_BITREFILL,
55657
55765
  confirmed,
55766
+ // False on a resumed order: the router does not attach an email to it.
55767
+ contactEmailProvided: contactEmailAttached(email, resumed),
55768
+ support: SUPPORT,
55658
55769
  reused: resumed,
55659
55770
  reusedNote: resumed ? `An order for this Bitrefill invoice already existed (${rozoPaymentId}); it was resumed. Nothing new was created.` : null,
55660
55771
  orderCost: "Creating an order moves no money. An order you never fund simply expires and costs nothing.",
@@ -55866,6 +55977,23 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
55866
55977
  errors: [statusError, paymentError].filter(Boolean)
55867
55978
  };
55868
55979
  }
55980
+ var EXPIRED_UNFUNDED_GUIDANCE = "This order expired before any funds arrived. First check your own wallet: if anything was sent to the old deposit address, or a Lightning payment is still pending, do not pay again; contact support with the linkId and rozoPaymentId (the router cannot see a pending Lightning payment). If nothing was sent, re-run the same pay on the same link with the same --with: it creates a new order for this link. If pay answers PAYMENT_EXPIRED with retryable: true, the old order cannot be verified yet; wait a few minutes and retry. If it answers LINK_USED_OR_EXPIRED, or PAYMENT_EXPIRED with confirmed: true, this link cannot be paid any more; get a new payment link from the merchant (OpenRouter). If it answers ORDER_ALREADY_ACTIVE, a payment was found: do not pay again; contact support.";
55981
+ function statusGuidance(result) {
55982
+ if (result.escalate) {
55983
+ return "MONEY DETECTED and the order is not on a healthy path. Do NOT pay again and do NOT create a new order for this link. Preserve linkId, rozoPaymentId and every tx hash, then escalate to the operator for manual reconciliation.";
55984
+ }
55985
+ if (result.unknown) {
55986
+ return "The order state could not be established. This is NOT evidence that nothing was paid \u2014 do not create a new order and do not send again on the strength of it. Retry, or pass --rozo-payment-id so the authoritative pay-in view can be read.";
55987
+ }
55988
+ if (!result.authoritativeView) {
55989
+ return "Only the fulfilment view was readable; the pay-in view is unavailable, so the money-detected rule cannot be enforced. Pass --rozo-payment-id for a complete answer.";
55990
+ }
55991
+ if ((result.state === "expired_unfunded" || result.state === "invoice_expired") && result.provider === "bitrefill") {
55992
+ return "Nothing was funded, so nothing was lost. Create a fresh Bitrefill invoice and run rozo-checkout pay --bitrefill-invoice <id> --to <0x\u2026> --amount <USDC> --with <coin>";
55993
+ }
55994
+ if (result.state === "expired_unfunded") return EXPIRED_UNFUNDED_GUIDANCE;
55995
+ return result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
55996
+ }
55869
55997
  async function main4(argv) {
55870
55998
  const args = parseArgs(argv);
55871
55999
  const rozoPaymentId = args["rozo-payment-id"] || (isRozoPaymentId(args._[0]) ? args._[0] : null);
@@ -55891,7 +56019,7 @@ async function main4(argv) {
55891
56019
  if (next.state !== result.state) history.push({ at: (/* @__PURE__ */ new Date()).toISOString(), state: next.state });
55892
56020
  result = next;
55893
56021
  }
55894
- const guidance = result.escalate ? "MONEY DETECTED and the order is not on a healthy path. Do NOT pay again and do NOT create a new order for this link. Preserve linkId, rozoPaymentId and every tx hash, then escalate to the operator for manual reconciliation." : result.unknown ? "The order state could not be established. This is NOT evidence that nothing was paid \u2014 do not create a new order and do not send again on the strength of it. Retry, or pass --rozo-payment-id so the authoritative pay-in view can be read." : !result.authoritativeView ? "Only the fulfilment view was readable; the pay-in view is unavailable, so the money-detected rule cannot be enforced. Pass --rozo-payment-id for a complete answer." : (result.state === "expired_unfunded" || result.state === "invoice_expired") && result.provider === "bitrefill" ? "Nothing was funded, so nothing was lost. Create a fresh Bitrefill invoice and run rozo-checkout pay --bitrefill-invoice <id> --to <0x\u2026> --amount <USDC> --with <coin>" : result.state === "expired_unfunded" ? "Nothing was funded, so nothing was lost. Start a fresh order with: rozo-checkout pay <coinbase-link> --with <coin> (or create-order.js --url <link> --chain <id> --token <SYMBOL>)" : result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
56022
+ const guidance = statusGuidance(result);
55895
56023
  const unresolved = watch && !result.terminal && !result.escalate && !result.unknown;
55896
56024
  const failed = result.escalate || result.unknown || !result.authoritativeView;
55897
56025
  emit(
@@ -55901,7 +56029,9 @@ async function main4(argv) {
55901
56029
  ...result,
55902
56030
  history,
55903
56031
  guidance,
55904
- timedOut: unresolved
56032
+ timedOut: unresolved,
56033
+ // Anything short of a clean answer gets a human contact.
56034
+ ...failed || unresolved ? { support: SUPPORT } : {}
55905
56035
  },
55906
56036
  failed ? EXIT_ERROR : unresolved ? EXIT_UNCONFIRMED : 0
55907
56037
  );
@@ -56971,6 +57101,9 @@ function printError(payload) {
56971
57101
  if (e.message) out(` ${e.message}`);
56972
57102
  if (payload?.guidance) out(` ${yellow(payload.guidance)}`);
56973
57103
  }
57104
+ function printSupport() {
57105
+ out(` ${dim(SUPPORT_TEXT)}`);
57106
+ }
56974
57107
  function printMoneyWarning(payload) {
56975
57108
  if (!payload?.moneyDetected) return;
56976
57109
  out();
@@ -57119,6 +57252,7 @@ async function cmdStatus(opts) {
57119
57252
  }
57120
57253
  if (payload.error) {
57121
57254
  printError(payload);
57255
+ printSupport();
57122
57256
  return exitCode;
57123
57257
  }
57124
57258
  out();
@@ -57130,6 +57264,7 @@ async function cmdStatus(opts) {
57130
57264
  if (payload.payin?.txHash) out(` Pay-in ${payload.payin.txHash}`);
57131
57265
  if (payload.guidance) out(` ${payload.escalate ? red(payload.guidance) : dim(payload.guidance)}`);
57132
57266
  printMoneyWarning(payload);
57267
+ if (payload.support) printSupport();
57133
57268
  out();
57134
57269
  return exitCode;
57135
57270
  }
@@ -57204,6 +57339,10 @@ async function cmdPay(opts) {
57204
57339
  }
57205
57340
  const { chainId, tokenSymbol } = opts.source;
57206
57341
  const createFlow = opts.bitrefill ? run3 : run2;
57342
+ const utmArgs = [
57343
+ ...opts.utmSource ? ["--utm-source", opts.utmSource] : [],
57344
+ ...opts.email ? ["--email", opts.email] : []
57345
+ ];
57207
57346
  const baseArgs = opts.bitrefill ? [
57208
57347
  "--invoice-id",
57209
57348
  opts.bitrefill.invoiceId,
@@ -57215,8 +57354,9 @@ async function cmdPay(opts) {
57215
57354
  "--chain",
57216
57355
  chainId,
57217
57356
  "--token",
57218
- tokenSymbol
57219
- ] : ["--url", opts.target, "--chain", chainId, "--token", tokenSymbol];
57357
+ tokenSymbol,
57358
+ ...utmArgs
57359
+ ] : ["--url", opts.target, "--chain", chainId, "--token", tokenSymbol, ...utmArgs];
57220
57360
  if (opts.send) {
57221
57361
  const family = chainFamily(chainId);
57222
57362
  if (family === "evm" || family === "solana") {
@@ -57235,6 +57375,7 @@ async function cmdPay(opts) {
57235
57375
  else {
57236
57376
  printError(created.payload);
57237
57377
  printMoneyWarning(created.payload);
57378
+ printSupport();
57238
57379
  }
57239
57380
  return created.exitCode;
57240
57381
  }
@@ -57264,6 +57405,12 @@ async function cmdPay(opts) {
57264
57405
  p.reused ? ` ${dim(`Reusing existing unpaid order ${p.rozoPaymentId} \u2014 nothing new was created.`)}` : ` ${dim(`Order created: ${p.rozoPaymentId}`)}`
57265
57406
  );
57266
57407
  out(` ${dim("An order you never fund simply expires and costs nothing.")}`);
57408
+ if (opts.email) {
57409
+ out(
57410
+ p.reused ? ` ${dim(`Contact ${maskEmail(opts.email)} was not attached: this order already existed.`)}` : ` ${dim(`Contact ${maskEmail(opts.email)}, so ROZO can reach you if this payment needs attention.`)}`
57411
+ );
57412
+ }
57413
+ printSupport();
57267
57414
  out();
57268
57415
  out(dim(` The amount you send includes bridge and network fees, so it is`));
57269
57416
  out(dim(` normally larger than the invoice.`));
@@ -57294,10 +57441,12 @@ async function cmdPay(opts) {
57294
57441
  else {
57295
57442
  printError(confirmed.payload);
57296
57443
  printMoneyWarning(confirmed.payload);
57444
+ printSupport();
57297
57445
  }
57298
57446
  return confirmed.exitCode;
57299
57447
  }
57300
57448
  const deposit = confirmed.payload.deposit;
57449
+ confirmed.payload.contactEmailProvided = Boolean(p.contactEmailProvided);
57301
57450
  if (!opts.json) {
57302
57451
  out(` ${dim(`Confirming order ${rozoPaymentId} \u2014 no second order was created.`)}`);
57303
57452
  }
@@ -57333,6 +57482,7 @@ async function cmdPay(opts) {
57333
57482
  } else if (!sent.payload.success) {
57334
57483
  printError(sent.payload);
57335
57484
  printMoneyWarning(sent.payload);
57485
+ printSupport();
57336
57486
  return sent.exitCode;
57337
57487
  } else if (opts.dryRun) {
57338
57488
  if (sent.payload.keySource) out(` ${dim(`would sign with ${sent.payload.keySource}`)}`);
@@ -57382,6 +57532,7 @@ async function cmdPay(opts) {
57382
57532
  out(` ${watched.payload.escalate ? red(watched.payload.guidance) : dim(watched.payload.guidance)}`);
57383
57533
  }
57384
57534
  printMoneyWarning(watched.payload);
57535
+ if (watched.payload.state !== "settled") printSupport();
57385
57536
  out();
57386
57537
  return watched.exitCode;
57387
57538
  }
@@ -57465,6 +57616,9 @@ async function main7() {
57465
57616
  main7().then((code) => process.exit(code ?? EXIT_OK)).catch((err) => {
57466
57617
  const payload = formatFailure(err);
57467
57618
  if (process.argv.includes("--json")) printJson(payload);
57468
- else printError(payload);
57619
+ else {
57620
+ printError(payload);
57621
+ printSupport();
57622
+ }
57469
57623
  process.exit(EXIT_ERROR);
57470
57624
  });
@@ -6,7 +6,42 @@ const require = __rozoCreateRequire(import.meta.url);
6
6
  const __filename = __rozoFileURLToPath(import.meta.url);
7
7
  const __dirname = __rozoDirname(__filename);
8
8
 
9
+ // scripts/src/lib/support.mjs
10
+ var SUPPORT = Object.freeze({
11
+ email: "hi@rozo.ai",
12
+ x: "https://x.com/ROZOai",
13
+ discord: "https://discord.gg/EfWejgTbuU"
14
+ });
15
+ var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
16
+ var CONTACT_EMAIL_MAX_LENGTH = 254;
17
+ var EMAIL_RE = /^[a-z0-9][^\s@]*@[^\s@.]+(\.[^\s@.]+)+$/;
18
+ function normalizeContactEmail(raw) {
19
+ if (raw === void 0 || raw === null) return null;
20
+ if (typeof raw !== "string") return void 0;
21
+ const email = raw.trim().toLowerCase();
22
+ if (email.length === 0) return null;
23
+ if (email.length > CONTACT_EMAIL_MAX_LENGTH) return void 0;
24
+ if (/[\x00-\x1f\x7f]/.test(email) || !EMAIL_RE.test(email)) return void 0;
25
+ return email;
26
+ }
27
+ function contactEmailAttached(email, reused) {
28
+ return Boolean(email) && !reused;
29
+ }
30
+ function contactEmailFromArgs(args) {
31
+ if (args.email === void 0) return null;
32
+ const email = args.email === true ? void 0 : normalizeContactEmail(String(args.email));
33
+ if (email === void 0) {
34
+ const err = new Error(
35
+ "--email must be a valid email address, for example name@example.com. It is optional: leave it out to continue without one."
36
+ );
37
+ err.code = "INVALID_EMAIL";
38
+ throw err;
39
+ }
40
+ return email;
41
+ }
42
+
9
43
  // scripts/src/lib/output.mjs
44
+ var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
10
45
  var EXIT_OK = 0;
11
46
  var EXIT_ERROR = 1;
12
47
  var EXIT_USAGE = 2;
@@ -17,7 +52,8 @@ function redact(text) {
17
52
  s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
18
53
  s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
19
54
  s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
20
- s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (_m, scheme, rest) => {
55
+ s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (m, scheme, rest) => {
56
+ if (PUBLIC_SUPPORT_URLS.has(m.replace(/[.,;:!?)]+$/, ""))) return m;
21
57
  const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
22
58
  const host = withoutUserinfo.split(/[/?#]/)[0];
23
59
  const hadMore = withoutUserinfo.length > host.length;
@@ -224,6 +260,14 @@ function postJson(url, body, opts) {
224
260
  return request("POST", url, { ...opts, body });
225
261
  }
226
262
 
263
+ // scripts/src/lib/utm.mjs
264
+ function normalizeUtmSource(value) {
265
+ if (typeof value !== "string") return null;
266
+ const s = value.trim().toLowerCase();
267
+ if (!s || s.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(s)) return null;
268
+ return s;
269
+ }
270
+
227
271
  // scripts/src/lib/api.mjs
228
272
  var PKG_VERSION = (() => {
229
273
  const requireFrom = createRequire(import.meta.url);
@@ -239,18 +283,26 @@ var PKG_VERSION = (() => {
239
283
  var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpprouter.dev/v1/services/rozo-agent-api";
240
284
  var CLIENT_LABEL = `rozo-checkout-cli/${PKG_VERSION}`;
241
285
  var ATTRIBUTION_CLIENT = `rozo-checkout-skill/${PKG_VERSION}`;
286
+ function buildAttribution({ utmSource } = {}) {
287
+ const raw = utmSource !== void 0 ? utmSource : process.env.ROZO_CHECKOUT_UTM_SOURCE;
288
+ const utm_source = normalizeUtmSource(raw);
289
+ return { client: ATTRIBUTION_CLIENT, ...utm_source ? { utm_source } : {} };
290
+ }
242
291
  var INTENTS_BASE = process.env.ROZO_CHECKOUT_INTENTS_BASE || "https://intentapiv4.rozo.ai/functions/v1/payment-api";
243
292
  async function quoteInvoice({ url, linkId }) {
244
293
  const body = url ? { url } : { payment_id: linkId };
245
294
  return postJson(`${MPP_BASE}/quote-invoice`, body);
246
295
  }
247
- async function createInvoice({ url, linkId, source, quoteReceipt }) {
296
+ async function createInvoice({ url, linkId, source, quoteReceipt, utmSource, email }) {
248
297
  const body = {
249
298
  ...url ? { url } : { payment_id: linkId },
250
299
  source: { chainId: String(source.chainId), tokenSymbol: source.tokenSymbol },
251
300
  ...quoteReceipt ? { quoteReceipt } : {},
252
301
  client: CLIENT_LABEL,
253
- attribution: { client: ATTRIBUTION_CLIENT }
302
+ attribution: buildAttribution({ utmSource }),
303
+ // Optional payer contact email (validated by the caller). Omitted, never
304
+ // sent empty, when the user gave none.
305
+ ...email ? { email } : {}
254
306
  };
255
307
  return postJson(`${MPP_BASE}/create-invoice`, body);
256
308
  }
@@ -1153,6 +1205,7 @@ async function main(argv) {
1153
1205
  }
1154
1206
  const requested = { chainId, tokenSymbol };
1155
1207
  const confirmed = Boolean(args.confirm);
1208
+ const email = contactEmailFromArgs(args);
1156
1209
  let blacklist;
1157
1210
  try {
1158
1211
  blacklist = loadBlacklist();
@@ -1169,7 +1222,9 @@ async function main(argv) {
1169
1222
  const created = await createInvoice({
1170
1223
  url: String(url),
1171
1224
  source: requested,
1172
- quoteReceipt
1225
+ quoteReceipt,
1226
+ utmSource: typeof args["utm-source"] === "string" ? args["utm-source"] : void 0,
1227
+ email
1173
1228
  });
1174
1229
  if (!created?.rozoPaymentId) {
1175
1230
  throw new SkillError("CREATE_FAILED", "create-invoice returned no rozoPaymentId.", {
@@ -1326,6 +1381,13 @@ async function main(argv) {
1326
1381
  success: true,
1327
1382
  step: "create-order",
1328
1383
  confirmed,
1384
+ // True only when an email went out with a NEW order. A reused order keeps
1385
+ // whatever email it was first created with (the router never rebinds), so
1386
+ // reporting true there would be a false promise. The address itself is
1387
+ // never echoed. Note the --confirm run always sees the order as reused;
1388
+ // the CLI carries the first run's value forward.
1389
+ contactEmailProvided: contactEmailAttached(email, created.reused),
1390
+ support: SUPPORT,
1329
1391
  reused: Boolean(created.reused),
1330
1392
  reusedNote: created.reused ? `An existing unpaid order for this link was reused (${rozoPaymentId}), valid for another ${formatRemaining(expiry.msRemaining)}. Nothing new was created.` : null,
1331
1393
  orderCost: "Creating an order moves no money. An order you never fund simply expires and costs nothing.",
@@ -6,7 +6,16 @@ const require = __rozoCreateRequire(import.meta.url);
6
6
  const __filename = __rozoFileURLToPath(import.meta.url);
7
7
  const __dirname = __rozoDirname(__filename);
8
8
 
9
+ // scripts/src/lib/support.mjs
10
+ var SUPPORT = Object.freeze({
11
+ email: "hi@rozo.ai",
12
+ x: "https://x.com/ROZOai",
13
+ discord: "https://discord.gg/EfWejgTbuU"
14
+ });
15
+ var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
16
+
9
17
  // scripts/src/lib/output.mjs
18
+ var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
10
19
  var EXIT_OK = 0;
11
20
  var EXIT_ERROR = 1;
12
21
  var EXIT_USAGE = 2;
@@ -17,7 +26,8 @@ function redact(text) {
17
26
  s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
18
27
  s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
19
28
  s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
20
- s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (_m, scheme, rest) => {
29
+ s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (m, scheme, rest) => {
30
+ if (PUBLIC_SUPPORT_URLS.has(m.replace(/[.,;:!?)]+$/, ""))) return m;
21
31
  const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
22
32
  const host = withoutUserinfo.split(/[/?#]/)[0];
23
33
  const hadMore = withoutUserinfo.length > host.length;
@@ -9972,7 +9972,16 @@ var init_call = __esm({
9972
9972
  }
9973
9973
  });
9974
9974
 
9975
+ // scripts/src/lib/support.mjs
9976
+ var SUPPORT = Object.freeze({
9977
+ email: "hi@rozo.ai",
9978
+ x: "https://x.com/ROZOai",
9979
+ discord: "https://discord.gg/EfWejgTbuU"
9980
+ });
9981
+ var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
9982
+
9975
9983
  // scripts/src/lib/output.mjs
9984
+ var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
9976
9985
  var EXIT_OK = 0;
9977
9986
  var EXIT_ERROR = 1;
9978
9987
  var EXIT_UNCONFIRMED = 3;
@@ -9983,7 +9992,8 @@ function redact(text) {
9983
9992
  s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
9984
9993
  s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
9985
9994
  s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
9986
- s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (_m, scheme, rest) => {
9995
+ s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (m, scheme, rest) => {
9996
+ if (PUBLIC_SUPPORT_URLS.has(m.replace(/[.,;:!?)]+$/, ""))) return m;
9987
9997
  const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
9988
9998
  const host = withoutUserinfo.split(/[/?#]/)[0];
9989
9999
  const hadMore = withoutUserinfo.length > host.length;
@@ -32143,7 +32143,16 @@ Message: ${transactionMessage}.
32143
32143
  }
32144
32144
  });
32145
32145
 
32146
+ // scripts/src/lib/support.mjs
32147
+ var SUPPORT = Object.freeze({
32148
+ email: "hi@rozo.ai",
32149
+ x: "https://x.com/ROZOai",
32150
+ discord: "https://discord.gg/EfWejgTbuU"
32151
+ });
32152
+ var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
32153
+
32146
32154
  // scripts/src/lib/output.mjs
32155
+ var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
32147
32156
  var EXIT_OK = 0;
32148
32157
  var EXIT_ERROR = 1;
32149
32158
  var EXIT_UNCONFIRMED = 3;
@@ -32154,7 +32163,8 @@ function redact(text) {
32154
32163
  s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
32155
32164
  s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
32156
32165
  s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
32157
- s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (_m, scheme, rest) => {
32166
+ s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (m, scheme, rest) => {
32167
+ if (PUBLIC_SUPPORT_URLS.has(m.replace(/[.,;:!?)]+$/, ""))) return m;
32158
32168
  const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
32159
32169
  const host = withoutUserinfo.split(/[/?#]/)[0];
32160
32170
  const hadMore = withoutUserinfo.length > host.length;
@@ -6,7 +6,16 @@ const require = __rozoCreateRequire(import.meta.url);
6
6
  const __filename = __rozoFileURLToPath(import.meta.url);
7
7
  const __dirname = __rozoDirname(__filename);
8
8
 
9
+ // scripts/src/lib/support.mjs
10
+ var SUPPORT = Object.freeze({
11
+ email: "hi@rozo.ai",
12
+ x: "https://x.com/ROZOai",
13
+ discord: "https://discord.gg/EfWejgTbuU"
14
+ });
15
+ var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
16
+
9
17
  // scripts/src/lib/output.mjs
18
+ var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
10
19
  var EXIT_OK = 0;
11
20
  var EXIT_ERROR = 1;
12
21
  var EXIT_USAGE = 2;
@@ -18,7 +27,8 @@ function redact(text) {
18
27
  s = s.replace(/\b[0-9a-fA-F]{64}\b/g, "<redacted>");
19
28
  s = s.replace(/\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g, "<redacted>");
20
29
  s = s.replace(/\[(?:\s*\d{1,3}\s*,){40,}\s*\d{1,3}\s*\]/g, "[<redacted>]");
21
- s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (_m, scheme, rest) => {
30
+ s = s.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]+)/g, (m, scheme, rest) => {
31
+ if (PUBLIC_SUPPORT_URLS.has(m.replace(/[.,;:!?)]+$/, ""))) return m;
22
32
  const withoutUserinfo = rest.includes("@") ? rest.slice(rest.indexOf("@") + 1) : rest;
23
33
  const host = withoutUserinfo.split(/[/?#]/)[0];
24
34
  const hadMore = withoutUserinfo.length > host.length;
@@ -423,7 +433,7 @@ function classifyStatus({
423
433
  }
424
434
  const exp = payment?.expiresAt ? Date.parse(payment.expiresAt) : NaN;
425
435
  if (Number.isFinite(exp) && exp < now) {
426
- return mk("expired_unfunded", "The order expired before any funds arrived. Safe to retry.", {
436
+ return mk("expired_unfunded", "The order expired before any funds arrived. This order can no longer be paid.", {
427
437
  terminal: true
428
438
  });
429
439
  }
@@ -449,7 +459,7 @@ function classifyStatus({
449
459
  case "payment_expired":
450
460
  return moneyDetected ? mk("stuck_after_payment", "Order expired AFTER funds arrived \u2014 escalate immediately.", {
451
461
  escalate: true
452
- }) : mk("expired_unfunded", "Order expired unfunded. Safe to retry.", { terminal: true });
462
+ }) : mk("expired_unfunded", "Order expired unfunded. This order can no longer be paid.", { terminal: true });
453
463
  case "payment_bounced":
454
464
  case "payment_refunded":
455
465
  return mk("stuck_after_payment", `Order ended as ${payment.status} \u2014 escalate.`, {
@@ -712,6 +722,23 @@ async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
712
722
  errors: [statusError, paymentError].filter(Boolean)
713
723
  };
714
724
  }
725
+ var EXPIRED_UNFUNDED_GUIDANCE = "This order expired before any funds arrived. First check your own wallet: if anything was sent to the old deposit address, or a Lightning payment is still pending, do not pay again; contact support with the linkId and rozoPaymentId (the router cannot see a pending Lightning payment). If nothing was sent, re-run the same pay on the same link with the same --with: it creates a new order for this link. If pay answers PAYMENT_EXPIRED with retryable: true, the old order cannot be verified yet; wait a few minutes and retry. If it answers LINK_USED_OR_EXPIRED, or PAYMENT_EXPIRED with confirmed: true, this link cannot be paid any more; get a new payment link from the merchant (OpenRouter). If it answers ORDER_ALREADY_ACTIVE, a payment was found: do not pay again; contact support.";
726
+ function statusGuidance(result) {
727
+ if (result.escalate) {
728
+ return "MONEY DETECTED and the order is not on a healthy path. Do NOT pay again and do NOT create a new order for this link. Preserve linkId, rozoPaymentId and every tx hash, then escalate to the operator for manual reconciliation.";
729
+ }
730
+ if (result.unknown) {
731
+ return "The order state could not be established. This is NOT evidence that nothing was paid \u2014 do not create a new order and do not send again on the strength of it. Retry, or pass --rozo-payment-id so the authoritative pay-in view can be read.";
732
+ }
733
+ if (!result.authoritativeView) {
734
+ return "Only the fulfilment view was readable; the pay-in view is unavailable, so the money-detected rule cannot be enforced. Pass --rozo-payment-id for a complete answer.";
735
+ }
736
+ if ((result.state === "expired_unfunded" || result.state === "invoice_expired") && result.provider === "bitrefill") {
737
+ return "Nothing was funded, so nothing was lost. Create a fresh Bitrefill invoice and run rozo-checkout pay --bitrefill-invoice <id> --to <0x\u2026> --amount <USDC> --with <coin>";
738
+ }
739
+ if (result.state === "expired_unfunded") return EXPIRED_UNFUNDED_GUIDANCE;
740
+ return result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
741
+ }
715
742
  async function main(argv) {
716
743
  const args = parseArgs(argv);
717
744
  const rozoPaymentId = args["rozo-payment-id"] || (isRozoPaymentId(args._[0]) ? args._[0] : null);
@@ -737,7 +764,7 @@ async function main(argv) {
737
764
  if (next.state !== result.state) history.push({ at: (/* @__PURE__ */ new Date()).toISOString(), state: next.state });
738
765
  result = next;
739
766
  }
740
- const guidance = result.escalate ? "MONEY DETECTED and the order is not on a healthy path. Do NOT pay again and do NOT create a new order for this link. Preserve linkId, rozoPaymentId and every tx hash, then escalate to the operator for manual reconciliation." : result.unknown ? "The order state could not be established. This is NOT evidence that nothing was paid \u2014 do not create a new order and do not send again on the strength of it. Retry, or pass --rozo-payment-id so the authoritative pay-in view can be read." : !result.authoritativeView ? "Only the fulfilment view was readable; the pay-in view is unavailable, so the money-detected rule cannot be enforced. Pass --rozo-payment-id for a complete answer." : (result.state === "expired_unfunded" || result.state === "invoice_expired") && result.provider === "bitrefill" ? "Nothing was funded, so nothing was lost. Create a fresh Bitrefill invoice and run rozo-checkout pay --bitrefill-invoice <id> --to <0x\u2026> --amount <USDC> --with <coin>" : result.state === "expired_unfunded" ? "Nothing was funded, so nothing was lost. Start a fresh order with: rozo-checkout pay <coinbase-link> --with <coin> (or create-order.js --url <link> --chain <id> --token <SYMBOL>)" : result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
767
+ const guidance = statusGuidance(result);
741
768
  const unresolved = watch && !result.terminal && !result.escalate && !result.unknown;
742
769
  const failed = result.escalate || result.unknown || !result.authoritativeView;
743
770
  emit(
@@ -747,7 +774,9 @@ async function main(argv) {
747
774
  ...result,
748
775
  history,
749
776
  guidance,
750
- timedOut: unresolved
777
+ timedOut: unresolved,
778
+ // Anything short of a clean answer gets a human contact.
779
+ ...failed || unresolved ? { support: SUPPORT } : {}
751
780
  },
752
781
  failed ? EXIT_ERROR : unresolved ? EXIT_UNCONFIRMED : 0
753
782
  );