@certen.io/cli 0.5.0 → 0.7.0
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/CHANGELOG.md +407 -0
- package/README.md +232 -160
- package/dist/chains.d.ts +67 -0
- package/dist/chains.js +242 -0
- package/dist/chains.js.map +1 -0
- package/dist/commands/admin.js +49 -4
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/auth.js +145 -12
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/billing.js +648 -40
- package/dist/commands/billing.js.map +1 -1
- package/dist/commands/call.d.ts +2 -0
- package/dist/commands/call.js +168 -0
- package/dist/commands/call.js.map +1 -0
- package/dist/commands/chains.d.ts +2 -0
- package/dist/commands/chains.js +123 -0
- package/dist/commands/chains.js.map +1 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +171 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/errors-catalogue.d.ts +2 -0
- package/dist/commands/errors-catalogue.js +83 -0
- package/dist/commands/errors-catalogue.js.map +1 -0
- package/dist/commands/identity.js +269 -19
- package/dist/commands/identity.js.map +1 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +307 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/keys.js +2 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/oauth-clients.d.ts +2 -0
- package/dist/commands/oauth-clients.js +131 -0
- package/dist/commands/oauth-clients.js.map +1 -0
- package/dist/commands/orgs.d.ts +2 -0
- package/dist/commands/orgs.js +117 -0
- package/dist/commands/orgs.js.map +1 -0
- package/dist/commands/pending.js +13 -1
- package/dist/commands/pending.js.map +1 -1
- package/dist/commands/portfolio.js +34 -1
- package/dist/commands/portfolio.js.map +1 -1
- package/dist/commands/proof.d.ts +2 -0
- package/dist/commands/proof.js +400 -0
- package/dist/commands/proof.js.map +1 -0
- package/dist/commands/signup.d.ts +14 -0
- package/dist/commands/signup.js +271 -0
- package/dist/commands/signup.js.map +1 -0
- package/dist/commands/transaction.js +139 -21
- package/dist/commands/transaction.js.map +1 -1
- package/dist/commands/webhooks.d.ts +2 -0
- package/dist/commands/webhooks.js +200 -0
- package/dist/commands/webhooks.js.map +1 -0
- package/dist/commands/whoami.d.ts +16 -0
- package/dist/commands/whoami.js +98 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/config.d.ts +56 -0
- package/dist/config.js +51 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +14 -1
- package/dist/errors.js +15 -1
- package/dist/errors.js.map +1 -1
- package/dist/funding-guard.d.ts +40 -0
- package/dist/funding-guard.js +120 -0
- package/dist/funding-guard.js.map +1 -0
- package/dist/help-root.d.ts +2 -0
- package/dist/help-root.js +94 -0
- package/dist/help-root.js.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/output.d.ts +23 -0
- package/dist/output.js +46 -0
- package/dist/output.js.map +1 -1
- package/dist/passphrase.d.ts +18 -0
- package/dist/passphrase.js +35 -3
- package/dist/passphrase.js.map +1 -1
- package/dist/payment-uri.d.ts +76 -0
- package/dist/payment-uri.js +135 -0
- package/dist/payment-uri.js.map +1 -0
- package/dist/signer.js +6 -3
- package/dist/signer.js.map +1 -1
- package/dist/solidity-args.d.ts +31 -0
- package/dist/solidity-args.js +111 -0
- package/dist/solidity-args.js.map +1 -0
- package/dist/wait.d.ts +79 -0
- package/dist/wait.js +168 -0
- package/dist/wait.js.map +1 -0
- package/package.json +5 -4
package/dist/commands/billing.js
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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(
|
|
91
|
+
human(` Left to commit ${usd(commitments.remaining_usd)}`);
|
|
62
92
|
human('');
|
|
63
|
-
if (
|
|
64
|
-
human(` ${
|
|
65
|
-
+ `${usd(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
+
|
|
769
|
+
+ `future deposit credits automatically: ${getPortalUrl()}`);
|
|
162
770
|
}
|
|
163
771
|
return;
|
|
164
772
|
}
|