@thetanuts-finance/odette-cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,570 @@
1
+ // Adapted from thetanuts-sdk cli/src/commands/wallet.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ // Changes: `transfer` dropped (not part of filling); `approve` grants USDC to
4
+ // the r12 OptionBook only (no --spender, no other token); --for optionBook only;
5
+ // `show` reports the fixed referrer; names; config errors exit 4. `--dry-run`
6
+ // never writes or deletes the config (create, import, reset), even with --force.
7
+ // `balance --all` redacts RPC error text.
8
+ import fs from 'node:fs';
9
+ import prompts from 'prompts';
10
+ import { ethers } from 'ethers';
11
+ import { getGlobalOpts } from '../options.js';
12
+ import { getClient, requireSigner } from '../client.js';
13
+ import { render, renderError, redactSecrets, buildTxReceiptPayload, fetchEthUsdSafe } from '../output.js';
14
+ import { confirm } from '../confirm.js';
15
+ import { defaultConfigPath, loadConfig, saveConfig, stopForDryRun, } from '../config.js';
16
+ import { DEFAULT_CHAIN_ID, DEFAULT_RPC_URL } from '../defaults.js';
17
+ const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/;
18
+ const PRIVATE_KEY_REGEX = /^0x[0-9a-fA-F]{64}$/;
19
+ /**
20
+ * Resolve a token symbol or raw address to a contract address
21
+ *
22
+ * Accepts either a known symbol from `client.chainConfig.tokens` (case-sensitive
23
+ * match preferred, case-insensitive fallback) or a 0x-prefixed 40-char hex
24
+ * address. Throws on unknown symbol
25
+ */
26
+ function resolveToken(client, symbolOrAddr) {
27
+ if (!symbolOrAddr) {
28
+ throw new Error('Token is required (symbol like USDC or 0x-address)');
29
+ }
30
+ if (ADDRESS_REGEX.test(symbolOrAddr)) {
31
+ return symbolOrAddr;
32
+ }
33
+ const tokens = client.chainConfig.tokens;
34
+ if (tokens[symbolOrAddr]) {
35
+ return tokens[symbolOrAddr].address;
36
+ }
37
+ const wanted = symbolOrAddr.toLowerCase();
38
+ for (const [sym, t] of Object.entries(tokens)) {
39
+ if (sym.toLowerCase() === wanted)
40
+ return t.address;
41
+ }
42
+ throw new Error(`Unknown token "${symbolOrAddr}". Known symbols: ${Object.keys(tokens).join(', ')}`);
43
+ }
44
+ function maskPrivateKey(pk) {
45
+ if (!pk)
46
+ return '<not set>';
47
+ if (pk.length < 6)
48
+ return '0x…';
49
+ return `0x…${pk.slice(-4)}`;
50
+ }
51
+ /** Resolve which source supplied the active private key. */
52
+ function privateKeySource(opts) {
53
+ if (process.env.ODETTE_PRIVATE_KEY)
54
+ return 'env';
55
+ const cfgPath = opts.config ?? defaultConfigPath();
56
+ const cfg = loadConfig(cfgPath);
57
+ if (cfg?.privateKey)
58
+ return 'config';
59
+ return 'none';
60
+ }
61
+ async function promptPrivateKey(prompt) {
62
+ while (true) {
63
+ const r = await prompts({ type: 'password', name: 'pk', message: prompt });
64
+ const pk = r.pk;
65
+ if (!pk) {
66
+ throw new Error('Aborted (no private key provided)');
67
+ }
68
+ if (!PRIVATE_KEY_REGEX.test(pk)) {
69
+ process.stderr.write('Invalid private key. Expect a 0x-prefixed 64-char hex string.\n');
70
+ continue;
71
+ }
72
+ return pk;
73
+ }
74
+ }
75
+ function parseAmount(amount, decimals, client) {
76
+ if (amount === 'max') {
77
+ return ethers.MaxUint256;
78
+ }
79
+ try {
80
+ return client.utils.toBigInt(amount, decimals);
81
+ }
82
+ catch {
83
+ throw Object.assign(new Error(`Invalid --amount "${amount}". Pass a USDC amount, for example 5, or "max".`), { exitCode: 2 });
84
+ }
85
+ }
86
+ function resolveSpender(client, spender, forFlag) {
87
+ if (spender) {
88
+ if (!ADDRESS_REGEX.test(spender)) {
89
+ throw new Error('--spender must be a 0x-prefixed 40-char hex address');
90
+ }
91
+ return spender;
92
+ }
93
+ if (forFlag) {
94
+ if (forFlag === 'optionBook') {
95
+ const addr = client.chainConfig.contracts.optionBook;
96
+ if (!addr)
97
+ throw new Error('optionBook is not deployed on this chain');
98
+ return addr;
99
+ }
100
+ throw new Error(`--for must be optionBook (got "${forFlag}")`);
101
+ }
102
+ throw new Error('Either --spender <addr> or --for optionBook is required');
103
+ }
104
+ async function resolveOwner(client, result, explicit) {
105
+ if (explicit) {
106
+ if (!ADDRESS_REGEX.test(explicit)) {
107
+ throw new Error('--owner must be a 0x-prefixed 40-char hex address');
108
+ }
109
+ return explicit;
110
+ }
111
+ if (!result.hasSigner) {
112
+ throw new Error('No --owner provided and no signer configured');
113
+ }
114
+ return client.getSignerAddress();
115
+ }
116
+ export function register(program) {
117
+ const grp = program
118
+ .command('wallet')
119
+ .description('Wallet, balances, allowances, approvals');
120
+ // ---- Reads ----
121
+ grp
122
+ .command('show')
123
+ .description('Show wallet address, source, and config path')
124
+ .action(async (_localOpts, cmd) => {
125
+ const opts = getGlobalOpts(cmd);
126
+ try {
127
+ const result = getClient(opts);
128
+ const path = opts.config ?? defaultConfigPath();
129
+ if (!result.hasSigner) {
130
+ render({ address: '<no signer configured>', source: 'none', configPath: path, referrer: result.referrer }, { output: opts.output, noColor: !opts.color });
131
+ return;
132
+ }
133
+ const address = await result.client.getSignerAddress();
134
+ const source = privateKeySource(opts);
135
+ render({ address, source, configPath: path, referrer: result.referrer },
136
+ // This is the primary copy/paste surface for a wallet address; keep
137
+ // it complete even though generic table rows compact long values.
138
+ { output: opts.output, noColor: !opts.color, truncate: false });
139
+ }
140
+ catch (err) {
141
+ renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
142
+ process.exit(err.exitCode ?? 1);
143
+ }
144
+ });
145
+ grp
146
+ .command('balance')
147
+ .description('Show native ETH and USDC balances, or one token. ' +
148
+ 'A token whose balanceOf call fails is skipped with a note — pass --all to inspect it.')
149
+ .option('--token <symbol>', 'token symbol or address')
150
+ .option('--address <addr>', 'owner address (defaults to signer)')
151
+ .option('--all', 'include tokens whose balanceOf call fails (truncated error in cell). Useful for SDK debugging.', false)
152
+ .action(async (_localOpts, cmd) => {
153
+ const opts = getGlobalOpts(cmd);
154
+ const local = cmd.opts();
155
+ try {
156
+ const result = getClient(opts);
157
+ const { client } = result;
158
+ const owner = await resolveOwner(client, result, local.address);
159
+ const nativeSymbol = client.chainConfig.nativeCurrency;
160
+ if (local.token) {
161
+ if (local.token.toUpperCase() === nativeSymbol.toUpperCase()) {
162
+ const balance = await client.provider.getBalance(owner);
163
+ render({
164
+ address: 'native',
165
+ symbol: nativeSymbol,
166
+ decimals: 18,
167
+ balance: client.utils.fromBigInt(balance, 18),
168
+ raw: balance.toString(),
169
+ }, { output: opts.output, noColor: !opts.color });
170
+ return;
171
+ }
172
+ const tokenAddr = resolveToken(client, local.token);
173
+ const balance = await client.erc20.getBalance(tokenAddr, owner);
174
+ // SDK types getDecimals() as number but ethers v6 returns bigint at
175
+ // runtime; coerce defensively to keep arithmetic in fromBigInt happy.
176
+ const decimals = Number(await client.erc20.getDecimals(tokenAddr));
177
+ let symbol = local.token;
178
+ // Try to look up symbol from chainConfig if we got a raw address.
179
+ for (const [sym, t] of Object.entries(client.chainConfig.tokens)) {
180
+ if (t.address.toLowerCase() === tokenAddr.toLowerCase())
181
+ symbol = sym;
182
+ }
183
+ render({
184
+ address: tokenAddr,
185
+ symbol,
186
+ decimals,
187
+ balance: client.utils.fromBigInt(balance, decimals),
188
+ raw: balance.toString(),
189
+ }, { output: opts.output, noColor: !opts.color });
190
+ return;
191
+ }
192
+ // Multi-token path. Default: hide tokens whose balanceOf fails — they
193
+ // tend to be SDK-config stragglers (stale Aave addresses, EIP-55
194
+ // checksum mismatches on cbDOGE/cbXRP)
195
+ const rows = [];
196
+ const skipped = [];
197
+ const nativeBalance = await client.provider.getBalance(owner);
198
+ rows.push({
199
+ symbol: nativeSymbol,
200
+ address: 'native',
201
+ balance: client.utils.fromBigInt(nativeBalance, 18),
202
+ raw: nativeBalance.toString(),
203
+ });
204
+ for (const [symbol, t] of Object.entries(client.chainConfig.tokens)) {
205
+ try {
206
+ const balance = await client.erc20.getBalance(t.address, owner);
207
+ rows.push({
208
+ symbol,
209
+ address: t.address,
210
+ balance: client.utils.fromBigInt(balance, Number(t.decimals)),
211
+ raw: balance.toString(),
212
+ });
213
+ }
214
+ catch (err) {
215
+ if (local.all) {
216
+ const msg = redactSecrets(err.message ?? String(err));
217
+ rows.push({
218
+ symbol,
219
+ address: t.address,
220
+ balance: 'error',
221
+ raw: msg.length > 80 ? `${msg.slice(0, 77)}...` : msg,
222
+ });
223
+ }
224
+ else {
225
+ skipped.push(symbol);
226
+ }
227
+ }
228
+ }
229
+ if (!local.all && skipped.length > 0) {
230
+ // Stderr note so JSON pipelines stay clean
231
+ process.stderr.write(`Skipped ${skipped.length} token(s) whose balance read failed: ${skipped.join(', ')}. ` +
232
+ 'Use --all to inspect.\n');
233
+ }
234
+ render(rows, { output: opts.output, noColor: !opts.color });
235
+ }
236
+ catch (err) {
237
+ renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
238
+ process.exit(err.exitCode ?? 1);
239
+ }
240
+ });
241
+ grp
242
+ .command('allowance')
243
+ .description('Show ERC20 allowance for a spender')
244
+ .requiredOption('--token <symbol>', 'token symbol or address')
245
+ .option('--spender <addr>', 'spender address')
246
+ .option('--for <name>', 'preset spender: optionBook')
247
+ .option('--owner <addr>', 'owner address (defaults to signer)')
248
+ .action(async (_localOpts, cmd) => {
249
+ const opts = getGlobalOpts(cmd);
250
+ const local = cmd.opts();
251
+ try {
252
+ const result = getClient(opts);
253
+ const { client } = result;
254
+ const tokenAddr = resolveToken(client, local.token);
255
+ const spender = resolveSpender(client, local.spender, local.for);
256
+ const owner = await resolveOwner(client, result, local.owner);
257
+ const allowance = await client.erc20.getAllowance(tokenAddr, owner, spender);
258
+ const decimals = Number(await client.erc20.getDecimals(tokenAddr));
259
+ render({
260
+ token: tokenAddr,
261
+ owner,
262
+ spender,
263
+ allowance: client.utils.fromBigInt(allowance, decimals),
264
+ raw: allowance.toString(),
265
+ }, { output: opts.output, noColor: !opts.color });
266
+ }
267
+ catch (err) {
268
+ renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
269
+ process.exit(err.exitCode ?? 1);
270
+ }
271
+ });
272
+ // ---- Writes / config mutations ----
273
+ grp
274
+ .command('create')
275
+ .description('Generate a fresh random wallet and store it in the config. ' +
276
+ 'The private key is NEVER printed unless --reveal-key is passed (with confirmation). ' +
277
+ 'For a one-time backup, use --reveal-key or copy the config file to a secure location.')
278
+ .option('--force', 'overwrite an existing private key (required when one exists; --yes is not accepted as consent)', false)
279
+ .option('--reveal-key', 'after saving, display the private key and BIP-39 mnemonic on stdout. Will appear in scrollback — pipe to a secure destination.', false)
280
+ .action(async (_localOpts, cmd) => {
281
+ const opts = getGlobalOpts(cmd);
282
+ const local = cmd.opts();
283
+ // Refuse --yes + --reveal-key: --yes blanket-consents the most dangerous
284
+ // render in the CLI (key + mnemonic to stdout). The reveal prompt is
285
+ // intentionally gated by a TTY confirm so the user has one last chance
286
+ // to back out before secrets land in scrollback or pipe targets.
287
+ if (local.revealKey && opts.yes) {
288
+ process.stderr.write('`--reveal-key` cannot be combined with `--yes`; the reveal prompt is intentionally gated by a TTY confirm to prevent accidental key disclosure in scrollback or pipe targets.\n');
289
+ process.exit(2);
290
+ }
291
+ try {
292
+ const path = opts.config ?? defaultConfigPath();
293
+ if (stopForDryRun(opts.dryRun, 'wallet create', path))
294
+ return;
295
+ const existing = loadConfig(path);
296
+ if (existing?.privateKey && !local.force) {
297
+ // Deliberately NOT passing `yes: opts.yes`. Overwriting destroys the
298
+ // only copy of an existing key (the mnemonic was never persisted),
299
+ // and the replacement wallet's own mnemonic is discarded too — so a
300
+ // habitual `--yes` in a script silently burned the user's funds with
301
+ // nothing but a stderr line to show for it. Key destruction now
302
+ // requires the dedicated `--force` flag, the same way `--reveal-key`
303
+ // refuses to accept `--yes` as consent for disclosure.
304
+ if (opts.yes) {
305
+ process.stderr.write(`Config at ${path} already has a private key (${maskPrivateKey(existing.privateKey)}).\n` +
306
+ '`--yes` does not authorize overwriting an existing key: the old key is unrecoverable ' +
307
+ 'and the new wallet\'s mnemonic is not persisted. Re-run with `--force` to overwrite ' +
308
+ 'deliberately, or point --config at a different path.\n');
309
+ process.exit(2);
310
+ }
311
+ const ok = await confirm(`Config at ${path} already has a private key (${maskPrivateKey(existing.privateKey)}). Overwrite with a NEW random wallet? ` +
312
+ `The existing key cannot be recovered after overwrite.`, { yes: false, dryRun: Boolean(opts.dryRun) });
313
+ if (!ok)
314
+ process.exit(3);
315
+ }
316
+ else if (existing?.privateKey && local.force) {
317
+ // --force skipped the confirm prompt entirely. Surface the address
318
+ // being destroyed so a user rotating keys can at least verify which
319
+ // one is going away — silent overwrite is too easy to mis-aim.
320
+ try {
321
+ const oldAddr = new ethers.Wallet(existing.privateKey).address;
322
+ process.stderr.write(`--force: overwriting wallet ${oldAddr} (key permanently destroyed)\n`);
323
+ }
324
+ catch {
325
+ process.stderr.write('--force: overwriting an unparseable existing key (no recovery possible)\n');
326
+ }
327
+ }
328
+ // Generate a fresh wallet. ethers v6's createRandom() returns a
329
+ // wallet whose mnemonic is accessible at this instance — once we
330
+ // persist only the raw private key, the mnemonic is unrecoverable.
331
+ // The mnemonic is strictly more sensitive than the key (HD master
332
+ // vs single-account); we deliberately do NOT persist it, instead
333
+ // nudging the user to capture a paper backup at create time.
334
+ const wallet = ethers.Wallet.createRandom();
335
+ const address = wallet.address;
336
+ const privateKey = wallet.privateKey;
337
+ const mnemonic = wallet.mnemonic?.phrase;
338
+ // Announcement goes to stderr so `-o json | jq` stays clean. The
339
+ // structured success record still lands on stdout via render() below.
340
+ process.stderr.write(`\nGenerated wallet ${address}\n\n`);
341
+ // ---- Interactive backup warning ----
342
+ //
343
+ // The mnemonic exists ONLY in this process's memory right now. Once
344
+ // we save the raw key to disk and the action returns, the mnemonic
345
+ // is irretrievable. This is THE moment to offer paper backup.
346
+ //
347
+ // Skipped when:
348
+ // - --reveal-key (user already opted in to seeing it below)
349
+ // - --yes (user pre-confirmed everything; respect the contract)
350
+ // - --dry-run (no broadcast pattern; skip prompts)
351
+ // - stdin is not a TTY (scripted invocation; warn on stderr instead)
352
+ let mnemonicShown = Boolean(local.revealKey);
353
+ const canPrompt = !local.revealKey &&
354
+ !opts.yes &&
355
+ !opts.dryRun &&
356
+ process.stdin.isTTY;
357
+ if (canPrompt) {
358
+ const showBackup = await prompts({
359
+ type: 'confirm',
360
+ name: 'show',
361
+ message: 'Display the 12-word mnemonic now for paper backup? ' +
362
+ '(One-shot — once you dismiss this, only the config file is your backup.)',
363
+ initial: true,
364
+ });
365
+ if (showBackup.show && mnemonic) {
366
+ process.stdout.write(`\nMnemonic (write this down somewhere safe — anyone with these 12 words controls the wallet):\n\n ${mnemonic}\n\n`);
367
+ const ack = await prompts({
368
+ type: 'confirm',
369
+ name: 'ok',
370
+ message: 'I have written down the mnemonic',
371
+ initial: false,
372
+ });
373
+ if (!ack.ok) {
374
+ process.stderr.write('Aborting. Wallet was NOT saved. Re-run `odette wallet create` to try again.\n');
375
+ process.exit(3);
376
+ }
377
+ mnemonicShown = true;
378
+ }
379
+ }
380
+ const cfg = existing
381
+ ? { ...existing, privateKey }
382
+ : {
383
+ version: 1,
384
+ chainId: DEFAULT_CHAIN_ID,
385
+ rpcUrl: DEFAULT_RPC_URL,
386
+ privateKey,
387
+ };
388
+ saveConfig(cfg, path);
389
+ const out = {
390
+ created: true,
391
+ address,
392
+ path,
393
+ };
394
+ if (local.revealKey) {
395
+ // Explicit --reveal-key path: gated by an additional confirm
396
+ // because the key + mnemonic about to land in stdout / scrollback.
397
+ const ack = await confirm('About to print your private key and mnemonic to stdout. They will appear in scrollback / pipe / log targets. Continue?', { yes: Boolean(opts.yes), dryRun: Boolean(opts.dryRun) });
398
+ if (ack) {
399
+ out.privateKey = privateKey;
400
+ if (mnemonic)
401
+ out.mnemonic = mnemonic;
402
+ out.backupReminder =
403
+ 'Write the mnemonic down on paper, not screenshots. Anyone with this key or mnemonic controls the wallet.';
404
+ mnemonicShown = true;
405
+ }
406
+ }
407
+ render(out, { output: opts.output, noColor: !opts.color });
408
+ // ---- Loud post-save warning when mnemonic was discarded ----
409
+ //
410
+ // If the user got here without backing up the mnemonic (declined the
411
+ // prompt, ran with --yes, --dry-run, or non-TTY stdin), the mnemonic
412
+ // is now unrecoverable. We can't bring it back — but we can be
413
+ // very loud about what just happened, on stderr so JSON-output
414
+ // pipelines still parse cleanly.
415
+ if (!mnemonicShown) {
416
+ process.stderr.write('\n⚠ Mnemonic discarded — the config file is now your ONLY backup of this wallet.\n' +
417
+ ` If ${path} is lost, deleted, or corrupted with no copy elsewhere, the wallet is unrecoverable.\n` +
418
+ ' Back up now: cp "' +
419
+ path +
420
+ '" /path/to/secure/location\n\n');
421
+ }
422
+ }
423
+ catch (err) {
424
+ renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
425
+ process.exit(err.exitCode ?? 1);
426
+ }
427
+ });
428
+ grp
429
+ .command('import')
430
+ .description('Interactively import a private key into the config')
431
+ .action(async (_localOpts, cmd) => {
432
+ const opts = getGlobalOpts(cmd);
433
+ try {
434
+ const path = opts.config ?? defaultConfigPath();
435
+ if (stopForDryRun(opts.dryRun, 'wallet import', path))
436
+ return;
437
+ const existing = loadConfig(path);
438
+ if (existing?.privateKey) {
439
+ // Same rule as `wallet create`: `--yes` is not consent to destroy an
440
+ // existing key. The old key has no other copy on disk, so a scripted
441
+ // import that blanket-answers prompts must not be able to silently
442
+ // discard it. There is no `--force` on import (the flow is inherently
443
+ // interactive — it prompts for the key), so this is a hard refusal.
444
+ if (opts.yes) {
445
+ process.stderr.write(`Config at ${path} already has a private key (${maskPrivateKey(existing.privateKey)}).\n` +
446
+ '`--yes` does not authorize overwriting an existing key — it is unrecoverable once replaced. ' +
447
+ 'Re-run without `--yes` to confirm interactively, or point --config at a different path.\n');
448
+ process.exit(2);
449
+ }
450
+ const ok = await confirm(`Config at ${path} already has a private key (${maskPrivateKey(existing.privateKey)}). Overwrite? ` +
451
+ 'The existing key cannot be recovered after overwrite.', { yes: false, dryRun: Boolean(opts.dryRun) });
452
+ if (!ok)
453
+ process.exit(3);
454
+ }
455
+ const pk = await promptPrivateKey('Paste your private key (input hidden)');
456
+ const cfg = existing
457
+ ? { ...existing, privateKey: pk }
458
+ : {
459
+ version: 1,
460
+ chainId: DEFAULT_CHAIN_ID,
461
+ rpcUrl: DEFAULT_RPC_URL,
462
+ privateKey: pk,
463
+ };
464
+ saveConfig(cfg, path);
465
+ const address = new ethers.Wallet(pk).address;
466
+ render({ imported: true, address, path }, { output: opts.output, noColor: !opts.color });
467
+ }
468
+ catch (err) {
469
+ renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
470
+ process.exit(err.exitCode ?? 1);
471
+ }
472
+ });
473
+ grp
474
+ .command('reset')
475
+ .description('Delete the config file')
476
+ .option('--force', 'do not prompt for confirmation', false)
477
+ .action(async (_localOpts, cmd) => {
478
+ const opts = getGlobalOpts(cmd);
479
+ const local = cmd.opts();
480
+ try {
481
+ const path = opts.config ?? defaultConfigPath();
482
+ if (stopForDryRun(opts.dryRun, 'wallet reset', path))
483
+ return;
484
+ if (!fs.existsSync(path)) {
485
+ render({ deleted: false, reason: 'no config', path }, { output: opts.output, noColor: !opts.color });
486
+ return;
487
+ }
488
+ if (!local.force) {
489
+ const ok = await confirm(`Delete config at ${path}?`, {
490
+ yes: Boolean(opts.yes),
491
+ dryRun: Boolean(opts.dryRun),
492
+ });
493
+ if (!ok)
494
+ process.exit(3);
495
+ }
496
+ fs.unlinkSync(path);
497
+ render({ deleted: true, path }, { output: opts.output, noColor: !opts.color });
498
+ }
499
+ catch (err) {
500
+ renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
501
+ process.exit(err.exitCode ?? 1);
502
+ }
503
+ });
504
+ // ---- On-chain writes ----
505
+ grp
506
+ .command('approve')
507
+ .description('Approve the Odette OptionBook (r12) to take USDC for fills')
508
+ .option('--token <symbol>', 'USDC (the only token Odette uses)', 'USDC')
509
+ .option('--for <name>', 'optionBook (the only spender)', 'optionBook')
510
+ // No default. This previously defaulted to "max", so a bare
511
+ // `wallet approve --token USDC --for optionBook --yes` silently granted an
512
+ // unlimited (MaxUint256) allowance with no interactive gate. Every other
513
+ // approval path in the CLI (`book fill`, `position close`, `rfq request
514
+ // --ensure-allowance`) defaults to the exact amount required; this command
515
+ // has no trade context to derive an exact amount from, so it now forces the
516
+ // caller to state one. `--amount max` still works, and still warns.
517
+ .requiredOption('--amount <amount>', 'decimal amount to approve, or "max" for unlimited (MaxUint256)')
518
+ .action(async (_localOpts, cmd) => {
519
+ const opts = getGlobalOpts(cmd);
520
+ const local = cmd.opts();
521
+ try {
522
+ const result = getClient(opts);
523
+ requireSigner(result);
524
+ const { client } = result;
525
+ // Odette fills take USDC through the OptionBook. Refuse any other
526
+ // token or spender so a pasted command cannot grant an allowance to
527
+ // an arbitrary address through this tool.
528
+ const tokenAddr = resolveToken(client, local.token);
529
+ const usdc = client.chainConfig.tokens.USDC;
530
+ if (tokenAddr.toLowerCase() !== usdc.address.toLowerCase()) {
531
+ const err = new Error(`--token must be USDC (${usdc.address}). Odette fills use USDC only.`);
532
+ err.exitCode = 2;
533
+ throw err;
534
+ }
535
+ const spender = resolveSpender(client, undefined, local.for);
536
+ const decimals = Number(await client.erc20.getDecimals(tokenAddr));
537
+ const amount = parseAmount(local.amount, decimals, client);
538
+ const preview = {
539
+ action: 'approve',
540
+ token: tokenAddr,
541
+ spender,
542
+ amount: amount === ethers.MaxUint256 ? 'max (MaxUint256)' : client.utils.fromBigInt(amount, decimals),
543
+ raw: amount.toString(),
544
+ };
545
+ render(preview, { output: opts.output, noColor: !opts.color });
546
+ if (amount === ethers.MaxUint256) {
547
+ process.stderr.write('WARNING: approving MaxUint256. The spender will be able to move any amount.\n');
548
+ }
549
+ if (opts.dryRun) {
550
+ const encoded = client.erc20.encodeApprove(tokenAddr, spender, amount);
551
+ render({ dryRun: true, ...encoded }, { output: opts.output, noColor: !opts.color, truncate: true });
552
+ return;
553
+ }
554
+ const ok = await confirm('Proceed with approve?', {
555
+ yes: Boolean(opts.yes),
556
+ dryRun: Boolean(opts.dryRun),
557
+ });
558
+ if (!ok)
559
+ process.exit(3);
560
+ const receipt = await client.erc20.approve(tokenAddr, spender, amount);
561
+ process.stderr.write(`Approval mined: ${receipt.hash}\n`);
562
+ const ethUsd = await fetchEthUsdSafe(client.api);
563
+ render(buildTxReceiptPayload(receipt, ethUsd), { output: opts.output, noColor: !opts.color });
564
+ }
565
+ catch (err) {
566
+ renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
567
+ process.exit(err.exitCode ?? 1);
568
+ }
569
+ });
570
+ }