@thetanuts-finance/odette-cli 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/NOTICE.md +65 -0
- package/README.md +128 -2
- package/dist/bookCheck.js +305 -0
- package/dist/bookEligibility.js +69 -0
- package/dist/bookMatch.js +131 -0
- package/dist/bookOrderIdentity.js +43 -0
- package/dist/bookPreview.js +35 -0
- package/dist/client.js +119 -0
- package/dist/commands/book.js +918 -0
- package/dist/commands/config.js +299 -0
- package/dist/commands/registry.js +14 -0
- package/dist/commands/setup.js +221 -0
- package/dist/commands/wallet.js +570 -0
- package/dist/config.js +146 -0
- package/dist/confirm.js +39 -0
- package/dist/defaults.js +17 -0
- package/dist/format.js +130 -0
- package/dist/index.js +84 -0
- package/dist/options.js +50 -0
- package/dist/output.js +417 -0
- package/package.json +52 -4
|
@@ -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
|
+
}
|