@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.
- package/LICENSE +21 -0
- package/NOTICE.md +65 -0
- package/README.md +156 -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,299 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { ethers } from 'ethers';
|
|
3
|
+
import { ODETTE_REFERRER } from '@thetanuts-finance/odette-sdk';
|
|
4
|
+
import { defaultConfigPath, loadConfig, saveConfig, stopForDryRun, } from '../config.js';
|
|
5
|
+
import { DEFAULT_CHAIN_ID, DEFAULT_RPC_URL } from '../defaults.js';
|
|
6
|
+
import { getGlobalOpts } from '../options.js';
|
|
7
|
+
import { render, renderError, redactSecrets } from '../output.js';
|
|
8
|
+
/**
|
|
9
|
+
* Read all bytes from stdin and return as a UTF-8 string. Used by
|
|
10
|
+
* `config set privateKey -` to accept the key over a pipe instead of as an
|
|
11
|
+
* argv positional (which would leak into shell history, ps aux, and terminal
|
|
12
|
+
* scrollback). Mirrors the OpenClaw `import-wallet.js --stdin` pattern.
|
|
13
|
+
*/
|
|
14
|
+
async function readStdin() {
|
|
15
|
+
if (process.stdin.isTTY) {
|
|
16
|
+
throw new Error('Expected input on stdin but stdin is a TTY. Example:\n' +
|
|
17
|
+
' printf "%s" "$KEY" | odette config set privateKey -\n' +
|
|
18
|
+
'Or use `odette wallet import` for an interactive prompt.');
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
let data = '';
|
|
22
|
+
process.stdin.setEncoding('utf8');
|
|
23
|
+
process.stdin.on('data', (chunk) => {
|
|
24
|
+
data += chunk;
|
|
25
|
+
});
|
|
26
|
+
process.stdin.on('end', () => resolve(data));
|
|
27
|
+
process.stdin.on('error', reject);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const ALLOWED_KEYS = [
|
|
31
|
+
'chainId',
|
|
32
|
+
'rpcUrl',
|
|
33
|
+
'privateKey',
|
|
34
|
+
];
|
|
35
|
+
/** Refusal for `config set|unset referrer`. The referrer is a constant. */
|
|
36
|
+
function referrerIsFixed() {
|
|
37
|
+
process.stderr.write(`The Odette referrer is fixed (${ODETTE_REFERRER}). It cannot be set, changed or removed.\n`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const PRIVATE_KEY_REGEX = /^0x[0-9a-fA-F]{64}$/;
|
|
41
|
+
function isAllowedKey(key) {
|
|
42
|
+
return ALLOWED_KEYS.includes(key);
|
|
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
|
+
/**
|
|
52
|
+
* Show an RPC URL without its path or query: hosted RPC URLs carry the API key
|
|
53
|
+
* there (for example /v2/<key>). `https://mainnet.base.org` prints unchanged.
|
|
54
|
+
*/
|
|
55
|
+
function maskRpcUrl(url) {
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(url);
|
|
58
|
+
const hidden = (u.pathname !== '/' && u.pathname !== '') || u.search !== '' || u.username !== '';
|
|
59
|
+
return hidden ? `${u.protocol}//${u.host}/… (path and query hidden)` : `${u.protocol}//${u.host}`;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return '<invalid URL>';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function emptyConfig() {
|
|
66
|
+
return {
|
|
67
|
+
version: 1,
|
|
68
|
+
chainId: DEFAULT_CHAIN_ID,
|
|
69
|
+
rpcUrl: DEFAULT_RPC_URL,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function parseValue(key, value) {
|
|
73
|
+
if (key === 'chainId') {
|
|
74
|
+
const n = Number.parseInt(value, 10);
|
|
75
|
+
if (n !== DEFAULT_CHAIN_ID) {
|
|
76
|
+
throw Object.assign(new Error(`chainId must be ${DEFAULT_CHAIN_ID} (Base). Odette trades on Base only. Got "${value}".`), { exitCode: 2 });
|
|
77
|
+
}
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
if (key === 'privateKey') {
|
|
81
|
+
if (!PRIVATE_KEY_REGEX.test(value)) {
|
|
82
|
+
throw Object.assign(new Error('privateKey must be a 0x-prefixed 64-char hex string'), { exitCode: 2 });
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
if (key === 'rpcUrl') {
|
|
87
|
+
// Same rule as at use time (client.ts): https, or localhost for dev.
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(value);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw Object.assign(new Error('rpcUrl is not a valid URL.'), { exitCode: 2 });
|
|
94
|
+
}
|
|
95
|
+
const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
|
96
|
+
if (!(parsed.protocol === 'https:' || (parsed.protocol === 'http:' && local))) {
|
|
97
|
+
throw Object.assign(new Error(`rpcUrl must use https:// (got ${parsed.protocol}//${parsed.host}).`), { exitCode: 2 });
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
export function register(program) {
|
|
104
|
+
const grp = program
|
|
105
|
+
.command('config')
|
|
106
|
+
.description('Inspect, get, set, or unset persisted CLI configuration');
|
|
107
|
+
grp
|
|
108
|
+
.command('show')
|
|
109
|
+
.description('Print the current configuration (private key masked)')
|
|
110
|
+
.action(async (_localOpts, cmd) => {
|
|
111
|
+
const opts = getGlobalOpts(cmd);
|
|
112
|
+
try {
|
|
113
|
+
const path = opts.config ?? defaultConfigPath();
|
|
114
|
+
const cfg = loadConfig(path);
|
|
115
|
+
if (!cfg) {
|
|
116
|
+
render({ path, exists: false }, { output: opts.output, noColor: !opts.color });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const masked = {
|
|
120
|
+
version: cfg.version,
|
|
121
|
+
chainId: cfg.chainId,
|
|
122
|
+
rpcUrl: maskRpcUrl(cfg.rpcUrl),
|
|
123
|
+
privateKey: maskPrivateKey(cfg.privateKey),
|
|
124
|
+
referrer: `${ODETTE_REFERRER} (fixed)`,
|
|
125
|
+
path,
|
|
126
|
+
};
|
|
127
|
+
render(masked, { output: opts.output, noColor: !opts.color });
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
|
|
131
|
+
process.exit(err.exitCode ?? 1);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
grp
|
|
135
|
+
.command('path')
|
|
136
|
+
.description('Print the config file path')
|
|
137
|
+
.action(async (_localOpts, cmd) => {
|
|
138
|
+
const opts = getGlobalOpts(cmd);
|
|
139
|
+
try {
|
|
140
|
+
const path = opts.config ?? defaultConfigPath();
|
|
141
|
+
render(path, { output: opts.output, noColor: !opts.color });
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
|
|
145
|
+
process.exit(err.exitCode ?? 1);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
grp
|
|
149
|
+
.command('set <key> <value>')
|
|
150
|
+
.description('Set one config field (chainId, rpcUrl, privateKey). The referrer is fixed and cannot be set. ' +
|
|
151
|
+
'For privateKey, only `-` (read from stdin) is accepted — argv would leak the key via shell history.')
|
|
152
|
+
.action(async (key, value, _localOpts, cmd) => {
|
|
153
|
+
const opts = getGlobalOpts(cmd);
|
|
154
|
+
try {
|
|
155
|
+
if (key === 'referrer')
|
|
156
|
+
referrerIsFixed();
|
|
157
|
+
if (!isAllowedKey(key)) {
|
|
158
|
+
process.stderr.write(`Unknown config key "${key}". Allowed: ${ALLOWED_KEYS.join(', ')}\n`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
if (stopForDryRun(opts.dryRun, `config set ${key}`, opts.config ?? defaultConfigPath()))
|
|
162
|
+
return;
|
|
163
|
+
// SECURITY: reject argv for privateKey. The shell records argv in
|
|
164
|
+
// history (~/.zsh_history, ~/.bash_history), and the running process
|
|
165
|
+
// is visible in `ps aux` for its lifetime. Either route is a key-leak
|
|
166
|
+
// vector that the safer commands (`wallet import`, `setup`) avoid by
|
|
167
|
+
// reading via masked prompt. Mirror that here by accepting only:
|
|
168
|
+
// - `-` → read the key from stdin (pipe / heredoc / here-string)
|
|
169
|
+
// - anything else → hard refusal
|
|
170
|
+
// The plain-argv path was the same footgun TODO §8 cited when it
|
|
171
|
+
// chose to ship import-only over BIP-39 wallet generation in v0.1.0.
|
|
172
|
+
let effectiveValue = value;
|
|
173
|
+
if (key === 'privateKey') {
|
|
174
|
+
if (value !== '-') {
|
|
175
|
+
throw new Error('Refusing to read a private key from argv — it would land in shell\n' +
|
|
176
|
+
'history, ps aux, and terminal scrollback. Use one of:\n' +
|
|
177
|
+
' 1. `odette wallet import` (recommended; interactive, masked)\n' +
|
|
178
|
+
' 2. `odette config set privateKey - <<< "$KEY"` (read from stdin, no argv exposure)\n' +
|
|
179
|
+
' 3. `ODETTE_PRIVATE_KEY=$KEY odette ...` (env var, per-invocation)');
|
|
180
|
+
}
|
|
181
|
+
effectiveValue = (await readStdin()).trim();
|
|
182
|
+
}
|
|
183
|
+
const path = opts.config ?? defaultConfigPath();
|
|
184
|
+
const cfg = loadConfig(path) ?? emptyConfig();
|
|
185
|
+
const parsed = parseValue(key, effectiveValue);
|
|
186
|
+
// Assign with a typed cast — Config field types vary per key.
|
|
187
|
+
cfg[key] = parsed;
|
|
188
|
+
saveConfig(cfg, path);
|
|
189
|
+
render({
|
|
190
|
+
key,
|
|
191
|
+
value: key === 'privateKey' ? maskPrivateKey(String(parsed)) : key === 'rpcUrl' ? maskRpcUrl(String(parsed)) : parsed,
|
|
192
|
+
path,
|
|
193
|
+
}, { output: opts.output, noColor: !opts.color });
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
|
|
197
|
+
process.exit(err.exitCode ?? 1);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
grp
|
|
201
|
+
.command('unset <key>')
|
|
202
|
+
.description('Remove one config field')
|
|
203
|
+
.action(async (key, _localOpts, cmd) => {
|
|
204
|
+
const opts = getGlobalOpts(cmd);
|
|
205
|
+
try {
|
|
206
|
+
if (key === 'referrer')
|
|
207
|
+
referrerIsFixed();
|
|
208
|
+
if (!isAllowedKey(key)) {
|
|
209
|
+
process.stderr.write(`Unknown config key "${key}". Allowed: ${ALLOWED_KEYS.join(', ')}\n`);
|
|
210
|
+
process.exit(2);
|
|
211
|
+
}
|
|
212
|
+
const path = opts.config ?? defaultConfigPath();
|
|
213
|
+
if (stopForDryRun(opts.dryRun, `config unset ${key}`, path))
|
|
214
|
+
return;
|
|
215
|
+
const cfg = loadConfig(path);
|
|
216
|
+
if (!cfg) {
|
|
217
|
+
process.stderr.write(`No config file at ${path}\n`);
|
|
218
|
+
process.exit(4);
|
|
219
|
+
}
|
|
220
|
+
// chainId and rpcUrl are required fields; rather than `delete` them, reset to defaults.
|
|
221
|
+
if (key === 'chainId') {
|
|
222
|
+
cfg.chainId = DEFAULT_CHAIN_ID;
|
|
223
|
+
}
|
|
224
|
+
else if (key === 'rpcUrl') {
|
|
225
|
+
cfg.rpcUrl = DEFAULT_RPC_URL;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
delete cfg[key];
|
|
229
|
+
}
|
|
230
|
+
saveConfig(cfg, path);
|
|
231
|
+
render({ key, unset: true, path }, { output: opts.output, noColor: !opts.color });
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
|
|
235
|
+
process.exit(err.exitCode ?? 1);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
grp
|
|
239
|
+
.command('validate')
|
|
240
|
+
.description('Ensure RPC is reachable and the private key (if any) is valid')
|
|
241
|
+
.action(async (_localOpts, cmd) => {
|
|
242
|
+
const opts = getGlobalOpts(cmd);
|
|
243
|
+
try {
|
|
244
|
+
const path = opts.config ?? defaultConfigPath();
|
|
245
|
+
if (!fs.existsSync(path)) {
|
|
246
|
+
process.stderr.write(`No config file at ${path}\n`);
|
|
247
|
+
process.exit(4);
|
|
248
|
+
}
|
|
249
|
+
const cfg = loadConfig(path);
|
|
250
|
+
if (!cfg) {
|
|
251
|
+
process.stderr.write(`Could not parse config at ${path}\n`);
|
|
252
|
+
process.exit(4);
|
|
253
|
+
}
|
|
254
|
+
const rpcUrl = cfg.rpcUrl;
|
|
255
|
+
let chainId;
|
|
256
|
+
try {
|
|
257
|
+
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
258
|
+
const network = await provider.getNetwork();
|
|
259
|
+
chainId = Number(network.chainId);
|
|
260
|
+
if (chainId !== cfg.chainId) {
|
|
261
|
+
process.stderr.write(`Chain id mismatch: RPC reports ${chainId}, config says ${cfg.chainId}\n`);
|
|
262
|
+
process.exit(4);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
const msg = err.message ?? String(err);
|
|
267
|
+
process.stderr.write(`RPC unreachable: ${redactSecrets(msg)}\n`);
|
|
268
|
+
process.exit(4);
|
|
269
|
+
}
|
|
270
|
+
let address;
|
|
271
|
+
if (cfg.privateKey !== undefined) {
|
|
272
|
+
// Check the shape ourselves and print a fixed message. ethers' error
|
|
273
|
+
// for an odd-length value echoes the whole value, i.e. the key.
|
|
274
|
+
if (typeof cfg.privateKey !== 'string' || !PRIVATE_KEY_REGEX.test(cfg.privateKey)) {
|
|
275
|
+
process.stderr.write('Private key invalid: expected a 0x-prefixed 64-character hex string.\n');
|
|
276
|
+
process.exit(4);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
address = new ethers.Wallet(cfg.privateKey).address;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
process.stderr.write('Private key invalid: not a usable secp256k1 key.\n');
|
|
283
|
+
process.exit(4);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
render({
|
|
287
|
+
path,
|
|
288
|
+
chainId,
|
|
289
|
+
rpcUrl,
|
|
290
|
+
address: address ?? '<no signer>',
|
|
291
|
+
valid: true,
|
|
292
|
+
}, { output: opts.output, noColor: !opts.color });
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
|
|
296
|
+
process.exit(err.exitCode ?? 1);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as setup from './setup.js';
|
|
2
|
+
import * as config from './config.js';
|
|
3
|
+
import * as wallet from './wallet.js';
|
|
4
|
+
import * as book from './book.js';
|
|
5
|
+
/**
|
|
6
|
+
* Centralized command registration. Each module under `commands/` exports a
|
|
7
|
+
* `register(program)` function that attaches its subcommand tree to the root.
|
|
8
|
+
*/
|
|
9
|
+
export function registerCommands(program) {
|
|
10
|
+
setup.register(program);
|
|
11
|
+
config.register(program);
|
|
12
|
+
wallet.register(program);
|
|
13
|
+
book.register(program);
|
|
14
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Adapted from thetanuts-sdk cli/src/commands/setup.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
|
|
2
|
+
// MIT License, Copyright (c) 2026 Thetanuts Finance.
|
|
3
|
+
// Changes: no referrer prompt (the Odette referrer is fixed and is shown in the
|
|
4
|
+
// summary); names; config errors exit 4; `--dry-run` writes nothing.
|
|
5
|
+
// RPC error text is redacted before it is printed.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import prompts from 'prompts';
|
|
8
|
+
import { ethers } from 'ethers';
|
|
9
|
+
import { ODETTE_REFERRER } from '@thetanuts-finance/odette-sdk';
|
|
10
|
+
import { defaultConfigPath, loadConfig, saveConfig, stopForDryRun } from '../config.js';
|
|
11
|
+
import { DEFAULT_CHAIN_ID, DEFAULT_RPC_URL } from '../defaults.js';
|
|
12
|
+
import { getGlobalOpts } from '../options.js';
|
|
13
|
+
import { getClient } from '../client.js';
|
|
14
|
+
import { render, renderError, redactSecrets } from '../output.js';
|
|
15
|
+
const PRIVATE_KEY_REGEX = /^0x[0-9a-fA-F]{64}$/;
|
|
16
|
+
function checkNodeVersion() {
|
|
17
|
+
const v = process.versions.node;
|
|
18
|
+
const major = Number.parseInt(v.split('.')[0] ?? '0', 10);
|
|
19
|
+
if (Number.isNaN(major) || major < 18) {
|
|
20
|
+
throw new Error(`Node.js >= 18 is required, but found ${v}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function promptPrivateKey() {
|
|
24
|
+
// Re-prompt until valid or aborted.
|
|
25
|
+
while (true) {
|
|
26
|
+
const response = await prompts({
|
|
27
|
+
type: 'password',
|
|
28
|
+
name: 'pk',
|
|
29
|
+
message: 'Paste your private key (input hidden)',
|
|
30
|
+
});
|
|
31
|
+
const pk = response.pk;
|
|
32
|
+
if (!pk) {
|
|
33
|
+
throw new Error('Setup aborted (no private key provided)');
|
|
34
|
+
}
|
|
35
|
+
if (!PRIVATE_KEY_REGEX.test(pk)) {
|
|
36
|
+
process.stderr.write('Invalid private key. Expect a 0x-prefixed 64-char hex string.\n');
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return pk;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function promptAndValidateRpc(defaultUrl = DEFAULT_RPC_URL, expectedChainId = DEFAULT_CHAIN_ID) {
|
|
43
|
+
while (true) {
|
|
44
|
+
const response = await prompts({
|
|
45
|
+
type: 'text',
|
|
46
|
+
name: 'url',
|
|
47
|
+
message: 'RPC URL',
|
|
48
|
+
initial: defaultUrl,
|
|
49
|
+
});
|
|
50
|
+
const url = response.url?.trim();
|
|
51
|
+
if (!url) {
|
|
52
|
+
throw new Error('Setup aborted (no RPC URL provided)');
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const provider = new ethers.JsonRpcProvider(url);
|
|
56
|
+
const network = await provider.getNetwork();
|
|
57
|
+
const actual = Number(network.chainId);
|
|
58
|
+
if (actual !== expectedChainId) {
|
|
59
|
+
process.stderr.write(`RPC chain id mismatch: got ${actual}, expected ${expectedChainId}. Try a different URL.\n`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const msg = err.message ?? String(err);
|
|
66
|
+
process.stderr.write(`RPC connection failed: ${redactSecrets(msg)}\nTry a different URL.\n`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function register(program) {
|
|
72
|
+
program
|
|
73
|
+
.command('setup')
|
|
74
|
+
.description('Interactive first-run setup: wallet and Base RPC URL')
|
|
75
|
+
.action(async (_localOpts, cmd) => {
|
|
76
|
+
const opts = getGlobalOpts(cmd);
|
|
77
|
+
try {
|
|
78
|
+
if (stopForDryRun(opts.dryRun, 'setup', opts.config ?? defaultConfigPath()))
|
|
79
|
+
return;
|
|
80
|
+
process.stdout.write('Odette CLI setup\n');
|
|
81
|
+
process.stdout.write('----------------\n');
|
|
82
|
+
process.stdout.write(`Referrer for every fill (fixed): ${ODETTE_REFERRER}\n`);
|
|
83
|
+
checkNodeVersion();
|
|
84
|
+
const cfgPath = opts.config ?? defaultConfigPath();
|
|
85
|
+
if (fs.existsSync(cfgPath)) {
|
|
86
|
+
const ow = await prompts({
|
|
87
|
+
type: 'confirm',
|
|
88
|
+
name: 'ok',
|
|
89
|
+
message: `Config exists at ${cfgPath}. Overwrite?`,
|
|
90
|
+
initial: false,
|
|
91
|
+
});
|
|
92
|
+
if (!ow.ok) {
|
|
93
|
+
process.stderr.write('Aborted.\n');
|
|
94
|
+
process.exit(3);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Existing config (if any) gives sensible defaults.
|
|
98
|
+
const existing = loadConfig(cfgPath) ?? null;
|
|
99
|
+
const mode = await prompts({
|
|
100
|
+
type: 'select',
|
|
101
|
+
name: 'value',
|
|
102
|
+
message: 'How would you like to configure a wallet?',
|
|
103
|
+
choices: [
|
|
104
|
+
{ title: 'Create a new wallet (random key, saved locally)', value: 'create' },
|
|
105
|
+
{ title: 'Import an existing private key', value: 'import' },
|
|
106
|
+
{ title: 'Skip — read-only mode', value: 'skip' },
|
|
107
|
+
],
|
|
108
|
+
initial: 0,
|
|
109
|
+
});
|
|
110
|
+
if (mode.value === undefined) {
|
|
111
|
+
throw new Error('Setup aborted');
|
|
112
|
+
}
|
|
113
|
+
let privateKey;
|
|
114
|
+
if (mode.value === 'create') {
|
|
115
|
+
// Generate in memory → give user a single chance
|
|
116
|
+
// to back up the mnemonic → save raw key to disk. Once the user
|
|
117
|
+
// acknowledges (or declines backup), the mnemonic is unrecoverable.
|
|
118
|
+
const wallet = ethers.Wallet.createRandom();
|
|
119
|
+
privateKey = wallet.privateKey;
|
|
120
|
+
const mnemonic = wallet.mnemonic?.phrase;
|
|
121
|
+
process.stdout.write(`\nGenerated new wallet\n`);
|
|
122
|
+
process.stdout.write(` Address: ${wallet.address}\n\n`);
|
|
123
|
+
process.stdout.write('You have ONE chance to back up the mnemonic before it is discarded.\n' +
|
|
124
|
+
'Anyone with this mnemonic (or the saved private key) controls the wallet.\n\n');
|
|
125
|
+
const showBackup = await prompts({
|
|
126
|
+
type: 'confirm',
|
|
127
|
+
name: 'show',
|
|
128
|
+
message: 'Display mnemonic phrase now for backup?',
|
|
129
|
+
initial: false,
|
|
130
|
+
});
|
|
131
|
+
if (showBackup.show && mnemonic) {
|
|
132
|
+
process.stdout.write(`\n ${mnemonic}\n\n`);
|
|
133
|
+
const ack = await prompts({
|
|
134
|
+
type: 'confirm',
|
|
135
|
+
name: 'ok',
|
|
136
|
+
message: 'I have written down the mnemonic somewhere safe',
|
|
137
|
+
initial: false,
|
|
138
|
+
});
|
|
139
|
+
if (!ack.ok) {
|
|
140
|
+
process.stderr.write('Aborting setup. Wallet was NOT saved. Re-run `odette setup` to try again.\n');
|
|
141
|
+
process.exit(3);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write('Saving wallet to config...\n');
|
|
145
|
+
}
|
|
146
|
+
else if (mode.value === 'import') {
|
|
147
|
+
privateKey = await promptPrivateKey();
|
|
148
|
+
}
|
|
149
|
+
// hardcode Base chain for current setup of CLI (since v4 mostly on Base)
|
|
150
|
+
const chainId = 8453;
|
|
151
|
+
const defaultRpc = existing?.rpcUrl ?? DEFAULT_RPC_URL;
|
|
152
|
+
const rpcUrl = await promptAndValidateRpc(defaultRpc, chainId);
|
|
153
|
+
// Persist. Preserve unrelated fields from any prior config.
|
|
154
|
+
const keep = privateKey ?? existing?.privateKey;
|
|
155
|
+
const newCfg = {
|
|
156
|
+
version: 1,
|
|
157
|
+
chainId,
|
|
158
|
+
rpcUrl,
|
|
159
|
+
...(keep ? { privateKey: keep } : {}),
|
|
160
|
+
};
|
|
161
|
+
saveConfig(newCfg, cfgPath);
|
|
162
|
+
process.stdout.write(`Saved to ${cfgPath}\n`);
|
|
163
|
+
// If a key was imported, show wallet summary.
|
|
164
|
+
if (privateKey) {
|
|
165
|
+
try {
|
|
166
|
+
const { client } = getClient({ ...opts, config: cfgPath });
|
|
167
|
+
const address = await client.getSignerAddress();
|
|
168
|
+
const tokens = client.chainConfig.tokens;
|
|
169
|
+
const optionBookAddr = client.chainConfig.contracts.optionBook;
|
|
170
|
+
const usdc = tokens.USDC;
|
|
171
|
+
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
172
|
+
const ethBal = await provider.getBalance(address);
|
|
173
|
+
let usdcBalanceStr = '<n/a>';
|
|
174
|
+
let approvalStr = '<n/a>';
|
|
175
|
+
if (usdc) {
|
|
176
|
+
const balance = await client.erc20.getBalance(usdc.address, address);
|
|
177
|
+
usdcBalanceStr = client.utils.fromBigInt(balance, Number(usdc.decimals));
|
|
178
|
+
if (optionBookAddr) {
|
|
179
|
+
const allowance = await client.erc20.getAllowance(usdc.address, address, optionBookAddr);
|
|
180
|
+
approvalStr =
|
|
181
|
+
allowance === 0n
|
|
182
|
+
? 'not approved'
|
|
183
|
+
: `approved (${client.utils.fromBigInt(allowance, Number(usdc.decimals))})`;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
approvalStr = 'optionBook not deployed on this chain';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const summary = {
|
|
190
|
+
address,
|
|
191
|
+
referrer: ODETTE_REFERRER,
|
|
192
|
+
eth: client.utils.fromBigInt(ethBal, 18),
|
|
193
|
+
usdc: usdcBalanceStr,
|
|
194
|
+
optionBookApproval: approvalStr,
|
|
195
|
+
};
|
|
196
|
+
render(summary, { output: opts.output, noColor: !opts.color });
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
// Don't fail setup if the post-save wallet check fails — config is already written.
|
|
200
|
+
const msg = err.message ?? String(err);
|
|
201
|
+
process.stderr.write(`Warning: could not fetch wallet summary: ${redactSecrets(msg)}\n`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
if (existing?.privateKey) {
|
|
206
|
+
// Skip keeps the saved wallet (deleting it could destroy the only
|
|
207
|
+
// copy of a key). Say so; upstream printed "Read-only mode" here.
|
|
208
|
+
process.stdout.write('No new wallet. The existing wallet in this config is kept and can still sign. ' +
|
|
209
|
+
'To remove it, run `odette wallet reset`.\n');
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
process.stdout.write('Read-only mode. Run `odette wallet import` later to add a key.\n');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
renderError(err, { jsonErrors: Boolean(opts.jsonErrors), noColor: !opts.color });
|
|
218
|
+
process.exit(err.exitCode ?? 1);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|