@rozoai/checkout 0.1.12 → 0.1.14

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
 
@@ -394,6 +400,14 @@ The full list of what this refuses to do, and why, is in
394
400
 
395
401
  ## Changelog
396
402
 
403
+ - **0.1.14**: optional `--email <addr>` on `pay` (and on the create-order
404
+ scripts) stores a contact email with the order so ROZO can reach you if the
405
+ payment needs attention. Never required; an invalid address is rejected
406
+ before any order is created. Support channels (hi@rozo.ai, X, Discord) are
407
+ shown at order creation and whenever an order is not clean.
408
+ - **0.1.13**: optional `--utm-source <label>` / `ROZO_CHECKOUT_UTM_SOURCE`
409
+ adds `attribution.utm_source` to created orders so distribution channels can
410
+ be measured. Invalid values are dropped, never failing an order.
397
411
  - **0.1.12**: pay Bitrefill invoices. `pay --bitrefill-invoice <id> --to <0x…>
398
412
  --amount <USDC> --expires-at <ISO>` creates a Rozo exactOut order through
399
413
  mpprouter that delivers the exact USDC on Base straight to the Bitrefill
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.
@@ -162,4 +168,11 @@ private key (EVM and Solana only); every other path is keyless.
162
168
 
163
169
  409 = link already used · 410 = link expired · UNSUPPORTED_SOURCE = the response's
164
170
  "supported" object lists valid (chainId, tokenSymbol) pairs (Lightning is supported
165
- but omitted from that list) · RATE_LIMITED = per-IP creation cap, retry later.
171
+ but omitted from that list) · RATE_LIMITED = per-IP creation cap, retry later ·
172
+ INVALID_EMAIL = the optional email is malformed; fix it or omit it.
173
+
174
+ ## Support
175
+
176
+ If an order is stuck or anything goes wrong: email hi@rozo.ai, X
177
+ https://x.com/ROZOai, Discord https://discord.gg/EfWejgTbuU. Include linkId,
178
+ 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.14",
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
  }
@@ -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.",
@@ -55901,7 +56012,9 @@ async function main4(argv) {
55901
56012
  ...result,
55902
56013
  history,
55903
56014
  guidance,
55904
- timedOut: unresolved
56015
+ timedOut: unresolved,
56016
+ // Anything short of a clean answer gets a human contact.
56017
+ ...failed || unresolved ? { support: SUPPORT } : {}
55905
56018
  },
55906
56019
  failed ? EXIT_ERROR : unresolved ? EXIT_UNCONFIRMED : 0
55907
56020
  );
@@ -56971,6 +57084,9 @@ function printError(payload) {
56971
57084
  if (e.message) out(` ${e.message}`);
56972
57085
  if (payload?.guidance) out(` ${yellow(payload.guidance)}`);
56973
57086
  }
57087
+ function printSupport() {
57088
+ out(` ${dim(SUPPORT_TEXT)}`);
57089
+ }
56974
57090
  function printMoneyWarning(payload) {
56975
57091
  if (!payload?.moneyDetected) return;
56976
57092
  out();
@@ -57119,6 +57235,7 @@ async function cmdStatus(opts) {
57119
57235
  }
57120
57236
  if (payload.error) {
57121
57237
  printError(payload);
57238
+ printSupport();
57122
57239
  return exitCode;
57123
57240
  }
57124
57241
  out();
@@ -57130,6 +57247,7 @@ async function cmdStatus(opts) {
57130
57247
  if (payload.payin?.txHash) out(` Pay-in ${payload.payin.txHash}`);
57131
57248
  if (payload.guidance) out(` ${payload.escalate ? red(payload.guidance) : dim(payload.guidance)}`);
57132
57249
  printMoneyWarning(payload);
57250
+ if (payload.support) printSupport();
57133
57251
  out();
57134
57252
  return exitCode;
57135
57253
  }
@@ -57204,6 +57322,10 @@ async function cmdPay(opts) {
57204
57322
  }
57205
57323
  const { chainId, tokenSymbol } = opts.source;
57206
57324
  const createFlow = opts.bitrefill ? run3 : run2;
57325
+ const utmArgs = [
57326
+ ...opts.utmSource ? ["--utm-source", opts.utmSource] : [],
57327
+ ...opts.email ? ["--email", opts.email] : []
57328
+ ];
57207
57329
  const baseArgs = opts.bitrefill ? [
57208
57330
  "--invoice-id",
57209
57331
  opts.bitrefill.invoiceId,
@@ -57215,8 +57337,9 @@ async function cmdPay(opts) {
57215
57337
  "--chain",
57216
57338
  chainId,
57217
57339
  "--token",
57218
- tokenSymbol
57219
- ] : ["--url", opts.target, "--chain", chainId, "--token", tokenSymbol];
57340
+ tokenSymbol,
57341
+ ...utmArgs
57342
+ ] : ["--url", opts.target, "--chain", chainId, "--token", tokenSymbol, ...utmArgs];
57220
57343
  if (opts.send) {
57221
57344
  const family = chainFamily(chainId);
57222
57345
  if (family === "evm" || family === "solana") {
@@ -57235,6 +57358,7 @@ async function cmdPay(opts) {
57235
57358
  else {
57236
57359
  printError(created.payload);
57237
57360
  printMoneyWarning(created.payload);
57361
+ printSupport();
57238
57362
  }
57239
57363
  return created.exitCode;
57240
57364
  }
@@ -57264,6 +57388,12 @@ async function cmdPay(opts) {
57264
57388
  p.reused ? ` ${dim(`Reusing existing unpaid order ${p.rozoPaymentId} \u2014 nothing new was created.`)}` : ` ${dim(`Order created: ${p.rozoPaymentId}`)}`
57265
57389
  );
57266
57390
  out(` ${dim("An order you never fund simply expires and costs nothing.")}`);
57391
+ if (opts.email) {
57392
+ out(
57393
+ 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.`)}`
57394
+ );
57395
+ }
57396
+ printSupport();
57267
57397
  out();
57268
57398
  out(dim(` The amount you send includes bridge and network fees, so it is`));
57269
57399
  out(dim(` normally larger than the invoice.`));
@@ -57294,10 +57424,12 @@ async function cmdPay(opts) {
57294
57424
  else {
57295
57425
  printError(confirmed.payload);
57296
57426
  printMoneyWarning(confirmed.payload);
57427
+ printSupport();
57297
57428
  }
57298
57429
  return confirmed.exitCode;
57299
57430
  }
57300
57431
  const deposit = confirmed.payload.deposit;
57432
+ confirmed.payload.contactEmailProvided = Boolean(p.contactEmailProvided);
57301
57433
  if (!opts.json) {
57302
57434
  out(` ${dim(`Confirming order ${rozoPaymentId} \u2014 no second order was created.`)}`);
57303
57435
  }
@@ -57333,6 +57465,7 @@ async function cmdPay(opts) {
57333
57465
  } else if (!sent.payload.success) {
57334
57466
  printError(sent.payload);
57335
57467
  printMoneyWarning(sent.payload);
57468
+ printSupport();
57336
57469
  return sent.exitCode;
57337
57470
  } else if (opts.dryRun) {
57338
57471
  if (sent.payload.keySource) out(` ${dim(`would sign with ${sent.payload.keySource}`)}`);
@@ -57382,6 +57515,7 @@ async function cmdPay(opts) {
57382
57515
  out(` ${watched.payload.escalate ? red(watched.payload.guidance) : dim(watched.payload.guidance)}`);
57383
57516
  }
57384
57517
  printMoneyWarning(watched.payload);
57518
+ if (watched.payload.state !== "settled") printSupport();
57385
57519
  out();
57386
57520
  return watched.exitCode;
57387
57521
  }
@@ -57465,6 +57599,9 @@ async function main7() {
57465
57599
  main7().then((code) => process.exit(code ?? EXIT_OK)).catch((err) => {
57466
57600
  const payload = formatFailure(err);
57467
57601
  if (process.argv.includes("--json")) printJson(payload);
57468
- else printError(payload);
57602
+ else {
57603
+ printError(payload);
57604
+ printSupport();
57605
+ }
57469
57606
  process.exit(EXIT_ERROR);
57470
57607
  });
@@ -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;
@@ -747,7 +757,9 @@ async function main(argv) {
747
757
  ...result,
748
758
  history,
749
759
  guidance,
750
- timedOut: unresolved
760
+ timedOut: unresolved,
761
+ // Anything short of a clean answer gets a human contact.
762
+ ...failed || unresolved ? { support: SUPPORT } : {}
751
763
  },
752
764
  failed ? EXIT_ERROR : unresolved ? EXIT_UNCONFIRMED : 0
753
765
  );