@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,131 @@
1
+ import { formatStructureTicker } from './format.js';
2
+ /**
3
+ * The single order-matching predicate shared by `book check`, `book preview`
4
+ * and `book fill`.
5
+ *
6
+ * Before this module the two paths matched differently: `check` compared only
7
+ * `strikes[0]` while `preview`/`fill` compared the whole vector element-wise.
8
+ * `check` therefore reported "No orderbook liquidity" for strikes that sat on
9
+ * a live structure's second/third/fourth leg — routing traders off the book
10
+ * (and off orderbook credit) onto RFQ — while simultaneously reporting 4-leg
11
+ * rangers as fillable vanillas whose recommended `preview` command then failed.
12
+ *
13
+ * One predicate here means `check` cannot disagree with `fill` about what the
14
+ * book holds.
15
+ */
16
+ /** Strikes are stored 1e8-scaled. */
17
+ export const STRIKE_DECIMALS = 8;
18
+ const STRIKE_SCALE = 10 ** STRIKE_DECIMALS;
19
+ export function orderStrikesRaw(order) {
20
+ const strikes = order.rawApiData?.strikes ?? [];
21
+ return strikes.map((s) => BigInt(s));
22
+ }
23
+ export function orderStrikesUsd(order) {
24
+ return orderStrikesRaw(order).map((s) => Number(s) / STRIKE_SCALE);
25
+ }
26
+ export function usdToStrikeRaw(strikeUsd) {
27
+ return BigInt(Math.round(strikeUsd * STRIKE_SCALE));
28
+ }
29
+ /**
30
+ * Compare two strike vectors as multisets rather than sequences.
31
+ *
32
+ * Makers do not store strikes in a canonical order: 19 of 310 live orders keep
33
+ * them descending (`PUT_SPREAD [68000, 67000]`, `PUT_FLY [69000, 66000,
34
+ * 63000]`). Element-wise comparison meant `--strikes 67000,68000` — the
35
+ * natural ascending form a human types — missed a live order that
36
+ * `--strikes 68000,67000` filled. Matching on the multiset is safe because the
37
+ * fill encodes the *signed order's own* strike vector; the caller's argument
38
+ * order never reaches the contract.
39
+ */
40
+ export function sameStrikeMultiset(a, b) {
41
+ if (a.length !== b.length)
42
+ return false;
43
+ const sortAsc = (xs) => [...xs].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
44
+ const sa = sortAsc(a);
45
+ const sb = sortAsc(b);
46
+ return sa.every((v, i) => v === sb[i]);
47
+ }
48
+ /** True when `order` is exactly the instrument `sel` describes. */
49
+ export function orderMatchesSelector(order, sel) {
50
+ const raw = order.rawApiData;
51
+ if (!raw)
52
+ return false;
53
+ const orderType = raw.isCall ? 'CALL' : 'PUT';
54
+ if (orderType !== sel.type)
55
+ return false;
56
+ if (raw.priceFeed?.toLowerCase() !== sel.priceFeed)
57
+ return false;
58
+ if (Number(order.order.expiry) !== sel.expiry)
59
+ return false;
60
+ return sameStrikeMultiset(orderStrikesRaw(order), sel.strikesRaw);
61
+ }
62
+ export function findMatchingOrders(orders, sel) {
63
+ return orders.filter((o) => orderMatchesSelector(o, sel));
64
+ }
65
+ /**
66
+ * Live multi-leg orders that carry `strikeRaw` on any leg.
67
+ *
68
+ * This is the liquidity `book check` used to be blind to. A caller asking
69
+ * about a single strike that only exists inside a spread/fly/ranger can still
70
+ * trade on the book — they just have to fill the whole structure, so the
71
+ * caller must be told which structure it is before acting.
72
+ */
73
+ export function ordersWithStrikeAsLeg(orders, priceFeed, type, expiry, strikeRaw) {
74
+ const matches = [];
75
+ for (const order of orders) {
76
+ const raw = order.rawApiData;
77
+ if (!raw)
78
+ continue;
79
+ const orderType = raw.isCall ? 'CALL' : 'PUT';
80
+ if (orderType !== type)
81
+ continue;
82
+ if (raw.priceFeed?.toLowerCase() !== priceFeed)
83
+ continue;
84
+ if (Number(order.order.expiry) !== expiry)
85
+ continue;
86
+ const strikes = orderStrikesRaw(order);
87
+ if (strikes.length < 2)
88
+ continue;
89
+ const legIndex = strikes.findIndex((s) => s === strikeRaw);
90
+ if (legIndex >= 0)
91
+ matches.push({ order, legIndex });
92
+ }
93
+ return matches;
94
+ }
95
+ /**
96
+ * Describe an order's structure for display.
97
+ *
98
+ * `numStrikes` from chain config is deliberately ignored: it disagrees across
99
+ * deployments (RANGER is 2 on one implementation address and 4 on another).
100
+ * The order's own strike vector is the only reliable leg count.
101
+ */
102
+ export function structureInfo(order, underlying, implementations) {
103
+ const implAddress = order.rawApiData?.implementation?.toLowerCase();
104
+ const meta = implAddress ? implementations[implAddress] : undefined;
105
+ const strikesUsd = orderStrikesUsd(order);
106
+ const type = order.rawApiData?.isCall ? 'CALL' : 'PUT';
107
+ const expiry = Number(order.order.expiry);
108
+ return {
109
+ name: meta?.name ?? implAddress ?? 'UNKNOWN',
110
+ kind: meta?.type ?? 'UNKNOWN',
111
+ strikesUsd,
112
+ ticker: formatStructureTicker(underlying, expiry, strikesUsd, type, meta?.name),
113
+ isMultiLeg: strikesUsd.length > 1,
114
+ };
115
+ }
116
+ /**
117
+ * Resolve an underlying symbol to a price-feed address.
118
+ *
119
+ * Chain config carries legacy alias keys (`ETH/USD` alongside `ETH`) that
120
+ * resolve to the same feed. They stay accepted as input but are filtered out
121
+ * of the "Known:" hint so the error message reads cleanly.
122
+ */
123
+ export function resolvePriceFeed(underlying, priceFeeds) {
124
+ const feed = priceFeeds[underlying.toUpperCase()];
125
+ if (feed)
126
+ return { feed: feed.toLowerCase() };
127
+ const known = Object.keys(priceFeeds)
128
+ .filter((k) => !k.includes('/'))
129
+ .join(', ');
130
+ return { error: `Unknown underlying "${underlying}". Known: ${known}.` };
131
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Find the exact signed order in a refreshed book.
3
+ *
4
+ * Odette reuses a nonce across batches of orders, so `(maker, nonce)` is not a
5
+ * unique identity. The EIP-712 signature commits to the complete order and is
6
+ * stable when list indices move or available liquidity changes.
7
+ */
8
+ export function findSignedOrder(orders, reference) {
9
+ const maker = reference.order.maker.toLowerCase();
10
+ const signature = reference.signature.toLowerCase();
11
+ return orders.find((candidate) => candidate.order.maker.toLowerCase() === maker &&
12
+ candidate.signature.toLowerCase() === signature);
13
+ }
14
+ /**
15
+ * Find the same instrument in a refreshed book, whatever its current quote.
16
+ *
17
+ * Identity follows the Odette web app's getOrderSelectionKey (config.js:297-303)
18
+ * plus the maker: implementation, strikes, expiry, call/put, price feed and
19
+ * collateral. The quote (price, signature, quote expiry) may differ; the caller
20
+ * must compare terms and re-confirm. When several quotes match, the cheapest wins.
21
+ */
22
+ export function findSameInstrument(orders, reference) {
23
+ const key = (o) => {
24
+ const r = o.rawApiData;
25
+ return [
26
+ o.order.maker.toLowerCase(),
27
+ r?.implementation.toLowerCase(),
28
+ (r?.strikes ?? []).join(','),
29
+ o.order.expiry.toString(),
30
+ r?.isCall ? '1' : '0',
31
+ r?.priceFeed.toLowerCase(),
32
+ r?.collateral.toLowerCase(),
33
+ // Part of the signed terms (option parameters and nonce), so a quote
34
+ // with different extra data is a different instrument.
35
+ (r?.extraOptionData ?? '0x').toLowerCase(),
36
+ ].join(':');
37
+ };
38
+ const want = key(reference);
39
+ const matches = orders.filter((o) => key(o) === want);
40
+ if (matches.length === 0)
41
+ return undefined;
42
+ return matches.reduce((best, o) => (o.order.price < best.order.price ? o : best));
43
+ }
@@ -0,0 +1,35 @@
1
+ // Adapted from thetanuts-sdk cli/src/bookPreview.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ /**
4
+ * OptionBook prices use the protocol's fixed 8-decimal scale. Contract counts
5
+ * for the CLI's supported USDC book fills use 6 decimals.
6
+ */
7
+ export const BOOK_PRICE_SCALE = 100000000n;
8
+ /**
9
+ * Correct the legacy SDK preview's `totalCollateral` field locally.
10
+ *
11
+ * Older SDK releases echo the requested spend ceiling when one is provided.
12
+ * That is harmless for contract encoding, but it overstates the premium when
13
+ * maker liquidity caps the fill. The CLI must approve and display the premium
14
+ * for the contracts that will actually be filled.
15
+ */
16
+ export function withActualBookPremium(preview) {
17
+ return {
18
+ ...preview,
19
+ totalCollateral: (preview.numContracts * preview.pricePerContract) / BOOK_PRICE_SCALE,
20
+ };
21
+ }
22
+ /**
23
+ * Ceiling of the same premium — for allowance sizing only.
24
+ *
25
+ * `withActualBookPremium` floors, matching the SDK's own formula. If the
26
+ * OptionBook contract instead rounds the taker's premium up, an exactly-floored
27
+ * approval is one unit short and the fill reverts at broadcast. Approving the
28
+ * ceiling costs at most one unit of the collateral token (1e-6 USDC) and makes
29
+ * the fill robust to either rounding convention. Display still shows the real
30
+ * (floored) premium.
31
+ */
32
+ export function bookPremiumCeiling(preview) {
33
+ const product = preview.numContracts * preview.pricePerContract;
34
+ return (product + BOOK_PRICE_SCALE - 1n) / BOOK_PRICE_SCALE;
35
+ }
package/dist/client.js ADDED
@@ -0,0 +1,119 @@
1
+ // Adapted from thetanuts-sdk cli/src/client.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ // Changes: OdetteClient; Base only; referrer resolution removed (the referrer is
4
+ // the constant ODETTE_REFERRER); RFQ keystore removed; ODETTE_FEED_URL;
5
+ // no --private-key flag (env or config only); RPC URL errors never print the
6
+ // URL path or query (they can hold an API key).
7
+ import { ethers } from 'ethers';
8
+ import { OdetteClient, ODETTE_REFERRER } from '@thetanuts-finance/odette-sdk';
9
+ import { loadConfig, ConfigError } from './config.js';
10
+ import { DEFAULT_CHAIN_ID, DEFAULT_RPC_URL } from './defaults.js';
11
+ function resolveChainId(cfg) {
12
+ // The CLI is Base-only. A config for another chain is a config error.
13
+ if (cfg?.chainId !== undefined && cfg.chainId !== DEFAULT_CHAIN_ID) {
14
+ throw new ConfigError(`Config chainId is ${cfg.chainId}. Odette trades on Base (${DEFAULT_CHAIN_ID}) only.`);
15
+ }
16
+ return DEFAULT_CHAIN_ID;
17
+ }
18
+ function resolveRpcUrl(opts, cfg, _chainId) {
19
+ const fromFlag = opts.rpcUrl;
20
+ let rpcUrl;
21
+ if (fromFlag) {
22
+ rpcUrl = fromFlag;
23
+ }
24
+ else {
25
+ const fromEnv = process.env.ODETTE_RPC_URL;
26
+ if (fromEnv) {
27
+ rpcUrl = fromEnv;
28
+ }
29
+ else {
30
+ rpcUrl = cfg?.rpcUrl ?? DEFAULT_RPC_URL;
31
+ }
32
+ }
33
+ // Reject non-https RPC URLs unless the host is localhost. Signed tx
34
+ // broadcasts over plain http are vulnerable to MITM tampering of gas /
35
+ // nonce / data. Localhost is the only sane dev escape hatch.
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(rpcUrl);
39
+ }
40
+ catch {
41
+ // Never echo the URL: hosted RPC URLs carry an API key.
42
+ throw new Error('odette: RPC URL is not a valid URL. Check --rpc-url, ODETTE_RPC_URL or the config rpcUrl.');
43
+ }
44
+ const host = parsed.hostname;
45
+ const isLocal = host === 'localhost' || host === '127.0.0.1';
46
+ // https anywhere; plain http only on localhost (upstream let any scheme
47
+ // through on localhost, e.g. ftp://127.0.0.1).
48
+ if (!(parsed.protocol === 'https:' || (parsed.protocol === 'http:' && isLocal))) {
49
+ throw new Error(`odette: RPC URL must use https:// (got ${parsed.protocol}//${parsed.host}); pass an https URL or use a localhost RPC for dev`);
50
+ }
51
+ return rpcUrl;
52
+ }
53
+ // Defined inline (not imported) to avoid pulling a command module into client bootstrap
54
+ const PRIVATE_KEY_REGEX = /^0x[0-9a-fA-F]{64}$/;
55
+ /**
56
+ * Keys come from the environment or the 0600 config file only (CLAUDE.md
57
+ * rule 2). Upstream's `--private-key` flag is not ported: argv lands in shell
58
+ * history and process listings.
59
+ */
60
+ function resolvePrivateKey(cfg) {
61
+ let key;
62
+ let source;
63
+ const fromEnv = process.env.ODETTE_PRIVATE_KEY;
64
+ if (fromEnv) {
65
+ key = fromEnv;
66
+ source = 'ODETTE_PRIVATE_KEY env var';
67
+ }
68
+ else if (cfg?.privateKey) {
69
+ key = cfg.privateKey;
70
+ source = 'config file';
71
+ }
72
+ if (key === undefined)
73
+ return undefined;
74
+ // Validate BEFORE handing to ethers — a malformed key would otherwise be echoed verbatim in ethers' "invalid BytesLike value" error message
75
+ if (!PRIVATE_KEY_REGEX.test(key)) {
76
+ throw new Error(`Invalid private key from ${source}. Expected 0x-prefixed 64-char hex string.`);
77
+ }
78
+ return key;
79
+ }
80
+ /**
81
+ * Build an `OdetteClient` from the merged CLI options object.
82
+ *
83
+ * Pass `opts` from `getGlobalOpts(cmd)`. The function does not touch the
84
+ * network; it constructs an ethers `JsonRpcProvider` (which is lazy) and
85
+ * returns immediately.
86
+ */
87
+ export function getClient(opts) {
88
+ const configPath = opts.config;
89
+ const cfg = loadConfig(configPath);
90
+ const chainId = resolveChainId(cfg);
91
+ const rpcUrl = resolveRpcUrl(opts, cfg, chainId);
92
+ const privateKey = resolvePrivateKey(cfg);
93
+ const feedUrl = process.env.ODETTE_FEED_URL;
94
+ const provider = new ethers.JsonRpcProvider(rpcUrl);
95
+ const signer = privateKey ? new ethers.Wallet(privateKey, provider) : undefined;
96
+ const client = new OdetteClient({
97
+ chainId: 8453,
98
+ provider,
99
+ ...(signer ? { signer } : {}),
100
+ ...(feedUrl ? { feedUrl } : {}),
101
+ });
102
+ return {
103
+ client,
104
+ hasSigner: Boolean(signer),
105
+ chainId,
106
+ rpcUrl,
107
+ referrer: ODETTE_REFERRER,
108
+ };
109
+ }
110
+ /**
111
+ * Throw a friendly error when a command needs a signer but none was supplied.
112
+ * Implementer agents call this at the top of any write-op action.
113
+ */
114
+ export function requireSigner(result) {
115
+ if (!result.hasSigner) {
116
+ // Exit 4: a wallet error (PRD 7.2), not a runtime failure.
117
+ throw Object.assign(new Error('This command requires a signer. Set ODETTE_PRIVATE_KEY, or run `odette wallet import` or `odette setup`.'), { exitCode: 4 });
118
+ }
119
+ }