@certen.io/cli 0.5.0 → 0.7.1

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +431 -0
  2. package/README.md +232 -160
  3. package/dist/chains.d.ts +67 -0
  4. package/dist/chains.js +242 -0
  5. package/dist/chains.js.map +1 -0
  6. package/dist/commands/admin.js +49 -4
  7. package/dist/commands/admin.js.map +1 -1
  8. package/dist/commands/auth.js +145 -12
  9. package/dist/commands/auth.js.map +1 -1
  10. package/dist/commands/billing.js +648 -40
  11. package/dist/commands/billing.js.map +1 -1
  12. package/dist/commands/call.d.ts +2 -0
  13. package/dist/commands/call.js +168 -0
  14. package/dist/commands/call.js.map +1 -0
  15. package/dist/commands/chains.d.ts +2 -0
  16. package/dist/commands/chains.js +123 -0
  17. package/dist/commands/chains.js.map +1 -0
  18. package/dist/commands/doctor.d.ts +2 -0
  19. package/dist/commands/doctor.js +171 -0
  20. package/dist/commands/doctor.js.map +1 -0
  21. package/dist/commands/errors-catalogue.d.ts +2 -0
  22. package/dist/commands/errors-catalogue.js +83 -0
  23. package/dist/commands/errors-catalogue.js.map +1 -0
  24. package/dist/commands/identity.js +269 -19
  25. package/dist/commands/identity.js.map +1 -1
  26. package/dist/commands/init.d.ts +2 -0
  27. package/dist/commands/init.js +307 -0
  28. package/dist/commands/init.js.map +1 -0
  29. package/dist/commands/keys.js +2 -0
  30. package/dist/commands/keys.js.map +1 -1
  31. package/dist/commands/oauth-clients.d.ts +2 -0
  32. package/dist/commands/oauth-clients.js +131 -0
  33. package/dist/commands/oauth-clients.js.map +1 -0
  34. package/dist/commands/orgs.d.ts +2 -0
  35. package/dist/commands/orgs.js +117 -0
  36. package/dist/commands/orgs.js.map +1 -0
  37. package/dist/commands/pending.d.ts +19 -0
  38. package/dist/commands/pending.js +117 -15
  39. package/dist/commands/pending.js.map +1 -1
  40. package/dist/commands/portfolio.js +34 -1
  41. package/dist/commands/portfolio.js.map +1 -1
  42. package/dist/commands/proof.d.ts +2 -0
  43. package/dist/commands/proof.js +400 -0
  44. package/dist/commands/proof.js.map +1 -0
  45. package/dist/commands/signup.d.ts +14 -0
  46. package/dist/commands/signup.js +271 -0
  47. package/dist/commands/signup.js.map +1 -0
  48. package/dist/commands/transaction.js +139 -21
  49. package/dist/commands/transaction.js.map +1 -1
  50. package/dist/commands/webhooks.d.ts +2 -0
  51. package/dist/commands/webhooks.js +200 -0
  52. package/dist/commands/webhooks.js.map +1 -0
  53. package/dist/commands/whoami.d.ts +16 -0
  54. package/dist/commands/whoami.js +98 -0
  55. package/dist/commands/whoami.js.map +1 -0
  56. package/dist/config.d.ts +56 -0
  57. package/dist/config.js +51 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/errors.d.ts +14 -1
  60. package/dist/errors.js +15 -1
  61. package/dist/errors.js.map +1 -1
  62. package/dist/funding-guard.d.ts +40 -0
  63. package/dist/funding-guard.js +120 -0
  64. package/dist/funding-guard.js.map +1 -0
  65. package/dist/help-root.d.ts +2 -0
  66. package/dist/help-root.js +94 -0
  67. package/dist/help-root.js.map +1 -0
  68. package/dist/index.js +27 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/output.d.ts +23 -0
  71. package/dist/output.js +46 -0
  72. package/dist/output.js.map +1 -1
  73. package/dist/passphrase.d.ts +18 -0
  74. package/dist/passphrase.js +35 -3
  75. package/dist/passphrase.js.map +1 -1
  76. package/dist/payment-uri.d.ts +76 -0
  77. package/dist/payment-uri.js +135 -0
  78. package/dist/payment-uri.js.map +1 -0
  79. package/dist/signer.js +6 -3
  80. package/dist/signer.js.map +1 -1
  81. package/dist/solidity-args.d.ts +31 -0
  82. package/dist/solidity-args.js +111 -0
  83. package/dist/solidity-args.js.map +1 -0
  84. package/dist/wait.d.ts +79 -0
  85. package/dist/wait.js +168 -0
  86. package/dist/wait.js.map +1 -0
  87. package/package.json +5 -4
@@ -1,7 +1,9 @@
1
- import { CertenClient } from '@certen.io/sdk';
2
- import { getApiKey, getApiUrl } from '../config.js';
3
- import { printOutput, human, hint, isJsonMode } from '../output.js';
1
+ import { CertenClient, CertenError } from '@certen.io/sdk';
2
+ import { getApiKey, getApiUrl, getPortalUrl, getOutputFormat } from '../config.js';
3
+ import { printOutput, human, hint, isJsonMode, usd } from '../output.js';
4
4
  import { CliError, EXIT } from '../errors.js';
5
+ import { assertChain } from '../chains.js';
6
+ import { buildPaymentUri, estimateWait, preciseDuration } from '../payment-uri.js';
5
7
  /**
6
8
  * Money commands.
7
9
  *
@@ -17,11 +19,13 @@ import { CliError, EXIT } from '../errors.js';
17
19
  async function getClient() {
18
20
  return new CertenClient({ apiKey: await getApiKey(), baseUrl: getApiUrl() });
19
21
  }
20
- /** "12.340000" -> "$12.34". Trims the string; never parses money into a float. */
21
- function usd(amount) {
22
- const [whole, frac = ''] = String(amount).split('.');
23
- const neg = whole.startsWith('-');
24
- return `${neg ? '-$' : '$'}${neg ? whole.slice(1) : whole}.${(frac + '00').slice(0, 2)}`;
22
+ /** Parse a whole-number option, refusing anything else before a network call is made. */
23
+ function intOption(raw, flag) {
24
+ const n = Number(raw);
25
+ if (!Number.isInteger(n) || n < 0) {
26
+ throw new CliError(`"${raw}" is not a whole number for ${flag}.`, 'INVALID_NUMBER', EXIT.USAGE);
27
+ }
28
+ return n;
25
29
  }
26
30
  function minutesUntil(iso) {
27
31
  return Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
@@ -32,51 +36,552 @@ export function registerBillingCommands(program) {
32
36
  .description('Show your balance and what pending work has already claimed')
33
37
  .action(async () => {
34
38
  const client = await getClient();
35
- // Both, because either alone misleads: the balance looks healthy while
36
- // every cent of it is committed to intents awaiting quorum.
37
- const [balance, obligations] = await Promise.all([
38
- client.billing.balance(),
39
- client.billing.obligations(),
40
- ]);
41
- printOutput({
42
- currency: balance.currency,
43
- available_usd: balance.available_usd,
44
- held_usd: balance.held_usd,
45
- credit_limit_usd: balance.credit_limit_usd,
46
- spendable_usd: balance.spendable_usd,
47
- remaining_usd: obligations.remaining_usd,
48
- pending_intents: obligations.pending_intents,
49
- uncovered_usd: obligations.uncovered_usd,
50
- status: balance.status,
51
- });
52
- if (isJsonMode())
39
+ // One call. The balance carries `remaining_usd` spendable minus what pending intents will
40
+ // consume because either number alone misleads: the balance looks healthy while every cent
41
+ // of it is committed to intents awaiting quorum. This used to be two concurrent requests, and
42
+ // it still falls back to the second one against a gateway that does not send the field yet,
43
+ // rather than reporting the flattering number as if it were safe.
44
+ const balance = await client.billing.balance();
45
+ const commitments = balance.remaining_usd !== undefined
46
+ ? {
47
+ remaining_usd: balance.remaining_usd,
48
+ pending_intents: balance.pending_intents ?? 0,
49
+ uncovered_usd: balance.uncovered_usd ?? '0.000000',
50
+ }
51
+ : await client.billing.obligations();
52
+ // Machine output only. In table mode this printed the whole payload AND the readable summary
53
+ // below it, so `certen balance` answered with the same figures twice — and `credit` is a
54
+ // nested object, which the generic key/value table renders as one line of raw JSON. The
55
+ // useful rendering came second, under eleven lines of noise. Every other command in this
56
+ // group early-returns for machines and then speaks to a person; this one did not.
57
+ if (isJsonMode() || getOutputFormat() === 'json') {
58
+ printOutput({
59
+ currency: balance.currency,
60
+ available_usd: balance.available_usd,
61
+ held_usd: balance.held_usd,
62
+ credit_limit_usd: balance.credit_limit_usd,
63
+ spendable_usd: balance.spendable_usd,
64
+ remaining_usd: commitments.remaining_usd,
65
+ pending_intents: commitments.pending_intents,
66
+ uncovered_usd: commitments.uncovered_usd,
67
+ status: balance.status,
68
+ suspended_reason: balance.suspended_reason ?? null,
69
+ credit: balance.credit ?? null,
70
+ });
53
71
  return;
72
+ }
73
+ // A negative `available` on a credit account is normal — it is the drawdown, not an error —
74
+ // but it reads as alarming with nothing beside it, and "Available -$72.35" is the first line
75
+ // anyone sees. Naming it as drawn-down turns a scare into a fact.
76
+ const drawn = Number(balance.available_usd) < 0;
54
77
  human('');
55
- human(` Available ${usd(balance.available_usd)}`);
56
- human(` Held for in-flight ${usd(balance.held_usd)}`);
78
+ if (drawn) {
79
+ human(` Drawn on credit ${usd(String(-Number(balance.available_usd)))}`);
80
+ }
81
+ else {
82
+ human(` Available ${usd(balance.available_usd)}`);
83
+ }
84
+ if (balance.held_usd !== '0.000000') {
85
+ human(` Held for in-flight ${usd(balance.held_usd)}`);
86
+ }
57
87
  if (balance.credit_limit_usd !== '0.000000') {
58
88
  human(` Credit line ${usd(balance.credit_limit_usd)}`);
59
89
  }
60
90
  human(` Spendable ${usd(balance.spendable_usd)}`);
61
- human(` Left to commit ${usd(obligations.remaining_usd)}`);
91
+ human(` Left to commit ${usd(commitments.remaining_usd)}`);
62
92
  human('');
63
- if (obligations.pending_intents > 0) {
64
- human(` ${obligations.pending_intents} pending intent(s) have claimed `
65
- + `${usd(obligations.uncovered_usd)} of that.`);
93
+ if (commitments.pending_intents > 0) {
94
+ human(` ${commitments.pending_intents} pending intent(s) have claimed `
95
+ + `${usd(commitments.uncovered_usd)} of that.`);
66
96
  }
67
- if (Number(obligations.remaining_usd) <= 0) {
97
+ if (Number(commitments.remaining_usd) <= 0) {
68
98
  human(' You cannot start new work until you add funds.');
69
99
  hint('certen fund <amount> --chain <chain>');
70
100
  }
101
+ // The two numbers an autonomous caller needs and could not previously see:
102
+ // when a trial ends, and the drawdown at which work stops being accepted.
103
+ // Publishing the threshold is what makes it possible to top up BEFORE
104
+ // being cut off instead of discovering it through a refusal.
105
+ const credit = balance.credit;
106
+ if (credit && credit.kind !== 'none') {
107
+ human('');
108
+ if (credit.expired) {
109
+ human(` Your ${credit.kind} credit has EXPIRED (was ${usd(credit.granted_limit_usd)}).`);
110
+ }
111
+ else if (credit.expires_at) {
112
+ const days = Math.max(0, Math.round((new Date(credit.expires_at).getTime() - Date.now()) / 86_400_000));
113
+ human(` ${credit.label ?? credit.kind} — ${usd(credit.granted_limit_usd)}, ends in ${days} day(s).`);
114
+ }
115
+ else {
116
+ human(` ${credit.label ?? credit.kind} — ${usd(credit.granted_limit_usd)} credit line.`);
117
+ }
118
+ // The thresholds were published without the one number they are measured against, so the
119
+ // reader had to find `available_usd` further up, negate it, and compare by hand — on the
120
+ // question of whether their service is about to stop. Stated as a distance instead.
121
+ const drawnNow = Math.max(0, -Number(balance.available_usd));
122
+ const stopsAt = Number(credit.suspends_at_usd);
123
+ const warnsAt = Number(credit.warns_at_usd);
124
+ const headroom = stopsAt - drawnNow;
125
+ if (drawnNow >= stopsAt) {
126
+ human(` You have drawn ${usd(String(drawnNow))} of ${usd(credit.suspends_at_usd)} — service stops here.`);
127
+ hint('certen fund <amount> --chain base-sepolia');
128
+ }
129
+ else if (drawnNow >= warnsAt) {
130
+ human(` Drawn ${usd(String(drawnNow))} of ${usd(credit.suspends_at_usd)}. `
131
+ + `${usd(String(headroom))} before service stops.`);
132
+ hint('certen fund <amount> --chain base-sepolia');
133
+ }
134
+ else {
135
+ human(` Drawn ${usd(String(drawnNow))} of ${usd(credit.suspends_at_usd)} `
136
+ + `(first warning at ${usd(credit.warns_at_usd)}).`);
137
+ }
138
+ }
71
139
  if (balance.status !== 'active') {
72
140
  human(` Account status: ${balance.status}.`);
141
+ if (balance.suspended_reason)
142
+ human(` Reason: ${balance.suspended_reason}.`);
143
+ hint('certen fund <amount> --chain base-sepolia');
73
144
  }
74
145
  });
146
+ program
147
+ .command('pricing')
148
+ .description('Everything CERTEN charges for, and what it costs')
149
+ .option('--chain <chain>', 'Only operations priced on this chain (plus the "*" fallback)')
150
+ .action(async (opts) => {
151
+ // The price list had no client surface at all. `certen quote` prices ONE operation and needs
152
+ // its sku spelled correctly up front — and the names are not guessable: it is
153
+ // `identity.provision`, not `identity.create`. Anyone asking "what does onboarding cost"
154
+ // had to guess a name, read the refusal, and guess again.
155
+ const chain = opts.chain ? assertChain(opts.chain) : undefined;
156
+ const client = await getClient();
157
+ const book = await client.billing.pricing();
158
+ // Filtering to a chain RESOLVES the fallback rather than listing both candidates, because
159
+ // the price book resolves it: a sku with an entry for this chain is charged at that entry,
160
+ // and "*" applies only where there is none. Listing both showed proof.execute at $0.50 (*)
161
+ // and $0.35 (base-sepolia) side by side — two prices for one operation on one chain, with
162
+ // nothing on screen to say which one you would actually be billed.
163
+ const items = chain
164
+ ? book.items.filter((i) => (i.chain === chain
165
+ || (i.chain === '*' && !book.items.some((j) => j.sku === i.sku && j.chain === chain))))
166
+ : book.items;
167
+ // Machine output carries the version and hash alongside the items, because a price is only
168
+ // traceable to a charge with them. Human output does NOT go through printOutput: this payload
169
+ // has a nested array, and the generic key/value table renders it as one line of raw JSON.
170
+ if (isJsonMode() || getOutputFormat() === 'json') {
171
+ printOutput({
172
+ price_book_version: book.price_book_version,
173
+ price_book_hash: book.price_book_hash,
174
+ currency: book.currency,
175
+ items,
176
+ });
177
+ return;
178
+ }
179
+ if (items.length === 0) {
180
+ human('');
181
+ human(` Nothing is priced on ${chain}.`);
182
+ hint('certen pricing # every chain');
183
+ return;
184
+ }
185
+ const w = Math.max(...items.map((i) => i.sku.length), 3);
186
+ const c = Math.max(...items.map((i) => i.chain.length), 5);
187
+ human('');
188
+ human(` ${'SKU'.padEnd(w)} ${'CHAIN'.padEnd(c)} PRICE`);
189
+ for (const i of items) {
190
+ // The distinction that decides whether the printed number is the answer or a floor:
191
+ // `flat` is all-in, `quoted` measures gas at execution and adds it.
192
+ const price = i.mode === 'flat'
193
+ ? usd(i.platform_fee_usd)
194
+ : `${usd(i.platform_fee_usd)} + gas`;
195
+ human(` ${i.sku.padEnd(w)} ${i.chain.padEnd(c)} ${price}`);
196
+ }
197
+ human('');
198
+ if (items.some((i) => i.mode === 'quoted')) {
199
+ human(' "+ gas" is priced at execution from live chain conditions.');
200
+ }
201
+ if (items.some((i) => i.chain === '*')) {
202
+ human(' "*" applies to any chain without an entry of its own.');
203
+ }
204
+ human(` Price book ${book.price_book_version}.`);
205
+ hint('certen quote --chain <chain> --sku <sku> # a binding price for real work');
206
+ });
207
+ /**
208
+ * One renderer for both a freshly-issued quote and one read back later.
209
+ *
210
+ * The shape is identical on the wire, so printing them differently would only teach a reader that
211
+ * the two are different things when the whole point is that they are the same price.
212
+ */
213
+ function renderQuote(q, remainingSeconds) {
214
+ printOutput({
215
+ quote_id: q.quote_id,
216
+ chain: q.chain,
217
+ proof_class: q.proof_class,
218
+ leg_count: q.leg_count,
219
+ platform_fee_usd: q.platform_fee_usd,
220
+ gas_usd: q.gas_usd,
221
+ total_usd: q.total_usd,
222
+ max_total_usd: q.max_total_usd,
223
+ expires_at: q.expires_at,
224
+ status: q.status ?? null,
225
+ seconds_remaining: remainingSeconds ?? null,
226
+ gas_estimate_basis: q.computation?.gas_estimate_basis ?? null,
227
+ });
228
+ if (isJsonMode())
229
+ return;
230
+ human('');
231
+ human(` ${q.chain} ${q.proof_class ?? 'unclassified'} ${q.leg_count} leg(s)`);
232
+ human('');
233
+ human(` Platform fee ${usd(q.platform_fee_usd)}`);
234
+ human(` Gas ${usd(q.gas_usd)}`);
235
+ human(` Total ${usd(q.total_usd)}`);
236
+ human(` Capped at ${usd(q.max_total_usd)} (gas above this is on us)`);
237
+ human('');
238
+ // A thin basis is a real caveat, not a footnote: it means the median
239
+ // behind this gas figure rests on very few observations and can move
240
+ // materially. Saying so is the difference between a price and a guess
241
+ // presented as a price.
242
+ if (q.computation?.gas_estimate_basis === 'class_thin') {
243
+ human(' Note: this gas estimate comes from a small sample for this proof class,');
244
+ human(' so it may move as more of this class executes.');
245
+ human('');
246
+ }
247
+ else if (q.computation?.gas_estimate_basis === 'unclassified_fallback') {
248
+ human(' Note: no cost history for this proof class yet — priced from mixed history.');
249
+ human('');
250
+ }
251
+ // Whether the price is still usable is the entire reason to read a quote back, so it is
252
+ // answered rather than left as a status string and a timestamp to compare by hand.
253
+ const expired = q.expires_at ? Date.parse(q.expires_at) <= Date.now() : false;
254
+ const spent = q.status !== undefined && q.status !== 'active';
255
+ if (spent) {
256
+ human(` This quote is "${q.status}" — it can no longer be used. Ask for a new one.`);
257
+ hint(`certen quote --chain ${q.chain}${q.sku ? ` --sku ${q.sku}` : ''}`);
258
+ return;
259
+ }
260
+ if (expired) {
261
+ human(` Expired at ${q.expires_at}. Ask for a new one.`);
262
+ hint(`certen quote --chain ${q.chain}${q.sku ? ` --sku ${q.sku}` : ''}`);
263
+ return;
264
+ }
265
+ // A duration, not an instant. Someone reading a quote back is deciding whether to act NOW, and
266
+ // "expires 2026-08-17T04:51:12Z" makes them do arithmetic to find out.
267
+ if (remainingSeconds !== null && remainingSeconds !== undefined) {
268
+ human(` Valid for another ${preciseDuration(remainingSeconds)}.`);
269
+ human('');
270
+ }
271
+ hint(`Lock this price: pass quote_id=${q.quote_id} on the transaction (expires ${q.expires_at}).`);
272
+ }
273
+ program
274
+ .command('quote')
275
+ .description('What a piece of work will cost, before you commit to it')
276
+ .option('--chain <chain>', 'Chain the work executes on, e.g. base-sepolia')
277
+ .option('--id <quote-id>', 'Read back a quote you already have, instead of asking for a new one')
278
+ .option('--sku <sku>', 'Operation to price, e.g. identity.provision. See: certen pricing')
279
+ .option('--proof-class <class>', 'on_cadence (batched, cheaper) or on_demand (immediate)', 'on_cadence')
280
+ .option('--legs <n>', 'Number of legs in the intent', '1')
281
+ .action(async (opts) => {
282
+ // Reading a quote back is the same question as asking for one — "what does this cost, and
283
+ // may I still use this price?" — so it is the same command rather than a second one to
284
+ // discover. Without it a caller who priced work, got interrupted, and came back had to
285
+ // either guess whether their quote was still good or burn it and let the price move.
286
+ if (opts.id) {
287
+ if (opts.chain) {
288
+ throw new CliError('--id reads an existing quote and --chain asks for a new one. Pass one or the other.', 'CONFLICTING_QUOTE_ARGS', EXIT.USAGE);
289
+ }
290
+ const existing = await (await getClient()).billing.quoteById(opts.id);
291
+ renderQuote(existing, existing.seconds_remaining);
292
+ return;
293
+ }
294
+ if (!opts.chain) {
295
+ throw new CliError('Pass --chain to price new work, or --id to read back a quote you already have.', 'MISSING_CHAIN', EXIT.USAGE);
296
+ }
297
+ if (opts.proofClass !== 'on_cadence' && opts.proofClass !== 'on_demand') {
298
+ throw new CliError(`"${opts.proofClass}" is not a proof class. Use on_cadence or on_demand.`, 'INVALID_PROOF_CLASS', EXIT.USAGE);
299
+ }
300
+ const legs = Number(opts.legs);
301
+ if (!Number.isInteger(legs) || legs < 1) {
302
+ throw new CliError(`"${opts.legs}" is not a leg count. Use a whole number of 1 or more.`, 'INVALID_LEG_COUNT', EXIT.USAGE);
303
+ }
304
+ const chain = assertChain(opts.chain);
305
+ const client = await getClient();
306
+ const q = await client.billing.quote({
307
+ chain,
308
+ sku: opts.sku,
309
+ proofClass: opts.proofClass,
310
+ legCount: legs,
311
+ });
312
+ renderQuote(q);
313
+ });
314
+ // ---- Evidence -----------------------------------------------------------------------------
315
+ //
316
+ // "What was I charged, and can I prove it?" The gateway has answered this from the start --
317
+ // signed receipts, transparency-log inclusion proofs, an append-only double-entry ledger -- and
318
+ // no command reached any of it. An audit or finance function had to hand-roll HTTP, which for
319
+ // most of them means the evidence may as well not exist.
320
+ program
321
+ .command('ledger')
322
+ .description('Every balance change, newest first - where the money went')
323
+ .option('--limit <n>', 'How many to fetch (page size with --all)', '50')
324
+ .option('--offset <n>', 'Skip this many')
325
+ .option('--all', 'Fetch every page, not just the first')
326
+ .action(async (opts) => {
327
+ const limit = intOption(opts.limit, '--limit');
328
+ const offset = opts.offset === undefined ? undefined : intOption(opts.offset, '--offset');
329
+ if (opts.all && offset !== undefined) {
330
+ throw new CliError('--all starts from the beginning, so --offset has no meaning with it. Use one or the other.', 'CONFLICTING_PAGING_FLAGS', EXIT.USAGE);
331
+ }
332
+ const client = await getClient();
333
+ const entries = [];
334
+ if (opts.all) {
335
+ // --limit is the PAGE size here, not a cap: the point of --all is to stop thinking about
336
+ // page boundaries.
337
+ for await (const e of client.billing.ledgerAll(limit))
338
+ entries.push(e);
339
+ }
340
+ else {
341
+ entries.push(...(await client.billing.ledger({ limit, offset })).entries);
342
+ }
343
+ if (isJsonMode() || getOutputFormat() === 'json') {
344
+ printOutput({ entries });
345
+ return;
346
+ }
347
+ human('');
348
+ if (entries.length === 0) {
349
+ human(' No ledger entries - nothing has moved on this account yet.');
350
+ return;
351
+ }
352
+ const kw = Math.max(...entries.map((e) => e.kind.length), 4);
353
+ const aw = Math.max(...entries.map((e) => e.account.length), 7);
354
+ human(` ${'WHEN'.padEnd(20)} ${'KIND'.padEnd(kw)} ${'ACCOUNT'.padEnd(aw)} ${'AMOUNT'.padStart(10)}`);
355
+ for (const e of entries) {
356
+ human(` ${e.created_at.slice(0, 19).replace('T', ' ').padEnd(20)} `
357
+ + `${e.kind.padEnd(kw)} ${e.account.padEnd(aw)} ${usd(e.amount_usd).padStart(10)}`
358
+ + `${e.memo ? ` ${e.memo}` : ''}`);
359
+ }
360
+ human('');
361
+ human(` ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}. `
362
+ + 'Corrections appear as new reversing entries, never as edits.');
363
+ });
364
+ const receipts = program
365
+ .command('receipts')
366
+ .description('Signed receipts for every charge, payment, refund and adjustment');
367
+ receipts
368
+ .command('list', { isDefault: true })
369
+ .description('List receipts, newest first')
370
+ .option('--limit <n>', 'How many to fetch (page size with --all)', '50')
371
+ .option('--offset <n>', 'Skip this many')
372
+ .option('--all', 'Fetch every page, not just the first')
373
+ .action(async (opts) => {
374
+ const limit = intOption(opts.limit, '--limit');
375
+ const offset = opts.offset === undefined ? undefined : intOption(opts.offset, '--offset');
376
+ if (opts.all && offset !== undefined) {
377
+ throw new CliError('--all starts from the beginning, so --offset has no meaning with it. Use one or the other.', 'CONFLICTING_PAGING_FLAGS', EXIT.USAGE);
378
+ }
379
+ const client = await getClient();
380
+ const receipts = [];
381
+ if (opts.all) {
382
+ for await (const r of client.billing.receiptsAll(limit))
383
+ receipts.push(r);
384
+ }
385
+ else {
386
+ receipts.push(...(await client.billing.receipts({ limit, offset })).receipts);
387
+ }
388
+ if (isJsonMode() || getOutputFormat() === 'json') {
389
+ printOutput({ receipts });
390
+ return;
391
+ }
392
+ human('');
393
+ if (receipts.length === 0) {
394
+ human(' No receipts yet - nothing has been charged or paid on this account.');
395
+ return;
396
+ }
397
+ human(` ${'NUMBER'.padStart(8)} ${'WHEN'.padEnd(20)} ${'TYPE'.padEnd(12)} ${'AMOUNT'.padStart(10)} EVIDENCE`);
398
+ for (const r of receipts) {
399
+ // `signed` and `logged` decide what can be proven, and `logged` in particular decides
400
+ // whether an inclusion proof can be fetched at all.
401
+ const evidence = [r.signed ? 'signed' : null, r.logged ? 'logged' : null]
402
+ .filter(Boolean).join(' + ') || 'pending';
403
+ human(` ${r.receipt_number.padStart(8)} ${r.issued_at.slice(0, 19).replace('T', ' ').padEnd(20)} `
404
+ + `${r.type.padEnd(12)} ${usd(r.amount_usd).padStart(10)} ${evidence}`);
405
+ }
406
+ human('');
407
+ hint('certen receipts get <id> --proof # the full receipt and its inclusion proof');
408
+ });
409
+ program
410
+ .command('verify <receipt-id>')
411
+ .description('Check a receipt yourself, against independently published data')
412
+ .action(async (id) => {
413
+ // The receipt already ships a `verification` block, and it is CERTEN checking CERTEN. This
414
+ // command is the difference between being told the evidence is good and confirming it: the
415
+ // digest is recomputed from the body, the signature is checked against the PUBLISHED key set,
416
+ // and the audit path is folded and compared against a tree head fetched separately - not
417
+ // against the root that travelled inside the proof, which would compare the proof to itself.
418
+ const client = await getClient();
419
+ const report = await client.billing.verifyReceipt(id);
420
+ // Emitted as a success envelope only when it actually verified. Anything else throws below
421
+ // and carries the same checks under `error.details`, matching `certen doctor`: the machine
422
+ // interface never has to choose between knowing something is wrong and knowing what.
423
+ //
424
+ // JSON only in this block. The table renderer serialises `checks` to one unreadable line, and
425
+ // the rendered report below is the human answer.
426
+ if (report.verified && (isJsonMode() || getOutputFormat() === 'json')) {
427
+ printOutput({ ...report });
428
+ return;
429
+ }
430
+ human('');
431
+ for (const c of report.checks) {
432
+ const mark = c.status === 'ok' ? 'PASS' : c.status === 'failed' ? 'FAIL' : 'SKIP';
433
+ human(` ${mark} ${c.name.padEnd(10)} ${c.detail}`);
434
+ }
435
+ human('');
436
+ if (report.verified) {
437
+ human(' Verified. Every check reproduced from published data.');
438
+ }
439
+ else if (report.checks.some((c) => c.status === 'failed')) {
440
+ human(' VERIFICATION FAILED. At least one check did not reproduce.');
441
+ }
442
+ else {
443
+ // Not a pass. A verifier that could not run every check must never imply one.
444
+ human(' Incomplete - some checks could not be run. This is not a verification.');
445
+ }
446
+ // `process.exitCode` does NOT survive here: run() returns EXIT.OK on any command that did
447
+ // not throw, and the entrypoint assigns that over it. Verified this the hard way — the
448
+ // command printed "this is not a verification" and exited 0, which a CI gate would have read
449
+ // as a pass. Throwing is the mechanism the CLI actually honours.
450
+ const failed = report.checks.filter((c) => c.status === 'failed');
451
+ if (failed.length > 0) {
452
+ throw new CliError(`Verification FAILED: ${failed.map((f) => f.name).join(', ')} did not reproduce.`, 'RECEIPT_VERIFICATION_FAILED', EXIT.FAILED, false, { ...report });
453
+ }
454
+ if (!report.verified) {
455
+ // Incomplete is not a pass. Exiting 0 here would let a script report an unverified receipt
456
+ // as verified, which is the one mistake this command exists to prevent.
457
+ const skipped = report.checks.filter((c) => c.status === 'skipped');
458
+ throw new CliError(`Verification INCOMPLETE: ${skipped.map((c) => c.name).join(', ')} could not be checked.`, 'RECEIPT_VERIFICATION_INCOMPLETE', EXIT.FAILED, false, { ...report });
459
+ }
460
+ });
461
+ receipts
462
+ .command('get <id>')
463
+ .description('One receipt, with its signature and computation')
464
+ .option('--proof', 'Also fetch the transparency-log inclusion proof')
465
+ .option('--tree-size <n>', 'Prove against this tree size instead of the newest anchored head')
466
+ .action(async (id, opts) => {
467
+ const treeSize = opts.treeSize === undefined
468
+ ? undefined
469
+ : intOption(opts.treeSize, '--tree-size');
470
+ if (treeSize !== undefined && !opts.proof) {
471
+ throw new CliError('--tree-size only applies to the inclusion proof. Add --proof.', 'TREE_SIZE_WITHOUT_PROOF', EXIT.USAGE);
472
+ }
473
+ const client = await getClient();
474
+ const receipt = await client.billing.receipt(id);
475
+ // Sequential, not concurrent: without the receipt there is nothing to prove, and a 404 on the
476
+ // id should not also produce a second confusing failure from the proof call.
477
+ const proof = opts.proof ? await client.billing.receiptProof(id, { treeSize }) : undefined;
478
+ // Machine output only. `body`, `computation`, `verification` and `proof` are deep objects,
479
+ // and the generic key/value table renders each as one enormous line of raw JSON — burying
480
+ // the four facts a person actually reads under a screenful of hashes.
481
+ if (isJsonMode() || getOutputFormat() === 'json') {
482
+ printOutput({ ...receipt, ...(proof ? { proof } : {}) });
483
+ return;
484
+ }
485
+ human('');
486
+ human(` Receipt ${receipt.receipt_number} - ${receipt.type} ${usd(receipt.amount_usd)}`);
487
+ human(` Issued ${receipt.issued_at}`);
488
+ human(` Digest ${receipt.digest}`);
489
+ human(receipt.signature
490
+ ? ` Signed ${receipt.algorithm ?? 'ed25519'} by key ${receipt.key_id}`
491
+ : ' NOT SIGNED yet.');
492
+ if (receipt.price_book_hash) {
493
+ human(` Priced from price book ${receipt.price_book_hash}`);
494
+ }
495
+ if (proof) {
496
+ human('');
497
+ human(` In the log at leaf ${proof.leaf_index} of ${proof.tree_size}.`);
498
+ // covering_head, not head: `head` is the head at this tree size and may not itself be
499
+ // anchored, while a later anchored root still commits to this leaf. Reporting head's
500
+ // status would call a perfectly good receipt unanchored for every gap between anchors.
501
+ const anchor = proof.covering_head;
502
+ if (anchor?.anchor_status === 'anchored') {
503
+ human(` Anchored on Accumulate in ${anchor.anchor_tx_hash}`);
504
+ human(anchor.timestamp_attested
505
+ ? ` Existed no later than ${anchor.anchor_block_time} (block timestamp).`
506
+ : ` Existed no later than ${anchor.anchor_block_time} - a loose upper bound, not the block time.`);
507
+ }
508
+ else {
509
+ human(' Not yet anchored on Accumulate - the proof holds against our signed head only.');
510
+ }
511
+ human('');
512
+ human(' Keep this proof with the receipt. It stays valid forever against that head.');
513
+ }
514
+ else if (receipt.leaf_seq !== null) {
515
+ hint(`certen receipts get ${id} --proof # prove it is in the anchored log`);
516
+ }
517
+ });
518
+ // Registering the wallet you pay from. The gateway's own 402 names this as the recommended fix
519
+ // ("register_sender ... makes every future deposit attribute automatically") and no client could
520
+ // do it, so the advice in the refusal pointed at a raw endpoint the reader had to call by hand.
521
+ const payers = program
522
+ .command('payers')
523
+ .description('Wallets whose deposits credit automatically');
524
+ payers
525
+ .command('list', { isDefault: true })
526
+ .description('Show the wallets registered for automatic attribution')
527
+ .action(async () => {
528
+ const client = await getClient();
529
+ const { addresses } = await client.billing.payerAddresses();
530
+ // Machine output only. The generic key/value table renders a nested array as one line of raw
531
+ // JSON — `addresses []` — which is noise above the readable list below.
532
+ if (isJsonMode() || getOutputFormat() === 'json') {
533
+ printOutput({ addresses });
534
+ return;
535
+ }
536
+ human('');
537
+ if (addresses.length === 0) {
538
+ human(' No wallets registered — deposits need a one-time payment to be attributed.');
539
+ hint('certen payers add <address> --chain base-sepolia');
540
+ return;
541
+ }
542
+ for (const a of addresses) {
543
+ human(` ${a.address} ${a.chain}${a.label ? ` (${a.label})` : ''}`
544
+ + `${a.is_active ? '' : ' [inactive]'}`);
545
+ }
546
+ human('');
547
+ human(' Deposits from these credit this organization on sight — no payment intent needed.');
548
+ });
549
+ payers
550
+ .command('add <address>')
551
+ .description('Register a wallet you pay from, so future deposits credit automatically')
552
+ .requiredOption('--chain <chain>', 'Chain the wallet sends on, e.g. base-sepolia')
553
+ .option('--label <label>', 'A name for your own reference')
554
+ .action(async (address, opts) => {
555
+ // Checked before the network call: a malformed address would otherwise be rejected by the
556
+ // gateway's pattern with no indication of which of the two arguments was wrong.
557
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
558
+ throw new CliError(`"${address}" is not an EVM address. Expected 0x followed by 40 hex characters.`, 'INVALID_ADDRESS', EXIT.USAGE);
559
+ }
560
+ const chain = assertChain(opts.chain);
561
+ const client = await getClient();
562
+ const record = await client.billing.registerPayerAddress({
563
+ chain, address, label: opts.label,
564
+ });
565
+ printOutput({ ...record });
566
+ if (isJsonMode() || getOutputFormat() === 'json')
567
+ return;
568
+ human('');
569
+ human(` Registered ${record.address} on ${record.chain}.`);
570
+ human(' Stablecoin sent from this wallet now credits automatically — any amount, any time.');
571
+ hint('certen balance');
572
+ });
75
573
  program
76
574
  .command('fund')
77
575
  .argument('<amount>', 'Amount in USD to send, e.g. 25 or 25.50')
78
576
  .description('Get payment details for adding funds, and wait until they are credited')
79
- .option('--chain <chain>', 'Chain to send stablecoin on (e.g. base)')
577
+ .option('--chain <chain>', 'Chain to send stablecoin on, e.g. base-sepolia')
578
+ // `--wait` is accepted although waiting is already the default, because every other
579
+ // long-running command (`identity create`, `tx create`, `call`) takes it. Someone who learned
580
+ // the flag there types it here and, without this, meets "unknown option" on a command that was
581
+ // about to do exactly what they asked.
582
+ .option('--payer <address>', 'Register the wallet you are sending from, so future deposits credit on sight')
583
+ .option('--uri', 'Print an EIP-681 payment URI a wallet can open')
584
+ .option('--wait', 'Wait for the deposit to be credited (the default)')
80
585
  .option('--no-wait', 'Print the payment details and exit instead of waiting')
81
586
  .option('--timeout <minutes>', 'How long to wait for the deposit', '60')
82
587
  // Scripted callers legitimately want a different cadence — a CI job funding a
@@ -88,8 +593,13 @@ export function registerBillingCommands(program) {
88
593
  throw new CliError(`"${amount}" is not a valid amount. Use a number of dollars, e.g. 25 or 25.50`, 'INVALID_AMOUNT', EXIT.USAGE);
89
594
  }
90
595
  if (!opts.chain) {
91
- throw new CliError('Which chain will you send on? Pass --chain (e.g. --chain base)', 'CHAIN_REQUIRED', EXIT.USAGE);
596
+ throw new CliError('Which chain will you send on? Pass --chain (e.g. --chain base-sepolia)', 'CHAIN_REQUIRED', EXIT.USAGE);
92
597
  }
598
+ // Validated here, with the other pre-flight checks, for the reason the comment below gives:
599
+ // a wrong chain must not be discovered after a real payment intent has been opened against
600
+ // it. `base` and `base-sepolia` are different networks and money sent to the wrong one is
601
+ // not recoverable by anything this CLI can do.
602
+ const chain = assertChain(opts.chain);
93
603
  // Every option is validated BEFORE the network call. Checking after would
94
604
  // mean a typo in --timeout had already opened a real payment intent that the
95
605
  // customer then has to wait out or abandon.
@@ -101,13 +611,62 @@ export function registerBillingCommands(program) {
101
611
  if (!Number.isFinite(pollSec) || pollSec <= 0) {
102
612
  throw new CliError('--poll-interval must be a positive number of seconds', 'INVALID_POLL_INTERVAL', EXIT.USAGE);
103
613
  }
614
+ // Checked with the other pre-flight validation, and for the same reason: a malformed address
615
+ // must not be discovered after a real payment intent has been opened.
616
+ if (opts.payer !== undefined && !/^0x[0-9a-fA-F]{40}$/.test(opts.payer)) {
617
+ throw new CliError(`"${opts.payer}" is not an address. Expected 0x followed by 40 hex characters.`, 'INVALID_PAYER_ADDRESS', EXIT.USAGE);
618
+ }
104
619
  const client = await getClient();
105
- const target = await client.billing.openPayment({ chain: opts.chain, amountUsd: amount });
620
+ const target = await client.billing.openPayment({ chain, amountUsd: amount });
106
621
  const intent = target.deposit_intent;
107
622
  if (!intent) {
108
623
  // Defensive: an amount was supplied, so the gateway should have opened one.
109
624
  throw new CliError('The gateway did not open a payment for that amount. Nothing was sent; try again.', 'PAYMENT_NOT_OPENED', EXIT.FAILED);
110
625
  }
626
+ // ── The payer, registered at the one moment the user knows which wallet they will send from ──
627
+ //
628
+ // `certen init --payer` exists, but `init` runs before anyone has chosen a wallet. This is the
629
+ // moment. Registering it makes every FUTURE deposit credit on sight — no intent, no
630
+ // exact-amount match, no expiry to beat — and it was reachable only by knowing that
631
+ // `certen payers add` existed, which nothing in this flow said.
632
+ //
633
+ // Never allowed to fail the funding flow: the deposit target above is valid regardless, and a
634
+ // 409 usually means the address is already registered. Reporting a payer problem as a payment
635
+ // problem would send someone looking for a lost transfer that was never made.
636
+ let payerNote = null;
637
+ if (opts.payer) {
638
+ payerNote = await client.billing
639
+ .registerPayerAddress({ chain, address: opts.payer })
640
+ .then(() => `Deposits from ${opts.payer} will credit automatically from now on.`)
641
+ .catch((err) => (err instanceof CertenError && err.status === 409
642
+ ? `${opts.payer} was already registered — nothing to do.`
643
+ : `Could not register ${opts.payer}: ${err instanceof Error ? err.message : String(err)}`));
644
+ }
645
+ // NEVER allowed to break the funding flow.
646
+ //
647
+ // The first version of this called `buildPaymentUri` directly and threw on a target whose
648
+ // addresses were not canonical 0x form — which took down the whole command: no deposit
649
+ // address printed, no payment instructions, on a payment intent the gateway had already
650
+ // opened. A convenience that can prevent someone paying is worse than no convenience, and the
651
+ // existing tests caught it immediately.
652
+ //
653
+ // So: a URI is offered when it can be built and silently omitted when it cannot. The deposit
654
+ // address is printed either way, and that is the load-bearing output.
655
+ let paymentUri = null;
656
+ let uriProblem = null;
657
+ try {
658
+ paymentUri = buildPaymentUri(target, intent.amount_usd);
659
+ }
660
+ catch (err) {
661
+ uriProblem = err instanceof Error ? err.message : String(err);
662
+ }
663
+ const wait = estimateWait(chain, target.min_confirmations);
664
+ // Machine payload, then a human summary — never both to the same reader.
665
+ //
666
+ // `fund` printed the raw key/value table AND the readable instructions, the same defect fixed
667
+ // in `balance`. It matters more here now: the table carries `payment_uri`, so a person saw the
668
+ // long link whether or not they asked, which both buried the deposit address and made `--uri`
669
+ // pointless. Machines keep every field; a person gets the four things they need to act.
111
670
  printOutput({
112
671
  reference: intent.reference,
113
672
  amount_usd: intent.amount_usd,
@@ -116,9 +675,15 @@ export function registerBillingCommands(program) {
116
675
  chain_id: target.chain_id,
117
676
  token_symbol: target.token_symbol,
118
677
  token_address: target.token_address,
678
+ token_decimals: target.token_decimals,
119
679
  deposit_address: target.deposit_address,
120
680
  min_confirmations: target.min_confirmations,
121
- });
681
+ // Both new fields are in the machine payload unconditionally. A script assembling a wallet
682
+ // deep link should not have to re-derive the smallest-unit amount — that arithmetic is
683
+ // where an off-by-one-decimal 10x error comes from.
684
+ payment_uri: paymentUri,
685
+ estimated_wait_seconds: wait?.seconds ?? null,
686
+ }, { machineOnly: true });
122
687
  if (!isJsonMode()) {
123
688
  human('');
124
689
  human(` Send exactly ${intent.amount_usd} ${target.token_symbol} on ${target.chain} to:`);
@@ -127,10 +692,53 @@ export function registerBillingCommands(program) {
127
692
  human('');
128
693
  human(` Reference ${intent.reference} · expires in ${minutesUntil(intent.expires_at)} min`);
129
694
  human(' The exact amount is how we know the payment is yours, so send it to the cent.');
130
- human(` Credited after ${target.min_confirmations} confirmation(s).`);
695
+ human(wait
696
+ ? ` Credited after ${target.min_confirmations} confirmation(s) — ${wait.text} on ${wait.basis}.`
697
+ : ` Credited after ${target.min_confirmations} confirmation(s).`);
698
+ if (wait)
699
+ human(' That is an estimate from block time, not a guarantee.');
700
+ // Printed on request rather than always. It is a long line that wraps badly, and the
701
+ // address above is what most people copy — burying that under a URI would be a regression
702
+ // for the common case.
703
+ if (opts.uri && paymentUri) {
704
+ human('');
705
+ human(' Open in a wallet (EIP-681 — carries token, chain, recipient and exact amount):');
706
+ human(` ${paymentUri}`);
707
+ }
708
+ else if (opts.uri) {
709
+ // Asked for explicitly, so say why it is absent rather than printing nothing. Silence
710
+ // here would look like the flag was ignored.
711
+ human('');
712
+ human(` No payment link for this target: ${uriProblem}`);
713
+ human(' Use the address above — it is the same payment either way.');
714
+ }
715
+ else if (paymentUri) {
716
+ hint('certen fund --uri … prints a link a wallet can open, with the amount already set');
717
+ }
718
+ if (payerNote) {
719
+ human('');
720
+ human(` ${payerNote}`);
721
+ }
722
+ // Offered as a HINT, and deliberately without checking first.
723
+ //
724
+ // Knowing whether this chain already has a registered payer costs a round trip on the money
725
+ // command, and a targeted suggestion is not worth latency here — the same reasoning that
726
+ // stripped unused enrichments off `call` and `init`. `payers add` is idempotent from the
727
+ // user's point of view (a duplicate is a 409 that says "already registered"), so a hint
728
+ // shown to someone who has already done it costs them one command and no confusion.
729
+ if (!opts.payer) {
730
+ hint('');
731
+ hint('Sending from this wallet again? Register it once and future deposits credit on');
732
+ hint(`sight, with no exact-amount match to get right:`);
733
+ hint(` certen fund <amount> --chain ${chain} --payer <your-address>`);
734
+ }
131
735
  human('');
132
736
  }
133
- if (!opts.wait) {
737
+ // `opts.wait !== false`, not `!opts.wait`. Declaring BOTH `--wait` and `--no-wait` makes
738
+ // commander default the value to undefined rather than true, so the plain `!` test silently
739
+ // stopped this command waiting at all — it printed the payment details and exited 0 on a
740
+ // deposit nobody was watching. Caught by the expiry case in test/billing.test.ts.
741
+ if (opts.wait === false) {
134
742
  hint('certen fund --no-wait was used; check later with certen balance');
135
743
  return;
136
744
  }
@@ -158,7 +766,7 @@ export function registerBillingCommands(program) {
158
766
  human(` Available now ${usd(balance.available_usd)}.`);
159
767
  human('');
160
768
  hint('Paying from this wallet again? Register it once in the portal and every '
161
- + 'future deposit credits automatically: /portal');
769
+ + `future deposit credits automatically: ${getPortalUrl()}`);
162
770
  }
163
771
  return;
164
772
  }