From 65ccf951afd7a2f94c9201a97c9e051d4c330d66 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Wed, 3 Jun 2026 16:33:10 +0200 Subject: [PATCH 1/2] feat(trade): route Treasures tokenized stock buys/sells through `acp trade` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Treasures Finance route to the unified `acp trade` command from #26: `--ticker ` triggers a new "treasures-stock" intent that wins over swap/HL/perp routing (no other path carries stock tickers). Direction comes from the amount field — `--amount-usdc` for buy, `--amount-shares` for sell — keeping `--side` perp-only. End-to-end inside one command, signing internal: 1. build canonical challenge (`treasures-finance-quote-v1\\n{ts}\\n{sol|""}\\n{eth|""}`) 2. `provider.signMessage(8453, challenge)` — EIP-191 ownership proof 3. POST `/quote/buy` or `/quote/sell` 4. `provider.signTypedData(domain.chainId, typed_data)` per EVM leg 5. POST `/trade/submit` 6. poll `/quote/{id}/status` until terminal `src/lib/treasures/client.ts` is bare `fetch` — no SDK, one import (`CliError`). HTTPS enforced, matching the trade.ts proxy's posture. `TREASURES_API_URL` overrides the default host (defaults to staging on `IS_TESTNET=true`, prod otherwise — decoupled from ACP_SERVER_URL so tests can mix prod-ACP + staging-Treasures). Solana legs are deliberately unsupported in this first cut: the keystore is EVM-only, so a `solana_versioned_tx` payload throws with a recovery hint instead of silently dropping the leg (which would 400 `incomplete_submit` on the server anyway). `--chain eth` filters them out at quote time. Bridging Base → mainnet USDC is also intentionally out of scope here — the existing `acp trade --token-in usdc --chain-in 8453 --chain-out 1` swap path already does it via `/trade/plan`. Tested end-to-end against staging-api.treasures.io up to the HTTP layer: signature accepted, schema accepted, request reached the compliance layer. Staging is currently 451 geo-fenced (same response from a raw curl, so not a CLI issue) — needs a whitelist for full fill-status verification. `tsc --noEmit` clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/trade.ts | 256 ++++++++++++++++++++++++++++++++++- src/lib/treasures/client.ts | 257 ++++++++++++++++++++++++++++++++++++ 2 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 src/lib/treasures/client.ts diff --git a/src/commands/trade.ts b/src/commands/trade.ts index a86b634..8601727 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -51,6 +51,18 @@ import { resolvePerpAsset, resolveSpotAsset, } from "../lib/hl/client"; +import { + buildChallenge as buildTreasuresChallenge, + quoteBuy as treasuresQuoteBuy, + quoteSell as treasuresQuoteSell, + quoteStatus as treasuresQuoteStatus, + tradeSubmit as treasuresTradeSubmit, + type QuoteLeg as TreasuresQuoteLeg, + type QuoteResponse as TreasuresQuoteResponse, + type SignedLeg as TreasuresSignedLeg, + type SignedPayload as TreasuresSignedPayload, + type QuoteStatusResponse as TreasuresQuoteStatusResponse, +} from "../lib/treasures/client"; // LiFi's chain id for Hyperliquid Core (the perps/spot collateral ledger). // Any leg whose chain is this is "on Hyperliquid". @@ -151,6 +163,12 @@ export function registerTradeCommands(program: Command): void { .option("--price ", "HL spot limit price (omit for a market order)") .option("--post-only", "HL post-only (Alo) limit order; rejects if it crosses", false) .option("--slippage ", "HL market-order slippage as a percent (default 5)", "5") + // -- Treasures tokenized stock (USDC ↔ stock token swap) ------------- + .option("--ticker ", "Tokenized stock ticker, e.g. AAPL (routes via Treasures)") + .option("--amount-usdc ", "USDC to spend on a Treasures buy") + .option("--amount-shares ", "Shares to liquidate on a Treasures sell") + .option("--protocol ", "Treasures protocol filter: ondo or xstocks") + .option("--chain ", "Treasures chain filter: sol or eth") // -- Hyperliquid perp (position shape) ------------------------------- .option("--side ", "Perp side: long or short") .option("--token ", "Perp market symbol — crypto, equity/stock, FX, or commodity (e.g. BTC, ETH, SOL)") @@ -163,6 +181,9 @@ export function registerTradeCommands(program: Command): void { try { const intent = detectIntent(opts, json); switch (intent) { + case "treasures-stock": + await runTreasuresStock(opts, json); + return; case "perp": await runPerp(opts, json); return; @@ -223,7 +244,14 @@ export function registerTradeCommands(program: Command): void { // ---------- Intent routing ---------- -type Intent = "perp" | "spot" | "deposit" | "withdraw" | "swap" | "interactive"; +type Intent = + | "treasures-stock" + | "perp" + | "spot" + | "deposit" + | "withdraw" + | "swap" + | "interactive"; // --side selects the perp venue and takes ONLY `long` or `short`. It's a perps // directional flag, so we deliberately do NOT accept buy/sell — those are spot @@ -237,6 +265,13 @@ function isPerpSide(side: string): boolean { } function detectIntent(opts: Record, json: boolean): Intent { + // --ticker is the unambiguous Treasures (tokenized stock) signal. It wins + // over every other route — none of the swap/HL flags carry stock tickers, + // so seeing one means the user wants `/quote/buy` or `/quote/sell` and + // nothing else can match. Treasures fills settle a tokenized stock token + // into the wallet (not a position), so it sits next to swaps shape-wise. + if (opts.ticker !== undefined) return "treasures-stock"; + const side = typeof opts.side === "string" ? opts.side.toLowerCase() : undefined; // --side is the unambiguous perp signal. If it's present it MUST be long or // short — reject anything else up front rather than silently falling through @@ -663,6 +698,225 @@ async function runWithdraw( outputResult(json, { status: res.status, destination: dest, amount: String(amount) }); } +// ---------- Treasures tokenized stock (USDC ↔ stock token swap) ---------- + +// Treasures EVM legs settle on Ethereum mainnet — the stock-token ERC-20s +// (Ondo's on, Backed's x) are deployed there, not on Base +// or any other L2. The Fusion order's EIP-712 domain.chainId is the source +// of truth for what we sign, but we expose this constant so error messages +// and helpers can reason about "the chain Treasures eth-legs live on". +const TREASURES_ETH_CHAIN_ID = 1; +// EIP-191 personal_sign is chain-agnostic by construction (no EIP-155 chain +// binding), so the chainId we hand the provider for the ownership-proof +// challenge is only used to pick which keystore client to call. Anything the +// adapter supports works. We use Base to match `acp wallet sign-message`'s +// default and avoid forcing a mainnet-chain wiring just to sign a string. +const TREASURES_PROOF_CHAIN_ID = 8453; +// Default slippage if --slippage-bps isn't passed. 300 bps (3%) is realistic +// for liquid AAPL/MSFT-style names; thinner tickers can need 500-1000+. +const DEFAULT_TREASURES_SLIPPAGE_BPS = 300; +// Status poll cadence + timeout. Most Fusion fills land in 5-20s; Ondo's +// settlement window can stretch closer to a minute. 120s gives all-or-nothing +// resolution before we hand the caller a TIMEOUT to retry status manually. +const TREASURES_POLL_MS = 3000; +const TREASURES_POLL_TIMEOUT_MS = 120_000; + +async function runTreasuresStock( + opts: Record, + json: boolean +): Promise { + const ticker = String(opts.ticker).trim().toUpperCase(); + const amountUsdc = + opts.amountUsdc !== undefined ? String(opts.amountUsdc) : undefined; + const amountShares = + opts.amountShares !== undefined ? String(opts.amountShares) : undefined; + + // Direction = which amount field you set. Requiring exactly one keeps the + // routing unambiguous: --amount-usdc means "spend this much USDC", which + // can only be a buy; --amount-shares means "sell this many shares", which + // can only be a sell. Sharing --amount-in across both would force a separate + // --side flag (and --side is already perp-only). + if ((amountUsdc !== undefined) === (amountShares !== undefined)) { + throw new CliError( + "Treasures needs exactly one of --amount-usdc (buy) or --amount-shares (sell).", + "VALIDATION_ERROR", + "e.g. `acp trade --ticker AAPL --amount-usdc 50` (buy) or " + + "`acp trade --ticker AAPL --amount-shares 0.1` (sell)." + ); + } + const isBuy = amountUsdc !== undefined; + + const slippageBps = + opts.slippageBps !== undefined + ? Number(opts.slippageBps) + : DEFAULT_TREASURES_SLIPPAGE_BPS; + const protocol = + opts.protocol !== undefined + ? validateTreasuresProtocol(String(opts.protocol)) + : undefined; + const chainFilter = + opts.chain !== undefined + ? validateTreasuresChain(String(opts.chain)) + : undefined; + + const owner = getWalletAddress() as Address; + const ethWallet = owner.toLowerCase() as Address; + const provider = await createProviderAdapter(); + + // 1) Sign the ownership-proof canonical challenge. We use the EVM signer + // only — Sol legs aren't submittable from this CLI yet. The challenge + // includes an empty sol_wallet line, which the server hashes; if we later + // add Sol signing we'll also need to send sol_wallet in the request body. + const issuedAt = Math.floor(Date.now() / 1000); + const challenge = buildTreasuresChallenge({ issuedAt, ethWallet }); + progress(json, `Signing Treasures ownership proof (issued_at=${issuedAt})`); + const ethSig = await provider.signMessage(TREASURES_PROOF_CHAIN_ID, challenge); + + // 2) Request a quote. The server returns up to N signable legs; for a buy + // it's at most 2 (one per chain, best first), for a sell it can be more + // (every leg the planner needs across the holding to liquidate the requested + // share amount). Either way we sign and submit all of them. + const baseQuoteReq = { + ticker, + max_slippage_bps: slippageBps, + eth_wallet: ethWallet, + ownership_proof: { eth_signature: ethSig, issued_at: issuedAt }, + ...(protocol ? { protocol } : {}), + ...(chainFilter ? { chain: chainFilter } : {}), + }; + progress( + json, + isBuy + ? `Requesting buy quote: ${ticker} for ${amountUsdc} USDC @ ${slippageBps}bps` + : `Requesting sell quote: ${amountShares} ${ticker} shares @ ${slippageBps}bps` + ); + const quote: TreasuresQuoteResponse = isBuy + ? await treasuresQuoteBuy({ ...baseQuoteReq, amount_usdc: amountUsdc! }) + : await treasuresQuoteSell({ ...baseQuoteReq, amount_shares: amountShares! }); + + if (quote.quotes.length === 0) { + throw new CliError( + `Treasures returned no legs for ${ticker}.`, + "API_ERROR", + "Try a higher --slippage-bps or a different --protocol / --chain." + ); + } + progress( + json, + `Got quote ${quote.quote_id} (${quote.quotes.length} leg${quote.quotes.length === 1 ? "" : "s"}, expires_at=${quote.expires_at})` + ); + + // 3) Sign every leg. EVM legs are 1inch Fusion EIP-712 orders bound to + // mainnet (domain.chainId=1); we honor whatever the server returns rather + // than hard-coding 1, since a future Treasures expansion to other EVM + // chains would just change the domain. Sol legs need Ed25519 over the + // serialized VersionedTransaction — the current CLI keystore doesn't sign + // Sol payloads, so we fail loudly rather than silently dropping the leg + // (which would 400 on submit with `incomplete_submit` anyway). + const signedLegs: TreasuresSignedLeg[] = []; + for (const leg of quote.quotes) { + const signedPayloads = await Promise.all( + leg.signable_payloads.map((payload) => + signTreasuresPayload(provider, leg, payload) + ) + ); + signedLegs.push({ quote_index: leg.quote_index, signed_payloads: signedPayloads }); + } + progress(json, `Signed ${signedLegs.length} leg${signedLegs.length === 1 ? "" : "s"}, submitting`); + + // 4) Submit atomically. The server is idempotent on (quote_id, quote_index), + // so a network retry of /trade/submit won't double-broadcast. If the quote's + // snapshot expired between issue and submit we get 410 quote_stale — the + // user just re-runs the command (we don't retry transparently because the + // re-quote may come back with different legs/prices the user should see). + const submitRes = await treasuresTradeSubmit({ + quote_id: quote.quote_id, + signed: signedLegs, + }); + + // 5) Poll status until terminal. /trade/submit returns optimistically once + // each leg is broadcast (or queued for broadcast); the on-chain fill lands + // asynchronously. Polling is the only way to see the final filled_shares / + // filled_usdc and any per-leg failures. + progress(json, `Polling ${quote.quote_id} for fill`); + const finalStatus = await pollTreasuresStatus(quote.quote_id, json); + + outputResult(json, { + quote_id: quote.quote_id, + side: isBuy ? "buy" : "sell", + ticker, + aggregate_status: finalStatus.aggregate_status, + submit: submitRes, + legs: finalStatus.legs, + }); +} + +async function signTreasuresPayload( + provider: IEvmProviderAdapter, + leg: TreasuresQuoteLeg, + payload: TreasuresQuoteLeg["signable_payloads"][number] +): Promise { + if (payload.type === "evm_eip712_typed_data") { + const signature = await provider.signTypedData( + Number(payload.typed_data.domain.chainId), + payload.typed_data + ); + return { type: "evm_eip712_signature", signature }; + } + // Sol path: we'd need to deserialize the VersionedTransaction, Ed25519-sign + // the message bytes with the agent's Sol keypair, splice the signature into + // the tx, and re-serialize as base64. The CLI keystore is EVM-only today, + // so refuse rather than half-submit. + throw new CliError( + `Treasures returned a ${payload.type} leg (chain=${leg.chain}); ` + + "the CLI keystore can't sign Solana payloads yet.", + "VALIDATION_ERROR", + "Filter to EVM-only with `--chain eth`, or sign and submit Sol legs out-of-band." + ); +} + +async function pollTreasuresStatus( + quoteId: string, + json: boolean +): Promise { + const deadline = Date.now() + TREASURES_POLL_TIMEOUT_MS; + let lastAgg: string | undefined; + while (Date.now() < deadline) { + const s = await treasuresQuoteStatus(quoteId); + if (s.aggregate_status !== "in_progress") return s; + if (s.aggregate_status !== lastAgg) { + progress(json, ` status=${s.aggregate_status} (cached=${s.is_cached})`); + lastAgg = s.aggregate_status; + } + await sleep(TREASURES_POLL_MS); + } + throw new CliError( + `Treasures quote ${quoteId} did not reach a terminal status within ${TREASURES_POLL_TIMEOUT_MS / 1000}s.`, + "TIMEOUT", + `Re-check later via GET /quote/${quoteId}/status (no auth required).` + ); +} + +function validateTreasuresProtocol(s: string): "ondo" | "xstocks" { + const v = s.trim().toLowerCase(); + if (v === "ondo" || v === "xstocks") return v; + throw new CliError( + `Invalid --protocol: ${s}`, + "VALIDATION_ERROR", + "Use --protocol ondo or --protocol xstocks." + ); +} + +function validateTreasuresChain(s: string): "sol" | "eth" { + const v = s.trim().toLowerCase(); + if (v === "sol" || v === "eth") return v; + throw new CliError( + `Invalid --chain: ${s}`, + "VALIDATION_ERROR", + "Use --chain sol or --chain eth." + ); +} + // Auto-balance the HL sub-wallets. HL keeps perp (collateral) and spot USDC in // separate wallets; deposits land in perp. Before an order, if the wallet that // must fund it is short, move the shortfall over from the other wallet so an diff --git a/src/lib/treasures/client.ts b/src/lib/treasures/client.ts new file mode 100644 index 0000000..86c12b3 --- /dev/null +++ b/src/lib/treasures/client.ts @@ -0,0 +1,257 @@ +// Treasures Finance — tokenized stock buy/sell via the public B2B API. +// Topologically this is just a swap (USDC ↔ ERC-20 stock token), so it slots +// into `acp trade` next to BondingV5/LiFi swaps and Hyperliquid orders. +// +// The CLI is still a thin signer here: +// - sign the ownership-proof challenge so `/quote/*` will issue legs, +// - sign each returned EIP-712 1inch Fusion order, +// - POST the signed legs to `/trade/submit`, +// - poll `/quote/{id}/status` until terminal. +// Private keys never leave the keystore — every signature comes from the same +// keystore-backed adapter the rest of `acp trade` uses. +// +// Solana legs are not signed by this module — the CLI keystore is EVM-only +// today, so a Sol-chain quote can be requested for inspection but cannot be +// submitted from here. EVM is the only first-cut path. + +import { CliError } from "../errors"; + +const TREASURES_PROD_URL = "https://api.treasures.io/public/v1"; +const TREASURES_STAGING_URL = "https://staging-api.treasures.io/public/v1"; + +// Resolve which Treasures host to hit. Mirrors how ACP_SERVER_URL switches +// on IS_TESTNET elsewhere in the CLI. An override lets ops point at a +// non-prod instance without rebuilding. +export function getTreasuresBaseUrl(): string { + const override = process.env.TREASURES_API_URL; + if (override) return override.replace(/\/$/, ""); + const isTestnet = process.env.IS_TESTNET === "true"; + return isTestnet ? TREASURES_STAGING_URL : TREASURES_PROD_URL; +} + +// Canonical challenge: UTF-8, lines joined with "\n". +// line 1: literal "treasures-finance-quote-v1" +// line 2: issued_at (unix seconds, as decimal) +// line 3: sol_wallet, or "" if absent +// line 4: eth_wallet lowercased, or "" if absent +// "All-or-nothing per proof": if you signed with both wallets, every request +// that reuses that proof must include both wallets. A proof signed over +// {sol, eth} is not valid for an eth-only request — the empty-string line for +// the absent side differs, so the digest differs, and recovery fails. +export function buildChallenge(args: { + issuedAt: number; + solWallet?: string; + ethWallet?: string; +}): string { + return [ + "treasures-finance-quote-v1", + String(args.issuedAt), + args.solWallet ?? "", + (args.ethWallet ?? "").toLowerCase(), + ].join("\n"); +} + +// ───── Wire types (subset — only what the CLI actually consumes) ──────────── + +export type Chain = "sol" | "eth"; +export type Protocol = "ondo" | "xstocks"; + +export interface OwnershipProof { + eth_signature?: string; + sol_signature?: string; + issued_at: number; +} + +export interface QuoteBuyRequest { + ticker: string; + amount_usdc: string; + max_slippage_bps: number; + chain?: Chain; + protocol?: Protocol; + sol_wallet?: string; + eth_wallet?: string; + ownership_proof: OwnershipProof; +} + +export interface QuoteSellRequest { + ticker: string; + amount_shares: string; + max_slippage_bps: number; + chain?: Chain; + protocol?: Protocol; + sol_wallet?: string; + eth_wallet?: string; + ownership_proof: OwnershipProof; +} + +export interface SignableEvmTypedData { + type: "evm_eip712_typed_data"; + typed_data: { + domain: { chainId: number; verifyingContract: string; name?: string; version?: string }; + types: Record>; + primaryType: string; + message: Record; + }; +} + +export interface SignableSolanaTx { + type: "solana_versioned_tx"; + tx_base64: string; +} + +export type SignablePayload = SignableEvmTypedData | SignableSolanaTx; + +export interface QuoteLeg { + quote_index: number; + chain: Chain; + protocol: Protocol; + price_usdc_per_share: string; + // buy-only + estimated_output_shares?: string; + estimated_output_tokens?: string; + // sell-only + shares_consumed?: string; + tokens_consumed?: string; + estimated_output_usdc?: string; + cost_breakdown_bps: { + treasures_fee_bps: number; + dex_swap_fee_bps: number; + estimated_slippage_bps: number; + slippage_vs_tradfi_bps?: number; + }; + signable_payloads: SignablePayload[]; +} + +export interface QuoteResponse { + quote_id: string; + side: "buy" | "sell"; + ticker: string; + expires_at: number; + tradfi_reference: { price_usd: string; market_status: "open" | "closed"; as_of: number } | null; + quotes: QuoteLeg[]; + totals?: { shares_total: string; usdc_total_estimated: string }; // sell only +} + +export interface SignedEvmPayload { + type: "evm_eip712_signature"; + signature: string; +} + +export interface SignedSolanaPayload { + type: "solana_versioned_tx"; + signed_tx_base64: string; +} + +export type SignedPayload = SignedEvmPayload | SignedSolanaPayload; + +export interface SignedLeg { + quote_index: number; + signed_payloads: SignedPayload[]; +} + +export interface TradeSubmitRequest { + quote_id: string; + signed: SignedLeg[]; +} + +export interface LegResult { + quote_index: number; + trade_id: string; + status: "broadcast" | "broadcast_unknown" | "completed" | "failed" | "broadcast_failed"; + tx_hash: string | null; + order_hash: string | null; + error_code: string | null; +} + +export interface TradeSubmitResponse { + results: LegResult[]; + failed_legs: Array<{ quote_index: number; error_code: "internal_error" }>; +} + +export type AggregateStatus = "in_progress" | "completed" | "partial_failed" | "all_failed"; + +export interface PublicLeg { + quote_index: number; + trade_id: string; + ticker: string; + chain: Chain; + protocol: Protocol; + side: "buy" | "sell"; + tx_hash: string | null; + order_hash: string | null; + status: "pending" | "completed" | "failed" | "broadcast_failed"; + error_code: string | null; + filled_shares: string; + filled_tokens: string; + filled_usdc: string; + last_synced_at: number; +} + +export interface QuoteStatusResponse { + quote_id: string; + aggregate_status: AggregateStatus; + is_cached: boolean; + legs: PublicLeg[]; +} + +// ───── HTTP ───────────────────────────────────────────────────────────────── + +// Same posture as the trade.ts proxy `post()`: refuse plaintext for any +// endpoint that returns calldata-to-sign or accepts signed actions. A +// downgraded hop could swap in a malicious order, and the keystore can't see +// what it's signing past raw bytes. +async function tfetch( + method: "GET" | "POST", + path: string, + body?: unknown +): Promise { + const base = getTreasuresBaseUrl(); + if (!/^https:\/\//i.test(base)) { + throw new CliError( + `Refusing to call a non-https Treasures endpoint: ${base}`, + "VALIDATION_ERROR", + "Set TREASURES_API_URL to an https:// URL or unset it to use the default." + ); + } + const res = await fetch(base + path, { + method, + headers: { "content-type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + // Read once as text so we can both surface non-JSON errors cleanly AND parse + // the structured error bodies the API emits (e.g. {error, reason, message}). + const raw = await res.text(); + if (!res.ok) { + let parsed: { error?: string; reason?: string; message?: string } | string; + try { + parsed = JSON.parse(raw) as typeof parsed; + } catch { + parsed = raw; + } + const detail = + typeof parsed === "string" + ? parsed + : [parsed.error, parsed.reason, parsed.message].filter(Boolean).join(": "); + throw new CliError( + `Treasures ${method} ${path} → ${res.status}: ${detail || res.statusText}`, + "API_ERROR" + ); + } + return JSON.parse(raw) as T; +} + +export function quoteBuy(req: QuoteBuyRequest): Promise { + return tfetch("POST", "/quote/buy", req); +} + +export function quoteSell(req: QuoteSellRequest): Promise { + return tfetch("POST", "/quote/sell", req); +} + +export function tradeSubmit(req: TradeSubmitRequest): Promise { + return tfetch("POST", "/trade/submit", req); +} + +export function quoteStatus(quoteId: string): Promise { + return tfetch("GET", `/quote/${encodeURIComponent(quoteId)}/status`); +} From 06222548ba2158c9036d2e20180be677fb67f805 Mon Sep 17 00:00:00 2001 From: miratisu_virtuals <89718498+psmiratisu@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:43:01 +0800 Subject: [PATCH 2/2] fix(trade): harden Treasures status polling, exit code, and slippage validation - pollTreasuresStatus: re-check status after the final sleep so a fill that lands during that window is observed instead of misreported as TIMEOUT - runTreasuresStock: set a non-zero exit code on terminal partial_failed/ all_failed so callers can branch on failure - validate --slippage-bps as a non-negative integer rather than letting a NaN serialize to max_slippage_bps: null --- src/commands/trade.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/commands/trade.ts b/src/commands/trade.ts index 8601727..503205b 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -748,7 +748,7 @@ async function runTreasuresStock( const slippageBps = opts.slippageBps !== undefined - ? Number(opts.slippageBps) + ? parseTreasuresSlippageBps(opts.slippageBps) : DEFAULT_TREASURES_SLIPPAGE_BPS; const protocol = opts.protocol !== undefined @@ -849,6 +849,18 @@ async function runTreasuresStock( submit: submitRes, legs: finalStatus.legs, }); + + // `completed` is the only success; pollTreasuresStatus only returns terminal + // states, so anything else here is `partial_failed`/`all_failed`. The per-leg + // detail was just printed above — flip the exit code (rather than throw and + // re-print via the error path) so scripts can branch on a non-zero exit. + if (finalStatus.aggregate_status !== "completed") { + process.exitCode = 1; + progress( + json, + `Treasures quote ${quote.quote_id} ended ${finalStatus.aggregate_status}` + ); + } } async function signTreasuresPayload( @@ -881,13 +893,16 @@ async function pollTreasuresStatus( ): Promise { const deadline = Date.now() + TREASURES_POLL_TIMEOUT_MS; let lastAgg: string | undefined; - while (Date.now() < deadline) { + for (;;) { const s = await treasuresQuoteStatus(quoteId); if (s.aggregate_status !== "in_progress") return s; if (s.aggregate_status !== lastAgg) { progress(json, ` status=${s.aggregate_status} (cached=${s.is_cached})`); lastAgg = s.aggregate_status; } + // Stop only *after* a fresh check, so a fill that lands during the final + // sleep is still observed rather than misreported as a TIMEOUT. + if (Date.now() >= deadline) break; await sleep(TREASURES_POLL_MS); } throw new CliError( @@ -897,6 +912,21 @@ async function pollTreasuresStatus( ); } +// Guard the bps before it hits JSON.stringify — an un-validated Number() turns +// garbage into NaN, which serializes as `max_slippage_bps: null` and quietly +// drops the cap server-side. Require a non-negative whole number of bps. +function parseTreasuresSlippageBps(raw: unknown): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new CliError( + `Invalid --slippage-bps: ${String(raw)}`, + "VALIDATION_ERROR", + "Pass a non-negative whole number of basis points, e.g. 300 (=3%)." + ); + } + return n; +} + function validateTreasuresProtocol(s: string): "ondo" | "xstocks" { const v = s.trim().toLowerCase(); if (v === "ondo" || v === "xstocks") return v;