@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.
@@ -0,0 +1,918 @@
1
+ import { Interface, MaxUint256 } from 'ethers';
2
+ import { ODETTE_REFERRER, OPTION_BOOK_ABI, } from '@thetanuts-finance/odette-sdk';
3
+ import { getGlobalOpts } from '../options.js';
4
+ import { getClient, requireSigner } from '../client.js';
5
+ import { render, renderError, buildTxReceiptPayload, fetchEthUsdSafe } from '../output.js';
6
+ import { confirm } from '../confirm.js';
7
+ import { formatStructureTicker, formatStrikeUsd, formatUsd, formatExpiry, tokenSymbolByAddress, underlyingSymbolByPriceFeed, } from '../format.js';
8
+ import { ORDER_PRICE_DECIMALS, bookOrderIneligibility, cashSettledImplementationSet, isEligibleBookOrder, } from '../bookEligibility.js';
9
+ import { bookPremiumCeiling, withActualBookPremium } from '../bookPreview.js';
10
+ import { findSameInstrument } from '../bookOrderIdentity.js';
11
+ import { findMatchingOrders, resolvePriceFeed } from '../bookMatch.js';
12
+ import { computeCheckResult, } from '../bookCheck.js';
13
+ function renderOpts(opts) {
14
+ return {
15
+ output: opts.output,
16
+ noColor: !opts.color,
17
+ jsonErrors: Boolean(opts.jsonErrors),
18
+ };
19
+ }
20
+ function eligibilityPolicy(client, direction = 'buy') {
21
+ const usdc = client.chainConfig.tokens.USDC;
22
+ if (!usdc)
23
+ throw new Error('USDC is missing from the active chain configuration.');
24
+ return {
25
+ usdcAddress: usdc.address,
26
+ cashSettledImplementations: cashSettledImplementationSet(client.chainConfig.implementations),
27
+ now: Math.floor(Date.now() / 1000),
28
+ direction,
29
+ };
30
+ }
31
+ function eligibleOrders(orders, client, direction = 'buy') {
32
+ const policy = eligibilityPolicy(client, direction);
33
+ return orders.filter((order) => isEligibleBookOrder(order, policy));
34
+ }
35
+ function assertEligibleOrder(order, client, direction = 'buy') {
36
+ const reason = bookOrderIneligibility(order, eligibilityPolicy(client, direction));
37
+ if (reason) {
38
+ throw new Error(`Order is not eligible for a cash-settled USDC ${direction}: ${reason}. ` +
39
+ 'Run `odette orders` and select one of the displayed orders.');
40
+ }
41
+ }
42
+ function previewBookFill(client, order, collateralAmount) {
43
+ return withActualBookPremium(client.optionBook.previewFillOrder(order, collateralAmount));
44
+ }
45
+ /**
46
+ * Per-invocation order cache so the same CLI run doesn't refetch when a command
47
+ * needs the orders list more than once (e.g. fill: preview, then encode/send)
48
+ */
49
+ let _ordersCache = null;
50
+ async function fetchOrdersOnce(client) {
51
+ if (_ordersCache)
52
+ return _ordersCache;
53
+ _ordersCache = await client.api.fetchOrders();
54
+ return _ordersCache;
55
+ }
56
+ /**
57
+ * Force-refetch live orders, bypassing the cache. Used by `book fill` right
58
+ * before broadcast to close the staleness window between confirm prompt and
59
+ * tx submission — mirrors the dApp's 30s React Query refetch behaviour
60
+ * (see thetanuts-1840 audit, `useOrders.ts`).
61
+ */
62
+ async function fetchOrdersFresh(client) {
63
+ const fresh = await client.api.fetchOrders();
64
+ _ordersCache = fresh;
65
+ return fresh;
66
+ }
67
+ /** Extract the option created by OptionBook.fillOrder from the mined receipt. */
68
+ function extractOrderFilledEvent(receipt, optionBookAddress) {
69
+ const iface = new Interface(OPTION_BOOK_ABI);
70
+ for (const log of receipt.logs) {
71
+ if (log.address.toLowerCase() !== optionBookAddress.toLowerCase())
72
+ continue;
73
+ try {
74
+ const parsed = iface.parseLog({ topics: [...log.topics], data: log.data });
75
+ if (!parsed || parsed.name !== 'OrderFilled')
76
+ continue;
77
+ return {
78
+ optionAddress: String(parsed.args['optionAddress']),
79
+ buyer: String(parsed.args['buyer']),
80
+ seller: String(parsed.args['seller']),
81
+ premiumAmountRaw: BigInt(parsed.args['premiumAmount']).toString(),
82
+ feeCollectedRaw: BigInt(parsed.args['feeCollected']).toString(),
83
+ // Surfaced so a fill can be audited for referral attribution without
84
+ // decoding the receipt on a block explorer.
85
+ referrer: String(parsed.args['referrer']),
86
+ referralFeePaidRaw: BigInt(parsed.args['referralFeePaid']).toString(),
87
+ sellerWasMaker: Boolean(parsed.args['sellerWasMaker']),
88
+ };
89
+ }
90
+ catch {
91
+ // Ignore unrelated/malformed logs; receipt success must still render.
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ function tickerForOrder(order, client) {
97
+ const raw = order.rawApiData;
98
+ if (!raw || raw.strikes.length === 0)
99
+ return undefined;
100
+ const underlying = underlyingSymbolByPriceFeed(raw.priceFeed, client.chainConfig.priceFeeds);
101
+ if (!underlying)
102
+ return undefined;
103
+ return formatStructureTicker(underlying, Number(order.order.expiry), raw.strikes.map((strike) => Number(strike) / 1e8), raw.isCall ? 'CALL' : 'PUT', client.chainConfig.optionImplementations[raw.implementation.toLowerCase()]?.name);
104
+ }
105
+ /**
106
+ * Parse a flag value strictly: the whole string must be a number. parseFloat
107
+ * and parseInt ignore trailing junk, so `--strike 2180x0` used to read as 2180
108
+ * and `--expiry 1793174400oops` as a valid expiry. Returns NaN when invalid.
109
+ */
110
+ function strictNumber(value) {
111
+ const s = (value ?? '').trim();
112
+ return /^\d+(\.\d+)?$/.test(s) ? Number(s) : Number.NaN;
113
+ }
114
+ function strictInt(value) {
115
+ const s = (value ?? '').trim();
116
+ return /^\d+$/.test(s) ? Number(s) : Number.NaN;
117
+ }
118
+ function resolveOrderByIndex(orders, rawIndex) {
119
+ if (rawIndex === undefined || rawIndex === null || rawIndex === '') {
120
+ throw new Error('--order-index is required');
121
+ }
122
+ if (typeof rawIndex === 'string' && rawIndex.startsWith('--')) {
123
+ throw new Error(`Invalid --order-index: "${rawIndex}" looks like a flag, not a number. ` +
124
+ 'Did the value resolve to empty (e.g. an unset shell variable like $INDEX)?');
125
+ }
126
+ const idx = typeof rawIndex === 'number' ? rawIndex : strictInt(String(rawIndex));
127
+ if (Number.isNaN(idx) || idx < 0) {
128
+ throw new Error(`Invalid --order-index: ${rawIndex}`);
129
+ }
130
+ if (idx >= orders.length) {
131
+ throw new Error(`Order index ${idx} out of range. Live orderbook has ${orders.length} order(s) (valid range: 0..${orders.length - 1}).`);
132
+ }
133
+ return orders[idx];
134
+ }
135
+ /**
136
+ * Compute the collateral amount (in raw token units) the user wants to spend
137
+ * on a fill.
138
+ * --collateral <human> e.g. 10 USDC -> 10 * 10^decimals
139
+ *
140
+ * Returns `undefined` when the flag wasn't passed; the SDK then fills max.
141
+ * Number-of-contracts is computed automatically by the SDK from this amount
142
+ * and the order's price-per-contract.
143
+ */
144
+ function computeCollateralAmount(order, client, flags) {
145
+ if (flags.collateral === undefined)
146
+ return undefined;
147
+ // An explicitly empty value (`--collateral "$BUDGET"` with BUDGET unset) is
148
+ // NOT "no budget": upstream read it as omitted and filled the maximum size.
149
+ const usage = (message) => Object.assign(new Error(message), { exitCode: 2 });
150
+ if (flags.collateral.trim() === '') {
151
+ throw usage('--collateral is empty. Pass the USDC amount to spend, for example --collateral 5.');
152
+ }
153
+ const decimals = collateralDecimalsFromOrder(order, client);
154
+ try {
155
+ return client.utils.toBigInt(flags.collateral, decimals);
156
+ }
157
+ catch {
158
+ throw usage(`Invalid --collateral "${flags.collateral}". Pass a USDC amount, for example 5 or 2.5.`);
159
+ }
160
+ }
161
+ function collateralDecimalsFromOrder(order, client) {
162
+ const addr = order.rawApiData?.collateral?.toLowerCase();
163
+ if (!addr) {
164
+ throw new Error('preview: order is missing rawApiData.collateral. Cannot determine ' +
165
+ 'decimal scale — refusing to fall back to 6-decimal default (TNU-AUDIT-0080).');
166
+ }
167
+ for (const cfg of Object.values(client.chainConfig.tokens)) {
168
+ if (cfg.address.toLowerCase() === addr)
169
+ return cfg.decimals;
170
+ }
171
+ // Fail loudly instead of silently rendering at 6-decimal — a future order with
172
+ // an unknown collateral token would otherwise mislead the trader.
173
+ throw new Error(`preview: unknown collateral token ${addr}. ` +
174
+ 'Add it to client.chainConfig.tokens before previewing (TNU-AUDIT-0080).');
175
+ }
176
+ function summarizeOrder(order, index) {
177
+ const strikes = order.rawApiData?.strikes ?? [];
178
+ return {
179
+ ...(index !== undefined ? { index } : {}),
180
+ product: order.product,
181
+ underlying: order.underlying,
182
+ maker: order.order.maker,
183
+ isCall: order.rawApiData?.isCall,
184
+ isLong: order.rawApiData?.isLong,
185
+ strikes: strikes.length ? strikes.join(',') : undefined,
186
+ pricePerContract: order.order.price.toString(),
187
+ expiry: order.order.expiry.toString(),
188
+ availableAmount: order.availableAmount.toString(),
189
+ collateral: order.rawApiData?.collateral,
190
+ implementation: order.rawApiData?.implementation,
191
+ settlement: 'cash',
192
+ orderExpiryTimestamp: order.rawApiData?.orderExpiryTimestamp,
193
+ };
194
+ }
195
+ /**
196
+ * Table-mode rendering of an order: replaces raw on-chain decimals with
197
+ * humanized columns. JSON output continues to call `summarizeOrder` so
198
+ * scripted consumers see the same byte-stable raw values as before.
199
+ *
200
+ * Columns: index | maker | ticker | strike | premium | expiry | available
201
+ * Plus a derived collateralSymbol when we can resolve it.
202
+ */
203
+ function summarizeOrderHuman(order, client, index) {
204
+ const rawStrikes = order.rawApiData?.strikes ?? [];
205
+ const strikesUsd = rawStrikes.map((s) => Number(s) / 1e8);
206
+ const isCall = order.rawApiData?.isCall ?? true;
207
+ const type = isCall ? 'CALL' : 'PUT';
208
+ const expiry = Number(order.order.expiry);
209
+ const underlyingSym = underlyingSymbolByPriceFeed(order.rawApiData?.priceFeed, client.chainConfig.priceFeeds) ??
210
+ 'UNKNOWN';
211
+ // TNU-AUDIT-0054: previously hardcoded /1e6, which mis-rendered premiums
212
+ // by 100x against the SDK's 8-decimal pricing convention. Derive scale from
213
+ // the order's collateral token so the table matches what `book check` and
214
+ // `humanizePreview` show pre-broadcast.
215
+ const collDec = collateralDecimalsFromOrder(order, client);
216
+ const priceScale = 10 ** ORDER_PRICE_DECIMALS;
217
+ const collScale = 10 ** collDec;
218
+ const premiumHuman = Number(order.order.price) / priceScale;
219
+ const availableHuman = Number(order.availableAmount) / collScale;
220
+ const collSym = tokenSymbolByAddress(order.rawApiData?.collateral, client.chainConfig.tokens) ?? 'UNKNOWN';
221
+ const implAddress = order.rawApiData?.implementation?.toLowerCase();
222
+ const implementation = implAddress
223
+ ? client.chainConfig.optionImplementations[implAddress]?.name ?? implAddress
224
+ : 'UNKNOWN';
225
+ return {
226
+ ...(index !== undefined ? { index } : {}),
227
+ maker: order.order.maker,
228
+ ticker: formatStructureTicker(underlyingSym, expiry, strikesUsd, type, implAddress ? client.chainConfig.optionImplementations[implAddress]?.name : undefined),
229
+ strike: formatStrikeUsd(strikesUsd),
230
+ premium: formatUsd(premiumHuman),
231
+ expiry: formatExpiry(expiry),
232
+ available: formatUsd(availableHuman),
233
+ collateralSymbol: collSym,
234
+ implementation,
235
+ settlement: 'cash',
236
+ };
237
+ }
238
+ /** Render pre-send check results: one row per check. */
239
+ function checkRows(checks) {
240
+ return checks.map((c) => ({
241
+ check: c.id,
242
+ name: c.name,
243
+ result: c.ok ? 'pass' : c.skipped ? 'skip' : 'FAIL',
244
+ detail: c.message,
245
+ }));
246
+ }
247
+ /**
248
+ * Humanized header for `book preview`: rebuilds the SDK preview shape with
249
+ * dollar-formatted strings and a derived ticker. Used only in table mode;
250
+ * JSON output preserves the raw SDK preview object.
251
+ */
252
+ function humanizePreview(preview, order, client) {
253
+ const strikesUsd = preview.strikes.map((s) => Number(s) / 1e8);
254
+ const type = preview.isCall ? 'CALL' : 'PUT';
255
+ const expiry = Number(preview.expiry);
256
+ const underlyingSym = underlyingSymbolByPriceFeed(order.rawApiData?.priceFeed, client.chainConfig.priceFeeds) ??
257
+ 'UNKNOWN';
258
+ const collDec = collateralDecimalsFromOrder(order, client);
259
+ const collSym = tokenSymbolByAddress(preview.collateralToken, client.chainConfig.tokens) ?? 'UNKNOWN';
260
+ // Contract quantities use collateral decimals (USDC = 6); only strikes and
261
+ // prices use the protocol's fixed 8-decimal scale.
262
+ const numContractsHuman = Number(preview.numContracts) / 10 ** collDec;
263
+ const maxContractsHuman = Number(preview.maxContracts) / 10 ** collDec;
264
+ const minContractsHuman = Number(preview.minContracts) / 10 ** collDec;
265
+ const pricePerContractHuman = Number(preview.pricePerContract) / 10 ** ORDER_PRICE_DECIMALS;
266
+ const totalCollateralHuman = Number(preview.totalCollateral) / 10 ** collDec;
267
+ // Multi-leg orders must not read as a vanilla `-C`/`-P` (bookMatch.structureInfo).
268
+ const structureName = client.chainConfig.optionImplementations[order.rawApiData.implementation.toLowerCase()]?.name;
269
+ return {
270
+ ticker: formatStructureTicker(underlyingSym, expiry, strikesUsd, type, structureName),
271
+ maker: preview.maker,
272
+ referrer: preview.referrer,
273
+ collateralToken: preview.collateralToken,
274
+ collateralSymbol: collSym,
275
+ product: order.product,
276
+ numContracts: numContractsHuman.toString(),
277
+ minContracts: minContractsHuman.toString(),
278
+ maxContracts: maxContractsHuman.toString(),
279
+ strikes: formatStrikeUsd(strikesUsd),
280
+ pricePerContract: formatUsd(pricePerContractHuman, 4),
281
+ // Table labels: what you pay, and the most you can be paid at expiry.
282
+ // (JSON keeps upstream's `totalCollateral` name for the premium.)
283
+ premium: formatUsd(totalCollateralHuman, 6),
284
+ maxPayout: formatUsd(Number(preview.maxPayout) / 10 ** collDec, 6),
285
+ expiry: formatExpiry(expiry),
286
+ };
287
+ }
288
+ /**
289
+ * Resolve an order from either --order-index OR the
290
+ * (--underlying/--type/--strike(s)/--expiry) selector group. Exactly one path
291
+ * must be specified.
292
+ *
293
+ * Exit codes:
294
+ * - 2 when no selector args at all (or both paths supplied)
295
+ * - 4 when zero live orders match the selector
296
+ *
297
+ * For multi-match, picks the cheapest pricePerContract (book fills always
298
+ * BUY per CLAUDE.md). `--strict` turns multi-match into a hard error instead.
299
+ */
300
+ function resolveOrderBySelector(orders, client, flags) {
301
+ const hasIndex = flags.orderIndex !== undefined && flags.orderIndex !== '';
302
+ const hasSelectorBits = (flags.underlying !== undefined && flags.underlying !== '') ||
303
+ (flags.type !== undefined && flags.type !== '') ||
304
+ (flags.strike !== undefined && flags.strike !== '') ||
305
+ (flags.strikes !== undefined && flags.strikes !== '') ||
306
+ (flags.expiry !== undefined && flags.expiry !== '');
307
+ if (!hasIndex && !hasSelectorBits) {
308
+ const err = new Error('Pick an order: pass either --order-index <n>, or the selector group ' +
309
+ '(--underlying <ETH|BTC> --type <PUT|CALL> --strike <usd> --expiry <unix-seconds>). ' +
310
+ 'The selector group is preferred — --order-index is volatile across calls.');
311
+ err.exitCode = 2;
312
+ throw err;
313
+ }
314
+ if (hasIndex && hasSelectorBits) {
315
+ const err = new Error('--order-index is mutually exclusive with --underlying/--type/--strike(s)/--expiry. Pick one path.');
316
+ err.exitCode = 2;
317
+ throw err;
318
+ }
319
+ if (hasIndex) {
320
+ process.stderr.write('Note: --order-index is volatile across calls. Prefer --underlying/--type/--strike/--expiry for stable selection.\n');
321
+ return resolveOrderByIndex(orders, flags.orderIndex);
322
+ }
323
+ // Selector path — validate.
324
+ const missing = [];
325
+ if (!flags.underlying)
326
+ missing.push('--underlying');
327
+ if (!flags.type)
328
+ missing.push('--type');
329
+ if (!flags.strike && !flags.strikes)
330
+ missing.push('--strike (or --strikes)');
331
+ if (!flags.expiry)
332
+ missing.push('--expiry');
333
+ if (missing.length > 0) {
334
+ const err = new Error(`Selector path is incomplete — missing: ${missing.join(', ')}. ` +
335
+ 'Pass all of --underlying, --type, --strike (or --strikes), --expiry.');
336
+ err.exitCode = 2;
337
+ throw err;
338
+ }
339
+ const underlying = flags.underlying.toUpperCase();
340
+ const typeInput = flags.type.toUpperCase();
341
+ // RANGER and LINEAR_CALL orders carry isCall=true, so they match as CALL;
342
+ // the strike count then tells them apart (RANGER has 4).
343
+ const isRanger = typeInput === 'RANGER';
344
+ const type = isRanger || typeInput === 'LINEAR_CALL' ? 'CALL' : typeInput;
345
+ if (type !== 'PUT' && type !== 'CALL') {
346
+ const err = new Error(`Invalid --type "${flags.type}". Expected PUT, CALL or RANGER.`);
347
+ err.exitCode = 2;
348
+ throw err;
349
+ }
350
+ if (isRanger && (!flags.strikes || flags.strikes.split(',').length !== 4)) {
351
+ const err = new Error('--type RANGER needs --strikes with 4 values, e.g. --strikes 78500,79000,80000,80500.');
352
+ err.exitCode = 2;
353
+ throw err;
354
+ }
355
+ const wantStrikesUsd = flags.strikes
356
+ ? flags.strikes.split(',').map((s) => strictNumber(s))
357
+ : [strictNumber(flags.strike)];
358
+ if (wantStrikesUsd.some((n) => Number.isNaN(n) || n <= 0)) {
359
+ const err = new Error(`Invalid strikes: ${flags.strikes ?? flags.strike}. Expected positive USD numbers.`);
360
+ err.exitCode = 2;
361
+ throw err;
362
+ }
363
+ const wantStrikesRaw = wantStrikesUsd.map((s) => BigInt(Math.round(s * 1e8)));
364
+ const wantExpiry = strictInt(flags.expiry);
365
+ if (Number.isNaN(wantExpiry) || wantExpiry <= 0) {
366
+ const err = new Error(`Invalid --expiry "${flags.expiry}". Expected unix seconds.`);
367
+ err.exitCode = 2;
368
+ throw err;
369
+ }
370
+ // Resolve underlying -> price feed for matching.
371
+ const feedResult = resolvePriceFeed(underlying, client.chainConfig.priceFeeds);
372
+ if ('error' in feedResult) {
373
+ const err = new Error(feedResult.error.replace('underlying', '--underlying'));
374
+ err.exitCode = 2;
375
+ throw err;
376
+ }
377
+ const wantPriceFeed = feedResult.feed;
378
+ // Shared predicate — `book check` uses the same one, so the two commands
379
+ // cannot disagree about what the book holds.
380
+ const matches = findMatchingOrders(orders, {
381
+ priceFeed: wantPriceFeed,
382
+ type,
383
+ strikesRaw: wantStrikesRaw,
384
+ expiry: wantExpiry,
385
+ });
386
+ if (matches.length === 0) {
387
+ const strikeLabel = wantStrikesUsd.join('/');
388
+ const err = new Error(`No live order matches underlying=${underlying} type=${type} strike=${strikeLabel} expiry=${wantExpiry}. ` +
389
+ `Run 'odette orders --underlying ${underlying} --type ${type}' to see what's listed.`);
390
+ err.exitCode = 4;
391
+ throw err;
392
+ }
393
+ if (matches.length === 1) {
394
+ return matches[0];
395
+ }
396
+ // Two implementations can share a strike count, type and expiry (e.g.
397
+ // CALL_CONDOR vs IRON_CONDOR, both 4 strikes). Their payoffs differ, so
398
+ // silently picking the cheaper one would hand the caller a different product
399
+ // than they priced. Make them disambiguate.
400
+ const matchedStructures = new Set(matches.map((o) => client.chainConfig.optionImplementations[o.rawApiData?.implementation?.toLowerCase() ?? '']?.name ?? 'UNKNOWN'));
401
+ if (matchedStructures.size > 1) {
402
+ const err = new Error(`Selector matches more than one structure: ${[...matchedStructures].sort().join(', ')}. ` +
403
+ 'These have different payoffs — pass --order-index to pick one explicitly.');
404
+ err.exitCode = 4;
405
+ throw err;
406
+ }
407
+ // Multi-match. --strict: hard error. Default: cheapest BUY (lowest price).
408
+ if (flags.strict) {
409
+ const err = new Error(`${matches.length} live orders match the selector. Pass --order-index to disambiguate, or drop --strict.`);
410
+ err.exitCode = 4;
411
+ throw err;
412
+ }
413
+ const chosen = matches.reduce((best, curr) => curr.order.price < best.order.price ? curr : best);
414
+ process.stderr.write(`Selected order from maker ${chosen.order.maker} (cheapest of ${matches.length} matching).\n`);
415
+ return chosen;
416
+ }
417
+ // ---------------------------------------------------------------------------
418
+ // register()
419
+ // ---------------------------------------------------------------------------
420
+ export function register(program) {
421
+ // Top level: `odette orders`, `odette fill`, ... (upstream: `thetanuts book ...`).
422
+ const grp = program;
423
+ registerReads(grp);
424
+ registerCheck(grp);
425
+ registerWrites(grp);
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // Pre-trade liquidity check
429
+ // ---------------------------------------------------------------------------
430
+ function registerCheck(grp) {
431
+ grp
432
+ .command('check')
433
+ .description('Pre-trade liquidity check: scan the Odette book for a matching strike/expiry/type ' +
434
+ '(including strikes that sit on a structure leg)')
435
+ .requiredOption('--underlying <asset>', 'underlying asset (e.g. ETH, BTC, SOL)')
436
+ .requiredOption('--type <type>', 'option type (PUT|CALL)')
437
+ .requiredOption('--strike <price>', 'target strike price in USD')
438
+ .requiredOption('--expiry <ts>', 'expiry unix timestamp')
439
+ .requiredOption('--direction <dir>', 'buy (the only side: the CLI buys options, it never sells or closes)')
440
+ .option('--size <contracts>', 'desired contract size (if omitted, shows all available)')
441
+ .action(async (_local, cmd) => {
442
+ const opts = getGlobalOpts(cmd);
443
+ const local = cmd.opts();
444
+ const underlying = local.underlying.toUpperCase();
445
+ const type = local.type.toUpperCase();
446
+ const direction = local.direction.toLowerCase();
447
+ const strike = strictNumber(local.strike);
448
+ const expiry = strictInt(local.expiry);
449
+ const size = local.size !== undefined ? strictNumber(local.size) : undefined;
450
+ // Underlying is validated against chain config after the client exists —
451
+ // the old hardcoded ETH|BTC gate made every SOL/DOGE/XRP/BNB/AVAX order
452
+ // on the book unreachable from this command.
453
+ const missing = [];
454
+ if (!['PUT', 'CALL'].includes(type))
455
+ missing.push('--type (PUT|CALL)');
456
+ if (!strike || Number.isNaN(strike))
457
+ missing.push('--strike (price)');
458
+ if (!expiry || Number.isNaN(expiry))
459
+ missing.push('--expiry (unix timestamp)');
460
+ if (direction !== 'buy')
461
+ missing.push('--direction (buy — Odette buys options only)');
462
+ if (size !== undefined && Number.isNaN(size))
463
+ missing.push('--size (number)');
464
+ if (missing.length > 0) {
465
+ renderError(new Error(`Missing or invalid required parameters: ${missing.join(', ')}`), renderOpts(opts));
466
+ process.exit(2);
467
+ }
468
+ const params = {
469
+ underlying,
470
+ type: type,
471
+ strike,
472
+ expiry,
473
+ direction: direction,
474
+ ...(size !== undefined ? { size } : {}),
475
+ };
476
+ try {
477
+ const { client, referrer } = getClient(opts);
478
+ const feedResult = resolvePriceFeed(underlying, client.chainConfig.priceFeeds);
479
+ if ('error' in feedResult) {
480
+ renderError(new Error(feedResult.error), renderOpts(opts));
481
+ process.exit(1);
482
+ return;
483
+ }
484
+ // Buy side only: Odette orders are maker asks.
485
+ const orders = eligibleOrders(await fetchOrdersOnce(client), client, params.direction);
486
+ const core = computeCheckResult(orders, params, {
487
+ priceFeed: feedResult.feed,
488
+ implementations: client.chainConfig.optionImplementations,
489
+ maxContracts: (o) => client.optionBook.calculateMaxContracts(o),
490
+ contractScale: 10 ** (client.chainConfig.tokens.USDC?.decimals ?? 6),
491
+ cliExecutable: params.direction === 'buy',
492
+ referrer,
493
+ });
494
+ render(core, renderOpts(opts));
495
+ }
496
+ catch (err) {
497
+ renderError(err, renderOpts(opts));
498
+ process.exit(err.exitCode ?? 1);
499
+ }
500
+ });
501
+ }
502
+ // ---------------------------------------------------------------------------
503
+ // Reads
504
+ // ---------------------------------------------------------------------------
505
+ function registerReads(grp) {
506
+ grp
507
+ .command('orders')
508
+ .description('List Odette orders you can buy now (PUT, LINEAR_CALL, RANGER on USDC)')
509
+ .option('--underlying <asset>', 'filter by underlying (e.g. ETH, BTC)')
510
+ .option('--type <type>', 'filter by product: PUT, CALL or RANGER')
511
+ .option('--min-expiry <ts>', 'filter by minimum expiry timestamp (unix seconds)')
512
+ .action(async (_local, cmd) => {
513
+ const opts = getGlobalOpts(cmd);
514
+ const local = cmd.opts();
515
+ try {
516
+ const { client } = getClient(opts);
517
+ // Fetch the full book and filter client-side. The SDK's filterOrders()
518
+ // currently throws on certain API response shapes (`response.orders`
519
+ // undefined); fetching once and filtering here keeps the CLI working
520
+ // and preserves the per-invocation cache for downstream commands.
521
+ const allOrders = await fetchOrdersOnce(client);
522
+ // Canonical CLI book: cash-settled USDC asks only. Assign indices
523
+ // before applying display filters so `--underlying` / `--type` never
524
+ // renumber a row relative to `book preview/fill --order-index`.
525
+ const indexedEligible = eligibleOrders(allOrders, client, 'buy').map((order, index) => ({ order, index }));
526
+ // Odette products: PUT, CALL (= LINEAR_CALL) and RANGER. A RANGER
527
+ // carries isCall=true on chain but is not a call, so filter by product.
528
+ let wantProduct = null;
529
+ if (local.type) {
530
+ const upper = local.type.toUpperCase();
531
+ const map = {
532
+ PUT: 'PUT', CALL: 'LINEAR_CALL', LINEAR_CALL: 'LINEAR_CALL', RANGER: 'RANGER',
533
+ };
534
+ if (!map[upper]) {
535
+ const err = new Error(`Invalid --type "${local.type}". Expected PUT, CALL or RANGER.`);
536
+ err.exitCode = 2;
537
+ throw err;
538
+ }
539
+ wantProduct = map[upper];
540
+ }
541
+ const minExpiry = local.minExpiry
542
+ ? strictInt(local.minExpiry)
543
+ : null;
544
+ if (minExpiry !== null && Number.isNaN(minExpiry)) {
545
+ const err = new Error(`Invalid --min-expiry "${local.minExpiry}". Expected unix seconds.`);
546
+ err.exitCode = 2;
547
+ throw err;
548
+ }
549
+ // Resolve underlying symbol -> price-feed address via chain config so
550
+ // we can match the order's priceFeed instead of relying on the API
551
+ // server-side filter.
552
+ let wantPriceFeed = null;
553
+ if (local.underlying) {
554
+ const feed = client.chainConfig.priceFeeds[local.underlying.toUpperCase()];
555
+ if (!feed) {
556
+ const known = Object.keys(client.chainConfig.priceFeeds).join(', ');
557
+ throw new Error(`Unknown --underlying "${local.underlying}". Known: ${known}.`);
558
+ }
559
+ wantPriceFeed = feed.toLowerCase();
560
+ }
561
+ const filtered = indexedEligible.filter(({ order: o }) => {
562
+ if (wantProduct !== null && o.product !== wantProduct)
563
+ return false;
564
+ if (wantPriceFeed !== null) {
565
+ const pf = o.rawApiData?.priceFeed?.toLowerCase();
566
+ if (pf !== wantPriceFeed)
567
+ return false;
568
+ }
569
+ if (minExpiry !== null) {
570
+ if (Number(o.order.expiry) < minExpiry)
571
+ return false;
572
+ }
573
+ return true;
574
+ });
575
+ // Table mode gets humanized columns (ticker, $-formatted strike/
576
+ // premium/available, ISO expiry). JSON/CSV/YAML stay byte-stable with
577
+ // the raw on-chain decimals scripts depend on.
578
+ const ro = renderOpts(opts);
579
+ const isTable = (ro.output ?? 'table') === 'table';
580
+ const rows = isTable
581
+ ? filtered.map(({ order, index }) => summarizeOrderHuman(order, client, index))
582
+ : filtered.map(({ order, index }) => summarizeOrder(order, index));
583
+ render(rows, ro);
584
+ }
585
+ catch (err) {
586
+ renderError(err, renderOpts(opts));
587
+ process.exit(err.exitCode ?? 1);
588
+ }
589
+ });
590
+ grp
591
+ .command('preview')
592
+ .description('Preview a fill against an order without sending a transaction')
593
+ .option('--order-index <n>', 'index into the live orders array (legacy — prefer the selector flags below)')
594
+ .option('--underlying <asset>', 'ETH or BTC (use with --type/--strike/--expiry instead of --order-index)')
595
+ .option('--type <type>', 'PUT, CALL or RANGER (use with --underlying/--strike(s)/--expiry)')
596
+ .option('--strike <usd>', 'single strike in USD (vanilla)')
597
+ .option('--strikes <csv>', 'comma-separated strikes in USD (multi-leg)')
598
+ .option('--expiry <ts>', 'unix expiry seconds')
599
+ .option('--strict', 'when multiple orders match the selector, error instead of picking the cheapest')
600
+ .option('--collateral <n>', 'USDC amount to spend on premium (e.g. 1 for $1). Number of contracts is auto-derived from the order price. Omit to preview a max fill.')
601
+ .action(async (_local, cmd) => {
602
+ const opts = getGlobalOpts(cmd);
603
+ const local = cmd.opts();
604
+ try {
605
+ const res = getClient(opts);
606
+ const { client } = res;
607
+ const orders = eligibleOrders(await fetchOrdersOnce(client), client, 'buy');
608
+ const order = resolveOrderBySelector(orders, client, local);
609
+ const collateralAmount = computeCollateralAmount(order, client, local);
610
+ const preview = previewBookFill(client, order, collateralAmount);
611
+ // Read-only pre-send checks. Without a signer the taker checks (11-13)
612
+ // cannot run; the chain-side checks (1-10) still do.
613
+ const checks = await client.optionBook.checkFill(order, collateralAmount, {
614
+ simulate: res.hasSigner,
615
+ });
616
+ const ro = renderOpts(opts);
617
+ const isTable = (ro.output ?? 'table') === 'table';
618
+ if (isTable) {
619
+ render(humanizePreview(preview, order, client), ro);
620
+ process.stdout.write('\nChecks:\n');
621
+ render(checkRows(checks), ro);
622
+ }
623
+ else {
624
+ render({ ...preview, checks }, ro);
625
+ }
626
+ }
627
+ catch (err) {
628
+ renderError(err, renderOpts(opts));
629
+ const exit = err.exitCode ?? 1;
630
+ process.exit(exit);
631
+ }
632
+ });
633
+ grp
634
+ .command('max-contracts')
635
+ .description('Compute the maximum fillable contracts for an order')
636
+ .option('--order-index <n>', 'index into the live orders array (legacy)')
637
+ .option('--underlying <asset>', 'ETH or BTC')
638
+ .option('--type <type>', 'PUT, CALL or RANGER')
639
+ .option('--strike <usd>', 'single strike in USD')
640
+ .option('--strikes <csv>', 'comma-separated strikes in USD')
641
+ .option('--expiry <ts>', 'unix expiry seconds')
642
+ .option('--strict', 'when multiple orders match, error instead of picking the cheapest')
643
+ .action(async (_local, cmd) => {
644
+ const opts = getGlobalOpts(cmd);
645
+ const local = cmd.opts();
646
+ try {
647
+ const { client } = getClient(opts);
648
+ const orders = eligibleOrders(await fetchOrdersOnce(client), client, 'buy');
649
+ const order = resolveOrderBySelector(orders, client, local);
650
+ const maxContracts = client.optionBook.calculateMaxContracts(order);
651
+ render({ maxContracts: maxContracts.toString() }, renderOpts(opts));
652
+ }
653
+ catch (err) {
654
+ renderError(err, renderOpts(opts));
655
+ const exit = err.exitCode ?? 1;
656
+ process.exit(exit);
657
+ }
658
+ });
659
+ }
660
+ // ---------------------------------------------------------------------------
661
+ // Writes
662
+ // ---------------------------------------------------------------------------
663
+ function registerWrites(grp) {
664
+ grp
665
+ .command('fill')
666
+ .description('Fill an order (preview → allowance check → confirm → send)')
667
+ .option('--order-index <n>', 'index into the live orders array (legacy — prefer the selector flags below)')
668
+ .option('--underlying <asset>', 'ETH or BTC (use with --type/--strike/--expiry instead of --order-index)')
669
+ .option('--type <type>', 'PUT, CALL or RANGER')
670
+ .option('--strike <usd>', 'single strike in USD (vanilla)')
671
+ .option('--strikes <csv>', 'comma-separated strikes in USD (multi-leg)')
672
+ .option('--expiry <ts>', 'unix expiry seconds')
673
+ .option('--strict', 'when multiple orders match the selector, error instead of picking the cheapest')
674
+ .option('--collateral <n>', 'USDC amount to spend on premium (e.g. 1 for $1). Number of contracts is auto-derived from the order price. Omit to fill the maximum available.')
675
+ .option('--approve-amount <max|n>', 'approval amount when allowance is insufficient: "exact", "max" (unlimited, MaxUint256), or a decimal USDC amount. Default: the --collateral budget, or the exact premium for a max fill.')
676
+ .action(async (_local, cmd) => {
677
+ const opts = getGlobalOpts(cmd);
678
+ const local = cmd.opts();
679
+ try {
680
+ // Without --collateral, fill buys the order's maximum size. Never do
681
+ // that unattended: --yes must come with an explicit budget.
682
+ if (opts.yes && !opts.dryRun && (local.collateral === undefined || local.collateral.trim() === '')) {
683
+ const err = new Error('--yes needs --collateral <usdc>. Without it, fill buys the maximum size of the order. ' +
684
+ 'Pass the USDC you want to spend, or drop --yes to confirm the size yourself.');
685
+ err.exitCode = 2;
686
+ throw err;
687
+ }
688
+ if (!opts.dryRun && local.orderIndex !== undefined && local.orderIndex !== '') {
689
+ const err = new Error('Live book fills require the stable selector flags ' +
690
+ '(--underlying, --type, --strike/--strikes, --expiry). ' +
691
+ '--order-index is volatile and is allowed only with --dry-run.');
692
+ err.exitCode = 2;
693
+ throw err;
694
+ }
695
+ const res = getClient(opts);
696
+ requireSigner(res);
697
+ const { client } = res;
698
+ const orders = eligibleOrders(await fetchOrdersOnce(client), client, 'buy');
699
+ const order = resolveOrderBySelector(orders, client, local);
700
+ assertEligibleOrder(order, client, 'buy');
701
+ const collateralAmount = computeCollateralAmount(order, client, local);
702
+ // 1+2+3: build preview, render it.
703
+ const preview = previewBookFill(client, order, collateralAmount);
704
+ const ro = renderOpts(opts);
705
+ const isTable = (ro.output ?? 'table') === 'table';
706
+ const previewWithPayout = isTable
707
+ ? humanizePreview(preview, order, client)
708
+ : { ...preview };
709
+ // In table mode the preview is its own block. For machine output we
710
+ // hold it back and fold it into the single dry-run object below —
711
+ // emitting two top-level JSON documents on stdout made `book fill
712
+ // --dry-run -o json` unparseable by `jq`/`JSON.parse`.
713
+ const deferPreview = !isTable && opts.dryRun;
714
+ if (!deferPreview)
715
+ render(previewWithPayout, ro);
716
+ const collateralAddr = preview.collateralToken;
717
+ const spender = preview.optionBook;
718
+ // Approve the ceiling, not the floored display premium: one unit of
719
+ // slack costs 1e-6 USDC and survives either contract rounding rule.
720
+ const required = bookPremiumCeiling(preview);
721
+ // Resolve how much to approve, IF approval ends up being needed.
722
+ // undefined → the --collateral budget (premium never exceeds it, so
723
+ // a re-quote before send cannot leave the allowance short),
724
+ // or the exact premium for a max fill
725
+ // "exact" → the exact premium
726
+ // "max" → MaxUint256
727
+ // numeric → toBigInt(value, decimals); below the premium is refused
728
+ const collateralDecimals = collateralDecimalsFromOrder(order, client);
729
+ let approveAmount;
730
+ let approveIsMax = false;
731
+ if (local.approveAmount === undefined) {
732
+ approveAmount = collateralAmount !== undefined && collateralAmount > required ? collateralAmount : required;
733
+ }
734
+ else if (local.approveAmount === 'exact') {
735
+ approveAmount = required;
736
+ }
737
+ else if (local.approveAmount === 'max') {
738
+ approveAmount = MaxUint256;
739
+ approveIsMax = true;
740
+ }
741
+ else {
742
+ let parsed;
743
+ try {
744
+ parsed = client.utils.toBigInt(local.approveAmount, collateralDecimals);
745
+ }
746
+ catch {
747
+ const err = new Error(`Invalid --approve-amount "${local.approveAmount}". Use exact, max, or a USDC amount.`);
748
+ err.exitCode = 2;
749
+ throw err;
750
+ }
751
+ if (parsed < required) {
752
+ const err = new Error(`--approve-amount ${local.approveAmount} USDC is below this fill's premium ` +
753
+ `(${client.utils.fromBigInt(required, collateralDecimals)} USDC). The fill would fail after the approval.`);
754
+ err.exitCode = 2;
755
+ throw err;
756
+ }
757
+ approveAmount = parsed;
758
+ }
759
+ // Dry-run: always emit the fill calldata regardless of allowance, plus
760
+ // the would-be approve calldata if approval is needed. The user can
761
+ // then submit both via a separate signer. We never prompt or broadcast.
762
+ if (opts.dryRun) {
763
+ const signerAddrDryRun = await client.signer.getAddress();
764
+ const currentAllowanceDryRun = await client.erc20.getAllowance(collateralAddr, signerAddrDryRun, spender);
765
+ const fillEncoded = client.optionBook.encodeFillOrder(order, collateralAmount);
766
+ let approveEncoded = null;
767
+ if (currentAllowanceDryRun < required) {
768
+ approveEncoded = client.erc20.encodeApprove(collateralAddr, spender, approveAmount);
769
+ }
770
+ // In table mode the raw fill calldata (often 4 KB+) crushes the
771
+ // terminal; flag the renderer to truncate hex blobs. JSON/CSV/YAML
772
+ // output is untouched so machine consumers still receive the full
773
+ // payload. For the table path we substitute an explicit marker on
774
+ // null-approve so the user doesn't see a confusing empty cell;
775
+ // JSON consumers still get `approve: null` via the json branch.
776
+ const ro = renderOpts(opts);
777
+ const isTable = (ro.output ?? 'table') === 'table';
778
+ const approveCell = isTable && approveEncoded === null
779
+ ? '(none — allowance sufficient)'
780
+ : approveEncoded;
781
+ // Pre-send checks. Simulate only when the allowance already covers
782
+ // the fill; otherwise the simulation would revert on approval alone.
783
+ const checksDryRun = await client.optionBook.checkFill(order, collateralAmount, {
784
+ simulate: currentAllowanceDryRun >= required,
785
+ });
786
+ render({
787
+ dryRun: true,
788
+ referrer: ODETTE_REFERRER,
789
+ ...(deferPreview ? { preview: previewWithPayout } : {}),
790
+ approve: approveCell,
791
+ fill: fillEncoded,
792
+ ...(isTable ? {} : { checks: checksDryRun }),
793
+ }, { ...ro, truncate: true });
794
+ if (isTable) {
795
+ process.stdout.write('\nChecks:\n');
796
+ render(checkRows(checksDryRun), ro);
797
+ }
798
+ process.exit(0);
799
+ }
800
+ // Live path: run the pre-send checks before any prompt. A missing
801
+ // approval (check 12) is fixed by the approve step below; any other
802
+ // failure stops here.
803
+ const preChecks = await client.optionBook.checkFill(order, collateralAmount, { simulate: false });
804
+ const blocking = preChecks.filter((c) => !c.ok && c.id !== 12);
805
+ if (blocking.length > 0) {
806
+ render(checkRows(preChecks), ro);
807
+ const err = new Error(blocking.map((c) => c.message).join(' '));
808
+ err.exitCode = 1;
809
+ throw err;
810
+ }
811
+ // Allowance check on the collateral token.
812
+ const signerAddr = await client.signer.getAddress();
813
+ const currentAllowance = await client.erc20.getAllowance(collateralAddr, signerAddr, spender);
814
+ if (currentAllowance < required) {
815
+ process.stderr.write(`Allowance is ${currentAllowance.toString()}, need ${required.toString()}. ` +
816
+ `Approving the OptionBook contract is required to proceed.\n`);
817
+ if (approveIsMax) {
818
+ process.stderr.write('WARNING: approving MaxUint256. The spender will be able to move any amount.\n');
819
+ }
820
+ const approvePromptAmount = approveIsMax
821
+ ? 'unlimited (MaxUint256)'
822
+ : approveAmount.toString();
823
+ const approveOk = await confirm(`Approve ${approvePromptAmount} ${collateralAddr} to ${spender}?`, { yes: opts.yes, dryRun: opts.dryRun });
824
+ if (!approveOk) {
825
+ process.stderr.write('Approval declined; aborting fill.\n');
826
+ process.exit(3);
827
+ }
828
+ await client.erc20.ensureAllowance(collateralAddr, spender, approveAmount);
829
+ }
830
+ // Final confirm for the fill itself (separate prompt — never bundled).
831
+ const ok = await confirm('Proceed with fill?', {
832
+ yes: opts.yes,
833
+ dryRun: opts.dryRun,
834
+ });
835
+ if (!ok)
836
+ process.exit(3);
837
+ // Refetch the live book RIGHT before broadcast to close the staleness
838
+ // window: the user may have paused on a prompt. Odette re-signs every
839
+ // order about once a minute with a new price, so match the same
840
+ // instrument (not the same signature) and re-confirm changed terms.
841
+ // This runs even under --yes; only --dry-run skips it above.
842
+ const freshOrders = await fetchOrdersFresh(client);
843
+ const freshOrder = findSameInstrument(freshOrders, order);
844
+ if (!freshOrder) {
845
+ process.stderr.write('This instrument is no longer in the Odette book (filled, cancelled or expired). Aborting.\n');
846
+ process.exit(1);
847
+ }
848
+ assertEligibleOrder(freshOrder, client, 'buy');
849
+ const freshPreview = previewBookFill(client, freshOrder, collateralAmount);
850
+ if (freshPreview.numContracts !== preview.numContracts ||
851
+ freshPreview.totalCollateral !== preview.totalCollateral) {
852
+ const usd = (v, d) => client.utils.fromBigInt(v, d);
853
+ process.stderr.write(`Odette re-quoted this order: price ${usd(preview.pricePerContract, ORDER_PRICE_DECIMALS)} -> ` +
854
+ `${usd(freshPreview.pricePerContract, ORDER_PRICE_DECIMALS)} USD per contract, ` +
855
+ `contracts ${usd(preview.numContracts, collateralDecimals)} -> ${usd(freshPreview.numContracts, collateralDecimals)}, ` +
856
+ `premium ${usd(preview.totalCollateral, collateralDecimals)} -> ${usd(freshPreview.totalCollateral, collateralDecimals)} USDC.\n`);
857
+ const priceRose = freshPreview.pricePerContract > preview.pricePerContract;
858
+ if (opts.yes && priceRose) {
859
+ process.stderr.write('The price rose. --yes does not accept a worse price. Run `odette fill` again.\n');
860
+ process.exit(3);
861
+ }
862
+ if (!opts.yes) {
863
+ const again = await confirm('Proceed at the new terms?', { yes: false, dryRun: opts.dryRun });
864
+ if (!again)
865
+ process.exit(3);
866
+ }
867
+ }
868
+ // 7+8: send + render receipt — broadcast against the FRESH order.
869
+ const receipt = await client.optionBook.fillOrder(freshOrder, collateralAmount, {
870
+ onSent: (h) => process.stderr.write(`Fill sent: ${h} (https://basescan.org/tx/${h}). Waiting for the receipt. Do not run fill again.\n`),
871
+ });
872
+ // Best-effort spot fetch for USD-equivalent gas cost. Never blocks
873
+ // success rendering — `fetchEthUsdSafe` swallows API failures.
874
+ // The fill is mined: show its hash before any optional lookup, so it is
875
+ // on screen even if the user interrupts. Never re-run a mined fill.
876
+ process.stderr.write(`Fill mined: ${receipt.hash} (https://basescan.org/tx/${receipt.hash})\n`);
877
+ const ethUsd = await fetchEthUsdSafe(client.api);
878
+ const fillEvent = extractOrderFilledEvent(receipt, freshPreview.optionBook);
879
+ // Print the referrer the CHAIN recorded, and say so when it is not Odette's.
880
+ const recordedReferrer = fillEvent?.['referrer'];
881
+ const referrerMatches = typeof recordedReferrer === 'string' &&
882
+ recordedReferrer.toLowerCase() === ODETTE_REFERRER.toLowerCase();
883
+ if (!referrerMatches) {
884
+ process.stderr.write(`Referrer mismatch: the chain recorded ${String(recordedReferrer ?? 'no OrderFilled event')}; ` +
885
+ `expected ${ODETTE_REFERRER}. This is a bug. Report it with the tx hash.\n`);
886
+ }
887
+ const optionAddress = fillEvent?.['optionAddress'];
888
+ const usdcDecimals = client.chainConfig.tokens.USDC?.decimals ?? 6;
889
+ const premiumAmountRaw = fillEvent?.['premiumAmountRaw'];
890
+ const feeCollectedRaw = fillEvent?.['feeCollectedRaw'];
891
+ const extra = {
892
+ ticker: tickerForOrder(freshOrder, client),
893
+ ...(fillEvent ?? {}),
894
+ ...(typeof premiumAmountRaw === 'string'
895
+ ? {
896
+ premiumPaid: `${formatUsd(Number(premiumAmountRaw) / 10 ** usdcDecimals, 6)} USDC`,
897
+ }
898
+ : {}),
899
+ ...(typeof feeCollectedRaw === 'string'
900
+ ? {
901
+ protocolFee: `${formatUsd(Number(feeCollectedRaw) / 10 ** usdcDecimals, 6)} USDC`,
902
+ }
903
+ : {}),
904
+ referrerMatches,
905
+ ...(typeof optionAddress === 'string'
906
+ ? { optionExplorer: `https://basescan.org/address/${optionAddress}` }
907
+ : {}),
908
+ txExplorer: `https://basescan.org/tx/${receipt.hash}`,
909
+ };
910
+ render(buildTxReceiptPayload(receipt, ethUsd, extra), renderOpts(opts));
911
+ }
912
+ catch (err) {
913
+ renderError(err, renderOpts(opts));
914
+ const exit = err.exitCode ?? 1;
915
+ process.exit(exit);
916
+ }
917
+ });
918
+ }