From 789999acb94fa05689dae20245f7920f89728c2a Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 14:37:08 +0100 Subject: [PATCH 01/12] PLT-676: add zerion-consolidate skill + CLI command Sweep all wallet positions on a single chain into one target token. Dry-run by default; --execute broadcasts sequentially after a single passphrase prompt. Filters: target token (by symbol and on-chain address), native gas, stables (flag/prompt/non-TTY default exclude), dust below --min-value, max-loss per quote. - cli/utils/trading/consolidate.js: pure helpers (filterCandidates, evaluateQuote, computeNativeSweepAmount, parseMaxLoss dual-form, DEFAULT_GAS_RESERVES, buildConsolidatePlan with injectable quoteFn). - cli/commands/trading/consolidate.js: CLI shell. Reuses getSwapQuote and executeSwap, coerceBoolFlag pattern from bridge.js, TTY check before confirm() for stables. - formatConsolidatePlan / formatConsolidateResult appended to cli/utils/common/format.js (mirrors formatBridgeOffers). - Registered between send and search in cli/zerion.js + router help. - skills/zerion-consolidate/SKILL.md with Safety section; umbrella zerion SKILL.md table updated. - Unit tests: 50+ assertions covering stables case-insensitivity, --max-loss dual form, gas-reserve math, target-by-address exclusion, sequential quote ordering, no_route resilience, coerceBoolFlag rejection via subprocess. Resolves PLT-676 --- cli/commands/trading/consolidate.js | 353 +++++++++ cli/router.js | 1 + .../cli/utils/trading/consolidate.test.mjs | 725 ++++++++++++++++++ cli/utils/common/format.js | 102 +++ cli/utils/trading/consolidate.js | 515 +++++++++++++ cli/zerion.js | 2 + skills/zerion-consolidate/SKILL.md | 197 +++++ skills/zerion/SKILL.md | 1 + 8 files changed, 1896 insertions(+) create mode 100644 cli/commands/trading/consolidate.js create mode 100644 cli/tests/unit/cli/utils/trading/consolidate.test.mjs create mode 100644 cli/utils/trading/consolidate.js create mode 100644 skills/zerion-consolidate/SKILL.md diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js new file mode 100644 index 00000000..8231252f --- /dev/null +++ b/cli/commands/trading/consolidate.js @@ -0,0 +1,353 @@ +/** + * Sweep all wallet positions on one chain into a single target token. + * + * Usage: zerion consolidate [--execute] [...flags] + * + * Default mode is dry-run — list a plan and exit without signing. Pass + * `--execute` to fetch fresh quotes and broadcast each ready row sequentially. + * + * Architecture: this file is the CLI shell — arg parsing, validation, prompts, + * and the broadcast loop. All pure logic (filter / evaluate / gas-reserve math + * / loss math) lives in `cli/utils/trading/consolidate.js` so it can be unit + * tested without network mocks. + */ + +import * as api from "../../utils/api/client.js"; +import { executeSwap } from "../../utils/trading/swap.js"; +import { + requireAgentToken, + parseTimeout, + parseSlippage, + handleTradingError, +} from "../../utils/trading/guards.js"; +import { resolveWallet } from "../../utils/wallet/resolve.js"; +import { validateTradingChainAsync } from "../../utils/common/validate.js"; +import { print, printError } from "../../utils/common/output.js"; +import { confirm } from "../../utils/common/prompt.js"; +import { getNativeFungible } from "../../utils/chain/catalog.js"; +import { formatConsolidatePlan, formatConsolidateResult } from "../../utils/common/format.js"; +import { + filterCandidates, + buildConsolidatePlan, + resolveGasReserve, + parseMaxLoss, + parseMinValue, + parseGasReserve, + parseSymbolList, +} from "../../utils/trading/consolidate.js"; + +// Mirrors `coerceBoolFlag` in cli/commands/trading/bridge.js (lines 55-64). +// parseFlags consumes the next non-`--` token as the value, so a bare boolean +// flag followed by a positional silently gets that positional as its value +// (e.g. `--include-stables ethereum` would set `include-stables="ethereum"`). +// Reject anything but true / "true" / false / "false" / undefined. +function coerceBoolFlag(value, name) { + if (value === undefined) return false; + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + printError( + "invalid_flag_value", + `--${name} does not take a value (got "${value}"). Pass --${name} on its own at the end of the command, or use --${name}=true / --no-${name}.`, + ); + process.exit(1); +} + +export default async function consolidate(args, flags) { + const [chain, toToken] = args; + + if (!chain || !toToken) { + printError("missing_args", "Usage: zerion consolidate ", { + example: "zerion consolidate base USDC", + }); + process.exit(1); + } + + const execute = coerceBoolFlag(flags.execute, "execute"); + const includeStablesFlag = coerceBoolFlag(flags["include-stables"], "include-stables"); + const excludeStablesFlag = coerceBoolFlag(flags["exclude-stables"], "exclude-stables"); + const includeNative = coerceBoolFlag(flags["include-native"], "include-native"); + const continueOnError = coerceBoolFlag(flags["continue-on-error"], "continue-on-error"); + + if (includeStablesFlag && excludeStablesFlag) { + printError("conflicting_flags", "Pass either --include-stables or --exclude-stables, not both."); + process.exit(1); + } + + let minValueUsd; + let maxLoss; + let explicitGasReserve; + try { + minValueUsd = parseMinValue(flags["min-value"]); + maxLoss = parseMaxLoss(flags["max-loss"]); + explicitGasReserve = parseGasReserve(flags["gas-reserve"]); + } catch (err) { + printError(err.code || "invalid_flag", err.message); + process.exit(1); + } + + if (explicitGasReserve !== undefined && !includeNative) { + printError( + "conflicting_flags", + "--gas-reserve requires --include-native.", + { + suggestion: + "Pass --include-native to opt in to sweeping the native gas token, then use --gas-reserve to set how much to keep.", + }, + ); + process.exit(1); + } + + const slippage = parseSlippage(flags.slippage); + + const includeSet = parseSymbolList(flags.include); + const excludeSet = parseSymbolList(flags.exclude); + + const chainCheck = await validateTradingChainAsync(chain, "trade"); + if (chainCheck.error) { + printError(chainCheck.error.code, chainCheck.error.message, { + supportedChains: chainCheck.error.supportedChains, + }); + process.exit(1); + } + + const { walletName, address } = resolveWallet({ ...flags, chain }); + + // Target token resolution — searchFungibles is fuzzy, so we filter to an + // exact symbol match on the requested chain. Capture the on-chain address + // so filterCandidates can do an address-level target exclusion in addition + // to the symbol check. + const targetUpper = String(toToken).toUpperCase(); + let targetFungible; + try { + const response = await api.searchFungibles(toToken, { chainId: chain, limit: 5 }); + const results = response.data || []; + targetFungible = results.find((r) => { + const sym = r.attributes?.symbol?.toUpperCase(); + const implOnChain = (r.attributes?.implementations || []).some((i) => i.chain_id === chain); + return sym === targetUpper && implOnChain; + }) || null; + } catch (err) { + printError(err.code || "search_error", err.message); + process.exit(1); + } + + if (!targetFungible) { + printError( + "target_token_not_found", + `Could not resolve "${toToken}" on chain "${chain}".`, + { + suggestion: `Confirm the symbol with: zerion search ${toToken} --chain ${chain}`, + }, + ); + process.exit(1); + } + + const targetUsdPrice = Number(targetFungible.attributes?.market_data?.price); + const targetImpl = (targetFungible.attributes?.implementations || []).find( + (i) => i.chain_id === chain, + ); + const targetAddress = targetImpl?.address ? String(targetImpl.address).toLowerCase() : null; + + // Native gas-token symbol — used by the filter to detect the chain's native + // currency in positions. Catalog lookup can fail (rate limit / no native + // fungible); in that case we surface a stderr warning and skip the native + // exclusion rule (the user can still --exclude it explicitly). + let nativeSymbol = null; + try { + const native = await getNativeFungible(chain); + nativeSymbol = native?.symbol ? native.symbol.toUpperCase() : null; + } catch (err) { + process.stderr.write( + `Warning: could not resolve native gas token for chain "${chain}" (${err.message}). ` + + `Skipping native-token exclusion — pass --exclude if needed.\n`, + ); + } + + // Resolve the stables decision. Flag wins; otherwise prompt in a TTY; + // otherwise default to exclude. The TTY check must happen BEFORE confirm() + // because confirm() reads non-TTY input via readline and would block on a + // pipe that never gets a line. + let includeStables; + if (includeStablesFlag) { + includeStables = true; + } else if (excludeStablesFlag) { + includeStables = false; + } else if (process.stdin.isTTY) { + includeStables = await confirm( + "Include stables (USDC/USDT/DAI/...) in this sweep? [y/N] ", + { defaultYes: false }, + ); + } else { + includeStables = false; + } + + // Resolve gas reserve — explicit value wins, else per-chain default, else + // fallback with warning. The reserve only takes effect for the native row; + // non-native rows ignore it. + const reserve = resolveGasReserve(chain, explicitGasReserve); + if (includeNative && reserve.isFallback) { + process.stderr.write( + `Warning: no default gas reserve configured for chain "${chain}". ` + + `Using ${reserve.value} native units — pass --gas-reserve to override.\n`, + ); + } + + // Positions fetch. Solana rejects `no_filter` on some endpoints — fall back + // to `only_simple` (which still covers wallet-held tokens, which is what we + // sweep) when the API returns an unsupported-filter error. + let positionsResponse; + try { + positionsResponse = await api.getPositions(address, { + chainId: chain, + positionFilter: "no_filter", + }); + } catch (err) { + const msg = err?.message || ""; + if (/position_filter_unsupported|not supported for solana/i.test(msg)) { + positionsResponse = await api.getPositions(address, { + chainId: chain, + positionFilter: "only_simple", + }); + } else { + printError(err.code || "positions_error", err.message); + process.exit(1); + } + } + + const ctx = { + chain, + targetSymbol: targetUpper, + targetAddress, + nativeSymbol, + includeNative, + includeStables, + includeSet, + excludeSet, + minValueUsd, + }; + + const { candidates, skippedDust } = filterCandidates(positionsResponse.data || [], ctx); + + if (candidates.length === 0 && skippedDust.length === 0) { + print( + { + chain, + toToken, + walletAddress: address, + targetUsdPrice, + rows: [], + totals: { ready: 0, blocked: 0, skipped: 0, no_route: 0, expected_output: 0, expected_output_usd: null }, + executed: false, + }, + formatConsolidatePlan, + ); + return; + } + + let plan; + try { + plan = await buildConsolidatePlan({ + candidates, + skippedDust, + chain, + toToken: targetUpper, + targetUsdPrice, + walletAddress: address, + slippage, + gasReserveValue: reserve.value, + maxLoss, + }); + } catch (err) { + handleTradingError(err, "consolidate_error"); + return; + } + + if (!execute) { + // Strip embedded `quote` objects from the dry-run JSON output — they're + // verbose and not part of the documented row shape. The execute path + // re-fetches quotes anyway, so persisting them across invocations would + // be misleading. + const sanitized = { ...plan, rows: plan.rows.map(stripQuote) }; + print(sanitized, formatConsolidatePlan); + return; + } + + // --execute: re-use the freshly-fetched quotes from the plan (within a + // single invocation we want to broadcast on the same quotes we just showed + // the user). Cross-invocation staleness is bounded by slippage + on-chain + // reverts — same approach as bridge's inspect-vs-execute flow. + const readyRows = plan.rows.filter((r) => r.status === "ready"); + if (readyRows.length === 0) { + print( + { + chain, + toToken: targetUpper, + walletAddress: address, + executed: true, + results: [], + summary: { succeeded: 0, failed: 0 }, + note: "No ready rows to execute.", + }, + formatConsolidateResult, + ); + return; + } + + let passphrase; + try { + passphrase = await requireAgentToken("for consolidation", walletName); + } catch (err) { + handleTradingError(err, "consolidate_error"); + return; + } + const timeout = parseTimeout(flags.timeout); + + const results = []; + let succeeded = 0; + let failed = 0; + for (const row of readyRows) { + try { + const result = await executeSwap(row.quote, walletName, passphrase, { timeout }); + results.push({ + symbol: row.symbol, + hash: result.hash, + status: result.status, + blockNumber: result.blockNumber, + gasUsed: result.gasUsed, + }); + if (result.status === "success") { + succeeded++; + } else { + failed++; + if (!continueOnError) break; + } + } catch (err) { + failed++; + results.push({ + symbol: row.symbol, + hash: null, + status: "failed", + error: err.message || String(err), + }); + if (!continueOnError) break; + } + } + + print( + { + chain, + toToken: targetUpper, + walletAddress: address, + executed: true, + results, + summary: { succeeded, failed }, + }, + formatConsolidateResult, + ); +} + +function stripQuote(row) { + if (!row || typeof row !== "object" || !("quote" in row)) return row; + const { quote, ...rest } = row; + return rest; +} diff --git a/cli/router.js b/cli/router.js index 84bd9027..26819600 100644 --- a/cli/router.js +++ b/cli/router.js @@ -54,6 +54,7 @@ function printUsage() { "bridge --to-wallet ": "Bridge with explicit destination wallet (Solana ↔ EVM)", "bridge --to-address ": "Bridge to a raw destination address (chain-format must match)", "send --to [--chain ]": "Send native ETH/SOL or ERC-20 to an address (chain auto-detected from address format)", + "consolidate ": "Sweep all wallet positions on into (dry-run by default; add --execute to broadcast)", "search ": "Search for tokens by name or symbol", }, agent_tokens: { diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs new file mode 100644 index 00000000..710c1b8c --- /dev/null +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -0,0 +1,725 @@ +// Unit tests for the consolidate skill's pure helpers — filterCandidates, +// evaluateQuote, gas-reserve math, stables matching, and --max-loss parsing. +// The CLI shell (`cli/commands/trading/consolidate.js`) is exercised +// indirectly: where it calls these helpers, the helpers' contracts are what +// the tests pin down. + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + STABLE_SYMBOLS, + isStable, + DEFAULT_GAS_RESERVES, + FALLBACK_GAS_RESERVE, + resolveGasReserve, + parseMaxLoss, + parseMinValue, + parseGasReserve, + parseSymbolList, + computeNativeSweepAmount, + getImplementationAddress, + classifyPosition, + filterCandidates, + evaluateQuote, + summarisePlan, + buildConsolidatePlan, +} from "#zerion/utils/trading/consolidate.js"; + +// Realistic position rows shaped like the /positions response. Keep these +// minimal but with the fields the filter actually reads. +function walletPosition({ symbol, value, quantity, chain = "base", address, decimals = 18, positionType = "wallet" }) { + return { + attributes: { + position_type: positionType, + value, + price: value != null && quantity ? value / quantity : null, + quantity: { float: quantity }, + fungible_info: { + symbol, + implementations: address + ? [{ chain_id: chain, address, decimals }] + : [], + }, + }, + }; +} + +const CHAIN = "base"; +const TARGET = { + symbol: "USDC", + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", +}; + +function baseCtx(overrides = {}) { + return { + chain: CHAIN, + targetSymbol: TARGET.symbol, + targetAddress: TARGET.address, + nativeSymbol: "ETH", + includeNative: false, + includeStables: false, + includeSet: new Set(), + excludeSet: new Set(), + minValueUsd: 1, + ...overrides, + }; +} + +describe("STABLE_SYMBOLS coverage", () => { + it("matches the documented stablecoin set case-insensitively", () => { + // The acceptance criteria lists these exact symbols (case-insensitive). + const documented = [ + "USDC", "USDT", "DAI", "USDS", "FRAX", "TUSD", "USDD", "PYUSD", + "LUSD", "GUSD", "USDe", "RLUSD", "FDUSD", "USDB", "crvUSD", + ]; + for (const sym of documented) { + assert.equal(isStable(sym), true, `${sym} should match`); + assert.equal(isStable(sym.toLowerCase()), true, `${sym.toLowerCase()} should match`); + assert.equal(isStable(sym.toUpperCase()), true, `${sym.toUpperCase()} should match`); + } + // Mixed-case forms also match — guards against a regression where the set + // was stored upper-case and lower-case input would silently fail. + for (const variant of ["Usdc", "uSdC", "Usde", "CrvUsd", "PyUsd"]) { + assert.equal(isStable(variant), true, `${variant} should match`); + } + }); + + it("does not flag non-stable symbols", () => { + for (const sym of ["ETH", "BTC", "MATIC", "SOL", "MON", "USD"]) { + // "USD" is not in the documented list — exact-symbol match only. + assert.equal(isStable(sym), false, `${sym} should not match`); + } + }); + + it("STABLE_SYMBOLS set is exposed lowercased (callers rely on .has(symbol.toLowerCase()))", () => { + for (const entry of STABLE_SYMBOLS) { + assert.equal(entry, entry.toLowerCase(), "set entries must be lowercased"); + } + }); +}); + +describe("parseMaxLoss — dual-form rule", () => { + it("treats values > 1 as percent", () => { + assert.equal(parseMaxLoss(5), 0.05); + assert.equal(parseMaxLoss("5"), 0.05); + assert.equal(parseMaxLoss("2.5"), 0.025); + assert.equal(parseMaxLoss(100), 1); + }); + + it("treats values ≤ 1 as fraction", () => { + assert.equal(parseMaxLoss(0.05), 0.05); + assert.equal(parseMaxLoss("0.05"), 0.05); + assert.equal(parseMaxLoss(1), 1); + assert.equal(parseMaxLoss(0), 0); + }); + + it("`--max-loss 5` and `--max-loss 0.05` resolve to the same fraction", () => { + // The dual-form contract is the most user-visible feature here — pin it. + assert.equal(parseMaxLoss(5), parseMaxLoss(0.05)); + assert.equal(parseMaxLoss("5"), parseMaxLoss("0.05")); + }); + + it("defaults to 5% when unset", () => { + assert.equal(parseMaxLoss(undefined), 0.05); + assert.equal(parseMaxLoss(null), 0.05); + assert.equal(parseMaxLoss(""), 0.05); + // Bare boolean flag (parseFlags treats `--max-loss` alone as `true`) + assert.equal(parseMaxLoss(true), 0.05); + }); + + it("rejects NaN, negative, and > 100 with invalid_max_loss", () => { + for (const bad of ["abc", -1, -0.1, 101, "200"]) { + assert.throws(() => parseMaxLoss(bad), (err) => err.code === "invalid_max_loss"); + } + }); +}); + +describe("parseMinValue / parseGasReserve", () => { + it("parseMinValue defaults to 1 and accepts non-negative numbers", () => { + assert.equal(parseMinValue(undefined), 1); + assert.equal(parseMinValue("0"), 0); + assert.equal(parseMinValue("10"), 10); + assert.equal(parseMinValue(2.5), 2.5); + }); + + it("parseMinValue rejects NaN and negative", () => { + for (const bad of ["abc", -1, -0.01, "-5"]) { + assert.throws(() => parseMinValue(bad), (err) => err.code === "invalid_min_value"); + } + }); + + it("parseGasReserve returns undefined when unset (so resolveGasReserve picks default)", () => { + assert.equal(parseGasReserve(undefined), undefined); + assert.equal(parseGasReserve(""), undefined); + assert.equal(parseGasReserve(true), undefined); + }); + + it("parseGasReserve accepts non-negative numbers", () => { + assert.equal(parseGasReserve("0"), 0); + assert.equal(parseGasReserve("0.005"), 0.005); + assert.equal(parseGasReserve(0.001), 0.001); + }); + + it("parseGasReserve rejects NaN and negative", () => { + for (const bad of ["abc", -0.001, "-1"]) { + assert.throws(() => parseGasReserve(bad), (err) => err.code === "invalid_gas_reserve"); + } + }); +}); + +describe("parseSymbolList", () => { + it("empty / undefined → empty Set", () => { + assert.equal(parseSymbolList(undefined).size, 0); + assert.equal(parseSymbolList("").size, 0); + assert.equal(parseSymbolList(true).size, 0); + }); + + it("upper-cases and trims comma-separated symbols", () => { + const result = parseSymbolList(" usdc , Weth ,eth "); + assert.deepEqual([...result], ["USDC", "WETH", "ETH"]); + }); +}); + +describe("resolveGasReserve", () => { + it("explicit value wins over the chain default", () => { + const r = resolveGasReserve("ethereum", 0.123); + assert.equal(r.value, 0.123); + assert.equal(r.isDefault, false); + assert.equal(r.isFallback, false); + }); + + it("known chains use the documented per-chain default", () => { + assert.equal(resolveGasReserve("ethereum").value, DEFAULT_GAS_RESERVES.ethereum); + assert.equal(resolveGasReserve("base").value, DEFAULT_GAS_RESERVES.base); + assert.equal(resolveGasReserve("solana").value, DEFAULT_GAS_RESERVES.solana); + assert.equal(resolveGasReserve("polygon").value, 1); + }); + + it("unknown chain falls back to FALLBACK_GAS_RESERVE with isFallback=true", () => { + const r = resolveGasReserve("monad"); + assert.equal(r.value, FALLBACK_GAS_RESERVE); + assert.equal(r.isDefault, true); + assert.equal(r.isFallback, true); + }); +}); + +describe("computeNativeSweepAmount", () => { + it("returns (quantity - reserve) when positive", () => { + // Float subtraction can carry a tiny epsilon (0.01 - 0.001 ≈ 0.009 + 1e-18). + // We assert closeness rather than exact equality — the contract is "qty - + // reserve", not "the exact decimal you'd get with arbitrary precision". + const a = computeNativeSweepAmount(0.01, 0.001); + assert.equal(a.reason, null); + assert.ok(Math.abs(a.amount - 0.009) < 1e-12, `amount=${a.amount}`); + + const b = computeNativeSweepAmount(1, 0.5); + assert.equal(b.reason, null); + assert.equal(b.amount, 0.5); + }); + + it("returns below_reserve when reserve >= quantity", () => { + assert.deepEqual(computeNativeSweepAmount(0.001, 0.001), { amount: 0, reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount(0.0005, 0.001), { amount: 0, reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount(0, 0.001), { amount: 0, reason: "below_reserve" }); + }); + + it("rejects non-finite inputs as below_reserve (safer than dividing by NaN downstream)", () => { + assert.deepEqual(computeNativeSweepAmount(NaN, 0.001), { amount: 0, reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount(0.01, undefined), { amount: 0, reason: "below_reserve" }); + }); +}); + +describe("getImplementationAddress", () => { + it("returns lowercased address for the matching chain_id", () => { + const fungible = { + implementations: [ + { chain_id: "ethereum", address: "0xAaBbCc" }, + { chain_id: "base", address: "0x833589FCD6EDb6E08f4c7C32D4f71b54bda02913" }, + ], + }; + assert.equal(getImplementationAddress(fungible, "base"), "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"); + assert.equal(getImplementationAddress(fungible, "arbitrum"), null); + }); + + it("returns null when implementations missing or empty", () => { + assert.equal(getImplementationAddress({}, "base"), null); + assert.equal(getImplementationAddress({ implementations: [] }, "base"), null); + assert.equal(getImplementationAddress(null, "base"), null); + }); +}); + +describe("classifyPosition / filterCandidates — target exclusion", () => { + it("excludes the target token by symbol", () => { + const row = walletPosition({ + symbol: "USDC", + value: 100, + quantity: 100, + address: "0xfaaafaaa", // a DIFFERENT address — still excluded by symbol + }); + const result = classifyPosition(row, baseCtx()); + assert.equal(result.kind, "skip"); + assert.equal(result.reason, "is_target"); + }); + + it("excludes the target token by on-chain address even when symbol differs (USDC.e vs USDC alias)", () => { + // A row that reports a different symbol (e.g. an alias) but maps to the + // target's on-chain address must still be excluded. This guards against + // a Zerion API quirk where bridged/wrapped variants surface alternate + // symbols. + const row = walletPosition({ + symbol: "USDCALIAS", + value: 100, + quantity: 100, + address: TARGET.address.toUpperCase(), // exercise case-insensitivity + }); + const result = classifyPosition(row, baseCtx()); + assert.equal(result.kind, "skip"); + assert.equal(result.reason, "is_target"); + }); + + it("does NOT exclude a different token that shares the same first chars (no prefix match)", () => { + const row = walletPosition({ + symbol: "USDCe", + value: 100, + quantity: 100, + address: "0xdeadbeef", + }); + const result = classifyPosition(row, baseCtx()); + assert.equal(result.kind, "candidate"); + }); +}); + +describe("filterCandidates — position type, stables, native, dust", () => { + it("excludes non-wallet position types entirely (no plan row emitted)", () => { + // Each of these must be filtered out completely — they don't show up + // even as a `skipped` plan row. + for (const positionType of ["deposit", "loan", "staked", "locked", "reward", "investment"]) { + const row = walletPosition({ + symbol: "WETH", + value: 100, + quantity: 0.05, + positionType, + }); + const { candidates, skippedDust } = filterCandidates([row], baseCtx()); + assert.equal(candidates.length, 0, `${positionType} → no candidate`); + assert.equal(skippedDust.length, 0, `${positionType} → no dust row either`); + } + }); + + it("excludes the native gas token by default", () => { + const row = walletPosition({ symbol: "ETH", value: 100, quantity: 0.05 }); + const { candidates } = filterCandidates([row], baseCtx()); + assert.equal(candidates.length, 0); + }); + + it("--include-native opts the native gas token back in", () => { + const row = walletPosition({ symbol: "ETH", value: 100, quantity: 0.05 }); + const { candidates } = filterCandidates([row], baseCtx({ includeNative: true })); + assert.equal(candidates.length, 1); + assert.equal(candidates[0].isNative, true); + }); + + it("excludes stables by default", () => { + const row = walletPosition({ + symbol: "DAI", + value: 50, + quantity: 50, + address: "0xdai", + }); + const { candidates } = filterCandidates([row], baseCtx()); + assert.equal(candidates.length, 0); + }); + + it("--include-stables opts stables back in", () => { + const row = walletPosition({ + symbol: "DAI", + value: 50, + quantity: 50, + address: "0xdai", + }); + const { candidates } = filterCandidates([row], baseCtx({ includeStables: true })); + assert.equal(candidates.length, 1); + assert.equal(candidates[0].symbol, "DAI"); + }); + + it("dust positions land on the skippedDust list (still emit a plan row)", () => { + const row = walletPosition({ + symbol: "WETH", + value: 0.5, + quantity: 0.0002, + address: "0xweth", + }); + const { candidates, skippedDust } = filterCandidates([row], baseCtx()); + assert.equal(candidates.length, 0); + assert.equal(skippedDust.length, 1); + assert.equal(skippedDust[0].symbol, "WETH"); + }); +}); + +describe("filterCandidates — --include / --exclude overrides", () => { + it("--include overrides the native-token exclusion (case-insensitive)", () => { + const row = walletPosition({ symbol: "ETH", value: 100, quantity: 0.05 }); + const { candidates } = filterCandidates([row], baseCtx({ includeSet: new Set(["ETH"]) })); + assert.equal(candidates.length, 1); + }); + + it("--include overrides the stables exclusion", () => { + const row = walletPosition({ + symbol: "DAI", + value: 50, + quantity: 50, + address: "0xdai", + }); + const { candidates } = filterCandidates([row], baseCtx({ includeSet: new Set(["DAI"]) })); + assert.equal(candidates.length, 1); + }); + + it("--include cannot resurrect the target token", () => { + const row = walletPosition({ + symbol: TARGET.symbol, + value: 50, + quantity: 50, + address: TARGET.address, + }); + const { candidates } = filterCandidates([row], baseCtx({ includeSet: new Set([TARGET.symbol]) })); + assert.equal(candidates.length, 0); + }); + + it("--exclude adds extra exclusions on top of defaults", () => { + const row = walletPosition({ + symbol: "WETH", + value: 100, + quantity: 0.05, + address: "0xweth", + }); + const { candidates } = filterCandidates([row], baseCtx({ excludeSet: new Set(["WETH"]) })); + assert.equal(candidates.length, 0); + }); + + it("--include still subject to --min-value (forced-include with dust value → dust row)", () => { + const row = walletPosition({ + symbol: "ETH", + value: 0.5, + quantity: 0.0002, + }); + const { candidates, skippedDust } = filterCandidates([row], baseCtx({ + includeNative: true, + includeSet: new Set(["ETH"]), + })); + assert.equal(candidates.length, 0); + assert.equal(skippedDust.length, 1); + }); +}); + +describe("evaluateQuote — loss math + tolerance", () => { + // The acceptance criteria fixture: a $100 position quoting to ~$95 of the + // target. With max-loss = 5%, the row must be ACCEPTED — float equality at + // the boundary is the most common source of flakes here. + it("100 USD position → 95 USD target output → loss=0.05 → accepted at max_loss=0.05", () => { + // Construct so that out * price / value == 0.95 exactly: + // out=95, price=1, value=100 → 0.95. + const r = evaluateQuote({ + estimatedOutput: 95, + targetUsdPrice: 1, + positionValueUsd: 100, + maxLoss: 0.05, + }); + assert.equal(r.status, "ready"); + // Tolerance check: the equality test in evaluateQuote uses 1e-9. + assert.ok(Math.abs(r.lossPct - 0.05) < 1e-9, `lossPct=${r.lossPct}`); + }); + + it("blocks when loss exceeds max_loss + 1e-9", () => { + // 94 / 100 = 0.94 → loss 0.06, clearly above 0.05. + const r = evaluateQuote({ + estimatedOutput: 94, + targetUsdPrice: 1, + positionValueUsd: 100, + maxLoss: 0.05, + }); + assert.equal(r.status, "blocked"); + assert.equal(r.reason, "max_loss"); + assert.ok(r.lossPct > 0.05); + }); + + it("accepts at exactly max_loss boundary (within 1e-9 tolerance)", () => { + // Float-equality boundary: epsilon must NOT block. + const r = evaluateQuote({ + estimatedOutput: 95 + 1e-12, // microscopically better + targetUsdPrice: 1, + positionValueUsd: 100, + maxLoss: 0.05, + }); + assert.equal(r.status, "ready"); + }); + + it("accepts when the quote returns MORE USD than the position is worth (negative loss)", () => { + // out * price > value → loss is negative — the user is gaining USD value + // through the swap. Still ready. + const r = evaluateQuote({ + estimatedOutput: 110, + targetUsdPrice: 1, + positionValueUsd: 100, + maxLoss: 0.05, + }); + assert.equal(r.status, "ready"); + assert.ok(r.lossPct < 0); + }); + + it("returns skipped: no_price when position value is missing or zero", () => { + for (const value of [undefined, null, 0, NaN]) { + const r = evaluateQuote({ + estimatedOutput: 95, + targetUsdPrice: 1, + positionValueUsd: value, + maxLoss: 0.05, + }); + assert.equal(r.status, "skipped"); + assert.equal(r.reason, "no_price"); + } + }); + + it("returns skipped: no_price when estimatedOutput is missing or unparseable", () => { + for (const out of [undefined, null, "abc", NaN]) { + const r = evaluateQuote({ + estimatedOutput: out, + targetUsdPrice: 1, + positionValueUsd: 100, + maxLoss: 0.05, + }); + assert.equal(r.status, "skipped"); + assert.equal(r.reason, "no_price"); + } + }); + + it("returns skipped: no_price when targetUsdPrice is missing (target fungible without market_data)", () => { + const r = evaluateQuote({ + estimatedOutput: 95, + targetUsdPrice: NaN, + positionValueUsd: 100, + maxLoss: 0.05, + }); + assert.equal(r.status, "skipped"); + }); +}); + +describe("buildConsolidatePlan — sequential quote loop", () => { + it("calls the injected quoteFn once per candidate, in order, and never in parallel", async () => { + // The reference impl (safe-manager/trade.js) is sequential and the API + // is rate-limited at 1 RPS on demo tier — Promise.all would be a + // regression. Track concurrency via a counter that the fake quoteFn + // increments on entry and decrements on exit. + let active = 0; + let maxActive = 0; + const order = []; + const quoteFn = async (input) => { + active++; + maxActive = Math.max(maxActive, active); + order.push(input.fromToken); + await new Promise((r) => setTimeout(r, 5)); + active--; + return { + estimatedOutput: "95", + from: { symbol: input.fromToken }, + to: { symbol: input.toToken }, + fromChain: input.fromChain, + toChain: input.toChain, + }; + }; + + const candidates = [ + { symbol: "WETH", valueUsd: 100, quantity: 0.05, fungible: {}, implAddress: "0xweth", isNative: false }, + { symbol: "WBTC", valueUsd: 100, quantity: 0.001, fungible: {}, implAddress: "0xwbtc", isNative: false }, + { symbol: "ARB", valueUsd: 100, quantity: 80, fungible: {}, implAddress: "0xarb", isNative: false }, + ]; + + const plan = await buildConsolidatePlan({ + candidates, + skippedDust: [], + chain: "base", + toToken: "USDC", + targetUsdPrice: 1, + walletAddress: "0xabc", + slippage: 2, + gasReserveValue: 0, + maxLoss: 0.05, + quoteFn, + }); + + assert.equal(maxActive, 1, "quotes must run one at a time"); + assert.deepEqual(order, ["WETH", "WBTC", "ARB"], "quotes must run in candidate order"); + assert.equal(plan.totals.ready, 3); + }); + + it("emits no_route on quote error and continues to the next candidate (does not bail the plan)", async () => { + let callCount = 0; + const quoteFn = async (input) => { + callCount++; + if (input.fromToken === "WBTC") { + const err = new Error("No swap route found for 0.001 WBTC → USDC on base"); + err.code = "no_route"; + throw err; + } + return { + estimatedOutput: "95", + from: { symbol: input.fromToken }, + to: { symbol: input.toToken }, + }; + }; + + const candidates = [ + { symbol: "WETH", valueUsd: 100, quantity: 0.05, fungible: {}, implAddress: "0xweth", isNative: false }, + { symbol: "WBTC", valueUsd: 100, quantity: 0.001, fungible: {}, implAddress: "0xwbtc", isNative: false }, + { symbol: "ARB", valueUsd: 100, quantity: 80, fungible: {}, implAddress: "0xarb", isNative: false }, + ]; + + const plan = await buildConsolidatePlan({ + candidates, + skippedDust: [], + chain: "base", + toToken: "USDC", + targetUsdPrice: 1, + walletAddress: "0xabc", + slippage: 2, + gasReserveValue: 0, + maxLoss: 0.05, + quoteFn, + }); + + assert.equal(callCount, 3, "must keep iterating past the error"); + assert.equal(plan.totals.ready, 2); + assert.equal(plan.totals.no_route, 1); + const wbtcRow = plan.rows.find((r) => r.symbol === "WBTC"); + assert.equal(wbtcRow.status, "no_route"); + assert.match(wbtcRow.reason, /No swap route/); + }); + + it("uses (quantity - reserve) for the native candidate sweep amount", async () => { + const quoteInputs = []; + const quoteFn = async (input) => { + quoteInputs.push(input); + return { + estimatedOutput: "95", + from: { symbol: input.fromToken }, + to: { symbol: input.toToken }, + }; + }; + + const candidates = [ + { symbol: "ETH", valueUsd: 100, quantity: 0.01, fungible: {}, implAddress: null, isNative: true }, + ]; + + await buildConsolidatePlan({ + candidates, + skippedDust: [], + chain: "base", + toToken: "USDC", + targetUsdPrice: 1, + walletAddress: "0xabc", + slippage: 2, + gasReserveValue: 0.001, + maxLoss: 0.05, + quoteFn, + }); + + assert.equal(quoteInputs.length, 1); + // 0.01 - 0.001 ≈ 0.009 (with a possible 1e-18 epsilon from float math). + // Parse the string back to a number and assert proximity — the contract + // is "sweep quantity minus reserve", not a particular decimal rendering. + const passed = parseFloat(quoteInputs[0].amount); + assert.ok(Math.abs(passed - 0.009) < 1e-12, `amount string=${quoteInputs[0].amount}`); + }); + + it("marks the native row below_reserve when reserve >= quantity (no quote call)", async () => { + let called = false; + const quoteFn = async () => { + called = true; + throw new Error("must not be called"); + }; + + const candidates = [ + { symbol: "ETH", valueUsd: 100, quantity: 0.001, fungible: {}, implAddress: null, isNative: true }, + ]; + + const plan = await buildConsolidatePlan({ + candidates, + skippedDust: [], + chain: "base", + toToken: "USDC", + targetUsdPrice: 1, + walletAddress: "0xabc", + slippage: 2, + gasReserveValue: 0.001, + maxLoss: 0.05, + quoteFn, + }); + + assert.equal(called, false, "must short-circuit without fetching a quote"); + assert.equal(plan.rows[0].status, "skipped"); + assert.equal(plan.rows[0].reason, "below_reserve"); + }); +}); + +describe("summarisePlan totals", () => { + it("sums expected_output across ready rows and projects to USD via targetUsdPrice", () => { + const rows = [ + { symbol: "A", status: "ready", expected_output: 10 }, + { symbol: "B", status: "ready", expected_output: 20 }, + { symbol: "C", status: "blocked" }, + { symbol: "D", status: "skipped" }, + { symbol: "E", status: "no_route" }, + ]; + const plan = summarisePlan(rows, { chain: "base", toToken: "USDC", walletAddress: "0xabc", targetUsdPrice: 1 }); + assert.equal(plan.totals.ready, 2); + assert.equal(plan.totals.blocked, 1); + assert.equal(plan.totals.skipped, 1); + assert.equal(plan.totals.no_route, 1); + assert.equal(plan.totals.expected_output, 30); + assert.equal(plan.totals.expected_output_usd, 30); + }); + + it("expected_output_usd is null when targetUsdPrice is missing", () => { + const rows = [{ symbol: "A", status: "ready", expected_output: 10 }]; + const plan = summarisePlan(rows, { chain: "base", toToken: "USDC", walletAddress: "0xabc", targetUsdPrice: NaN }); + assert.equal(plan.totals.expected_output_usd, null); + }); +}); + +describe("coerceBoolFlag integration — invalid_flag_value", () => { + // The consolidate command file inlines coerceBoolFlag with the same shape + // as bridge.js. We exercise the contract by spawning the CLI shell with a + // bad invocation and asserting it exits non-zero and prints the documented + // error code. + // + // Spawning a subprocess keeps this an integration-style assertion without + // actually hitting the network — argv parsing happens before any API call, + // so the process exits at the validation step. + + it("rejects --include-stables with a non-positional value", async () => { + const { spawn } = await import("node:child_process"); + const { resolve, dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const here = dirname(fileURLToPath(import.meta.url)); + // From .../cli/tests/unit/cli/utils/trading/ up to repo root is 6 levels. + const cliPath = resolve(here, "../../../../../..", "cli/zerion.js"); + + const child = spawn(process.execPath, [ + cliPath, + "consolidate", + "base", + "USDC", + "--include-stables", + "something-bad", + ], { + env: { ...process.env, ZERION_API_KEY: "zk_dummy_for_argv_only" }, + }); + + let stderr = ""; + child.stderr.on("data", (c) => (stderr += c.toString())); + + const code = await new Promise((done) => child.on("exit", done)); + assert.notEqual(code, 0, "CLI must exit non-zero on invalid_flag_value"); + assert.match(stderr, /invalid_flag_value/, `stderr should contain invalid_flag_value; got: ${stderr}`); + assert.match(stderr, /include-stables/); + }); +}); diff --git a/cli/utils/common/format.js b/cli/utils/common/format.js index ee298c1d..02154eed 100644 --- a/cli/utils/common/format.js +++ b/cli/utils/common/format.js @@ -279,6 +279,108 @@ export function formatBridgeOffers(data) { return lines.join("\n"); } +function formatStatusCell(row) { + // Colour-coded status cell with reason annotation. `ready` is green; + // `blocked` / `no_route` are red; `skipped` is dim so dust/no-price rows + // recede visually. + if (row.status === "ready") return `${GREEN}ready${RESET}`; + if (row.status === "blocked") return `${RED}blocked${RESET}${row.reason ? ` ${DIM}(${row.reason})${RESET}` : ""}`; + if (row.status === "no_route") return `${RED}no_route${RESET}`; + return `${DIM}skipped${row.reason ? ` (${row.reason})` : ""}${RESET}`; +} + +function lossCell(value) { + if (value == null) return "-"; + // Loss is reported as a fraction (0.05 = 5% loss). Render as a positive + // percent in red when > 0, green when ≤ 0 (the quote returns *more* USD). + const n = Number(value); + if (!Number.isFinite(n)) return "-"; + const pctValue = n * 100; + const color = pctValue > 0 ? RED : GREEN; + const sign = pctValue >= 0 ? "" : "+"; + return `${color}${sign}${pctValue.toFixed(2)}%${RESET}`; +} + +export function formatConsolidatePlan(data) { + // Column widths sized for 80-col terminals. Symbol fits common tickers; + // Output gets a wider column because target-token decimals can grow long. + const W = { idx: 3, symbol: 10, qty: 14, value: 12, output: 16, loss: 10, status: 22 }; + const totalWidth = Object.values(W).reduce((a, b) => a + b, 0) + Object.keys(W).length - 1; + const rows = data.rows || []; + + const lines = [ + `${BOLD}Consolidate Plan${RESET} — ${data.chain} → ${data.toToken}` + + (data.walletAddress ? ` ${DIM}(${data.walletAddress.slice(0, 8)}…)${RESET}` : "") + + "\n", + ]; + lines.push( + ` ${DIM}${pad("#", W.idx)} ${pad("Symbol", W.symbol)} ${padStart("Qty", W.qty)} ${padStart("Value (USD)", W.value)} ${padStart("Output", W.output)} ${padStart("Loss %", W.loss)} ${pad("Status", W.status)}${RESET}`, + ); + lines.push(` ${DIM}${"─".repeat(totalWidth)}${RESET}`); + + if (rows.length === 0) { + lines.push(` ${DIM}(no candidate positions)${RESET}`); + } + + for (const [i, r] of rows.entries()) { + const qty = Number.isFinite(r.quantity) ? Number(r.quantity).toFixed(6) : "-"; + const value = Number.isFinite(r.value_usd) ? usd(r.value_usd) : "-"; + const out = Number.isFinite(r.expected_output) ? Number(r.expected_output).toFixed(6) : "-"; + const loss = lossCell(r.loss_pct); + const status = formatStatusCell(r); + const row = ` ${pad(i + 1, W.idx)} ${pad(r.symbol || "?", W.symbol)} ${padStart(qty, W.qty)} ${padStart(value, W.value)} ${padStart(out, W.output)} ${padStart(loss, W.loss + 9)} ${status}`; + if (r.status === "ready") lines.push(row); + else lines.push(`${DIM}${row}${RESET}`); + } + + const t = data.totals || {}; + const expectedUsd = t.expected_output_usd != null ? ` (~${usd(t.expected_output_usd)})` : ""; + lines.push(""); + lines.push( + ` ${BOLD}${t.ready || 0} ready${RESET}, ${t.blocked || 0} blocked, ${t.skipped || 0} skipped` + + (t.no_route ? `, ${t.no_route} no_route` : "") + + ` — expected ~${Number(t.expected_output || 0).toFixed(6)} ${data.toToken}${expectedUsd}`, + ); + if (!data.executed) { + lines.push( + ` ${YELLOW}Re-run with ${BOLD}--execute${RESET}${YELLOW} to broadcast the ready rows.${RESET}`, + ); + } + return lines.join("\n"); +} + +export function formatConsolidateResult(data) { + const W = { idx: 3, symbol: 10, hash: 18, status: 12, error: 28 }; + const totalWidth = Object.values(W).reduce((a, b) => a + b, 0) + Object.keys(W).length - 1; + const results = data.results || []; + const summary = data.summary || {}; + + const lines = [ + `${BOLD}Consolidate Result${RESET} — ${data.chain || "?"} → ${data.toToken || "?"}` + + (data.walletAddress ? ` ${DIM}(${data.walletAddress.slice(0, 8)}…)${RESET}` : "") + + "\n", + ]; + lines.push( + ` ${DIM}${pad("#", W.idx)} ${pad("Symbol", W.symbol)} ${pad("Hash", W.hash)} ${pad("Status", W.status)} ${pad("Error", W.error)}${RESET}`, + ); + lines.push(` ${DIM}${"─".repeat(totalWidth)}${RESET}`); + if (results.length === 0) { + lines.push(` ${DIM}(no swaps executed)${RESET}`); + } + for (const [i, r] of results.entries()) { + const hash = r.hash ? `${r.hash.slice(0, 10)}…${r.hash.slice(-4)}` : "-"; + const ok = r.status === "success"; + const status = ok ? `${GREEN}success${RESET}` : `${RED}${r.status || "failed"}${RESET}`; + const err = r.error ? padTrunc(r.error, W.error) : pad("-", W.error); + lines.push(` ${pad(i + 1, W.idx)} ${pad(r.symbol, W.symbol)} ${pad(hash, W.hash)} ${pad(status, W.status + 9)} ${err}`); + } + lines.push(""); + lines.push( + ` ${BOLD}${summary.succeeded || 0} succeeded${RESET}, ${summary.failed || 0} failed`, + ); + return lines.join("\n"); +} + export function formatHistory(data) { const lines = [`${BOLD}Transactions${RESET} — ${data.wallet.name} (${data.count})\n`]; for (const tx of data.transactions) { diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js new file mode 100644 index 00000000..c97f5eda --- /dev/null +++ b/cli/utils/trading/consolidate.js @@ -0,0 +1,515 @@ +/** + * Pure logic for `zerion consolidate` — filtering, gas-reserve math, loss + * evaluation, and dry-run plan assembly. The CLI shell in + * `cli/commands/trading/consolidate.js` handles flag parsing, the stables + * prompt, target-token resolution, and the `--execute` broadcast loop; + * everything testable without network mocks lives here. + * + * Quotes are fetched sequentially via the shared `getSwapQuote` to stay under + * the Zerion API rate limit — same approach as the reference impl in + * `~/WebstormProjects/safe-manager/trade.js`. + */ + +import { getSwapQuote } from "./swap.js"; + +// Lowercase set so callers can do an O(1) `STABLE_SYMBOLS.has(sym.toLowerCase())` +// match. The literal symbol casings used by the Zerion fungibles API mix case +// (USDe, crvUSD, ...) — lowercasing the comparison side normalises that. +export const STABLE_SYMBOLS = new Set([ + "usdc", + "usdt", + "dai", + "usds", + "frax", + "tusd", + "usdd", + "pyusd", + "lusd", + "gusd", + "usde", + "rlusd", + "fdusd", + "usdb", + "crvusd", +]); + +export function isStable(symbol) { + if (!symbol) return false; + return STABLE_SYMBOLS.has(String(symbol).toLowerCase()); +} + +// Per-chain native gas reserve when `--include-native` is passed without an +// explicit `--gas-reserve`. Keys match Zerion chain ids. Unknown chains fall +// through to FALLBACK_GAS_RESERVE with a stderr warning surfaced by the CLI. +export const DEFAULT_GAS_RESERVES = { + ethereum: 0.005, + base: 0.001, + arbitrum: 0.001, + optimism: 0.001, + polygon: 1, + "binance-smart-chain": 0.005, + avalanche: 0.05, + gnosis: 1, + scroll: 0.001, + linea: 0.001, + "zksync-era": 0.001, + zora: 0.001, + blast: 0.001, + solana: 0.01, +}; + +export const FALLBACK_GAS_RESERVE = 0.01; + +/** + * Look up the default gas reserve for a chain. Returns + * `{ value, isDefault, isFallback }` so the CLI can surface a stderr warning + * when it falls back to the conservative default. + */ +export function resolveGasReserve(chainId, explicit) { + if (explicit !== undefined && explicit !== null) { + return { value: explicit, isDefault: false, isFallback: false }; + } + if (Object.prototype.hasOwnProperty.call(DEFAULT_GAS_RESERVES, chainId)) { + return { value: DEFAULT_GAS_RESERVES[chainId], isDefault: true, isFallback: false }; + } + return { value: FALLBACK_GAS_RESERVE, isDefault: true, isFallback: true }; +} + +/** + * Parse `--max-loss` with the dual-form rule: + * - value > 1 → percent (e.g. `5` → 0.05) + * - value ≤ 1 → fraction (e.g. `0.05` → 0.05) + * + * Rejects NaN, negative, or > 100. Returns the fraction. + * Throws `{ code: "invalid_max_loss", message }` on bad input so the caller + * can surface a `printError` consistently. + */ +export function parseMaxLoss(raw) { + if (raw === undefined || raw === null || raw === "" || raw === true || raw === false) { + // Default = 5% — apply the same dual-form rule (5 > 1 → percent). + return 0.05; + } + const n = typeof raw === "number" ? raw : Number(String(raw).trim()); + if (!Number.isFinite(n) || n < 0 || n > 100) { + const err = new Error( + `Invalid --max-loss: ${raw}. Must be a non-negative number ≤ 100. ` + + `Pass either a percent ("5") or a fraction ("0.05") — values > 1 are treated as percent.`, + ); + err.code = "invalid_max_loss"; + throw err; + } + return n > 1 ? n / 100 : n; +} + +/** + * Parse `--min-value` (USD). Returns a non-negative number; defaults to 1. + * Throws `{ code: "invalid_min_value" }` on bad input. + */ +export function parseMinValue(raw) { + if (raw === undefined || raw === null || raw === "" || raw === true || raw === false) { + return 1; + } + const n = typeof raw === "number" ? raw : Number(String(raw).trim()); + if (!Number.isFinite(n) || n < 0) { + const err = new Error(`Invalid --min-value: ${raw}. Must be a non-negative number.`); + err.code = "invalid_min_value"; + throw err; + } + return n; +} + +/** + * Parse `--gas-reserve` (native units). Returns a non-negative number or + * `undefined` if the flag isn't set. Throws `{ code: "invalid_gas_reserve" }` + * on bad input. + */ +export function parseGasReserve(raw) { + if (raw === undefined || raw === null || raw === "" || raw === true || raw === false) { + return undefined; + } + const n = typeof raw === "number" ? raw : Number(String(raw).trim()); + if (!Number.isFinite(n) || n < 0) { + const err = new Error(`Invalid --gas-reserve: ${raw}. Must be a non-negative number.`); + err.code = "invalid_gas_reserve"; + throw err; + } + return n; +} + +/** + * Normalise a comma-separated symbol list flag into an upper-case Set. + * Empty / undefined → empty Set. Whitespace around symbols is trimmed. + */ +export function parseSymbolList(raw) { + if (!raw || raw === true) return new Set(); + return new Set( + String(raw) + .split(",") + .map((s) => s.trim().toUpperCase()) + .filter(Boolean), + ); +} + +/** + * Compute the sweepable native-gas amount. + * Returns `{ amount, reason }` — `reason` is set when amount ≤ 0 so the caller + * can mark the row `skipped: below_reserve`. + */ +export function computeNativeSweepAmount(quantity, reserve) { + const q = Number(quantity); + const r = Number(reserve); + if (!Number.isFinite(q) || !Number.isFinite(r)) { + return { amount: 0, reason: "below_reserve" }; + } + const amount = q - r; + if (amount <= 0) { + return { amount: 0, reason: "below_reserve" }; + } + return { amount, reason: null }; +} + +/** + * Pick the on-chain address (lowercased) for a fungible on a given chain by + * scanning `attributes.implementations[]`. Returns `null` when the fungible + * has no implementation for that chain (e.g. the native gas token, which is + * symbol-only on most chains). + */ +export function getImplementationAddress(fungibleInfo, chainId) { + const impls = fungibleInfo?.implementations || []; + const match = impls.find((i) => i?.chain_id === chainId); + if (!match?.address) return null; + return String(match.address).toLowerCase(); +} + +/** + * Decide whether a single position row is a sweep candidate. + * + * Inputs: + * row: a `data[]` element from `getPositions` (full JSON:API shape). + * ctx: + * chain - the consolidate chain id + * targetSymbol - upper-case target token symbol + * targetAddress - lowercased target on-chain address for `chain`, or null + * nativeSymbol - upper-case native gas token symbol, or null + * includeNative - boolean + * includeStables - boolean + * includeSet - Set of upper-case symbols to force-include (overrides + * native/stables exclusions; still subject to dust filter) + * excludeSet - Set of upper-case symbols to force-exclude + * minValueUsd - dust threshold + * + * Returns one of: + * { kind: "skip", reason } — row excluded entirely (no plan entry) + * { kind: "dust" } — emit a plan row `status: skipped, dust` + * { kind: "candidate", symbol, valueUsd, quantity, fungible, implAddress } + */ +export function classifyPosition(row, ctx) { + const attrs = row?.attributes || {}; + const fungible = attrs.fungible_info || {}; + const symbol = (fungible.symbol || "").toUpperCase(); + const positionType = attrs.position_type; + const valueUsd = Number(attrs.value); + const quantity = Number(attrs.quantity?.float); + const implAddress = getImplementationAddress(fungible, ctx.chain); + const forceInclude = ctx.includeSet.has(symbol); + + // Non-wallet positions never sweep — skip entirely, no plan row. + if (positionType !== "wallet") { + return { kind: "skip", reason: "non_wallet" }; + } + + // Target token — exclude by symbol OR by on-chain address (when both sides + // expose an impl for this chain). `--include` does NOT override the target + // exclusion — converting the target into itself is nonsense. + if (symbol === ctx.targetSymbol) { + return { kind: "skip", reason: "is_target" }; + } + if (ctx.targetAddress && implAddress && implAddress === ctx.targetAddress) { + return { kind: "skip", reason: "is_target" }; + } + + if (ctx.excludeSet.has(symbol)) { + return { kind: "skip", reason: "excluded" }; + } + + // Native gas token — opt-in via --include-native or explicit --include. + if (ctx.nativeSymbol && symbol === ctx.nativeSymbol) { + if (!ctx.includeNative && !forceInclude) { + return { kind: "skip", reason: "native_excluded" }; + } + } + + // Stables — flag → prompt → non-TTY default exclude. The caller resolves the + // boolean before calling us; we just honor it. `--include` still wins. + if (!ctx.includeStables && !forceInclude && isStable(symbol)) { + return { kind: "skip", reason: "stable_excluded" }; + } + + if (!Number.isFinite(valueUsd) || valueUsd < ctx.minValueUsd) { + return { kind: "dust", symbol, valueUsd, quantity, fungible, implAddress }; + } + + return { kind: "candidate", symbol, valueUsd, quantity, fungible, implAddress }; +} + +/** + * Apply `classifyPosition` to the full positions array. Returns: + * { + * candidates: Array<{symbol, valueUsd, quantity, fungible, implAddress, isNative}>, + * skippedDust: Array<{symbol, quantity, valueUsd, fungible}>, + * } + * + * Skipped non-wallet / target / native-excluded / stable-excluded / excluded + * rows are dropped silently — they don't appear in the plan. + */ +export function filterCandidates(positions, ctx) { + const candidates = []; + const skippedDust = []; + for (const row of positions || []) { + const result = classifyPosition(row, ctx); + if (result.kind === "skip") continue; + if (result.kind === "dust") { + skippedDust.push({ + symbol: result.symbol, + quantity: result.quantity, + valueUsd: result.valueUsd, + fungible: result.fungible, + }); + continue; + } + candidates.push({ + symbol: result.symbol, + valueUsd: result.valueUsd, + quantity: result.quantity, + fungible: result.fungible, + implAddress: result.implAddress, + isNative: ctx.nativeSymbol && result.symbol === ctx.nativeSymbol, + }); + } + return { candidates, skippedDust }; +} + +/** + * Evaluate a quote against the loss filter. + * + * loss_pct = 1 - (estimatedOutput * targetUsdPrice / positionValueUsd) + * + * Float-equality at exactly max_loss must be accepted — use the documented + * 1e-9 tolerance to avoid flakes around things like `0.05 + 1e-17`. + * + * Returns: + * { status: "ready", lossPct, expectedOutput, expectedOutputUsd } + * { status: "blocked", reason: "max_loss", lossPct, expectedOutput, expectedOutputUsd } + * { status: "skipped", reason: "no_price" } — missing inputs we can't divide + */ +export function evaluateQuote({ estimatedOutput, targetUsdPrice, positionValueUsd, maxLoss }) { + // Treat null/undefined/empty-string as "missing" up-front — `Number(null)` + // is 0 (finite), which would otherwise compute a 100%-loss row and surface + // as `blocked` instead of `skipped: no_price`. + if ( + estimatedOutput == null || estimatedOutput === "" || + targetUsdPrice == null || targetUsdPrice === "" || + positionValueUsd == null || positionValueUsd === "" + ) { + return { status: "skipped", reason: "no_price" }; + } + const out = Number(estimatedOutput); + const price = Number(targetUsdPrice); + const posValue = Number(positionValueUsd); + if (!Number.isFinite(out) || !Number.isFinite(price) || !Number.isFinite(posValue) || posValue <= 0) { + return { status: "skipped", reason: "no_price" }; + } + const expectedOutputUsd = out * price; + const lossPct = 1 - expectedOutputUsd / posValue; + if (lossPct > maxLoss + 1e-9) { + return { + status: "blocked", + reason: "max_loss", + lossPct, + expectedOutput: out, + expectedOutputUsd, + }; + } + return { + status: "ready", + lossPct, + expectedOutput: out, + expectedOutputUsd, + }; +} + +/** + * Build the dry-run plan by fetching one quote per candidate sequentially. + * + * `quoteFn` is injected so tests can drive the loop without network mocks. + * Defaults to `getSwapQuote` from the shared swap utils. + * + * The returned plan is a structured object suitable for `print(..., formatter)`. + */ +export async function buildConsolidatePlan({ + candidates, + skippedDust, + chain, + toToken, + targetUsdPrice, + walletAddress, + slippage, + gasReserveValue, + maxLoss, + quoteFn = getSwapQuote, +}) { + const rows = []; + + // Dust rows first so the table groups visually-skipped entries together. + for (const d of skippedDust) { + rows.push({ + symbol: d.symbol, + quantity: d.quantity, + value_usd: d.valueUsd, + expected_output: null, + expected_output_usd: null, + loss_pct: null, + status: "skipped", + reason: "dust", + }); + } + + for (const c of candidates) { + // Native sweep amount uses (quantity - reserve) when the row is the chain's + // native gas token. Non-native rows sweep the full quantity. + let sweepQuantity = c.quantity; + if (c.isNative) { + const { amount, reason } = computeNativeSweepAmount(c.quantity, gasReserveValue); + if (reason) { + rows.push({ + symbol: c.symbol, + quantity: c.quantity, + value_usd: c.valueUsd, + expected_output: null, + expected_output_usd: null, + loss_pct: null, + status: "skipped", + reason: "below_reserve", + }); + continue; + } + sweepQuantity = amount; + } + + let quote; + try { + quote = await quoteFn({ + fromToken: c.symbol, + toToken, + amount: String(sweepQuantity), + fromChain: chain, + toChain: chain, + walletAddress, + outputReceiver: walletAddress, + slippage, + }); + } catch (err) { + rows.push({ + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: null, + expected_output_usd: null, + loss_pct: null, + status: "no_route", + reason: err?.message || "no route", + }); + continue; + } + + const evaluation = evaluateQuote({ + estimatedOutput: quote.estimatedOutput, + targetUsdPrice, + positionValueUsd: c.valueUsd, + maxLoss, + }); + + if (evaluation.status === "skipped") { + rows.push({ + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: null, + expected_output_usd: null, + loss_pct: null, + status: "skipped", + reason: evaluation.reason, + quote, + }); + continue; + } + if (evaluation.status === "blocked") { + rows.push({ + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: evaluation.expectedOutput, + expected_output_usd: evaluation.expectedOutputUsd, + loss_pct: evaluation.lossPct, + status: "blocked", + reason: "max_loss", + quote, + }); + continue; + } + rows.push({ + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: evaluation.expectedOutput, + expected_output_usd: evaluation.expectedOutputUsd, + loss_pct: evaluation.lossPct, + status: "ready", + quote, + }); + } + + return summarisePlan(rows, { chain, toToken, walletAddress, targetUsdPrice }); +} + +/** + * Roll up the plan rows into the printable structure (totals + counts). + * Kept separate so tests can assemble rows manually and exercise totals. + */ +export function summarisePlan(rows, { chain, toToken, walletAddress, targetUsdPrice }) { + let ready = 0; + let blocked = 0; + let skipped = 0; + let noRoute = 0; + let expectedOutputTotal = 0; + + for (const r of rows) { + if (r.status === "ready") { + ready++; + if (Number.isFinite(r.expected_output)) expectedOutputTotal += r.expected_output; + } else if (r.status === "blocked") { + blocked++; + } else if (r.status === "no_route") { + noRoute++; + } else { + skipped++; + } + } + + return { + chain, + toToken, + walletAddress, + targetUsdPrice, + rows, + totals: { + ready, + blocked, + skipped, + no_route: noRoute, + expected_output: expectedOutputTotal, + expected_output_usd: Number.isFinite(targetUsdPrice) ? expectedOutputTotal * targetUsdPrice : null, + }, + executed: false, + }; +} diff --git a/cli/zerion.js b/cli/zerion.js index 51aacbdb..31a8d5c2 100755 --- a/cli/zerion.js +++ b/cli/zerion.js @@ -58,6 +58,7 @@ registerSingle("analyze", analyze); import swap from "./commands/trading/swap.js"; import bridge from "./commands/trading/bridge.js"; import send from "./commands/trading/send.js"; +import consolidate from "./commands/trading/consolidate.js"; import swapTokens from "./commands/trading/list-tokens.js"; import search from "./commands/trading/search.js"; import chainsCmd from "./commands/trading/chains.js"; @@ -65,6 +66,7 @@ registerSingle("swap", swap); register("swap", "tokens", swapTokens); registerSingle("bridge", bridge); registerSingle("send", send); +registerSingle("consolidate", consolidate); registerSingle("search", search); registerSingle("chains", chainsCmd); diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md new file mode 100644 index 00000000..37acb183 --- /dev/null +++ b/skills/zerion-consolidate/SKILL.md @@ -0,0 +1,197 @@ +--- +name: zerion-consolidate +description: "Sweep all wallet positions on a single chain into one target token via the Zerion CLI. Use when the user says: consolidate dust tokens, sweep tokens into X, convert all tokens to ETH, treasury cleanup, convert dust to USDC, consolidate wallet on Base, swap all positions into , gather stragglers, collapse balances. Default mode is a dry-run plan; --execute broadcasts each ready row sequentially. Pair with `zerion-analyze` to inspect positions first and `zerion-trading` for the underlying swap primitive." +license: MIT +allowed-tools: Bash +--- + +# Zerion — Consolidate + +Sweep every sweepable wallet position on a single chain into one target token. The command lists positions, filters defaults (target token, native gas, stables, dust, protocol positions), fetches one Zerion swap quote per remaining row, applies a max-loss filter, and either prints a plan (dry-run) or broadcasts each swap sequentially (`--execute`). + +## Setup + +If a `zerion` command fails with `command not found`, install once: + +```bash +npm install -g zerion-cli +``` + +Requires Node.js ≥ 20. For auth see the `zerion` umbrella skill. **Trading needs an API key + agent token** (pay-per-call does NOT apply). + +## When to use + +- "Consolidate all tokens on Base into USDC" +- "Sweep dust on ethereum into ETH" +- "Convert everything in this wallet to MON on monad" +- "Treasury cleanup — gather every position on arbitrum into one token" +- "Solana same-chain sweep into SOL or USDC" + +For balance inspection before sweeping → `zerion-analyze`. For a single swap → `zerion-trading`. For setting up an agent token → `zerion-agent-management`. + +## Command shape + +``` +zerion consolidate [flags] +``` + +| Flag | Default | Meaning | +|---|---|---| +| `--execute` | _(off)_ | Broadcast the ready rows. Without this, the command prints a plan only. | +| `--min-value ` | `1` | Skip positions below this USD value (marked `skipped: dust`). | +| `--max-loss ` | `5` | Reject quotes losing more than this fraction vs current value. Dual form: values > 1 treated as percent (`5` → 5%), values ≤ 1 as fraction (`0.05` → 5%). | +| `--include-stables` | _(off)_ | Include stablecoins (USDC, USDT, DAI, USDS, FRAX, TUSD, USDD, PYUSD, LUSD, GUSD, USDe, RLUSD, FDUSD, USDB, crvUSD). | +| `--exclude-stables` | _(off)_ | Force-exclude stables, no prompt. | +| `--include ` | _(none)_ | Comma-separated symbols to force-include even if filtered (case-insensitive). | +| `--exclude ` | _(none)_ | Comma-separated extra exclusions on top of defaults. | +| `--include-native` | _(off)_ | Sweep the chain's native gas token (ETH/SOL/etc). | +| `--gas-reserve ` | per-chain default | Native units to reserve when `--include-native` is on. Requires `--include-native`. | +| `--slippage ` | `2` | Per-quote slippage tolerance (max 3, same as swap). | +| `--wallet ` | default | Source wallet. | +| `--continue-on-error` | _(off)_ | When `--execute`-ing, keep going past a failed swap instead of stopping. | +| `--timeout ` | `120` | Per-swap confirmation timeout. | + +Boolean flags (`--execute`, `--include-stables`, `--exclude-stables`, `--include-native`, `--continue-on-error`) should appear **last on the command line**, or use the `--flag=true` / `--no-flag` forms. The flag parser consumes the next non-`--` token as the value, so `--include-native ethereum` would mistakenly set `include-native="ethereum"`. The CLI rejects that with `invalid_flag_value`. + +## Examples + +```bash +# Dry-run — plan only, no signing. +zerion consolidate base USDC + +# Sweep everything on base into ETH, including the native ETH balance. +zerion consolidate base ETH --include-native + +# Execute — broadcasts each ready row sequentially after one passphrase prompt. +zerion consolidate base USDC --execute + +# Allow looser loss tolerance (8%), force-exclude WETH, keep going on errors. +zerion consolidate base USDC --max-loss 8 --exclude WETH --continue-on-error --execute + +# Include stables in the sweep, override the dust threshold. +zerion consolidate ethereum USDC --include-stables --min-value 5 + +# Solana same-chain consolidation into SOL. +zerion consolidate solana SOL --execute + +# Sweep into the native chain token while keeping 0.002 ETH for gas. +zerion consolidate base ETH --include-native --gas-reserve 0.002 --execute +``` + +## Output + +### Dry-run plan (default) + +JSON to stdout. Each row carries: + +```json +{ + "symbol": "WETH", + "quantity": 0.052, + "value_usd": 187.4, + "expected_output": 186.1, + "expected_output_usd": 186.1, + "loss_pct": 0.0069, + "status": "ready" +} +``` + +`status` is one of: + +| Status | Meaning | +|---|---| +| `ready` | Quote within max-loss; ready to broadcast on `--execute`. | +| `blocked` | Quote exceeds max-loss (`reason: "max_loss"`). | +| `no_route` | The Zerion API returned no executable route for this pair. | +| `skipped` | Filtered out: `dust`, `below_reserve` (native row), or `no_price`. | + +The totals line shows: `N ready, M blocked, K skipped, expected ~X TARGET (~$Y)`. + +### `--execute` result + +```json +{ + "executed": true, + "results": [ + { "symbol": "WETH", "hash": "0x…", "status": "success", "blockNumber": 1234, "gasUsed": "42000" } + ], + "summary": { "succeeded": 1, "failed": 0 } +} +``` + +## Filter defaults + +By default the plan **excludes**: + +1. The target token itself — by symbol AND on-chain address (so bridged variants like USDC.e mapping to the canonical USDC address are also excluded). +2. The chain's native gas token (use `--include-native` to opt in). +3. Stablecoins (use `--include-stables` to opt in, or answer the interactive prompt yes). +4. Positions below `--min-value` (default $1, marked `skipped: dust`). +5. All non-wallet positions — `deposit`, `loan`, `staked`, `locked`, `reward`, `investment`. These never appear in the plan. + +`--include ` overrides exclusions 2 and 3 (still subject to dust). `--exclude ` adds further symbol exclusions. + +## Stables behavior + +| Context | Result | +|---|---| +| `--include-stables` set | Include, no prompt. | +| `--exclude-stables` set | Exclude, no prompt. | +| Neither set, TTY | Prompt: `Include stables (USDC/USDT/DAI/...) in this sweep? [y/N]` (default No). | +| Neither set, non-TTY (pipe / agent invocation) | Default to **exclude**, no prompt — agents never block on stdin. | + +## Native gas-token handling + +`--include-native` sweeps the chain's native currency too (e.g. ETH on base, SOL on solana, POL on polygon). Pair with `--gas-reserve ` to leave a buffer for future txns; otherwise the per-chain default applies: + +| Chain | Default reserve | +|---|---| +| ethereum | 0.005 ETH | +| base | 0.001 ETH | +| arbitrum | 0.001 ETH | +| optimism | 0.001 ETH | +| polygon | 1 POL | +| binance-smart-chain | 0.005 BNB | +| avalanche | 0.05 AVAX | +| gnosis | 1 xDAI | +| scroll | 0.001 ETH | +| linea | 0.001 ETH | +| zksync-era | 0.001 ETH | +| zora | 0.001 ETH | +| blast | 0.001 ETH | +| solana | 0.01 SOL | +| _other_ | 0.01 (with stderr warning — set `--gas-reserve` explicitly) | + +`--gas-reserve` without `--include-native` errors with `conflicting_flags`. If the reserve is ≥ the position quantity, the row is marked `skipped: below_reserve`. + +## Safety + +- **Dry-run by default.** No signing happens without `--execute`. Always run the bare command first and read the plan before broadcasting. +- **Native token excluded by default.** Gas reserve protection only kicks in when you explicitly pass `--include-native`. Without it, the native row is silently filtered out — your ETH/SOL stays put. +- **Stables excluded by default in non-TTY contexts.** Agents and pipelines never auto-sweep stables without an explicit `--include-stables` flag. +- **Max-loss filter is a backstop, not a cap.** A row marked `blocked: max_loss` will NOT be broadcast even with `--execute`. Tighten the filter for low-liquidity tokens with `--max-loss 2`. +- **Sequential execution — no atomicity.** Each row is its own swap. A failure mid-batch leaves earlier swaps confirmed and remaining ones unattempted (unless `--continue-on-error` is set). Plan accordingly when sweeping volatile tokens. +- **Fresh quotes on `--execute`.** The execute path re-fetches quotes at run time — but it does not re-prompt the operator. Treat `--execute` as a commitment to broadcast every ready row. +- **One passphrase prompt for the whole batch.** The agent token is read once up-front; if you abort mid-batch, the remaining swaps will simply not run. + +## Common errors + +| Code | Cause | Fix | +|---|---|---| +| `missing_args` | `` or `` missing | `zerion consolidate base USDC` | +| `unsupported_chain` | Invalid chain | `zerion chains` | +| `target_token_not_found` | The target symbol has no implementation on the chain | Check `zerion search --chain ` | +| `invalid_min_value` | `--min-value` is NaN or negative | Pass a non-negative number, e.g. `--min-value 1` | +| `invalid_max_loss` | `--max-loss` is NaN, negative, or > 100 | Use percent (`5`) or fraction (`0.05`); see Dual form above | +| `invalid_gas_reserve` | `--gas-reserve` is NaN or negative | Pass a non-negative native-units number | +| `conflicting_flags` | `--gas-reserve` without `--include-native`, or `--include-stables` with `--exclude-stables` | Pass `--include-native` to opt in, or pick one stables flag | +| `invalid_flag_value` | Bare boolean flag got a non-positional consumed as value | Pass the boolean flag last, or use `--flag=true` / `--no-flag` | +| `invalid_slippage` | `--slippage` not in 0–100 | `--slippage 2` | +| `no_agent_token` | Trading needs an agent token | See `zerion-agent-management` | +| `insufficient_funds` | A row's balance dropped between quote and broadcast | Refresh and re-run `--execute` | + +## Pair with + +- `zerion-analyze` — inspect positions before sweeping. Useful to spot the long tail of dust and decide on `--min-value`. +- `zerion-trading` — the underlying same-chain swap primitive. Use it for one-off conversions; use `zerion-consolidate` when you want to sweep many at once. +- `zerion-agent-management` — set up the agent token + policies the `--execute` path will use. diff --git a/skills/zerion/SKILL.md b/skills/zerion/SKILL.md index b1286b54..b9cfaace 100644 --- a/skills/zerion/SKILL.md +++ b/skills/zerion/SKILL.md @@ -71,6 +71,7 @@ export ZERION_MPP=true |--------|-------| | What's in this wallet? portfolio, positions, history, PnL, watchlist | **`zerion-analyze`** | | Swap / bridge / send tokens | **`zerion-trading`** | +| Sweep / consolidate / convert all tokens on a chain into one target | **`zerion-consolidate`** | | Sign a message or EIP-712 typed data (no broadcast) | **`zerion-sign`** | | Create / import / list / backup / delete wallets | **`zerion-wallet`** | | Set up agent tokens + policies for autonomous trading | **`zerion-agent-management`** | From 39100787cc483a308f2d5e88cccb543a73aca2af Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 14:41:07 +0100 Subject: [PATCH 02/12] PLT-676: drop refs to user's private reference repo from code comments --- cli/tests/unit/cli/utils/trading/consolidate.test.mjs | 7 +++---- cli/utils/trading/consolidate.js | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index 710c1b8c..6b303a04 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -505,10 +505,9 @@ describe("evaluateQuote — loss math + tolerance", () => { describe("buildConsolidatePlan — sequential quote loop", () => { it("calls the injected quoteFn once per candidate, in order, and never in parallel", async () => { - // The reference impl (safe-manager/trade.js) is sequential and the API - // is rate-limited at 1 RPS on demo tier — Promise.all would be a - // regression. Track concurrency via a counter that the fake quoteFn - // increments on entry and decrements on exit. + // The Zerion API is rate-limited at 1 RPS on the demo tier — Promise.all + // would be a regression. Track concurrency via a counter that the fake + // quoteFn increments on entry and decrements on exit. let active = 0; let maxActive = 0; const order = []; diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js index c97f5eda..edf8d12c 100644 --- a/cli/utils/trading/consolidate.js +++ b/cli/utils/trading/consolidate.js @@ -6,8 +6,7 @@ * everything testable without network mocks lives here. * * Quotes are fetched sequentially via the shared `getSwapQuote` to stay under - * the Zerion API rate limit — same approach as the reference impl in - * `~/WebstormProjects/safe-manager/trade.js`. + * the Zerion API rate limit. */ import { getSwapQuote } from "./swap.js"; From 41b4a312bd988f7f4e6569587a93448c6f4ad6c2 Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 14:55:26 +0100 Subject: [PATCH 03/12] PLT-676: add API-key-tier-aware quote concurrency (folded from PLT-677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev keys (zk_dev_*) stay sequential; paid keys (other zk_*) fan out to 5 by default. --concurrency overrides (1..10). The --execute broadcast phase remains strictly sequential regardless — parallel signed broadcasts would race EVM nonces. - cli/utils/api/auth.js: export getApiKeyTier() with a keyOverride test seam so tests don't observe whatever config the dev has stored. - cli/utils/trading/consolidate.js: add parseConcurrency, generalise buildConsolidatePlan to accept a concurrency option, refactor per-row work into buildCandidateRow + a small in-line worker pool that preserves row order. Default concurrency=1 preserves the prior sequential contract. - cli/commands/trading/consolidate.js: auto-pick from tier when --concurrency is unset (AUTO_CONCURRENCY_BY_TIER), surface concurrency + apiKeyTier + concurrencySource in plan output. - cli/utils/common/format.js: pretty header line for concurrency + tier provenance. - skills/zerion-consolidate/SKILL.md: --concurrency row, tier-aware defaults explainer, invalid_concurrency in error table. - cli/router.js: help row mentions --concurrency. Tests: 17 new assertions across getApiKeyTier classification (6), parseConcurrency validation (5), bounded-fan-out cap (2), CLI invalid_ concurrency rejection (2), auto-pick mapping (1), and a source-grep test pinning the broadcast loop to for-await (1). 241/241 passing. --- cli/commands/trading/consolidate.js | 33 +++ cli/router.js | 2 +- cli/tests/unit/cli/utils/api/auth.test.mjs | 39 ++- .../cli/utils/trading/consolidate.test.mjs | 268 +++++++++++++++++ cli/utils/api/auth.js | 26 ++ cli/utils/common/format.js | 9 + cli/utils/trading/consolidate.js | 276 ++++++++++++------ skills/zerion-consolidate/SKILL.md | 6 +- 8 files changed, 561 insertions(+), 98 deletions(-) diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js index 8231252f..4eb2ad9e 100644 --- a/cli/commands/trading/consolidate.js +++ b/cli/commands/trading/consolidate.js @@ -13,6 +13,7 @@ */ import * as api from "../../utils/api/client.js"; +import { getApiKeyTier } from "../../utils/api/auth.js"; import { executeSwap } from "../../utils/trading/swap.js"; import { requireAgentToken, @@ -33,9 +34,21 @@ import { parseMaxLoss, parseMinValue, parseGasReserve, + parseConcurrency, parseSymbolList, } from "../../utils/trading/consolidate.js"; +// Auto-pick the plan-phase quote concurrency from the API-key tier when the +// user didn't pass --concurrency. Paid keys can comfortably handle a small +// bounded parallel pool; dev / unknown keys stay sequential to respect the +// 120 req/min limit and avoid burst-tripping the 5K/day quota during a sweep +// of a wallet with many positions. +const AUTO_CONCURRENCY_BY_TIER = { + paid: 5, + dev: 1, + unknown: 1, +}; + // Mirrors `coerceBoolFlag` in cli/commands/trading/bridge.js (lines 55-64). // parseFlags consumes the next non-`--` token as the value, so a bare boolean // flag followed by a positional silently gets that positional as its value @@ -76,15 +89,25 @@ export default async function consolidate(args, flags) { let minValueUsd; let maxLoss; let explicitGasReserve; + let explicitConcurrency; try { minValueUsd = parseMinValue(flags["min-value"]); maxLoss = parseMaxLoss(flags["max-loss"]); explicitGasReserve = parseGasReserve(flags["gas-reserve"]); + explicitConcurrency = parseConcurrency(flags.concurrency); } catch (err) { printError(err.code || "invalid_flag", err.message); process.exit(1); } + // Resolve plan-phase quote concurrency. Explicit --concurrency wins; otherwise + // pick from the active API-key tier. The chosen value + provenance is surfaced + // in the plan output so callers can verify what actually ran. + const tier = getApiKeyTier(); + const autoConcurrency = AUTO_CONCURRENCY_BY_TIER[tier] ?? 1; + const concurrency = explicitConcurrency ?? autoConcurrency; + const concurrencySource = explicitConcurrency !== undefined ? "flag" : "auto"; + if (explicitGasReserve !== undefined && !includeNative) { printError( "conflicting_flags", @@ -237,6 +260,9 @@ export default async function consolidate(args, flags) { targetUsdPrice, rows: [], totals: { ready: 0, blocked: 0, skipped: 0, no_route: 0, expected_output: 0, expected_output_usd: null }, + concurrency, + apiKeyTier: tier, + concurrencySource, executed: false, }, formatConsolidatePlan, @@ -256,12 +282,19 @@ export default async function consolidate(args, flags) { slippage, gasReserveValue: reserve.value, maxLoss, + concurrency, }); } catch (err) { handleTradingError(err, "consolidate_error"); return; } + // Annotate the plan with API-key-tier provenance so the pretty formatter + // can render e.g. "Concurrency: 5 (paid key, auto)" and the JSON consumer + // sees which mode the planner actually ran in. + plan.apiKeyTier = tier; + plan.concurrencySource = concurrencySource; + if (!execute) { // Strip embedded `quote` objects from the dry-run JSON output — they're // verbose and not part of the documented row shape. The execute path diff --git a/cli/router.js b/cli/router.js index 26819600..db00c63e 100644 --- a/cli/router.js +++ b/cli/router.js @@ -54,7 +54,7 @@ function printUsage() { "bridge --to-wallet ": "Bridge with explicit destination wallet (Solana ↔ EVM)", "bridge --to-address ": "Bridge to a raw destination address (chain-format must match)", "send --to [--chain ]": "Send native ETH/SOL or ERC-20 to an address (chain auto-detected from address format)", - "consolidate ": "Sweep all wallet positions on into (dry-run by default; add --execute to broadcast)", + "consolidate ": "Sweep all wallet positions on into (dry-run by default; --execute to broadcast; --concurrency for plan-phase quote concurrency, auto-picked by API-key tier)", "search ": "Search for tokens by name or symbol", }, agent_tokens: { diff --git a/cli/tests/unit/cli/utils/api/auth.test.mjs b/cli/tests/unit/cli/utils/api/auth.test.mjs index 200c022a..a8dd4fbc 100644 --- a/cli/tests/unit/cli/utils/api/auth.test.mjs +++ b/cli/tests/unit/cli/utils/api/auth.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { resolveAuth, resolveApiKeyAuth, basicAuthHeader } from "#zerion/utils/api/auth.js"; +import { resolveAuth, resolveApiKeyAuth, basicAuthHeader, getApiKeyTier } from "#zerion/utils/api/auth.js"; // Synthetic keys that pass the format detectors but are never used for // actual crypto operations — resolveAuth is pure and doesn't touch the network. @@ -216,3 +216,40 @@ describe("basicAuthHeader", () => { assert.equal(decoded, "key+with/special=chars:"); }); }); + +// AC 21a — getApiKeyTier classification. The Zerion dev API key (`zk_dev_*`) +// is throttled at 120 req/min; paid keys have substantially higher limits. +// Callers (consolidate) auto-size concurrency from this signal, so the +// classification must be exact and stable. +// +// We pass the key directly via the `keyOverride` test seam so the test never +// observes whatever the dev's local config has stored at ~/.zerion/config.json +// (otherwise `getApiKey()` would fall through to that and the "missing" case +// would falsely classify as "paid"). +describe("getApiKeyTier — classification", () => { + it("classifies `zk_dev_*` as dev", () => { + assert.equal(getApiKeyTier("zk_dev_abc123"), "dev"); + }); + + it("classifies `zk_prod_*` as paid", () => { + assert.equal(getApiKeyTier("zk_prod_abc123"), "paid"); + }); + + it("classifies `zk_live_*` as paid", () => { + assert.equal(getApiKeyTier("zk_live_xyz789"), "paid"); + }); + + it("classifies bare `zk_*` (no second segment) as paid", () => { + // Defensive: any `zk_` prefix that isn't `zk_dev_` is treated as paid so + // a future production-key shape doesn't accidentally inherit the dev cap. + assert.equal(getApiKeyTier("zk_anything"), "paid"); + }); + + it("classifies empty / missing as unknown (treated as dev for safety)", () => { + assert.equal(getApiKeyTier(""), "unknown"); + }); + + it("classifies a non-zk_ key as unknown (e.g. a stray placeholder)", () => { + assert.equal(getApiKeyTier("not-a-real-key"), "unknown"); + }); +}); diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index 6b303a04..35099445 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -15,6 +15,7 @@ import { parseMaxLoss, parseMinValue, parseGasReserve, + parseConcurrency, parseSymbolList, computeNativeSweepAmount, getImplementationAddress, @@ -722,3 +723,270 @@ describe("coerceBoolFlag integration — invalid_flag_value", () => { assert.match(stderr, /include-stables/); }); }); + +// --------------------------------------------------------------------------- +// AC 21 — API-key-tier concurrency. parseConcurrency validation, parallel +// quote-fetch cap, auto-pick by tier, --execute always-sequential. +// --------------------------------------------------------------------------- + +describe("parseConcurrency — validation (AC 21b)", () => { + it("returns undefined when the flag is unset (so the CLI auto-picks by tier)", () => { + assert.equal(parseConcurrency(undefined), undefined); + assert.equal(parseConcurrency(null), undefined); + assert.equal(parseConcurrency(""), undefined); + // Bare flag (`--concurrency` with nothing after) parses as `true` — + // treat as "not set" so auto-pick applies. The architect's spec is silent + // here; rejecting outright would surprise users who fat-fingered. + assert.equal(parseConcurrency(true), undefined); + }); + + it("accepts integers in [1, 10]", () => { + for (const n of [1, 2, 3, 5, 9, 10]) { + assert.equal(parseConcurrency(n), n); + assert.equal(parseConcurrency(String(n)), n); + } + // Whitespace tolerance — matches the other parseX helpers. + assert.equal(parseConcurrency(" 5 "), 5); + }); + + it("rejects 0 with invalid_concurrency", () => { + assert.throws(() => parseConcurrency(0), (err) => err.code === "invalid_concurrency"); + assert.throws(() => parseConcurrency("0"), (err) => err.code === "invalid_concurrency"); + }); + + it("rejects 11 (and any value > 10) with invalid_concurrency", () => { + assert.throws(() => parseConcurrency(11), (err) => err.code === "invalid_concurrency"); + assert.throws(() => parseConcurrency("100"), (err) => err.code === "invalid_concurrency"); + }); + + it("rejects negative, NaN, and non-integer with invalid_concurrency", () => { + for (const bad of [-1, "-1", "abc", NaN, 1.5, "1.5", 2.7]) { + assert.throws(() => parseConcurrency(bad), (err) => err.code === "invalid_concurrency"); + } + }); +}); + +describe("buildConsolidatePlan — bounded concurrency (AC 21c)", () => { + it("respects the concurrency cap when fanning out quotes (max in-flight ≤ N)", async () => { + // 7 candidates with concurrency=3 → at most 3 in flight at any time. + // The fake quoteFn increments a counter on entry, sleeps a tick, then + // decrements. We assert the observed max matches the cap. + let active = 0; + let maxActive = 0; + const quoteFn = async (input) => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 8)); + active--; + return { + estimatedOutput: "95", + from: { symbol: input.fromToken }, + to: { symbol: input.toToken }, + }; + }; + + const candidates = Array.from({ length: 7 }, (_, i) => ({ + symbol: `T${i}`, + valueUsd: 100, + quantity: 1, + fungible: {}, + implAddress: `0xt${i}`, + isNative: false, + })); + + const plan = await buildConsolidatePlan({ + candidates, + skippedDust: [], + chain: "base", + toToken: "USDC", + targetUsdPrice: 1, + walletAddress: "0xabc", + slippage: 2, + gasReserveValue: 0, + maxLoss: 0.05, + concurrency: 3, + quoteFn, + }); + + assert.ok(maxActive <= 3, `max in-flight should be ≤ 3, got ${maxActive}`); + assert.ok(maxActive >= 2, `concurrency=3 with 7 items should achieve > 1 in-flight, got ${maxActive}`); + assert.equal(plan.totals.ready, 7); + assert.equal(plan.concurrency, 3); + // Row order must still match candidate order — bounded fan-out preserves it. + assert.deepEqual(plan.rows.map((r) => r.symbol), candidates.map((c) => c.symbol)); + }); + + it("default concurrency stays at 1 (sequential — dev-key safe)", async () => { + // The default preserves the pre-PLT-677 contract: no concurrency arg → + // strictly one-at-a-time. The original sequential test ("never in + // parallel") still passes with this default. + let active = 0; + let maxActive = 0; + const quoteFn = async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 3)); + active--; + return { estimatedOutput: "95" }; + }; + const candidates = Array.from({ length: 5 }, (_, i) => ({ + symbol: `T${i}`, + valueUsd: 100, + quantity: 1, + fungible: {}, + implAddress: `0xt${i}`, + isNative: false, + })); + const plan = await buildConsolidatePlan({ + candidates, + skippedDust: [], + chain: "base", + toToken: "USDC", + targetUsdPrice: 1, + walletAddress: "0xabc", + slippage: 2, + gasReserveValue: 0, + maxLoss: 0.05, + quoteFn, + }); + assert.equal(maxActive, 1, "default must be sequential"); + assert.equal(plan.concurrency, 1); + }); +}); + +describe("consolidate CLI — concurrency auto-pick & --execute serial (AC 21b/d/e)", () => { + // These exercise the CLI shell via subprocess. We don't need a real API key + // for the early-exit codepaths (invalid_concurrency, target_token_not_found + // before any network call) and for the network-touching cases we stub fetch + // with a tiny test server… too heavy for this scope. Instead, we rely on + // the CLI surfacing the chosen concurrency in the empty-plan output, which + // happens after positions fetch returns no candidates. We swap fetch via a + // child env hook (NODE_OPTIONS preloader) — not portable. Instead, the + // simpler approach: test parse-then-exit codepaths only. + // + // Net: 21b is covered with subprocess (invalid_concurrency rejection); + // 21d/e are covered by direct unit tests above (auto-pick via + // AUTO_CONCURRENCY_BY_TIER is wired in commands/trading/consolidate.js, and + // the broadcast loop in the same file is unconditionally `for await`). + // The subprocess-driven assertion for 21b is the strongest signal we can + // give without standing up a fake Zerion API in-process. + + it("rejects --concurrency 0 with invalid_concurrency (AC 21b)", async () => { + const { spawn } = await import("node:child_process"); + const { resolve, dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const here = dirname(fileURLToPath(import.meta.url)); + const cliPath = resolve(here, "../../../../../..", "cli/zerion.js"); + + const child = spawn(process.execPath, [ + cliPath, + "consolidate", + "base", + "USDC", + "--concurrency", + "0", + ], { + env: { ...process.env, ZERION_API_KEY: "zk_dummy_for_argv_only" }, + }); + + let stderr = ""; + child.stderr.on("data", (c) => (stderr += c.toString())); + + const code = await new Promise((done) => child.on("exit", done)); + assert.notEqual(code, 0); + assert.match(stderr, /invalid_concurrency/, `expected invalid_concurrency in stderr: ${stderr}`); + }); + + it("rejects --concurrency 11 with invalid_concurrency (AC 21b)", async () => { + const { spawn } = await import("node:child_process"); + const { resolve, dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const here = dirname(fileURLToPath(import.meta.url)); + const cliPath = resolve(here, "../../../../../..", "cli/zerion.js"); + + const child = spawn(process.execPath, [ + cliPath, + "consolidate", + "base", + "USDC", + "--concurrency", + "11", + ], { + env: { ...process.env, ZERION_API_KEY: "zk_dummy_for_argv_only" }, + }); + + let stderr = ""; + child.stderr.on("data", (c) => (stderr += c.toString())); + + const code = await new Promise((done) => child.on("exit", done)); + assert.notEqual(code, 0); + assert.match(stderr, /invalid_concurrency/); + }); +}); + +// AC 21d (auto-pick) — verified directly against the tier→concurrency map +// that the CLI uses. We re-derive the map from the same source by re- +// classifying via getApiKeyTier under controlled env vars and asserting the +// expected auto-pick values. This stays in lockstep with the production +// code path because the CLI references the same getApiKeyTier function. +describe("auto-pick from tier (AC 21d)", () => { + it("`zk_prod_*` → tier=paid → auto concurrency 5; `zk_dev_*` → tier=dev → auto concurrency 1", async () => { + const { getApiKeyTier } = await import("#zerion/utils/api/auth.js"); + + // Inline copy of AUTO_CONCURRENCY_BY_TIER from the CLI file — pin the + // mapping here so a refactor that moves the constant elsewhere is caught + // by a failing test rather than a silent divergence. + const AUTO_CONCURRENCY_BY_TIER = { paid: 5, dev: 1, unknown: 1 }; + + // Use the keyOverride seam so this test doesn't observe whatever key + // happens to be in the dev's config — env-only manipulation isn't enough + // because getApiKey() falls through to config. + assert.equal(getApiKeyTier("zk_prod_xyz"), "paid"); + assert.equal(AUTO_CONCURRENCY_BY_TIER[getApiKeyTier("zk_prod_xyz")], 5); + + assert.equal(getApiKeyTier("zk_dev_abc"), "dev"); + assert.equal(AUTO_CONCURRENCY_BY_TIER[getApiKeyTier("zk_dev_abc")], 1); + + assert.equal(getApiKeyTier(""), "unknown"); + assert.equal(AUTO_CONCURRENCY_BY_TIER[getApiKeyTier("")], 1); + }); +}); + +// AC 21e — the broadcast loop in cli/commands/trading/consolidate.js uses a +// plain `for (const row of readyRows) { await executeSwap(...) }`. That is +// strictly sequential regardless of the `concurrency` value passed earlier to +// `buildConsolidatePlan`. We pin this by direct inspection of the source — +// the broadcast loop must NOT call any concurrency-aware helper, and must +// NOT call `Promise.all` over `readyRows`. A future refactor that introduces +// parallel broadcasts would race EVM nonces and lose user funds. +describe("--execute broadcast loop is unconditionally sequential (AC 21e)", () => { + it("the broadcast loop in commands/trading/consolidate.js uses for-await on readyRows, no Promise.all", async () => { + const { readFile } = await import("node:fs/promises"); + const { resolve, dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const here = dirname(fileURLToPath(import.meta.url)); + const cmdPath = resolve(here, "../../../../../..", "cli/commands/trading/consolidate.js"); + const src = await readFile(cmdPath, "utf8"); + + // The broadcast loop's exact shape: `for (const row of readyRows)` with an + // `await executeSwap(...)` inside. If a future refactor changes this we + // want the test to fail loudly. + assert.match(src, /for\s*\(\s*const\s+row\s+of\s+readyRows\s*\)/); + assert.match(src, /await\s+executeSwap\(/); + + // Guard against any Promise.all over readyRows — that would broadcast + // in parallel and race nonces. + assert.equal( + /Promise\.all\([^)]*readyRows/.test(src), + false, + "broadcast loop must not call Promise.all over readyRows", + ); + // Defensive: also guard against runWithConcurrency / buildCandidateRow + // being misapplied to readyRows for parallel execution. + assert.equal( + /runWithConcurrency\([^)]*readyRows/.test(src), + false, + "broadcast loop must not pass readyRows through runWithConcurrency", + ); + }); +}); diff --git a/cli/utils/api/auth.js b/cli/utils/api/auth.js index 66b88daa..c650e47a 100644 --- a/cli/utils/api/auth.js +++ b/cli/utils/api/auth.js @@ -13,6 +13,32 @@ export function basicAuthHeader(key) { return `Basic ${Buffer.from(`${key}:`).toString("base64")}`; } +/** + * Classify a Zerion API key into a rate-limit tier so callers can size + * concurrency appropriately. Dev keys are throttled at 120 req/min / + * 5K req/day; paid keys have substantially higher limits. + * + * - `zk_dev_*` → "dev" + * - any other `zk_*` → "paid" (covers zk_prod_, zk_live_, etc.) + * - missing or non-zk_-prefixed → "unknown" (treat as dev for safety) + * + * When called with no arg, reads the key via `getApiKey()` (env or config) so + * config-stored keys count the same as env-supplied ones. The optional + * `keyOverride` parameter is the test seam — pass `""` to simulate "no key + * configured anywhere", or pass a literal key to drive classification + * without touching config / env. + * + * @param {string | undefined} [keyOverride] + * @returns {"dev" | "paid" | "unknown"} + */ +export function getApiKeyTier(keyOverride) { + const key = (keyOverride !== undefined ? keyOverride : getApiKey()) || ""; + if (!key) return "unknown"; + if (key.startsWith("zk_dev_")) return "dev"; + if (key.startsWith("zk_")) return "paid"; + return "unknown"; +} + // Solana keypairs are 64 bytes; base58-encoded they are 87-88 characters. const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; const isEvmKey = (k) => typeof k === "string" && k.startsWith("0x"); diff --git a/cli/utils/common/format.js b/cli/utils/common/format.js index 02154eed..196fc5c7 100644 --- a/cli/utils/common/format.js +++ b/cli/utils/common/format.js @@ -313,6 +313,15 @@ export function formatConsolidatePlan(data) { (data.walletAddress ? ` ${DIM}(${data.walletAddress.slice(0, 8)}…)${RESET}` : "") + "\n", ]; + if (Number.isFinite(data.concurrency)) { + // Surface the tier + provenance ("auto" vs "flag") so it's obvious why + // the planner picked a particular concurrency without having to read the + // JSON body — important when a dev key silently caps the user's + // expectation. + const tier = data.apiKeyTier ? `${data.apiKeyTier} key` : "unknown key"; + const src = data.concurrencySource === "flag" ? "--concurrency" : "auto"; + lines.push(` ${DIM}Concurrency:${RESET} ${data.concurrency} ${DIM}(${tier}, ${src})${RESET}\n`); + } lines.push( ` ${DIM}${pad("#", W.idx)} ${pad("Symbol", W.symbol)} ${padStart("Qty", W.qty)} ${padStart("Value (USD)", W.value)} ${padStart("Output", W.output)} ${padStart("Loss %", W.loss)} ${pad("Status", W.status)}${RESET}`, ); diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js index edf8d12c..2d46156d 100644 --- a/cli/utils/trading/consolidate.js +++ b/cli/utils/trading/consolidate.js @@ -117,6 +117,32 @@ export function parseMinValue(raw) { return n; } +/** + * Parse `--concurrency` (positive integer in `[1, 10]`). Returns `undefined` + * when the flag isn't set so the CLI can auto-pick by API-key tier. Throws + * `{ code: "invalid_concurrency" }` on NaN, negative, zero, > 10, or + * non-integer input. + * + * The upper bound is a defensive ceiling: even on paid keys, more than 10 + * in-flight quotes risks tripping per-IP rate limits and hides scaling bugs + * (e.g. /chains/ catalog cache races) behind a "looks fast" surface. + */ +export function parseConcurrency(raw) { + if (raw === undefined || raw === null || raw === "" || raw === true || raw === false) { + return undefined; + } + const trimmed = typeof raw === "string" ? raw.trim() : raw; + const n = typeof trimmed === "number" ? trimmed : Number(trimmed); + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 10) { + const err = new Error( + `Invalid --concurrency: ${raw}. Must be an integer between 1 and 10.`, + ); + err.code = "invalid_concurrency"; + throw err; + } + return n; +} + /** * Parse `--gas-reserve` (native units). Returns a non-negative number or * `undefined` if the flag isn't set. Throws `{ code: "invalid_gas_reserve" }` @@ -338,7 +364,145 @@ export function evaluateQuote({ estimatedOutput, targetUsdPrice, positionValueUs } /** - * Build the dry-run plan by fetching one quote per candidate sequentially. + * Build a single plan row for one candidate. Pure of order — safe to run in + * parallel because each call only touches its own candidate. Errors thrown + * by `quoteFn` are caught and folded into the row as `status: "no_route"`, + * so callers don't need a try/catch around this. + */ +async function buildCandidateRow(c, ctx) { + // Native sweep amount uses (quantity - reserve) when the row is the chain's + // native gas token. Non-native rows sweep the full quantity. + let sweepQuantity = c.quantity; + if (c.isNative) { + const { amount, reason } = computeNativeSweepAmount(c.quantity, ctx.gasReserveValue); + if (reason) { + return { + symbol: c.symbol, + quantity: c.quantity, + value_usd: c.valueUsd, + expected_output: null, + expected_output_usd: null, + loss_pct: null, + status: "skipped", + reason: "below_reserve", + }; + } + sweepQuantity = amount; + } + + let quote; + try { + quote = await ctx.quoteFn({ + fromToken: c.symbol, + toToken: ctx.toToken, + amount: String(sweepQuantity), + fromChain: ctx.chain, + toChain: ctx.chain, + walletAddress: ctx.walletAddress, + outputReceiver: ctx.walletAddress, + slippage: ctx.slippage, + }); + } catch (err) { + return { + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: null, + expected_output_usd: null, + loss_pct: null, + status: "no_route", + reason: err?.message || "no route", + }; + } + + const evaluation = evaluateQuote({ + estimatedOutput: quote.estimatedOutput, + targetUsdPrice: ctx.targetUsdPrice, + positionValueUsd: c.valueUsd, + maxLoss: ctx.maxLoss, + }); + + if (evaluation.status === "skipped") { + return { + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: null, + expected_output_usd: null, + loss_pct: null, + status: "skipped", + reason: evaluation.reason, + quote, + }; + } + if (evaluation.status === "blocked") { + return { + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: evaluation.expectedOutput, + expected_output_usd: evaluation.expectedOutputUsd, + loss_pct: evaluation.lossPct, + status: "blocked", + reason: "max_loss", + quote, + }; + } + return { + symbol: c.symbol, + quantity: sweepQuantity, + value_usd: c.valueUsd, + expected_output: evaluation.expectedOutput, + expected_output_usd: evaluation.expectedOutputUsd, + loss_pct: evaluation.lossPct, + status: "ready", + quote, + }; +} + +/** + * Worker-pool runner. Spawns up to `concurrency` workers that pull items off + * a shared index counter; each worker writes the result to `results[i]` so + * the final array preserves the input order. Bounded in-flight count is + * exactly `concurrency` — no batching artefacts where one slow item holds up + * the next batch. + * + * The pool degenerates to a strict sequential `for await` loop when + * `concurrency <= 1`, so callers that want deterministic in-order quote + * fetches (dev-tier API keys, the existing in-order test) get the original + * behaviour exactly. + */ +async function runWithConcurrency(items, concurrency, work) { + const n = items.length; + const results = new Array(n); + const limit = Math.max(1, Math.floor(concurrency) || 1); + + if (limit === 1 || n <= 1) { + for (let i = 0; i < n; i++) { + results[i] = await work(items[i], i); + } + return results; + } + + let cursor = 0; + async function worker() { + while (true) { + const idx = cursor++; + if (idx >= n) return; + results[idx] = await work(items[idx], idx); + } + } + const workers = Array.from({ length: Math.min(limit, n) }, () => worker()); + await Promise.all(workers); + return results; +} + +/** + * Build the dry-run plan by fetching quotes for each candidate. Concurrency + * defaults to `1` (strictly sequential, preserves rate-limit-safe behaviour + * on dev API keys). With `concurrency > 1`, quotes fan out via a bounded + * worker pool; the resulting `rows` array still preserves the candidate + * order so plan output is deterministic. * * `quoteFn` is injected so tests can drive the loop without network mocks. * Defaults to `getSwapQuote` from the shared swap utils. @@ -355,6 +519,7 @@ export async function buildConsolidatePlan({ slippage, gasReserveValue, maxLoss, + concurrency = 1, quoteFn = getSwapQuote, }) { const rows = []; @@ -373,102 +538,23 @@ export async function buildConsolidatePlan({ }); } - for (const c of candidates) { - // Native sweep amount uses (quantity - reserve) when the row is the chain's - // native gas token. Non-native rows sweep the full quantity. - let sweepQuantity = c.quantity; - if (c.isNative) { - const { amount, reason } = computeNativeSweepAmount(c.quantity, gasReserveValue); - if (reason) { - rows.push({ - symbol: c.symbol, - quantity: c.quantity, - value_usd: c.valueUsd, - expected_output: null, - expected_output_usd: null, - loss_pct: null, - status: "skipped", - reason: "below_reserve", - }); - continue; - } - sweepQuantity = amount; - } - - let quote; - try { - quote = await quoteFn({ - fromToken: c.symbol, - toToken, - amount: String(sweepQuantity), - fromChain: chain, - toChain: chain, - walletAddress, - outputReceiver: walletAddress, - slippage, - }); - } catch (err) { - rows.push({ - symbol: c.symbol, - quantity: sweepQuantity, - value_usd: c.valueUsd, - expected_output: null, - expected_output_usd: null, - loss_pct: null, - status: "no_route", - reason: err?.message || "no route", - }); - continue; - } - - const evaluation = evaluateQuote({ - estimatedOutput: quote.estimatedOutput, - targetUsdPrice, - positionValueUsd: c.valueUsd, - maxLoss, - }); + const ctx = { + chain, + toToken, + targetUsdPrice, + walletAddress, + slippage, + gasReserveValue, + maxLoss, + quoteFn, + }; - if (evaluation.status === "skipped") { - rows.push({ - symbol: c.symbol, - quantity: sweepQuantity, - value_usd: c.valueUsd, - expected_output: null, - expected_output_usd: null, - loss_pct: null, - status: "skipped", - reason: evaluation.reason, - quote, - }); - continue; - } - if (evaluation.status === "blocked") { - rows.push({ - symbol: c.symbol, - quantity: sweepQuantity, - value_usd: c.valueUsd, - expected_output: evaluation.expectedOutput, - expected_output_usd: evaluation.expectedOutputUsd, - loss_pct: evaluation.lossPct, - status: "blocked", - reason: "max_loss", - quote, - }); - continue; - } - rows.push({ - symbol: c.symbol, - quantity: sweepQuantity, - value_usd: c.valueUsd, - expected_output: evaluation.expectedOutput, - expected_output_usd: evaluation.expectedOutputUsd, - loss_pct: evaluation.lossPct, - status: "ready", - quote, - }); - } + const candidateRows = await runWithConcurrency(candidates, concurrency, (c) => buildCandidateRow(c, ctx)); + rows.push(...candidateRows); - return summarisePlan(rows, { chain, toToken, walletAddress, targetUsdPrice }); + const plan = summarisePlan(rows, { chain, toToken, walletAddress, targetUsdPrice }); + plan.concurrency = Math.max(1, Math.floor(concurrency) || 1); + return plan; } /** diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md index 37acb183..1f2945a4 100644 --- a/skills/zerion-consolidate/SKILL.md +++ b/skills/zerion-consolidate/SKILL.md @@ -47,10 +47,13 @@ zerion consolidate [flags] | `--include-native` | _(off)_ | Sweep the chain's native gas token (ETH/SOL/etc). | | `--gas-reserve ` | per-chain default | Native units to reserve when `--include-native` is on. Requires `--include-native`. | | `--slippage ` | `2` | Per-quote slippage tolerance (max 3, same as swap). | +| `--concurrency ` | tier-aware (paid → 5, dev → 1) | Plan-phase quote-fetch concurrency. Integer `1..10`. Does NOT affect `--execute`; the broadcast phase is always sequential. | | `--wallet ` | default | Source wallet. | | `--continue-on-error` | _(off)_ | When `--execute`-ing, keep going past a failed swap instead of stopping. | | `--timeout ` | `120` | Per-swap confirmation timeout. | +The plan-phase concurrency is auto-picked from your active `ZERION_API_KEY` tier: `zk_dev_*` keys stay sequential (the 120 req/min dev limit trips quickly when sweeping a wallet with many positions); other `zk_*` (paid/prod/live) keys fan out to 5. Override with `--concurrency ` (1..10). The chosen value is reported in both the JSON output (`concurrency`, `apiKeyTier`, `concurrencySource` fields) and the pretty header (`Concurrency: 5 (paid key, auto)`). + Boolean flags (`--execute`, `--include-stables`, `--exclude-stables`, `--include-native`, `--continue-on-error`) should appear **last on the command line**, or use the `--flag=true` / `--no-flag` forms. The flag parser consumes the next non-`--` token as the value, so `--include-native ethereum` would mistakenly set `include-native="ethereum"`. The CLI rejects that with `invalid_flag_value`. ## Examples @@ -170,7 +173,7 @@ By default the plan **excludes**: - **Native token excluded by default.** Gas reserve protection only kicks in when you explicitly pass `--include-native`. Without it, the native row is silently filtered out — your ETH/SOL stays put. - **Stables excluded by default in non-TTY contexts.** Agents and pipelines never auto-sweep stables without an explicit `--include-stables` flag. - **Max-loss filter is a backstop, not a cap.** A row marked `blocked: max_loss` will NOT be broadcast even with `--execute`. Tighten the filter for low-liquidity tokens with `--max-loss 2`. -- **Sequential execution — no atomicity.** Each row is its own swap. A failure mid-batch leaves earlier swaps confirmed and remaining ones unattempted (unless `--continue-on-error` is set). Plan accordingly when sweeping volatile tokens. +- **Sequential broadcast — no atomicity, regardless of `--concurrency`.** Plan-phase quote fetches may run in parallel (paid keys), but the `--execute` broadcast phase is always serial — parallel signed broadcasts would race EVM nonces. A failure mid-batch leaves earlier swaps confirmed and remaining ones unattempted (unless `--continue-on-error` is set). Plan accordingly when sweeping volatile tokens. - **Fresh quotes on `--execute`.** The execute path re-fetches quotes at run time — but it does not re-prompt the operator. Treat `--execute` as a commitment to broadcast every ready row. - **One passphrase prompt for the whole batch.** The agent token is read once up-front; if you abort mid-batch, the remaining swaps will simply not run. @@ -184,6 +187,7 @@ By default the plan **excludes**: | `invalid_min_value` | `--min-value` is NaN or negative | Pass a non-negative number, e.g. `--min-value 1` | | `invalid_max_loss` | `--max-loss` is NaN, negative, or > 100 | Use percent (`5`) or fraction (`0.05`); see Dual form above | | `invalid_gas_reserve` | `--gas-reserve` is NaN or negative | Pass a non-negative native-units number | +| `invalid_concurrency` | `--concurrency` is NaN, non-integer, < 1, or > 10 | Pass an integer in `1..10`, e.g. `--concurrency 5` | | `conflicting_flags` | `--gas-reserve` without `--include-native`, or `--include-stables` with `--exclude-stables` | Pass `--include-native` to opt in, or pick one stables flag | | `invalid_flag_value` | Bare boolean flag got a non-positional consumed as value | Pass the boolean flag last, or use `--flag=true` / `--no-flag` | | `invalid_slippage` | `--slippage` not in 0–100 | `--slippage 2` | From 3d3cbe58d467f2c27e1b6476fd40ee5913db8909 Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 15:10:43 +0100 Subject: [PATCH 04/12] PLT-676: surface full per-row error messages in formatConsolidateResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table's Error column truncates at ~27 chars + ellipsis, which buries the reason for any failure ("Quote not executable: Input asset balance is not enough" → "Quote not executable: Input…"). Append a Failures block under the totals line that prints the full error string per non-success row, leaving the compact table intact for the quick-scan view. --- .../cli/utils/trading/consolidate.test.mjs | 66 +++++++++++++++++++ cli/utils/common/format.js | 18 +++++ 2 files changed, 84 insertions(+) diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index 35099445..72db6ac5 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -990,3 +990,69 @@ describe("--execute broadcast loop is unconditionally sequential (AC 21e)", () = ); }); }); + +// --------------------------------------------------------------------------- +// formatConsolidateResult — full failure messages are visible by default. +// The summary table truncates at ~27 chars + ellipsis, which buries actionable +// reasons ("Quote not executable: Input asset balance is not enough" becomes +// "Quote not executable: Input…"). The Failures block below the totals prints +// the full string per failed row. +// --------------------------------------------------------------------------- +describe("formatConsolidateResult — full failure messages", () => { + it("prints the un-truncated error string for every non-success row in a Failures block", async () => { + const { formatConsolidateResult } = await import("#zerion/utils/common/format.js"); + const longError = + "Quote not executable: insufficient_liquidity on uniswap-v3 (hint: try a smaller amount)"; + // Sanity: this string is well past the in-table truncation cap of ~27. + assert.ok(longError.length > 27); + + const out = formatConsolidateResult({ + chain: "base", + toToken: "ETH", + walletAddress: "0xabc", + results: [ + { symbol: "USDC", hash: "0xaaa", status: "success" }, + { symbol: "WSTETH", hash: null, status: "failed", error: longError }, + ], + summary: { succeeded: 1, failed: 1 }, + }); + + // Top of the formatter output still renders the compact table — that's + // by design (the user can scan many rows quickly). The Failures block at + // the bottom is the new escape hatch. + assert.match(out, /Failures:/); + // The full string is present without ellipsis. Use a substring check + // rather than a regex so ANSI escapes between the row prefix and the + // message don't trip us up. + assert.ok( + out.includes(longError), + `formatter output must include the full error string; got:\n${out}`, + ); + // The symbol prefixes the failed message so an operator can correlate + // it back to the row in the table above. + assert.match(out, /WSTETH: Quote not executable/); + // Successful rows must NOT appear under Failures — otherwise the block + // would just duplicate the table. + assert.equal( + out.lastIndexOf("USDC:") < out.indexOf("Failures:") || !out.includes("USDC:"), + true, + "successful rows must not appear in the Failures block", + ); + }); + + it("omits the Failures block when no rows failed", async () => { + const { formatConsolidateResult } = await import("#zerion/utils/common/format.js"); + const out = formatConsolidateResult({ + chain: "base", + toToken: "ETH", + walletAddress: "0xabc", + results: [{ symbol: "USDC", hash: "0xaaa", status: "success" }], + summary: { succeeded: 1, failed: 0 }, + }); + assert.equal( + out.includes("Failures:"), + false, + "Failures block should be hidden when summary.failed is 0", + ); + }); +}); diff --git a/cli/utils/common/format.js b/cli/utils/common/format.js index 196fc5c7..e7d620df 100644 --- a/cli/utils/common/format.js +++ b/cli/utils/common/format.js @@ -387,6 +387,24 @@ export function formatConsolidateResult(data) { lines.push( ` ${BOLD}${summary.succeeded || 0} succeeded${RESET}, ${summary.failed || 0} failed`, ); + + // The table's Error column truncates at ~27 chars, which buries the actual + // reason for any failure ("Quote not executable: Input asset balance is not + // enough…" gets clipped to "Quote not executable: Input…"). Print the full + // error string per failed row below the summary so operators can act on it + // without re-running with --verbose or scraping JSON. + if ((summary.failed || 0) > 0) { + const failures = results.filter((r) => r.status !== "success"); + if (failures.length > 0) { + lines.push(""); + lines.push(` ${RED}Failures:${RESET}`); + for (const r of failures) { + const msg = r.error || r.status || "(no error message)"; + lines.push(` ${r.symbol}: ${msg}`); + } + } + } + return lines.join("\n"); } From abf5ca6065888223eadcb2a16caaec44b73367a7 Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 15:16:19 +0100 Subject: [PATCH 05/12] PLT-676: invert --execute default to partial-success; remove --continue-on-error flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each swap is an independent on-chain transaction. One failing quote should not gate the rest of a sweep — that was a user-blocking issue in a local test where WSTETH failed and the remaining 6 ready rows never broadcast. - cli/commands/trading/consolidate.js: drop continueOnError parsing and the two conditional break statements. Broadcast loop is now extracted into executeReadyRows for testability. - cli/utils/trading/consolidate.js: new executeReadyRows(readyRows, executeFn, { walletName, passphrase, timeout }) helper. Iterates every ready row, captures per-row failures into the results array with the full error string, never aborts. - skills/zerion-consolidate/SKILL.md: drop the --continue-on-error flag row, drop it from the boolean-flag pitfall sentence, remove the example that used it. Add a paragraph above the concurrency block explaining the partial-success contract. Safety section split into separate bullets for sequential-broadcast and partial-success. - cli/tests/unit/cli/utils/trading/consolidate.test.mjs: 3 new tests on executeReadyRows pinning (a) 5 rows where row 2 throws all fire, (b) non-success status ("reverted") counts as failed without halting, (c) bare-string throws still produce a usable error string. AC 21e source-grep test updated to look at the new helper location. 246/246 tests passing. --- cli/commands/trading/consolidate.js | 42 ++------ .../cli/utils/trading/consolidate.test.mjs | 99 +++++++++++++++++-- cli/utils/trading/consolidate.js | 43 ++++++++ skills/zerion-consolidate/SKILL.md | 12 ++- 4 files changed, 149 insertions(+), 47 deletions(-) diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js index 4eb2ad9e..9e08109d 100644 --- a/cli/commands/trading/consolidate.js +++ b/cli/commands/trading/consolidate.js @@ -30,6 +30,7 @@ import { formatConsolidatePlan, formatConsolidateResult } from "../../utils/comm import { filterCandidates, buildConsolidatePlan, + executeReadyRows, resolveGasReserve, parseMaxLoss, parseMinValue, @@ -79,7 +80,6 @@ export default async function consolidate(args, flags) { const includeStablesFlag = coerceBoolFlag(flags["include-stables"], "include-stables"); const excludeStablesFlag = coerceBoolFlag(flags["exclude-stables"], "exclude-stables"); const includeNative = coerceBoolFlag(flags["include-native"], "include-native"); - const continueOnError = coerceBoolFlag(flags["continue-on-error"], "continue-on-error"); if (includeStablesFlag && excludeStablesFlag) { printError("conflicting_flags", "Pass either --include-stables or --exclude-stables, not both."); @@ -335,36 +335,14 @@ export default async function consolidate(args, flags) { } const timeout = parseTimeout(flags.timeout); - const results = []; - let succeeded = 0; - let failed = 0; - for (const row of readyRows) { - try { - const result = await executeSwap(row.quote, walletName, passphrase, { timeout }); - results.push({ - symbol: row.symbol, - hash: result.hash, - status: result.status, - blockNumber: result.blockNumber, - gasUsed: result.gasUsed, - }); - if (result.status === "success") { - succeeded++; - } else { - failed++; - if (!continueOnError) break; - } - } catch (err) { - failed++; - results.push({ - symbol: row.symbol, - hash: null, - status: "failed", - error: err.message || String(err), - }); - if (!continueOnError) break; - } - } + // Partial-success broadcast — see `executeReadyRows` in + // cli/utils/trading/consolidate.js for the contract. One failing swap does + // not gate the rest; failures land in the result with the full error string. + const { results, summary } = await executeReadyRows(readyRows, executeSwap, { + walletName, + passphrase, + timeout, + }); print( { @@ -373,7 +351,7 @@ export default async function consolidate(args, flags) { walletAddress: address, executed: true, results, - summary: { succeeded, failed }, + summary, }, formatConsolidateResult, ); diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index 72db6ac5..5cb45454 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -24,6 +24,7 @@ import { evaluateQuote, summarisePlan, buildConsolidatePlan, + executeReadyRows, } from "#zerion/utils/trading/consolidate.js"; // Realistic position rows shaped like the /positions response. Keep these @@ -960,29 +961,30 @@ describe("auto-pick from tier (AC 21d)", () => { // NOT call `Promise.all` over `readyRows`. A future refactor that introduces // parallel broadcasts would race EVM nonces and lose user funds. describe("--execute broadcast loop is unconditionally sequential (AC 21e)", () => { - it("the broadcast loop in commands/trading/consolidate.js uses for-await on readyRows, no Promise.all", async () => { + it("the broadcast loop in utils/trading/consolidate.js (executeReadyRows) uses for-await on readyRows, no Promise.all", async () => { const { readFile } = await import("node:fs/promises"); const { resolve, dirname } = await import("node:path"); const { fileURLToPath } = await import("node:url"); const here = dirname(fileURLToPath(import.meta.url)); - const cmdPath = resolve(here, "../../../../../..", "cli/commands/trading/consolidate.js"); - const src = await readFile(cmdPath, "utf8"); + const utilPath = resolve(here, "../../../../../..", "cli/utils/trading/consolidate.js"); + const src = await readFile(utilPath, "utf8"); - // The broadcast loop's exact shape: `for (const row of readyRows)` with an - // `await executeSwap(...)` inside. If a future refactor changes this we - // want the test to fail loudly. + // The broadcast loop now lives in `executeReadyRows`. Pin its shape: + // `for (const row of readyRows)` with `await executeFn(...)` inside. If a + // future refactor changes this we want the test to fail loudly. + assert.match(src, /export\s+async\s+function\s+executeReadyRows\b/); assert.match(src, /for\s*\(\s*const\s+row\s+of\s+readyRows\s*\)/); - assert.match(src, /await\s+executeSwap\(/); + assert.match(src, /await\s+executeFn\(/); // Guard against any Promise.all over readyRows — that would broadcast - // in parallel and race nonces. + // in parallel and race EVM nonces. assert.equal( /Promise\.all\([^)]*readyRows/.test(src), false, "broadcast loop must not call Promise.all over readyRows", ); - // Defensive: also guard against runWithConcurrency / buildCandidateRow - // being misapplied to readyRows for parallel execution. + // Defensive: also guard against runWithConcurrency being misapplied to + // readyRows for parallel execution. assert.equal( /runWithConcurrency\([^)]*readyRows/.test(src), false, @@ -991,6 +993,83 @@ describe("--execute broadcast loop is unconditionally sequential (AC 21e)", () = }); }); +// --------------------------------------------------------------------------- +// executeReadyRows — partial-success contract. Each row is an independent +// on-chain transaction; one failing quote must not gate the rest of the +// sweep. This was a user-blocking issue in the local test where WSTETH +// failed and the remaining 6 ready rows never ran. +// --------------------------------------------------------------------------- +describe("executeReadyRows — partial-success contract", () => { + function mkRow(symbol) { + return { symbol, quote: { from: { symbol }, to: { symbol: "ETH" } } }; + } + + it("with 5 ready rows where row 2 throws, all 5 executeFn calls fire", async () => { + const calls = []; + const executeFn = async (quote) => { + calls.push(quote.from.symbol); + if (quote.from.symbol === "ROW2") { + const err = new Error("Quote not executable: synthetic test failure"); + throw err; + } + return { hash: `0xhash-${quote.from.symbol}`, status: "success", blockNumber: 1, gasUsed: "21000" }; + }; + + const readyRows = ["ROW1", "ROW2", "ROW3", "ROW4", "ROW5"].map(mkRow); + const { results, summary } = await executeReadyRows(readyRows, executeFn, { + walletName: "test-wallet", + passphrase: "test-pass", + timeout: 120, + }); + + assert.deepEqual(calls, ["ROW1", "ROW2", "ROW3", "ROW4", "ROW5"], "every row must be attempted in order"); + assert.equal(results.length, 5); + assert.equal(summary.succeeded, 4); + assert.equal(summary.failed, 1); + assert.equal(results[1].status, "failed"); + assert.match(results[1].error, /synthetic test failure/); + // The successful rows after the failure carry their hash and status. + assert.equal(results[2].status, "success"); + assert.equal(results[2].hash, "0xhash-ROW3"); + assert.equal(results[4].status, "success"); + }); + + it("records non-success result.status as `failed` count (e.g. on-chain revert)", async () => { + // An on-chain revert returns `{ status: "reverted" }` rather than throwing. + // The contract: any non-`success` status counts as failed and the loop + // still continues to the next row. + const calls = []; + const executeFn = async (quote) => { + calls.push(quote.from.symbol); + if (quote.from.symbol === "REVERT") { + return { hash: "0xreverted", status: "reverted", blockNumber: 1, gasUsed: "21000" }; + } + return { hash: `0x${quote.from.symbol}`, status: "success", blockNumber: 1, gasUsed: "21000" }; + }; + + const readyRows = ["A", "REVERT", "C"].map(mkRow); + const { results, summary } = await executeReadyRows(readyRows, executeFn, {}); + + assert.equal(calls.length, 3, "must keep iterating past the reverted row"); + assert.equal(summary.succeeded, 2); + assert.equal(summary.failed, 1); + assert.equal(results[1].status, "reverted"); + }); + + it("falls back to err.toString() when err.message is empty", async () => { + // Defensive: some thrown values aren't proper Error instances. The loop + // must still produce a usable string in the result. + const executeFn = async () => { + // eslint-disable-next-line no-throw-literal + throw "bare-string error"; + }; + const readyRows = [mkRow("X")]; + const { results, summary } = await executeReadyRows(readyRows, executeFn, {}); + assert.equal(summary.failed, 1); + assert.equal(results[0].error, "bare-string error"); + }); +}); + // --------------------------------------------------------------------------- // formatConsolidateResult — full failure messages are visible by default. // The summary table truncates at ~27 chars + ellipsis, which buries actionable diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js index 2d46156d..b4db4537 100644 --- a/cli/utils/trading/consolidate.js +++ b/cli/utils/trading/consolidate.js @@ -557,6 +557,49 @@ export async function buildConsolidatePlan({ return plan; } +/** + * Broadcast each ready row sequentially via the injected `executeFn`. Partial + * success is the only mode — per-row failures are appended to `results` with + * a `failed` status and the full error string, and the loop continues. Each + * swap is an independent on-chain transaction; one failing quote should not + * gate the rest of a sweep. + * + * `executeFn(quote, walletName, passphrase, { timeout })` matches the + * `executeSwap` signature so the CLI passes it through unwrapped. Tests inject + * a fake to drive failure scenarios without touching the keystore or RPC. + * + * Returns `{ results, summary: { succeeded, failed } }`. The caller is + * responsible for echoing this to stdout via `print(..., formatConsolidateResult)`. + */ +export async function executeReadyRows(readyRows, executeFn, { walletName, passphrase, timeout }) { + const results = []; + let succeeded = 0; + let failed = 0; + for (const row of readyRows) { + try { + const result = await executeFn(row.quote, walletName, passphrase, { timeout }); + results.push({ + symbol: row.symbol, + hash: result.hash, + status: result.status, + blockNumber: result.blockNumber, + gasUsed: result.gasUsed, + }); + if (result.status === "success") succeeded++; + else failed++; + } catch (err) { + failed++; + results.push({ + symbol: row.symbol, + hash: null, + status: "failed", + error: err?.message || String(err), + }); + } + } + return { results, summary: { succeeded, failed } }; +} + /** * Roll up the plan rows into the printable structure (totals + counts). * Kept separate so tests can assemble rows manually and exercise totals. diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md index 1f2945a4..95917ad2 100644 --- a/skills/zerion-consolidate/SKILL.md +++ b/skills/zerion-consolidate/SKILL.md @@ -49,12 +49,13 @@ zerion consolidate [flags] | `--slippage ` | `2` | Per-quote slippage tolerance (max 3, same as swap). | | `--concurrency ` | tier-aware (paid → 5, dev → 1) | Plan-phase quote-fetch concurrency. Integer `1..10`. Does NOT affect `--execute`; the broadcast phase is always sequential. | | `--wallet ` | default | Source wallet. | -| `--continue-on-error` | _(off)_ | When `--execute`-ing, keep going past a failed swap instead of stopping. | | `--timeout ` | `120` | Per-swap confirmation timeout. | +Per-row failures during `--execute` are isolated — the batch continues. Each successful swap lands on chain immediately; each failure is recorded with its full error string and surfaced in the final `Failures:` block of the result. There is no opt-out: a single failing quote should not gate a sweep of independent on-chain transactions. If you genuinely want to halt mid-batch, Ctrl-C the process. + The plan-phase concurrency is auto-picked from your active `ZERION_API_KEY` tier: `zk_dev_*` keys stay sequential (the 120 req/min dev limit trips quickly when sweeping a wallet with many positions); other `zk_*` (paid/prod/live) keys fan out to 5. Override with `--concurrency ` (1..10). The chosen value is reported in both the JSON output (`concurrency`, `apiKeyTier`, `concurrencySource` fields) and the pretty header (`Concurrency: 5 (paid key, auto)`). -Boolean flags (`--execute`, `--include-stables`, `--exclude-stables`, `--include-native`, `--continue-on-error`) should appear **last on the command line**, or use the `--flag=true` / `--no-flag` forms. The flag parser consumes the next non-`--` token as the value, so `--include-native ethereum` would mistakenly set `include-native="ethereum"`. The CLI rejects that with `invalid_flag_value`. +Boolean flags (`--execute`, `--include-stables`, `--exclude-stables`, `--include-native`) should appear **last on the command line**, or use the `--flag=true` / `--no-flag` forms. The flag parser consumes the next non-`--` token as the value, so `--include-native ethereum` would mistakenly set `include-native="ethereum"`. The CLI rejects that with `invalid_flag_value`. ## Examples @@ -68,8 +69,8 @@ zerion consolidate base ETH --include-native # Execute — broadcasts each ready row sequentially after one passphrase prompt. zerion consolidate base USDC --execute -# Allow looser loss tolerance (8%), force-exclude WETH, keep going on errors. -zerion consolidate base USDC --max-loss 8 --exclude WETH --continue-on-error --execute +# Allow looser loss tolerance (8%) and force-exclude WETH. +zerion consolidate base USDC --max-loss 8 --exclude WETH --execute # Include stables in the sweep, override the dust threshold. zerion consolidate ethereum USDC --include-stables --min-value 5 @@ -173,7 +174,8 @@ By default the plan **excludes**: - **Native token excluded by default.** Gas reserve protection only kicks in when you explicitly pass `--include-native`. Without it, the native row is silently filtered out — your ETH/SOL stays put. - **Stables excluded by default in non-TTY contexts.** Agents and pipelines never auto-sweep stables without an explicit `--include-stables` flag. - **Max-loss filter is a backstop, not a cap.** A row marked `blocked: max_loss` will NOT be broadcast even with `--execute`. Tighten the filter for low-liquidity tokens with `--max-loss 2`. -- **Sequential broadcast — no atomicity, regardless of `--concurrency`.** Plan-phase quote fetches may run in parallel (paid keys), but the `--execute` broadcast phase is always serial — parallel signed broadcasts would race EVM nonces. A failure mid-batch leaves earlier swaps confirmed and remaining ones unattempted (unless `--continue-on-error` is set). Plan accordingly when sweeping volatile tokens. +- **Sequential broadcast — no atomicity, regardless of `--concurrency`.** Plan-phase quote fetches may run in parallel (paid keys), but the `--execute` broadcast phase is always serial — parallel signed broadcasts would race EVM nonces. Each row's swap is an independent on-chain transaction. +- **Partial success is the only mode.** Per-row failures during `--execute` are recorded with their full error string and the batch continues to the next row — one failing quote does not gate the rest of a sweep. The final result lists which tokens succeeded and which failed under a `Failures:` block; correlate by symbol back to the table above. - **Fresh quotes on `--execute`.** The execute path re-fetches quotes at run time — but it does not re-prompt the operator. Treat `--execute` as a commitment to broadcast every ready row. - **One passphrase prompt for the whole batch.** The agent token is read once up-front; if you abort mid-batch, the remaining swaps will simply not run. From a1cf59f190817a357ac72390f8a1fb766908941d Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 15:37:28 +0100 Subject: [PATCH 06/12] PLT-676: fix nonce reuse, BigInt amount precision, USDT0 in stables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent bugs surfaced in a local --execute test. All three fold into PR #83. 1) Nonce reuse across the broadcast loop. Back-to-back approvals read RPC `latest` which lags the previous swap's submission — row K+1 collides on the prior nonce. Fix: additive `approvalNonceOverride` on executeSwap (default behaviour unchanged for swap/bridge); the consolidate loop seeds from `pending` once, then tracks +2 per row with an approval and +1 per row without. On thrown error, re-read pending so recovery is at worst as good as the RPC-latest path. Skipped on Solana (no EVM nonce); falls back to RPC-latest with a stderr warning if the initial pending read fails. 2) Number(quantity.float) loses precision past ~15 sigfigs on 18-dp balances; the API then over-reconstructs wei and rejects with "Input asset balance is not enough." Fix: new rawWeiToDecimalString helper; classifyPosition pulls quantity.int + impl decimals and carries both a precise decimal string (the swap amount) and a Number (display-only). Native sweep math moves to BigInt — the float→wei conversion of the user-typed --gas-reserve is the only Number step and is well within precision limits. 3) USDT0 (LayerZero-bridged USDT, e.g. on base) wasn't in STABLE_SYMBOLS, so it leaked through into the sweep even with the default stables-exclude. Added usdt0 plus usd0 (Usual), bold (Liquity v2), usdy (Ondo) — all real stables; flagged here so reviewers can prune if the additions feel too aggressive. Extracted executeReadyRows now takes walletAddress + chain + an optional clientFactory test seam. Tests inject a fake getPublicClient to drive the nonce-tracking behaviour without touching RPC. 263/263 passing. --- cli/commands/trading/consolidate.js | 5 + .../cli/utils/trading/consolidate.test.mjs | 386 ++++++++++++++++-- cli/utils/trading/consolidate.js | 231 +++++++++-- cli/utils/trading/swap.js | 26 +- 4 files changed, 585 insertions(+), 63 deletions(-) diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js index 9e08109d..039521d5 100644 --- a/cli/commands/trading/consolidate.js +++ b/cli/commands/trading/consolidate.js @@ -338,10 +338,15 @@ export default async function consolidate(args, flags) { // Partial-success broadcast — see `executeReadyRows` in // cli/utils/trading/consolidate.js for the contract. One failing swap does // not gate the rest; failures land in the result with the full error string. + // `chain` + `walletAddress` are passed in so the helper can manage a local + // nonce counter across the batch (RPC `latest` lags between successive + // approvals/swaps, which would otherwise cause `nonce too low` on row K+1). const { results, summary } = await executeReadyRows(readyRows, executeSwap, { walletName, passphrase, timeout, + walletAddress: address, + chain, }); print( diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index 5cb45454..1d03156b 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -25,17 +25,34 @@ import { summarisePlan, buildConsolidatePlan, executeReadyRows, + rawWeiToDecimalString, } from "#zerion/utils/trading/consolidate.js"; // Realistic position rows shaped like the /positions response. Keep these -// minimal but with the fields the filter actually reads. -function walletPosition({ symbol, value, quantity, chain = "base", address, decimals = 18, positionType = "wallet" }) { +// minimal but with the fields the filter actually reads. `quantity.int` is +// derived from the float + decimals so tests can stay in human-readable +// numbers; tests that need a precise smallest-units quantity (e.g. the +// 18-decimal precision test) pass `quantityIntOverride` to bypass the +// derivation. +function walletPosition({ + symbol, + value, + quantity, + chain = "base", + address, + decimals = 18, + positionType = "wallet", + quantityIntOverride, +}) { + const qInt = quantityIntOverride != null + ? String(quantityIntOverride) + : (quantity != null ? BigInt(Math.round(Number(quantity) * 10 ** decimals)).toString() : "0"); return { attributes: { position_type: positionType, value, price: value != null && quantity ? value / quantity : null, - quantity: { float: quantity }, + quantity: { float: quantity, int: qInt }, fungible_info: { symbol, implementations: address @@ -206,28 +223,39 @@ describe("resolveGasReserve", () => { }); describe("computeNativeSweepAmount", () => { - it("returns (quantity - reserve) when positive", () => { - // Float subtraction can carry a tiny epsilon (0.01 - 0.001 ≈ 0.009 + 1e-18). - // We assert closeness rather than exact equality — the contract is "qty - - // reserve", not "the exact decimal you'd get with arbitrary precision". - const a = computeNativeSweepAmount(0.01, 0.001); + // New signature: (quantityIntWei, reserveHumanReadable, decimals=18) → + // { amount: decimal string, reason }. BigInt subtraction avoids the float + // epsilon that the previous Number-based implementation carried. + const wei = (human, decimals = 18) => BigInt(Math.round(human * 10 ** decimals)).toString(); + + it("returns (quantity - reserve) as a precise decimal string when positive", () => { + const a = computeNativeSweepAmount(wei(0.01), 0.001, 18); assert.equal(a.reason, null); - assert.ok(Math.abs(a.amount - 0.009) < 1e-12, `amount=${a.amount}`); + assert.equal(a.amount, "0.009"); - const b = computeNativeSweepAmount(1, 0.5); + const b = computeNativeSweepAmount(wei(1), 0.5, 18); assert.equal(b.reason, null); - assert.equal(b.amount, 0.5); + assert.equal(b.amount, "0.5"); + }); + + it("returns below_reserve when reserve >= quantity (with amount=\"0\")", () => { + assert.deepEqual(computeNativeSweepAmount(wei(0.001), 0.001, 18), { amount: "0", reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount(wei(0.0005), 0.001, 18), { amount: "0", reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount("0", 0.001, 18), { amount: "0", reason: "below_reserve" }); }); - it("returns below_reserve when reserve >= quantity", () => { - assert.deepEqual(computeNativeSweepAmount(0.001, 0.001), { amount: 0, reason: "below_reserve" }); - assert.deepEqual(computeNativeSweepAmount(0.0005, 0.001), { amount: 0, reason: "below_reserve" }); - assert.deepEqual(computeNativeSweepAmount(0, 0.001), { amount: 0, reason: "below_reserve" }); + it("rejects unusable inputs as below_reserve", () => { + assert.deepEqual(computeNativeSweepAmount(null, 0.001, 18), { amount: "0", reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount(wei(0.01), undefined, 18), { amount: "0", reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount("not-a-bigint", 0.001, 18), { amount: "0", reason: "below_reserve" }); + assert.deepEqual(computeNativeSweepAmount(wei(0.01), -1, 18), { amount: "0", reason: "below_reserve" }); }); - it("rejects non-finite inputs as below_reserve (safer than dividing by NaN downstream)", () => { - assert.deepEqual(computeNativeSweepAmount(NaN, 0.001), { amount: 0, reason: "below_reserve" }); - assert.deepEqual(computeNativeSweepAmount(0.01, undefined), { amount: 0, reason: "below_reserve" }); + it("respects chain-specific decimals (e.g. 6 for USDC-style natives)", () => { + // wei(1, 6) = "1000000" → 1 unit; reserve 0.5 → 0.5 unit sweepable. + const r = computeNativeSweepAmount(wei(1, 6), 0.5, 6); + assert.equal(r.reason, null); + assert.equal(r.amount, "0.5"); }); }); @@ -607,7 +635,17 @@ describe("buildConsolidatePlan — sequential quote loop", () => { }; const candidates = [ - { symbol: "ETH", valueUsd: 100, quantity: 0.01, fungible: {}, implAddress: null, isNative: true }, + { + symbol: "ETH", + valueUsd: 100, + quantity: "0.01", + quantityFloat: 0.01, + rawInt: "10000000000000000", // 0.01 ETH = 1e16 wei + decimals: 18, + fungible: {}, + implAddress: null, + isNative: true, + }, ]; await buildConsolidatePlan({ @@ -624,11 +662,8 @@ describe("buildConsolidatePlan — sequential quote loop", () => { }); assert.equal(quoteInputs.length, 1); - // 0.01 - 0.001 ≈ 0.009 (with a possible 1e-18 epsilon from float math). - // Parse the string back to a number and assert proximity — the contract - // is "sweep quantity minus reserve", not a particular decimal rendering. - const passed = parseFloat(quoteInputs[0].amount); - assert.ok(Math.abs(passed - 0.009) < 1e-12, `amount string=${quoteInputs[0].amount}`); + // 0.01 ETH - 0.001 ETH = 0.009 ETH, exact in BigInt — no float epsilon. + assert.equal(quoteInputs[0].amount, "0.009"); }); it("marks the native row below_reserve when reserve >= quantity (no quote call)", async () => { @@ -639,7 +674,17 @@ describe("buildConsolidatePlan — sequential quote loop", () => { }; const candidates = [ - { symbol: "ETH", valueUsd: 100, quantity: 0.001, fungible: {}, implAddress: null, isNative: true }, + { + symbol: "ETH", + valueUsd: 100, + quantity: "0.001", + quantityFloat: 0.001, + rawInt: "1000000000000000", // 0.001 ETH = 1e15 wei + decimals: 18, + fungible: {}, + implAddress: null, + isNative: true, + }, ]; const plan = await buildConsolidatePlan({ @@ -1135,3 +1180,294 @@ describe("formatConsolidateResult — full failure messages", () => { ); }); }); + +// --------------------------------------------------------------------------- +// AC 22 — executeReadyRows nonce tracking. RPC `latest` lags between +// back-to-back approvals during a sweep, causing `nonce too low` on row K+1. +// The helper now tracks its own counter, seeded from `pending`, and feeds it +// to executeFn as `approvalNonceOverride`. Pure unit test via a fake client. +// --------------------------------------------------------------------------- +describe("executeReadyRows — nonce tracking (AC 22)", () => { + // Tiny fake `getPublicClient` that returns a constant starting nonce. The + // helper reads pending once at start, then advances locally. + function mkClientFactory(startingNonce) { + return async () => ({ + getTransactionCount: async () => BigInt(startingNonce), + }); + } + + function mkRow(symbol) { + return { symbol, quote: { from: { symbol }, to: { symbol: "ETH" }, fromChain: "base", toChain: "base" } }; + } + + it("feeds approvalNonceOverride to executeFn as [N, N+2, N+4, ...] when every row needs approval", async () => { + const overrides = []; + const executeFn = async (quote, _wallet, _passphrase, opts) => { + overrides.push(opts.approvalNonceOverride); + return { hash: `0x${quote.from.symbol}`, status: "success", approvalHash: "0xapproval" }; + }; + + const readyRows = ["A", "B", "C", "D", "E"].map(mkRow); + const { summary } = await executeReadyRows(readyRows, executeFn, { + walletName: "w", + passphrase: "p", + timeout: 120, + walletAddress: "0xabc", + chain: "base", + clientFactory: mkClientFactory(42), + }); + + assert.deepEqual(overrides, [42, 44, 46, 48, 50], "every approval-needed row advances by +2"); + assert.equal(summary.succeeded, 5); + assert.equal(summary.failed, 0); + }); + + it("advances by +1 when a row's approval was skipped (no approvalHash)", async () => { + const overrides = []; + const executeFn = async (quote, _w, _p, opts) => { + overrides.push(opts.approvalNonceOverride); + // Rows B and D had the allowance already in place → no approval tx → + // approvalHash is null → counter advances by +1 (swap only). + const needsApproval = !["B", "D"].includes(quote.from.symbol); + return { + hash: `0x${quote.from.symbol}`, + status: "success", + approvalHash: needsApproval ? "0xapproval" : null, + }; + }; + + const readyRows = ["A", "B", "C", "D", "E"].map(mkRow); + await executeReadyRows(readyRows, executeFn, { + walletName: "w", + passphrase: "p", + timeout: 120, + walletAddress: "0xabc", + chain: "base", + clientFactory: mkClientFactory(10), + }); + + // A: needs approval (+2) → 12. B: no approval (+1) → 13. C: needs (+2) + // → 15. D: no approval (+1) → 16. E: needs (+2) → 18. + assert.deepEqual(overrides, [10, 12, 13, 15, 16]); + }); + + it("re-reads pending nonce after a row throws (so the next override isn't stale)", async () => { + const overrides = []; + let pendingReads = 0; + const factory = async () => ({ + getTransactionCount: async () => { + pendingReads++; + // First call (start of batch) returns 100; after the throw, returns + // 102 (e.g. the approval-only tx landed but the swap reverted in + // simulation, advancing the chain's pending count). + return pendingReads === 1 ? 100n : 102n; + }, + }); + + const executeFn = async (quote, _w, _p, opts) => { + overrides.push(opts.approvalNonceOverride); + if (quote.from.symbol === "ROW2") { + throw new Error("synthetic failure mid-batch"); + } + return { hash: `0x${quote.from.symbol}`, status: "success", approvalHash: "0xapproval" }; + }; + + const readyRows = ["ROW1", "ROW2", "ROW3"].map(mkRow); + const { summary } = await executeReadyRows(readyRows, executeFn, { + walletName: "w", + passphrase: "p", + timeout: 120, + walletAddress: "0xabc", + chain: "base", + clientFactory: factory, + }); + + // ROW1: start at 100, success +2. ROW2: tried with 102, throws → re-fetch + // pending → 102. ROW3: tried with 102 from the re-fetch. + assert.equal(overrides[0], 100); + assert.equal(overrides[1], 102); + assert.equal(overrides[2], 102); + assert.equal(summary.succeeded, 2); + assert.equal(summary.failed, 1); + assert.equal(pendingReads, 2, "pending nonce must be re-read after the throw"); + }); + + it("skips nonce tracking on Solana (no EVM nonce concept) — no approvalNonceOverride passed", async () => { + const overrides = []; + const executeFn = async (quote, _w, _p, opts) => { + overrides.push(opts.approvalNonceOverride); + return { hash: `0x${quote.from.symbol}`, status: "success" }; + }; + let factoryCalled = false; + const factory = async () => { + factoryCalled = true; + return { getTransactionCount: async () => 0n }; + }; + + const readyRows = [mkRow("USDC"), mkRow("BONK")]; + await executeReadyRows(readyRows, executeFn, { + walletName: "w", + passphrase: "p", + timeout: 120, + walletAddress: "8xLdoxKr3J5dQX2dQuzC7v3sqXq6ZwVz1aVzaB6gqW9F", + chain: "solana", + clientFactory: factory, + }); + + assert.equal(factoryCalled, false, "no public client should be built on Solana"); + assert.deepEqual(overrides, [undefined, undefined]); + }); + + it("falls back to per-row RPC nonce when the starting-nonce read throws", async () => { + const overrides = []; + const stderrChunks = []; + const origStderrWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; }; + + try { + const executeFn = async (quote, _w, _p, opts) => { + overrides.push(opts.approvalNonceOverride); + return { hash: `0x${quote.from.symbol}`, status: "success" }; + }; + const factory = async () => ({ + getTransactionCount: async () => { throw new Error("RPC down"); }, + }); + + const readyRows = [mkRow("A"), mkRow("B")]; + await executeReadyRows(readyRows, executeFn, { + walletName: "w", + passphrase: "p", + timeout: 120, + walletAddress: "0xabc", + chain: "base", + clientFactory: factory, + }); + + assert.deepEqual(overrides, [undefined, undefined], "fallback means no override is passed"); + const stderrText = stderrChunks.join(""); + assert.match(stderrText, /could not read starting nonce/i); + } finally { + process.stderr.write = origStderrWrite; + } + }); +}); + +// --------------------------------------------------------------------------- +// AC 23 — BigInt-safe amount path. `Number(quantity.float)` truncates 18- +// decimal balances past ~15 significant digits, so the API reconstructs a +// smaller wei amount than the wallet actually holds and rejects the quote +// with "Input asset balance is not enough." Use the raw `quantity.int` +// + impl `decimals` to build a precise decimal string. +// --------------------------------------------------------------------------- +describe("rawWeiToDecimalString — precision contract (AC 23)", () => { + it("preserves all 18 fractional digits of a wstETH-style balance", () => { + // 1.234567890123456789 — exactly the kind of value that would lose its + // trailing digits via parseFloat / Number(). + assert.equal(rawWeiToDecimalString("1234567890123456789", 18), "1.234567890123456789"); + }); + + it("strips trailing zeros — `1.0` collapses to `1`", () => { + assert.equal(rawWeiToDecimalString("1000000000000000000", 18), "1"); + }); + + it("handles sub-unit amounts (`100000` USDC-6dp = `0.1`)", () => { + assert.equal(rawWeiToDecimalString("100000", 6), "0.1"); + }); + + it("returns `\"0\"` for zero", () => { + assert.equal(rawWeiToDecimalString("0", 18), "0"); + }); + + it("handles 0-decimals tokens (e.g. some NFT-like fungibles)", () => { + assert.equal(rawWeiToDecimalString("42", 0), "42"); + }); + + it("preserves precision past JavaScript Number's ~15-sigfig ceiling", () => { + // Number("12345678901234567") rounds to 12345678901234568 — silently lossy. + const intStr = "12345678901234567"; // 17 digits, past Number's safe range + const out = rawWeiToDecimalString(intStr, 0); + assert.equal(out, "12345678901234567"); + }); +}); + +describe("classifyPosition — precise quantity threads through (AC 23)", () => { + function preciseRow({ symbol, valueUsd, intStr, decimals, address }) { + return { + attributes: { + position_type: "wallet", + value: valueUsd, + quantity: { float: parseFloat(intStr) / 10 ** decimals, int: intStr }, + fungible_info: { + symbol, + implementations: [{ chain_id: "base", address, decimals }], + }, + }, + }; + } + + it("threads quantity.int + decimals into the candidate's `quantity` string", () => { + const row = preciseRow({ + symbol: "WSTETH", + valueUsd: 4800, + intStr: "1234567890123456789", // 1.234567890123456789 WSTETH + decimals: 18, + address: "0xwsteth", + }); + const result = classifyPosition(row, baseCtx()); + assert.equal(result.kind, "candidate"); + assert.equal(result.quantity, "1.234567890123456789", "precise decimal string"); + // quantityFloat is a (lossy) Number for display only. + assert.ok(Math.abs(result.quantityFloat - 1.234567890123456789) < 1e-9); + // The decimals + rawInt propagate so the native sweep path can do BigInt math. + assert.equal(result.decimals, 18); + assert.equal(result.rawInt, "1234567890123456789"); + }); + + it("falls back to the float when quantity.int / decimals are missing", () => { + // Some positions (e.g. for a chain where the impl is missing decimals) + // can lack one or both fields. We must still produce SOMETHING usable + // rather than crashing. + const row = { + attributes: { + position_type: "wallet", + value: 100, + quantity: { float: 1.5 }, // no .int + fungible_info: { + symbol: "WEIRD", + implementations: [{ chain_id: "base", address: "0xweird" }], // no decimals + }, + }, + }; + const result = classifyPosition(row, baseCtx()); + assert.equal(result.kind, "candidate"); + assert.equal(result.quantity, "1.5"); + }); +}); + +// --------------------------------------------------------------------------- +// AC 24 — STABLE_SYMBOLS additions. USDT0 was the user-blocking case from a +// real local sweep. Also added: USD0 (Usual), BOLD (Liquity v2), USDY (Ondo). +// All are bona-fide stables; recognising them by default avoids the user +// having to remember `--exclude USDT0,USD0,BOLD,USDY` on every sweep. +// --------------------------------------------------------------------------- +describe("STABLE_SYMBOLS — new additions (AC 24)", () => { + it("recognises USDT0 in every casing (the user-blocking variant)", () => { + assert.equal(isStable("USDT0"), true); + assert.equal(isStable("usdt0"), true); + assert.equal(isStable("Usdt0"), true); + }); + + it("recognises USD0, BOLD, USDY (additional bona-fide stables)", () => { + for (const sym of ["USD0", "usd0", "BOLD", "bold", "USDY", "usdy"]) { + assert.equal(isStable(sym), true, `${sym} should match`); + } + }); + + it("does NOT match similar-looking non-stables (defence against false positives)", () => { + // No "USDT00" / "BOLDED" / "USD000" — these would be malformed but the + // O(1) Set.has check is exact-match, so we just confirm the contract. + for (const sym of ["USDT00", "BOLDED", "USD000", "USDT01", "USDS0"]) { + assert.equal(isStable(sym), false, `${sym} should NOT match`); + } + }); +}); diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js index b4db4537..28b74457 100644 --- a/cli/utils/trading/consolidate.js +++ b/cli/utils/trading/consolidate.js @@ -10,6 +10,8 @@ */ import { getSwapQuote } from "./swap.js"; +import { getPublicClient } from "./transaction.js"; +import { isSolana } from "../chain/registry.js"; // Lowercase set so callers can do an O(1) `STABLE_SYMBOLS.has(sym.toLowerCase())` // match. The literal symbol casings used by the Zerion fungibles API mix case @@ -17,6 +19,7 @@ import { getSwapQuote } from "./swap.js"; export const STABLE_SYMBOLS = new Set([ "usdc", "usdt", + "usdt0", // LayerZero-bridged USDT (e.g. base) — user-blocking discovery in local test "dai", "usds", "frax", @@ -30,6 +33,9 @@ export const STABLE_SYMBOLS = new Set([ "fdusd", "usdb", "crvusd", + "usd0", // Usual (USD0) + "bold", // Liquity v2 + "usdy", // Ondo Yield-bearing USD ]); export function isStable(symbol) { @@ -176,21 +182,69 @@ export function parseSymbolList(raw) { } /** - * Compute the sweepable native-gas amount. - * Returns `{ amount, reason }` — `reason` is set when amount ≤ 0 so the caller - * can mark the row `skipped: below_reserve`. + * Convert a raw on-chain quantity (string of wei / smallest-units) into a + * precise decimal string. Avoids the precision loss `Number(quantity.float)` + * would suffer on 18-decimal balances above ~15 significant digits, which + * the swap API then over-reconstructs into wei and rejects with + * "Input asset balance is not enough." + * + * Inputs: + * - `intStr`: string of the smallest-units integer (e.g. "1234567890123456789") + * - `decimals`: number of fractional decimals (e.g. 18 for ETH/ERC-20 18-dp) + * + * Returns a canonical decimal string with trailing zeros stripped: + * rawWeiToDecimalString("1234567890123456789", 18) → "1.234567890123456789" + * rawWeiToDecimalString("1000000000000000000", 18) → "1" + * rawWeiToDecimalString("100000", 6) → "0.1" + * rawWeiToDecimalString("0", 18) → "0" */ -export function computeNativeSweepAmount(quantity, reserve) { - const q = Number(quantity); - const r = Number(reserve); - if (!Number.isFinite(q) || !Number.isFinite(r)) { - return { amount: 0, reason: "below_reserve" }; +export function rawWeiToDecimalString(intStr, decimals) { + const big = BigInt(intStr); + if (decimals === 0) return big.toString(); + const div = 10n ** BigInt(decimals); + const whole = big / div; + const frac = big % div; + if (frac === 0n) return whole.toString(); + const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, ""); + return `${whole}.${fracStr}`; +} + +/** + * Compute the sweepable native-gas amount in BigInt to avoid float precision + * loss. `quantityIntWei` is the position's raw smallest-units quantity string, + * `reserveHumanReadable` is the user-typed `--gas-reserve` number (or + * per-chain default), `decimals` is the chain's native decimals. + * + * Returns `{ amount, reason }`. `amount` is a decimal string (e.g. "0.004") + * suitable for the swap endpoint's `input[amount]` field; `reason` is set + * to "below_reserve" when reserve ≥ quantity (or the inputs are unusable). + * + * The float→wei conversion of `reserveHumanReadable` is the one place we + * tolerate Number arithmetic: the user types these values directly (e.g. + * `--gas-reserve 0.001`), and they're well below the 15-sigfig precision + * ceiling. Math.floor() rounds towards zero so we never reserve LESS than + * the operator asked for. + */ +export function computeNativeSweepAmount(quantityIntWei, reserveHumanReadable, decimals = 18) { + if (quantityIntWei == null || reserveHumanReadable == null) { + return { amount: "0", reason: "below_reserve" }; + } + let qBig; + try { + qBig = BigInt(String(quantityIntWei)); + } catch { + return { amount: "0", reason: "below_reserve" }; + } + const reserveNum = Number(reserveHumanReadable); + if (!Number.isFinite(reserveNum) || reserveNum < 0) { + return { amount: "0", reason: "below_reserve" }; } - const amount = q - r; - if (amount <= 0) { - return { amount: 0, reason: "below_reserve" }; + const reserveWei = BigInt(Math.floor(reserveNum * 10 ** decimals)); + if (qBig <= reserveWei) { + return { amount: "0", reason: "below_reserve" }; } - return { amount, reason: null }; + const amountWei = qBig - reserveWei; + return { amount: rawWeiToDecimalString(amountWei.toString(), decimals), reason: null }; } /** @@ -206,6 +260,15 @@ export function getImplementationAddress(fungibleInfo, chainId) { return String(match.address).toLowerCase(); } +/** + * Pick the impl entry (including `decimals`) for a fungible on the given + * chain. Returns `null` if no matching impl exists. + */ +export function getImplementation(fungibleInfo, chainId) { + const impls = fungibleInfo?.implementations || []; + return impls.find((i) => i?.chain_id === chainId) || null; +} + /** * Decide whether a single position row is a sweep candidate. * @@ -234,8 +297,28 @@ export function classifyPosition(row, ctx) { const symbol = (fungible.symbol || "").toUpperCase(); const positionType = attrs.position_type; const valueUsd = Number(attrs.value); - const quantity = Number(attrs.quantity?.float); - const implAddress = getImplementationAddress(fungible, ctx.chain); + const quantityFloat = Number(attrs.quantity?.float); + const impl = getImplementation(fungible, ctx.chain); + const implAddress = impl?.address ? String(impl.address).toLowerCase() : null; + const decimals = impl?.decimals; + // Precise decimal string for the swap-amount field. Convert from the raw + // `quantity.int` (smallest units) using the chain-specific decimals so we + // never feed a lossy Number into the API. When inputs are missing, fall + // back to the float so we degrade rather than crash; the dust-filter will + // still classify these correctly. + const rawInt = attrs.quantity?.int; + let quantity; + if (rawInt != null && Number.isFinite(decimals)) { + try { + quantity = rawWeiToDecimalString(String(rawInt), Number(decimals)); + } catch { + quantity = Number.isFinite(quantityFloat) ? String(quantityFloat) : "0"; + } + } else if (Number.isFinite(quantityFloat)) { + quantity = String(quantityFloat); + } else { + quantity = "0"; + } const forceInclude = ctx.includeSet.has(symbol); // Non-wallet positions never sweep — skip entirely, no plan row. @@ -270,11 +353,13 @@ export function classifyPosition(row, ctx) { return { kind: "skip", reason: "stable_excluded" }; } + // Dust uses `value` (USD), not `quantity` — fine in float because USD + // values are small-magnitude numbers. if (!Number.isFinite(valueUsd) || valueUsd < ctx.minValueUsd) { - return { kind: "dust", symbol, valueUsd, quantity, fungible, implAddress }; + return { kind: "dust", symbol, valueUsd, quantity, quantityFloat, fungible, implAddress, decimals, rawInt }; } - return { kind: "candidate", symbol, valueUsd, quantity, fungible, implAddress }; + return { kind: "candidate", symbol, valueUsd, quantity, quantityFloat, fungible, implAddress, decimals, rawInt }; } /** @@ -297,6 +382,7 @@ export function filterCandidates(positions, ctx) { skippedDust.push({ symbol: result.symbol, quantity: result.quantity, + quantityFloat: result.quantityFloat, valueUsd: result.valueUsd, fungible: result.fungible, }); @@ -305,7 +391,10 @@ export function filterCandidates(positions, ctx) { candidates.push({ symbol: result.symbol, valueUsd: result.valueUsd, - quantity: result.quantity, + quantity: result.quantity, // precise decimal string for the swap amount + quantityFloat: result.quantityFloat, // lossy Number for display only + rawInt: result.rawInt, // raw smallest-units string (used by native sweep math) + decimals: result.decimals, fungible: result.fungible, implAddress: result.implAddress, isNative: ctx.nativeSymbol && result.symbol === ctx.nativeSymbol, @@ -370,15 +459,25 @@ export function evaluateQuote({ estimatedOutput, targetUsdPrice, positionValueUs * so callers don't need a try/catch around this. */ async function buildCandidateRow(c, ctx) { - // Native sweep amount uses (quantity - reserve) when the row is the chain's - // native gas token. Non-native rows sweep the full quantity. - let sweepQuantity = c.quantity; + // Native sweep amount uses (quantity - reserve) computed in BigInt to avoid + // float precision loss on 18-decimal balances. Non-native rows sweep the + // full precise `quantity` string carried through from classifyPosition. + // + // `sweepAmount` is the string we send to the quote API. `displayQuantity` + // is the Number we put in the printable row for the formatter (which + // already calls toFixed(6) on it). + let sweepAmount; + let displayQuantity; if (c.isNative) { - const { amount, reason } = computeNativeSweepAmount(c.quantity, ctx.gasReserveValue); + const { amount, reason } = computeNativeSweepAmount( + c.rawInt, + ctx.gasReserveValue, + Number.isFinite(c.decimals) ? c.decimals : 18, + ); if (reason) { return { symbol: c.symbol, - quantity: c.quantity, + quantity: c.quantityFloat, value_usd: c.valueUsd, expected_output: null, expected_output_usd: null, @@ -387,7 +486,11 @@ async function buildCandidateRow(c, ctx) { reason: "below_reserve", }; } - sweepQuantity = amount; + sweepAmount = amount; + displayQuantity = parseFloat(amount); + } else { + sweepAmount = c.quantity; + displayQuantity = Number.isFinite(c.quantityFloat) ? c.quantityFloat : parseFloat(sweepAmount); } let quote; @@ -395,7 +498,7 @@ async function buildCandidateRow(c, ctx) { quote = await ctx.quoteFn({ fromToken: c.symbol, toToken: ctx.toToken, - amount: String(sweepQuantity), + amount: sweepAmount, fromChain: ctx.chain, toChain: ctx.chain, walletAddress: ctx.walletAddress, @@ -405,7 +508,7 @@ async function buildCandidateRow(c, ctx) { } catch (err) { return { symbol: c.symbol, - quantity: sweepQuantity, + quantity: displayQuantity, value_usd: c.valueUsd, expected_output: null, expected_output_usd: null, @@ -425,7 +528,7 @@ async function buildCandidateRow(c, ctx) { if (evaluation.status === "skipped") { return { symbol: c.symbol, - quantity: sweepQuantity, + quantity: displayQuantity, value_usd: c.valueUsd, expected_output: null, expected_output_usd: null, @@ -438,7 +541,7 @@ async function buildCandidateRow(c, ctx) { if (evaluation.status === "blocked") { return { symbol: c.symbol, - quantity: sweepQuantity, + quantity: displayQuantity, value_usd: c.valueUsd, expected_output: evaluation.expectedOutput, expected_output_usd: evaluation.expectedOutputUsd, @@ -450,7 +553,7 @@ async function buildCandidateRow(c, ctx) { } return { symbol: c.symbol, - quantity: sweepQuantity, + quantity: displayQuantity, value_usd: c.valueUsd, expected_output: evaluation.expectedOutput, expected_output_usd: evaluation.expectedOutputUsd, @@ -525,10 +628,12 @@ export async function buildConsolidatePlan({ const rows = []; // Dust rows first so the table groups visually-skipped entries together. + // Use the lossy float for display — dust rows are by definition tiny values + // where the precision loss is irrelevant for human readability. for (const d of skippedDust) { rows.push({ symbol: d.symbol, - quantity: d.quantity, + quantity: Number.isFinite(d.quantityFloat) ? d.quantityFloat : parseFloat(d.quantity), value_usd: d.valueUsd, expected_output: null, expected_output_usd: null, @@ -564,20 +669,60 @@ export async function buildConsolidatePlan({ * swap is an independent on-chain transaction; one failing quote should not * gate the rest of a sweep. * - * `executeFn(quote, walletName, passphrase, { timeout })` matches the - * `executeSwap` signature so the CLI passes it through unwrapped. Tests inject - * a fake to drive failure scenarios without touching the keystore or RPC. + * On EVM chains the helper tracks an externally-managed nonce counter across + * the batch and feeds it to `executeFn` as `approvalNonceOverride`. Without + * this, back-to-back approvals read RPC `latest` and can collide on the + * previous tx's pending nonce, surfacing as `nonce too low` on row K+1. + * + * `executeFn(quote, walletName, passphrase, { timeout, approvalNonceOverride })` + * matches the `executeSwap` signature so the CLI passes it through unwrapped. + * Tests inject a fake (and optionally `clientFactory`) to drive scenarios + * without touching the keystore or RPC. * * Returns `{ results, summary: { succeeded, failed } }`. The caller is * responsible for echoing this to stdout via `print(..., formatConsolidateResult)`. */ -export async function executeReadyRows(readyRows, executeFn, { walletName, passphrase, timeout }) { +export async function executeReadyRows( + readyRows, + executeFn, + { walletName, passphrase, timeout, walletAddress, chain, clientFactory = getPublicClient } = {}, +) { const results = []; let succeeded = 0; let failed = 0; + + // Solana has no EVM-style nonce, and we don't manage Solana nonces from the + // consolidate side. Skip the override path entirely there; the executor + // ignores `approvalNonceOverride` for Solana rows anyway, but skipping the + // public-client setup keeps test fixtures simple. + const useNonceTracking = Boolean(chain) && !isSolana(chain) && Boolean(walletAddress); + + let nextNonce = null; + let client = null; + if (useNonceTracking) { + try { + client = await clientFactory(chain); + nextNonce = Number( + await client.getTransactionCount({ address: walletAddress, blockTag: "pending" }), + ); + } catch (err) { + // If we can't read the starting nonce, fall back to the no-override + // path — each row will use RPC `latest` like the single-swap flow. + // Surface the warning so an operator running with --pretty can see it. + process.stderr.write( + `Warning: could not read starting nonce for batch (${err?.message || err}). ` + + `Falling back to per-row RPC nonce — back-to-back approvals may collide.\n`, + ); + nextNonce = null; + } + } + for (const row of readyRows) { + const opts = { timeout }; + if (nextNonce != null) opts.approvalNonceOverride = nextNonce; + try { - const result = await executeFn(row.quote, walletName, passphrase, { timeout }); + const result = await executeFn(row.quote, walletName, passphrase, opts); results.push({ symbol: row.symbol, hash: result.hash, @@ -587,6 +732,12 @@ export async function executeReadyRows(readyRows, executeFn, { walletName, passp }); if (result.status === "success") succeeded++; else failed++; + // Advance the nonce: approval-sent → +2 (approval + swap), approval- + // skipped → +1 (swap only). `result.approvalHash` is null/absent when + // the on-chain allowance already covered the swap (see executeEvmSwap). + if (nextNonce != null) { + nextNonce += result.approvalHash ? 2 : 1; + } } catch (err) { failed++; results.push({ @@ -595,6 +746,18 @@ export async function executeReadyRows(readyRows, executeFn, { walletName, passp status: "failed", error: err?.message || String(err), }); + // Recovery: we don't know how far into the approve/swap pair we got + // before the throw. Re-fetch `pending` so the next iteration's override + // is at least no worse than the original RPC-`latest` behaviour. + if (client) { + try { + nextNonce = Number( + await client.getTransactionCount({ address: walletAddress, blockTag: "pending" }), + ); + } catch { + // Leave nextNonce as-is; the loop continues. + } + } } } return { results, summary: { succeeded, failed } }; diff --git a/cli/utils/trading/swap.js b/cli/utils/trading/swap.js index af300563..fbf61b35 100644 --- a/cli/utils/trading/swap.js +++ b/cli/utils/trading/swap.js @@ -300,8 +300,13 @@ export async function getSwapOffers(input) { * @param {string} passphrase * @param {object} [options] * @param {number} [options.timeout] - broadcast timeout in seconds + * @param {number} [options.approvalNonceOverride] - force the approval tx's + * nonce instead of trusting RPC `latest`. Used by batch flows (e.g. + * `zerion consolidate --execute`) where successive swaps share a wallet + * and the RPC's `latest` nonce can lag the previous swap's submission. + * Ignored on Solana (no nonce concept) and when no approval is needed. */ -export async function executeSwap(quote, walletName, passphrase, { timeout } = {}) { +export async function executeSwap(quote, walletName, passphrase, { timeout, approvalNonceOverride } = {}) { if (quote.blocking) { const err = new Error( `Quote not executable: ${quote.blocking.message || quote.blocking.code}` + @@ -318,6 +323,9 @@ export async function executeSwap(quote, walletName, passphrase, { timeout } = { if (!quote.transactionSwapSolana?.raw) { throw new Error("Quote did not include a Solana transaction"); } + // Solana has no EVM-style nonce — approvalNonceOverride is irrelevant + // here. Forward it anyway in case future Solana flows want to thread + // their own equivalent; the executor ignores it. return executeSolanaSwap(quote, walletName, passphrase); } @@ -325,7 +333,11 @@ export async function executeSwap(quote, walletName, passphrase, { timeout } = { throw new Error("Quote did not include an EVM transaction"); } - return executeEvmSwap(quote, walletName, passphrase, zerionChainId, { timeout, isCrossChain }); + return executeEvmSwap(quote, walletName, passphrase, zerionChainId, { + timeout, + isCrossChain, + approvalNonceOverride, + }); } async function executeSolanaSwap(quote, walletName, passphrase) { @@ -347,7 +359,7 @@ async function executeSolanaSwap(quote, walletName, passphrase) { }; } -async function executeEvmSwap(quote, walletName, passphrase, zerionChainId, { timeout, isCrossChain = false } = {}) { +async function executeEvmSwap(quote, walletName, passphrase, zerionChainId, { timeout, isCrossChain = false, approvalNonceOverride } = {}) { // Snapshot destination balance before bridge (for delivery detection) let preBalance = null; if (isCrossChain) { @@ -379,11 +391,17 @@ async function executeEvmSwap(quote, walletName, passphrase, zerionChainId, { ti }); process.stderr.write(`Approving ${quote.from.symbol} for swap...\n`); + // Honour an external nonce hint when the caller knows where the wallet's + // pending queue should be (batch flows that don't trust RPC `latest` + // between back-to-back swaps). With no override we fall through to the + // signer's default (read `latest` from RPC), preserving the one-shot + // swap/bridge contract. const { signedTxHex, client, tx: signedApprove } = await signSwapTransaction( approveTx, zerionChainId, walletName, - passphrase + passphrase, + approvalNonceOverride != null ? { nonceOverride: approvalNonceOverride } : undefined, ); approvalNonce = signedApprove.nonce; const approvalResult = await broadcastAndWait(client, signedTxHex, { timeout }); From 9593d44a71710751d582403966bda7f4daca63af Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 16:10:21 +0100 Subject: [PATCH 07/12] =?UTF-8?q?PLT-676:=20invert=20getApiKeyTier=20defau?= =?UTF-8?q?lt=20=E2=80=94=20any=20non-dev=20non-empty=20key=20is=20paid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zk_dev_* is the only paid-key prefix documented in this codebase (skills/zerion/SKILL.md). The previous classifier extrapolated zk_prod_ and zk_live_ — speculative — and returned "unknown" (→ dev concurrency) for anything else, which would silently cap real paid customers whose key shapes don't match. Invert the default: only zk_dev_* is dev; anything else with a non- empty key is paid. False positives (typo'd / garbage keys) burn a few extra request slots before the API errors — strictly safer than throttling a paying customer. - cli/utils/api/auth.js: drop the zk_-prefix gate; any non-empty non-dev key now returns "paid". - cli/tests/unit/cli/utils/api/auth.test.mjs: flipped the "non-zk_ key → unknown" assertion to "→ paid" with extra coverage (pk_prod_*, arbitrary prefix). Added an edge case pinning the acceptable startsWith match on bare "zk_dev_" (no suffix). 264/264 passing. --- cli/tests/unit/cli/utils/api/auth.test.mjs | 33 +++++++++++++++++----- cli/utils/api/auth.js | 23 +++++++++------ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/cli/tests/unit/cli/utils/api/auth.test.mjs b/cli/tests/unit/cli/utils/api/auth.test.mjs index a8dd4fbc..15dc8239 100644 --- a/cli/tests/unit/cli/utils/api/auth.test.mjs +++ b/cli/tests/unit/cli/utils/api/auth.test.mjs @@ -222,6 +222,11 @@ describe("basicAuthHeader", () => { // Callers (consolidate) auto-size concurrency from this signal, so the // classification must be exact and stable. // +// `zk_dev_*` is the only documented prefix. Anything else with a key is +// conservatively classified as paid — risking too-aggressive concurrency on +// a typo is preferable to silently capping a real paid customer at dev +// throughput. A garbage key still fails the underlying API call quickly. +// // We pass the key directly via the `keyOverride` test seam so the test never // observes whatever the dev's local config has stored at ~/.zerion/config.json // (otherwise `getApiKey()` would fall through to that and the "missing" case @@ -231,25 +236,39 @@ describe("getApiKeyTier — classification", () => { assert.equal(getApiKeyTier("zk_dev_abc123"), "dev"); }); - it("classifies `zk_prod_*` as paid", () => { + it("classifies `zk_prod_*` as paid (a plausible non-dev prefix)", () => { assert.equal(getApiKeyTier("zk_prod_abc123"), "paid"); }); - it("classifies `zk_live_*` as paid", () => { + it("classifies `zk_live_*` as paid (another plausible non-dev prefix)", () => { assert.equal(getApiKeyTier("zk_live_xyz789"), "paid"); }); it("classifies bare `zk_*` (no second segment) as paid", () => { - // Defensive: any `zk_` prefix that isn't `zk_dev_` is treated as paid so - // a future production-key shape doesn't accidentally inherit the dev cap. assert.equal(getApiKeyTier("zk_anything"), "paid"); }); - it("classifies empty / missing as unknown (treated as dev for safety)", () => { + it("classifies a non-`zk_` non-empty key as paid (defensive inversion)", () => { + // The old behaviour returned "unknown" here, which silently capped + // paid customers whose keys don't match the documented `zk_` shape at + // dev concurrency. Inversion means a key that fails the API will burn + // a few extra request slots before erroring — strictly better than + // throttling a real paid customer. + assert.equal(getApiKeyTier("pk_prod_abc123"), "paid"); + assert.equal(getApiKeyTier("not-a-real-key"), "paid"); + assert.equal(getApiKeyTier("any_other_prefix_abc123"), "paid"); + }); + + it("classifies empty / missing as unknown (callers treat as dev for safety)", () => { assert.equal(getApiKeyTier(""), "unknown"); }); - it("classifies a non-zk_ key as unknown (e.g. a stray placeholder)", () => { - assert.equal(getApiKeyTier("not-a-real-key"), "unknown"); + it("classifies `zk_dev_` (bare prefix, no suffix) as dev (startsWith match — acceptable edge)", () => { + // The startsWith check is intentionally lax — a malformed `zk_dev_` + // with no suffix matches as dev. A real key always has a suffix; this + // edge can only arise from a typo or partial paste, in which case the + // API call fails anyway. Document the edge so a future reader doesn't + // assume a stricter `^zk_dev_[a-z0-9_-]+$` regex. + assert.equal(getApiKeyTier("zk_dev_"), "dev"); }); }); diff --git a/cli/utils/api/auth.js b/cli/utils/api/auth.js index c650e47a..5bc98944 100644 --- a/cli/utils/api/auth.js +++ b/cli/utils/api/auth.js @@ -14,13 +14,21 @@ export function basicAuthHeader(key) { } /** - * Classify a Zerion API key into a rate-limit tier so callers can size - * concurrency appropriately. Dev keys are throttled at 120 req/min / - * 5K req/day; paid keys have substantially higher limits. + * Classify the active Zerion API key into a rate-limit tier so callers can + * size concurrency appropriately. Dev keys (`zk_dev_*` per + * `skills/zerion/SKILL.md`) are throttled at 120 req/min / 5K req/day; paid + * keys have substantially higher limits. * - * - `zk_dev_*` → "dev" - * - any other `zk_*` → "paid" (covers zk_prod_, zk_live_, etc.) - * - missing or non-zk_-prefixed → "unknown" (treat as dev for safety) + * `zk_dev_*` is the only documented prefix in this codebase. Paid-key + * formats are not documented here, so we conservatively treat any non-empty + * non-dev key as paid rather than risk silently capping a paid customer at + * dev concurrency. The cost of a false positive is bounded: a garbage key + * still fails the underlying API call, so a too-aggressive concurrency only + * burns a handful of request slots before erroring out. + * + * - `zk_dev_*` → "dev" + * - any other non-empty key → "paid" + * - missing / empty → "unknown" (callers treat as dev for safety) * * When called with no arg, reads the key via `getApiKey()` (env or config) so * config-stored keys count the same as env-supplied ones. The optional @@ -35,8 +43,7 @@ export function getApiKeyTier(keyOverride) { const key = (keyOverride !== undefined ? keyOverride : getApiKey()) || ""; if (!key) return "unknown"; if (key.startsWith("zk_dev_")) return "dev"; - if (key.startsWith("zk_")) return "paid"; - return "unknown"; + return "paid"; } // Solana keypairs are 64 bytes; base58-encoded they are 87-88 characters. From f31598a030e05ef56da7faa46458fc03fa3452ba Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 18:00:16 +0100 Subject: [PATCH 08/12] PLT-676: replace fuzzy target resolution with curated-fungible-id + address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symbol-based searchFungibles matching surfaced two failure modes during the polygon dry-run: case-sensitivity smell on `USDC.E` vs `USDC.e`, and broad ambiguity from symbol collisions. The new resolver in cli/utils/trading/consolidate-targets.js accepts only: 1. a curated symbol — looked up in a flat SYMBOL→fungibleId map, with the per-chain impl address fetched live from getFungible. Whatever Zerion lists as USDC's impl on a given chain (bridged USDC.e on some, Circle-native on others) is what the address-based target exclusion catches. 2. a raw contract address — for any token outside the curated set, or to override Zerion's choice. Anything else throws target_token_not_found and names the curated list. --- cli/commands/trading/consolidate.js | 47 +- .../trading/consolidate-targets.test.mjs | 419 ++++++++++++++++++ cli/utils/trading/consolidate-targets.js | 159 +++++++ skills/zerion-consolidate/SKILL.md | 11 +- 4 files changed, 604 insertions(+), 32 deletions(-) create mode 100644 cli/tests/unit/cli/utils/trading/consolidate-targets.test.mjs create mode 100644 cli/utils/trading/consolidate-targets.js diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js index 039521d5..a13adf8b 100644 --- a/cli/commands/trading/consolidate.js +++ b/cli/commands/trading/consolidate.js @@ -38,6 +38,7 @@ import { parseConcurrency, parseSymbolList, } from "../../utils/trading/consolidate.js"; +import { resolveTargetToken } from "../../utils/trading/consolidate-targets.js"; // Auto-pick the plan-phase quote concurrency from the API-key tier when the // user didn't pass --concurrency. Paid keys can comfortably handle a small @@ -135,41 +136,27 @@ export default async function consolidate(args, flags) { const { walletName, address } = resolveWallet({ ...flags, chain }); - // Target token resolution — searchFungibles is fuzzy, so we filter to an - // exact symbol match on the requested chain. Capture the on-chain address - // so filterCandidates can do an address-level target exclusion in addition - // to the symbol check. - const targetUpper = String(toToken).toUpperCase(); - let targetFungible; + // Target token resolution. Symbol matching is unreliable (case, bridged + // variants, collisions), so resolveTargetToken accepts only curated symbols + // or raw contract addresses — anything else throws target_token_not_found. + let target; try { - const response = await api.searchFungibles(toToken, { chainId: chain, limit: 5 }); - const results = response.data || []; - targetFungible = results.find((r) => { - const sym = r.attributes?.symbol?.toUpperCase(); - const implOnChain = (r.attributes?.implementations || []).some((i) => i.chain_id === chain); - return sym === targetUpper && implOnChain; - }) || null; + target = await resolveTargetToken({ + toToken, + chain, + api, + getNativeFungible, + }); } catch (err) { - printError(err.code || "search_error", err.message); - process.exit(1); - } - - if (!targetFungible) { - printError( - "target_token_not_found", - `Could not resolve "${toToken}" on chain "${chain}".`, - { - suggestion: `Confirm the symbol with: zerion search ${toToken} --chain ${chain}`, - }, - ); + printError(err.code || "target_token_not_found", err.message, { + suggestion: err.suggestion, + }); process.exit(1); } - const targetUsdPrice = Number(targetFungible.attributes?.market_data?.price); - const targetImpl = (targetFungible.attributes?.implementations || []).find( - (i) => i.chain_id === chain, - ); - const targetAddress = targetImpl?.address ? String(targetImpl.address).toLowerCase() : null; + const targetUpper = target.symbol; + const targetAddress = target.address; + const targetUsdPrice = target.usdPrice; // Native gas-token symbol — used by the filter to detect the chain's native // currency in positions. Catalog lookup can fail (rate limit / no native diff --git a/cli/tests/unit/cli/utils/trading/consolidate-targets.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate-targets.test.mjs new file mode 100644 index 00000000..d92ee7ff --- /dev/null +++ b/cli/tests/unit/cli/utils/trading/consolidate-targets.test.mjs @@ -0,0 +1,419 @@ +// Unit tests for the consolidate target-token resolver. The resolver replaces +// the old searchFungibles+symbol-filter path. Two accepted input shapes: +// 1. curated symbol → look up the canonical Zerion fungible id, then +// pick its impl on the target chain +// 2. raw contract address (EVM 0x… or Solana base58) → resolve via API +// Anything else throws target_token_not_found. + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + TARGET_FUNGIBLE_IDS, + isAddressInput, + getCuratedFungibleId, + listCuratedSymbols, + resolveTargetToken, +} from "#zerion/utils/trading/consolidate-targets.js"; + +// Fake API. `getFungible(id)` returns whatever fixture is keyed by id; +// `searchFungibles(query, {chainId})` returns the first match keyed by +// (chain, lowercased address). +function makeApi({ getFungibles = {}, search = {}, unknownAddresses = new Set() } = {}) { + return { + async getFungible(id) { + const data = getFungibles[id]; + return data ? { data } : { data: null }; + }, + async searchFungibles(query, options = {}) { + const chain = options.chainId; + const addr = String(query).toLowerCase(); + if (unknownAddresses.has(addr)) return { data: [] }; + const fungible = search[chain]?.[addr]; + return { data: fungible ? [fungible] : [] }; + }, + }; +} + +function makeFungible({ id, symbol, impls, price = 1.0 }) { + return { + id, + attributes: { + symbol, + market_data: { price }, + implementations: impls.map(([chain_id, address, decimals = 18]) => ({ + chain_id, + address, + decimals, + })), + }, + }; +} + +function noNative() { + return async () => null; +} + +describe("isAddressInput", () => { + it("recognizes EVM 0x… addresses on non-solana chains", () => { + assert.equal(isAddressInput("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "ethereum"), true); + assert.equal(isAddressInput("0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48", "ethereum"), true); + }); + + it("rejects 0x-prefixed but wrong-length inputs", () => { + assert.equal(isAddressInput("0xabc", "ethereum"), false); + }); + + it("rejects pure symbols", () => { + assert.equal(isAddressInput("USDC", "ethereum"), false); + assert.equal(isAddressInput("USDC.e", "polygon"), false); + }); + + it("recognizes Solana base58 mints on the solana chain", () => { + assert.equal(isAddressInput("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "solana"), true); + }); + + it("rejects empty / nullish input", () => { + assert.equal(isAddressInput("", "ethereum"), false); + assert.equal(isAddressInput(null, "ethereum"), false); + assert.equal(isAddressInput(undefined, "ethereum"), false); + }); +}); + +describe("TARGET_FUNGIBLE_IDS shape", () => { + it("is a flat symbol→fungibleId map (one id per asset, not per chain)", () => { + for (const [symbol, id] of Object.entries(TARGET_FUNGIBLE_IDS)) { + assert.equal(typeof id, "string", `${symbol} id must be a string`); + assert.ok(id.length > 0, `${symbol} id must be non-empty`); + // The convention in this codebase uses the Ethereum-mainnet address as + // the canonical fungible id for ERC-20-rooted assets. + assert.match(id, /^0x[a-fA-F0-9]{40}$/, `${symbol} id is not a 0x address`); + } + }); + + it("contains the canonical bluechips", () => { + for (const sym of ["USDC", "USDT", "DAI", "WETH", "WBTC"]) { + assert.ok(TARGET_FUNGIBLE_IDS[sym], `${sym} missing from curated map`); + } + }); + + it("does NOT contain bridged-variant symbols (USDC.e, etc.)", () => { + for (const symbol of Object.keys(TARGET_FUNGIBLE_IDS)) { + assert.ok(!symbol.toUpperCase().includes(".E"), `${symbol} looks like a bridged variant`); + } + }); +}); + +describe("getCuratedFungibleId", () => { + it("looks up by uppercase symbol", () => { + assert.equal( + getCuratedFungibleId("USDC"), + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ); + }); + + it("returns null for unknown symbols", () => { + assert.equal(getCuratedFungibleId("USDC.E"), null); + assert.equal(getCuratedFungibleId("PEPE"), null); + }); +}); + +describe("listCuratedSymbols", () => { + it("returns the sorted list of curated symbols", () => { + const symbols = listCuratedSymbols(); + assert.deepEqual([...symbols].sort(), symbols); + assert.ok(symbols.includes("USDC")); + assert.ok(symbols.includes("WETH")); + }); +}); + +describe("resolveTargetToken — curated symbol path", () => { + it("fetches by fungible id and picks the impl Zerion reports for this chain", async () => { + // Zerion's USDC fungible advertises one impl per chain. Whatever address + // it returns for polygon is what we target — bridged USDC.e or native + // Circle USDC, whichever Zerion considers canonical. + const usdcId = TARGET_FUNGIBLE_IDS.USDC; + const fungible = makeFungible({ + id: usdcId, + symbol: "USDC", + price: 1.0001, + impls: [ + ["ethereum", "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 6], + ["polygon", "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", 6], // USDC.e + ["base", "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", 6], + ], + }); + const api = makeApi({ getFungibles: { [usdcId]: fungible } }); + + const onPolygon = await resolveTargetToken({ + toToken: "USDC", + chain: "polygon", + api, + getNativeFungible: noNative(), + }); + // Whatever Zerion lists as USDC's polygon impl is the address we target, + // even if it's the bridged variant — that's the whole point of trusting + // the API for per-chain impls. + assert.equal(onPolygon.symbol, "USDC"); + assert.equal(onPolygon.address, "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"); + assert.equal(onPolygon.usdPrice, 1.0001); + assert.equal(onPolygon.fungibleId, usdcId); + + const onBase = await resolveTargetToken({ + toToken: "USDC", + chain: "base", + api, + getNativeFungible: noNative(), + }); + assert.equal(onBase.address, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"); + }); + + it("normalizes lowercase symbol input (usdc → USDC)", async () => { + const usdcId = TARGET_FUNGIBLE_IDS.USDC; + const fungible = makeFungible({ + id: usdcId, + symbol: "USDC", + impls: [["base", "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", 6]], + }); + const api = makeApi({ getFungibles: { [usdcId]: fungible } }); + const result = await resolveTargetToken({ + toToken: "usdc", + chain: "base", + api, + getNativeFungible: noNative(), + }); + assert.equal(result.symbol, "USDC"); + }); + + it("keeps the user's symbol (not the API's) so position-side exclusion matches", async () => { + // If Zerion's `attributes.symbol` is "USD Coin" or "USDC.e", positions + // still expose "USDC" as their symbol — we must use the user's intent + // for the symbol-based exclusion to work. + const usdcId = TARGET_FUNGIBLE_IDS.USDC; + const fungible = makeFungible({ + id: usdcId, + symbol: "USD Coin", // API name drift + impls: [["ethereum", "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 6]], + }); + const api = makeApi({ getFungibles: { [usdcId]: fungible } }); + const result = await resolveTargetToken({ + toToken: "USDC", + chain: "ethereum", + api, + getNativeFungible: noNative(), + }); + assert.equal(result.symbol, "USDC"); + }); + + it("throws target_token_not_found when the curated fungible has no impl on the chain", async () => { + const wbtcId = TARGET_FUNGIBLE_IDS.WBTC; + const fungible = makeFungible({ + id: wbtcId, + symbol: "WBTC", + impls: [["ethereum", "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", 8]], + }); + const api = makeApi({ getFungibles: { [wbtcId]: fungible } }); + await assert.rejects( + resolveTargetToken({ toToken: "WBTC", chain: "solana", api, getNativeFungible: noNative() }), + (err) => { + assert.equal(err.code, "target_token_not_found"); + return true; + }, + ); + }); +}); + +describe("resolveTargetToken — address path", () => { + it("accepts a raw 0x address and resolves it via searchFungibles", async () => { + const fixture = makeFungible({ + id: "id-some-token", + symbol: "PEPE", + price: 0.000012, + impls: [["ethereum", "0x6982508145454ce325ddbe47a25d4ec3d2311933", 18]], + }); + const api = makeApi({ + search: { ethereum: { "0x6982508145454ce325ddbe47a25d4ec3d2311933": fixture } }, + }); + + const result = await resolveTargetToken({ + toToken: "0x6982508145454ce325ddbe47a25d4ec3d2311933", + chain: "ethereum", + api, + getNativeFungible: noNative(), + }); + + assert.equal(result.symbol, "PEPE"); + assert.equal(result.address, "0x6982508145454ce325ddbe47a25d4ec3d2311933"); + assert.equal(result.fungibleId, "id-some-token"); + }); + + it("lowercases mixed-case addresses before lookup", async () => { + const lower = "0x6982508145454ce325ddbe47a25d4ec3d2311933"; + const fixture = makeFungible({ + id: "id-x", + symbol: "PEPE", + impls: [["ethereum", lower, 18]], + }); + const api = makeApi({ search: { ethereum: { [lower]: fixture } } }); + + const result = await resolveTargetToken({ + toToken: "0x6982508145454CE325DDBE47A25D4EC3D2311933", + chain: "ethereum", + api, + getNativeFungible: noNative(), + }); + assert.equal(result.address, lower); + }); + + it("lets the user reach a bridged variant by passing its address", async () => { + // Curated USDC on polygon would point at whichever impl Zerion lists. + // If the user wants the OTHER variant (e.g. Circle-native USDC at 0x3c… + // when Zerion's USDC returns USDC.e), they pass the address explicitly. + const nativeUsdc = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"; + const fixture = makeFungible({ + id: "polygon-native-usdc-fungible", + symbol: "USDC", + impls: [["polygon", nativeUsdc, 6]], + }); + const api = makeApi({ search: { polygon: { [nativeUsdc]: fixture } } }); + + const result = await resolveTargetToken({ + toToken: nativeUsdc, + chain: "polygon", + api, + getNativeFungible: noNative(), + }); + assert.equal(result.address, nativeUsdc); + assert.equal(result.fungibleId, "polygon-native-usdc-fungible"); + }); +}); + +describe("resolveTargetToken — rejection path", () => { + it("throws target_token_not_found for unknown symbols", async () => { + const api = makeApi(); + await assert.rejects( + resolveTargetToken({ + toToken: "USDC.E", + chain: "polygon", + api, + getNativeFungible: noNative(), + }), + (err) => { + assert.equal(err.code, "target_token_not_found"); + assert.match(err.message, /USDC\.E/); + assert.match(err.message, /polygon/); + // The error must name the curated list so the user can self-correct + assert.match(err.message, /USDC/); + assert.ok(err.suggestion, "suggestion should be populated"); + return true; + }, + ); + }); + + it("throws target_token_not_found when an address has no impl on the chain", async () => { + const unknown = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + const api = makeApi({ unknownAddresses: new Set([unknown]) }); + await assert.rejects( + resolveTargetToken({ + toToken: unknown, + chain: "polygon", + api, + getNativeFungible: noNative(), + }), + (err) => { + assert.equal(err.code, "target_token_not_found"); + return true; + }, + ); + }); + + it("error names every curated symbol so the user knows the full list", async () => { + const api = makeApi(); + try { + await resolveTargetToken({ + toToken: "FAKE", + chain: "ethereum", + api, + getNativeFungible: noNative(), + }); + assert.fail("expected throw"); + } catch (err) { + for (const sym of ["USDC", "USDT", "DAI", "WETH", "WBTC"]) { + assert.match(err.message, new RegExp(sym)); + } + } + }); +}); + +describe("resolveTargetToken — native token path", () => { + it("matches the chain's native symbol via getNativeFungible + getFungible (for price)", async () => { + const api = makeApi({ + getFungibles: { + "eth-fungible-id": { + id: "eth-fungible-id", + attributes: { symbol: "ETH", market_data: { price: 2500.42 }, implementations: [] }, + }, + }, + }); + const getNativeFungible = async (chain) => { + assert.equal(chain, "base"); + return { fungibleId: "eth-fungible-id", symbol: "ETH", name: "Ethereum", decimals: 18 }; + }; + + const result = await resolveTargetToken({ + toToken: "ETH", + chain: "base", + api, + getNativeFungible, + }); + + assert.equal(result.symbol, "ETH"); + assert.equal(result.address, null, "native address should be null so only symbol exclusion fires"); + assert.equal(result.usdPrice, 2500.42); + assert.equal(result.fungibleId, "eth-fungible-id"); + }); + + it("native lookup is best-effort — falls through to curated/address paths if it throws", async () => { + const usdcId = TARGET_FUNGIBLE_IDS.USDC; + const fungible = makeFungible({ + id: usdcId, + symbol: "USDC", + impls: [["polygon", "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", 6]], + }); + const api = makeApi({ getFungibles: { [usdcId]: fungible } }); + const getNativeFungible = async () => { + throw new Error("catalog rate limit"); + }; + + const result = await resolveTargetToken({ + toToken: "USDC", + chain: "polygon", + api, + getNativeFungible, + }); + assert.equal(result.symbol, "USDC"); + assert.equal(result.address, "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"); + }); + + it("does NOT take the native path when input is an address (even if it matches the native symbol)", async () => { + const fixture = makeFungible({ + id: "weth", + symbol: "WETH", + impls: [["base", "0x4200000000000000000000000000000000000006", 18]], + }); + const api = makeApi({ search: { base: { "0x4200000000000000000000000000000000000006": fixture } } }); + let nativeCalled = false; + const getNativeFungible = async () => { + nativeCalled = true; + return { fungibleId: "eth", symbol: "ETH", decimals: 18 }; + }; + + const result = await resolveTargetToken({ + toToken: "0x4200000000000000000000000000000000000006", + chain: "base", + api, + getNativeFungible, + }); + assert.equal(nativeCalled, false, "native path must not run for address inputs"); + assert.equal(result.symbol, "WETH"); + }); +}); diff --git a/cli/utils/trading/consolidate-targets.js b/cli/utils/trading/consolidate-targets.js new file mode 100644 index 00000000..e2d932e5 --- /dev/null +++ b/cli/utils/trading/consolidate-targets.js @@ -0,0 +1,159 @@ +/** + * Consolidate target-token resolution. + * + * Symbol-based fuzzy lookup is unreliable: case ("USDC.E" vs "USDC.e"), + * symbol collisions across unrelated tokens, and bridged variants surface + * as silent wrong-token resolutions. This module replaces the fuzzy path + * with two explicit forms: + * + * 1. Curated symbol — a short map of canonical Zerion fungible IDs for + * tokens users actually consolidate into (USDC, USDT, DAI, WETH, + * WBTC, …). The per-chain implementation address is fetched live + * from the Zerion API, which guarantees one impl per chain per + * fungible. Whichever address Zerion treats as the canonical + * implementation of "USDC" on a given chain (Circle-native on some, + * bridged USDC.e on others) is what we target. + * 2. Raw contract address — `0x…` (EVM) or base58 (Solana). The user + * takes responsibility for identifying the token. + * + * Anything else throws `target_token_not_found` and points the user at + * the address path. + */ + +// Zerion fungible IDs for the most common consolidation targets. One id +// per asset — the per-chain implementation address is resolved at runtime +// from `getFungible(id).attributes.implementations[chain]`. Bridged +// variants like USDC.e are NOT entries here; if Zerion groups one under a +// canonical fungible's per-chain impl, the address-based exclusion picks +// it up automatically. +export const TARGET_FUNGIBLE_IDS = { + USDC: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + USDT: "0xdac17f958d2ee523a2206206994597c13d831ec7", + DAI: "0x6b175474e89094c44da98b954eedeac495271d0f", + WETH: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + WBTC: "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", +}; + +const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; +// Solana base58, 32-44 chars. Naive but enough to disambiguate from symbols. +const SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; + +export function isAddressInput(value, chain) { + if (!value) return false; + if (chain === "solana") return SOLANA_ADDRESS_RE.test(value); + return EVM_ADDRESS_RE.test(value); +} + +export function getCuratedFungibleId(symbolUpper) { + return TARGET_FUNGIBLE_IDS[symbolUpper] || null; +} + +export function listCuratedSymbols() { + return Object.keys(TARGET_FUNGIBLE_IDS).sort(); +} + +function targetNotFound(toToken, chain) { + const list = listCuratedSymbols().join(", "); + const err = new Error( + `"${toToken}" is not a recognized consolidate target on chain "${chain}". ` + + `Pass the token contract address, or use one of: ${list}.`, + ); + err.code = "target_token_not_found"; + err.suggestion = `Find the contract address with: zerion search ${toToken} --chain ${chain}`; + return err; +} + +function pickImplOnChain(fungible, chain) { + const impls = fungible?.attributes?.implementations || []; + return impls.find((i) => i?.chain_id === chain) || null; +} + +/** + * Resolve the consolidate target token. + * + * Inputs: + * toToken - user-typed symbol or contract address + * chain - validated Zerion chain id + * api - { searchFungibles, getFungible } from utils/api/client + * getNativeFungible - utils/chain/catalog#getNativeFungible + * + * Returns: + * { symbol, address, usdPrice, fungibleId } + * symbol — upper-case symbol. For curated input we keep the user's + * input ("USDC") rather than the API's `symbol` field, so + * downstream symbol-based exclusion in filterCandidates + * matches what positions actually expose ("USDC", not + * "USD Coin"). For address input we use the API symbol. + * address — lowercased on-chain address on `chain` (the impl Zerion + * returns for this fungible on this chain), or null for + * native. Bridged variants that Zerion groups under a + * canonical fungible are excluded automatically through + * this address. + * usdPrice — Number, may be NaN if market data is missing + * fungibleId — Zerion fungible id + * + * Throws an Error with `.code = "target_token_not_found"` when the input is + * neither curated nor an address, or when the resolved fungible has no + * implementation on `chain`. + */ +export async function resolveTargetToken({ toToken, chain, api, getNativeFungible }) { + const raw = String(toToken).trim(); + const upper = raw.toUpperCase(); + const addressLike = isAddressInput(raw, chain); + + // Native gas token — only when the input is a bare symbol and matches the + // chain's native. `getFungible(id)` for native fungibles isn't reliable + // (no contract address impl per chain), so route through the catalog. + if (!addressLike) { + let native = null; + try { + native = await getNativeFungible(chain); + } catch { + // catalog failure — fall through; curated/address paths still work + } + if (native?.symbol && native.symbol.toUpperCase() === upper) { + const detail = await api.getFungible(native.fungibleId); + const attrs = detail?.data?.attributes || {}; + return { + symbol: native.symbol.toUpperCase(), + address: null, + usdPrice: Number(attrs.market_data?.price), + fungibleId: native.fungibleId, + }; + } + } + + // Curated symbol path — fetch the fungible by id, then pick its impl on + // the target chain. The chain-impl address is the authoritative target + // for address-based exclusion. + if (!addressLike) { + const fungibleId = getCuratedFungibleId(upper); + if (!fungibleId) throw targetNotFound(toToken, chain); + + const detail = await api.getFungible(fungibleId); + const fungible = detail?.data; + const impl = pickImplOnChain(fungible, chain); + if (!impl?.address) throw targetNotFound(toToken, chain); + + return { + symbol: upper, + address: String(impl.address).toLowerCase(), + usdPrice: Number(fungible?.attributes?.market_data?.price), + fungibleId, + }; + } + + // Address path — let the API map the address to a fungible. + const address = raw.toLowerCase(); + const response = await api.searchFungibles(address, { chainId: chain, limit: 1 }); + const fungible = response.data?.[0]; + if (!fungible) throw targetNotFound(toToken, chain); + + const impl = pickImplOnChain(fungible, chain); + return { + symbol: (fungible.attributes?.symbol || upper).toUpperCase(), + address: impl?.address ? String(impl.address).toLowerCase() : address, + usdPrice: Number(fungible.attributes?.market_data?.price), + fungibleId: fungible.id, + }; +} diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md index 95917ad2..3bbfaaf4 100644 --- a/skills/zerion-consolidate/SKILL.md +++ b/skills/zerion-consolidate/SKILL.md @@ -35,6 +35,13 @@ For balance inspection before sweeping → `zerion-analyze`. For a single swap zerion consolidate [flags] ``` +`` accepts either: + +- A **curated symbol** — the chain's native gas token (e.g. `ETH` on base, `POL` on polygon, `SOL` on solana) or one of the canonical bluechips in `cli/utils/trading/consolidate-targets.js` (`USDC`, `USDT`, `DAI`, `WETH`, `WBTC`). The curated map stores one Zerion fungible id per asset; the chain implementation address is fetched live from `GET /fungibles/{id}`. Whichever address Zerion lists for that fungible on the chain — Circle-native USDC on some chains, bridged USDC.e on others — is what gets targeted. +- A **contract address** — `0x…` on EVM chains, base58 on Solana. Use this for any token outside the curated list, or to override Zerion's choice (e.g. to target Circle-native USDC on a chain where Zerion's canonical USDC impl is the bridged variant). + +Anything else fails with `target_token_not_found` and the error names the curated symbols. + | Flag | Default | Meaning | |---|---|---| | `--execute` | _(off)_ | Broadcast the ready rows. Without this, the command prints a plan only. | @@ -127,7 +134,7 @@ The totals line shows: `N ready, M blocked, K skipped, expected ~X TARGET (~$Y)` By default the plan **excludes**: -1. The target token itself — by symbol AND on-chain address (so bridged variants like USDC.e mapping to the canonical USDC address are also excluded). +1. The target token itself — by symbol AND by the address Zerion lists for the target's fungible on this chain. On chains where Zerion treats a bridged variant (e.g. USDC.e on polygon) as the canonical impl of `USDC`, positions at that address are excluded automatically; positions whose symbol is `USDC` at any other address are also excluded via the symbol check. 2. The chain's native gas token (use `--include-native` to opt in). 3. Stablecoins (use `--include-stables` to opt in, or answer the interactive prompt yes). 4. Positions below `--min-value` (default $1, marked `skipped: dust`). @@ -185,7 +192,7 @@ By default the plan **excludes**: |---|---|---| | `missing_args` | `` or `` missing | `zerion consolidate base USDC` | | `unsupported_chain` | Invalid chain | `zerion chains` | -| `target_token_not_found` | The target symbol has no implementation on the chain | Check `zerion search --chain ` | +| `target_token_not_found` | Target is neither a curated symbol on this chain nor a contract address | Pass a contract address (`0x…` / Solana base58), or use one of the curated symbols named in the error | | `invalid_min_value` | `--min-value` is NaN or negative | Pass a non-negative number, e.g. `--min-value 1` | | `invalid_max_loss` | `--max-loss` is NaN, negative, or > 100 | Use percent (`5`) or fraction (`0.05`); see Dual form above | | `invalid_gas_reserve` | `--gas-reserve` is NaN or negative | Pass a non-negative native-units number | From 36e0efd4c25d8a7ed7f177651cd222b1fad45c7a Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 18:02:13 +0100 Subject: [PATCH 09/12] =?UTF-8?q?PLT-676:=20add=20AI=20prompt=20=E2=86=92?= =?UTF-8?q?=20invocation=20examples=20to=20consolidate=20SKILL.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback (graysonhyc): show how common natural-language requests (clear dust, treasury sweep, conservative slippage, send-to-address, etc.) map to consolidate invocations. --- skills/zerion-consolidate/SKILL.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md index 3bbfaaf4..ee82d6fd 100644 --- a/skills/zerion-consolidate/SKILL.md +++ b/skills/zerion-consolidate/SKILL.md @@ -203,6 +203,24 @@ By default the plan **excludes**: | `no_agent_token` | Trading needs an agent token | See `zerion-agent-management` | | `insufficient_funds` | A row's balance dropped between quote and broadcast | Refresh and re-run `--execute` | +## AI prompt examples + +How common natural-language requests map to invocations. + +| User prompt | Invocation | +|---|---| +| `clear dust tokens on base for default wallet` | `zerion consolidate base USDC --min-value 5` (USDC chosen as a sensible stable target on base; raise `--min-value` to widen what counts as dust) | +| `consolidate everything on arbitrum into USDC` | `zerion consolidate arbitrum USDC` (dry-run first, then re-run with `--execute`) | +| `sweep dust on polygon into USDC including the small stables` | `zerion consolidate polygon USDC --include-stables --min-value 2` | +| `convert all my base tokens to ETH and keep some for gas` | `zerion consolidate base ETH --include-native --gas-reserve 0.002` | +| `dust cleanup on optimism but be conservative on slippage` | `zerion consolidate optimism USDC --max-loss 2 --slippage 1` | +| `treasury sweep on ethereum into USDC for wallet treasury-1` | `zerion consolidate ethereum USDC --wallet treasury-1` | +| `consolidate solana wallet into SOL` | `zerion consolidate solana SOL --include-native` | +| `move all my polygon tokens to <0x…>` | `zerion consolidate polygon 0x… --min-value 1` (curated symbols don't cover this token; pass the address) | +| `clear dust but skip WETH and WBTC` | `zerion consolidate base USDC --exclude WETH,WBTC --min-value 5` | + +Always start in dry-run (omit `--execute`) so the operator can read the plan before broadcasting. Surface the totals line (`N ready, M blocked, K skipped`) before suggesting `--execute`. + ## Pair with - `zerion-analyze` — inspect positions before sweeping. Useful to spot the long tail of dust and decide on `--min-value`. From 560a3d43ce7452a934ffe04c792b251d72638740 Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 18:10:31 +0100 Subject: [PATCH 10/12] PLT-676: add --max-value (upper-bound filter) for dust-clearing use case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --min-value alone can only express \"sweep everything above $N\" — the opposite of what \"clear dust\" actually means. Adding --max-value flips the dust-clearing prompt from \"sweep my main bags\" (broken) to \"sweep rows up to $N, leave main bags alone\" (correct). Combined with --min-value the two flags form a band: rows below min are dust, rows above max are above_max (surfaced as skipped plan rows so the operator sees what was excluded). --max-value defaults to Infinity, so existing callers see no behavior change. Also fixes the AI example I just added in 36e0efd — \"clear dust\" was mapped to --min-value 5, which sweeps the opposite of dust. --- cli/commands/trading/consolidate.js | 18 +++ .../cli/utils/trading/consolidate.test.mjs | 110 ++++++++++++++++++ cli/utils/trading/consolidate.js | 52 +++++++-- skills/zerion-consolidate/SKILL.md | 26 +++-- 4 files changed, 190 insertions(+), 16 deletions(-) diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js index a13adf8b..7074fcf9 100644 --- a/cli/commands/trading/consolidate.js +++ b/cli/commands/trading/consolidate.js @@ -34,6 +34,7 @@ import { resolveGasReserve, parseMaxLoss, parseMinValue, + parseMaxValue, parseGasReserve, parseConcurrency, parseSymbolList, @@ -88,11 +89,13 @@ export default async function consolidate(args, flags) { } let minValueUsd; + let maxValueUsd; let maxLoss; let explicitGasReserve; let explicitConcurrency; try { minValueUsd = parseMinValue(flags["min-value"]); + maxValueUsd = parseMaxValue(flags["max-value"]); maxLoss = parseMaxLoss(flags["max-loss"]); explicitGasReserve = parseGasReserve(flags["gas-reserve"]); explicitConcurrency = parseConcurrency(flags.concurrency); @@ -101,6 +104,20 @@ export default async function consolidate(args, flags) { process.exit(1); } + // Band sanity: if both bounds are set and the window collapses, every row + // would be filtered. Surface this as a clear validation error rather than + // an empty plan. + if (Number.isFinite(maxValueUsd) && maxValueUsd < minValueUsd) { + printError( + "conflicting_flags", + `--max-value (${maxValueUsd}) must be ≥ --min-value (${minValueUsd}).`, + { + suggestion: "Widen the band, drop one of the flags, or swap their values.", + }, + ); + process.exit(1); + } + // Resolve plan-phase quote concurrency. Explicit --concurrency wins; otherwise // pick from the active API-key tier. The chosen value + provenance is surfaced // in the plan output so callers can verify what actually ran. @@ -234,6 +251,7 @@ export default async function consolidate(args, flags) { includeSet, excludeSet, minValueUsd, + maxValueUsd, }; const { candidates, skippedDust } = filterCandidates(positionsResponse.data || [], ctx); diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index 1d03156b..eb7bc5e7 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -14,6 +14,7 @@ import { resolveGasReserve, parseMaxLoss, parseMinValue, + parseMaxValue, parseGasReserve, parseConcurrency, parseSymbolList, @@ -80,6 +81,7 @@ function baseCtx(overrides = {}) { includeSet: new Set(), excludeSet: new Set(), minValueUsd: 1, + maxValueUsd: Infinity, ...overrides, }; } @@ -167,6 +169,27 @@ describe("parseMinValue / parseGasReserve", () => { } }); + it("parseMaxValue defaults to Infinity (uncapped) when unset", () => { + assert.equal(parseMaxValue(undefined), Infinity); + assert.equal(parseMaxValue(""), Infinity); + assert.equal(parseMaxValue(null), Infinity); + // Bare boolean flag (next positional swallowed by parseFlags before the + // CLI guard kicks in) — also unset. + assert.equal(parseMaxValue(true), Infinity); + }); + + it("parseMaxValue accepts positive numbers", () => { + assert.equal(parseMaxValue("10"), 10); + assert.equal(parseMaxValue(50.5), 50.5); + assert.equal(parseMaxValue("0.5"), 0.5); + }); + + it("parseMaxValue rejects NaN, zero, and negative (zero would skip every row)", () => { + for (const bad of ["abc", 0, "0", -1, -0.01]) { + assert.throws(() => parseMaxValue(bad), (err) => err.code === "invalid_max_value"); + } + }); + it("parseGasReserve returns undefined when unset (so resolveGasReserve picks default)", () => { assert.equal(parseGasReserve(undefined), undefined); assert.equal(parseGasReserve(""), undefined); @@ -383,6 +406,93 @@ describe("filterCandidates — position type, stables, native, dust", () => { assert.equal(candidates.length, 0); assert.equal(skippedDust.length, 1); assert.equal(skippedDust[0].symbol, "WETH"); + assert.equal(skippedDust[0].reason, "dust"); + }); + + it("max-value: positions above the cap are surfaced as `above_max`, not silently skipped", () => { + const big = walletPosition({ + symbol: "WETH", + value: 500, + quantity: 0.15, + address: "0xweth", + }); + const small = walletPosition({ + symbol: "WBTC", + value: 5, + quantity: 0.0001, + address: "0xwbtc", + }); + const ctx = baseCtx({ maxValueUsd: 50 }); + const { candidates, skippedDust } = filterCandidates([big, small], ctx); + assert.equal(candidates.length, 1); + assert.equal(candidates[0].symbol, "WBTC"); + assert.equal(skippedDust.length, 1); + assert.equal(skippedDust[0].symbol, "WETH"); + assert.equal(skippedDust[0].reason, "above_max"); + }); + + it("max-value: uses inclusive upper bound (exactly at the cap → candidate, just over → skipped)", () => { + const atCap = walletPosition({ symbol: "WETH", value: 50, quantity: 0.02, address: "0xweth" }); + const overCap = walletPosition({ symbol: "WBTC", value: 50.01, quantity: 0.001, address: "0xwbtc" }); + const ctx = baseCtx({ maxValueUsd: 50 }); + const { candidates, skippedDust } = filterCandidates([atCap, overCap], ctx); + assert.deepEqual(candidates.map((c) => c.symbol), ["WETH"]); + assert.deepEqual(skippedDust.map((d) => d.symbol), ["WBTC"]); + assert.equal(skippedDust[0].reason, "above_max"); + }); + + it("band: --min-value + --max-value sweeps only the window between the two", () => { + const dust = walletPosition({ symbol: "AAA", value: 0.5, quantity: 1, address: "0xaaa" }); + const inBand1 = walletPosition({ symbol: "BBB", value: 10, quantity: 1, address: "0xbbb" }); + const inBand2 = walletPosition({ symbol: "CCC", value: 40, quantity: 1, address: "0xccc" }); + const main = walletPosition({ symbol: "DDD", value: 500, quantity: 1, address: "0xddd" }); + + const ctx = baseCtx({ minValueUsd: 5, maxValueUsd: 50 }); + const { candidates, skippedDust } = filterCandidates([dust, inBand1, inBand2, main], ctx); + assert.deepEqual(candidates.map((c) => c.symbol).sort(), ["BBB", "CCC"]); + + const reasonsBySymbol = Object.fromEntries(skippedDust.map((d) => [d.symbol, d.reason])); + assert.equal(reasonsBySymbol.AAA, "dust"); + assert.equal(reasonsBySymbol.DDD, "above_max"); + }); + + it("max-value: defaults to Infinity (no cap) so existing callers see no behavior change", () => { + const big = walletPosition({ + symbol: "WETH", + value: 1_000_000, + quantity: 300, + address: "0xweth", + }); + const { candidates, skippedDust } = filterCandidates([big], baseCtx()); + assert.equal(candidates.length, 1); + assert.equal(skippedDust.length, 0); + }); + + it("buildConsolidatePlan emits reason='above_max' on the plan row (not 'dust')", async () => { + const big = walletPosition({ + symbol: "WETH", + value: 500, + quantity: 0.15, + address: "0xweth", + }); + const ctx = baseCtx({ maxValueUsd: 50 }); + const { skippedDust } = filterCandidates([big], ctx); + const plan = await buildConsolidatePlan({ + candidates: [], + skippedDust, + chain: CHAIN, + toToken: TARGET.symbol, + targetUsdPrice: 1, + walletAddress: "0xwallet", + slippage: 2, + gasReserveValue: 0, + maxLoss: 0.05, + quoteFn: async () => { throw new Error("not used — no candidates"); }, + }); + const row = plan.rows.find((r) => r.symbol === "WETH"); + assert.ok(row, "WETH row should appear in plan"); + assert.equal(row.status, "skipped"); + assert.equal(row.reason, "above_max"); }); }); diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js index 28b74457..885ef6bc 100644 --- a/cli/utils/trading/consolidate.js +++ b/cli/utils/trading/consolidate.js @@ -123,6 +123,27 @@ export function parseMinValue(raw) { return n; } +/** + * Parse `--max-value` (USD). Returns a positive number, or `Infinity` when + * the flag is unset (no upper bound). Combined with `--min-value`, expresses + * a band: positions with `value ∈ [min, max]` are sweep candidates; below + * `min` is dust, above `max` is "main holdings" (skipped, surfaced). + * + * Throws `{ code: "invalid_max_value" }` on bad input. + */ +export function parseMaxValue(raw) { + if (raw === undefined || raw === null || raw === "" || raw === true || raw === false) { + return Infinity; + } + const n = typeof raw === "number" ? raw : Number(String(raw).trim()); + if (!Number.isFinite(n) || n <= 0) { + const err = new Error(`Invalid --max-value: ${raw}. Must be a positive number.`); + err.code = "invalid_max_value"; + throw err; + } + return n; +} + /** * Parse `--concurrency` (positive integer in `[1, 10]`). Returns `undefined` * when the flag isn't set so the CLI can auto-pick by API-key tier. Throws @@ -284,11 +305,16 @@ export function getImplementation(fungibleInfo, chainId) { * includeSet - Set of upper-case symbols to force-include (overrides * native/stables exclusions; still subject to dust filter) * excludeSet - Set of upper-case symbols to force-exclude - * minValueUsd - dust threshold + * minValueUsd - inclusive lower bound; rows below are dust + * maxValueUsd - inclusive upper bound; rows above are "main + * holdings" (skipped, surfaced). `Infinity` = + * no cap (the default). * * Returns one of: * { kind: "skip", reason } — row excluded entirely (no plan entry) - * { kind: "dust" } — emit a plan row `status: skipped, dust` + * { kind: "dust", reason } — emit a plan row `status: skipped`. + * `reason` is "dust" (below min) or + * "above_max" (above max). * { kind: "candidate", symbol, valueUsd, quantity, fungible, implAddress } */ export function classifyPosition(row, ctx) { @@ -354,9 +380,18 @@ export function classifyPosition(row, ctx) { } // Dust uses `value` (USD), not `quantity` — fine in float because USD - // values are small-magnitude numbers. + // values are small-magnitude numbers. NaN/missing values fail closed as + // dust so the row is surfaced rather than silently swept. if (!Number.isFinite(valueUsd) || valueUsd < ctx.minValueUsd) { - return { kind: "dust", symbol, valueUsd, quantity, quantityFloat, fungible, implAddress, decimals, rawInt }; + return { kind: "dust", reason: "dust", symbol, valueUsd, quantity, quantityFloat, fungible, implAddress, decimals, rawInt }; + } + + // Above the upper bound — likely a main holding the operator doesn't want + // to sweep. Surfaced (not silently dropped) so the operator sees what got + // filtered. `maxValueUsd === Infinity` when --max-value isn't set, so this + // branch is a no-op by default. + if (Number.isFinite(ctx.maxValueUsd) && valueUsd > ctx.maxValueUsd) { + return { kind: "dust", reason: "above_max", symbol, valueUsd, quantity, quantityFloat, fungible, implAddress, decimals, rawInt }; } return { kind: "candidate", symbol, valueUsd, quantity, quantityFloat, fungible, implAddress, decimals, rawInt }; @@ -381,6 +416,7 @@ export function filterCandidates(positions, ctx) { if (result.kind === "dust") { skippedDust.push({ symbol: result.symbol, + reason: result.reason, quantity: result.quantity, quantityFloat: result.quantityFloat, valueUsd: result.valueUsd, @@ -627,9 +663,9 @@ export async function buildConsolidatePlan({ }) { const rows = []; - // Dust rows first so the table groups visually-skipped entries together. - // Use the lossy float for display — dust rows are by definition tiny values - // where the precision loss is irrelevant for human readability. + // Value-filtered rows first so the table groups visually-skipped entries + // together. `reason` is "dust" for below-min or "above_max" for the + // optional upper-bound filter. for (const d of skippedDust) { rows.push({ symbol: d.symbol, @@ -639,7 +675,7 @@ export async function buildConsolidatePlan({ expected_output_usd: null, loss_pct: null, status: "skipped", - reason: "dust", + reason: d.reason || "dust", }); } diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md index ee82d6fd..89a46e44 100644 --- a/skills/zerion-consolidate/SKILL.md +++ b/skills/zerion-consolidate/SKILL.md @@ -46,6 +46,7 @@ Anything else fails with `target_token_not_found` and the error names the curate |---|---|---| | `--execute` | _(off)_ | Broadcast the ready rows. Without this, the command prints a plan only. | | `--min-value ` | `1` | Skip positions below this USD value (marked `skipped: dust`). | +| `--max-value ` | _(no cap)_ | Skip positions above this USD value (marked `skipped: above_max`). Pair with `--min-value` to sweep only a band — useful for "clear dust, keep main bags". | | `--max-loss ` | `5` | Reject quotes losing more than this fraction vs current value. Dual form: values > 1 treated as percent (`5` → 5%), values ≤ 1 as fraction (`0.05` → 5%). | | `--include-stables` | _(off)_ | Include stablecoins (USDC, USDT, DAI, USDS, FRAX, TUSD, USDD, PYUSD, LUSD, GUSD, USDe, RLUSD, FDUSD, USDB, crvUSD). | | `--exclude-stables` | _(off)_ | Force-exclude stables, no prompt. | @@ -82,6 +83,12 @@ zerion consolidate base USDC --max-loss 8 --exclude WETH --execute # Include stables in the sweep, override the dust threshold. zerion consolidate ethereum USDC --include-stables --min-value 5 +# Dust cleanup only — sweep rows up to $10 into USDC, leave main bags alone. +zerion consolidate base USDC --max-value 10 + +# Band — sweep mid-size positions ($5–$50). Outside the band: dust below, main bags above. +zerion consolidate base USDC --min-value 5 --max-value 50 + # Solana same-chain consolidation into SOL. zerion consolidate solana SOL --execute @@ -114,7 +121,7 @@ JSON to stdout. Each row carries: | `ready` | Quote within max-loss; ready to broadcast on `--execute`. | | `blocked` | Quote exceeds max-loss (`reason: "max_loss"`). | | `no_route` | The Zerion API returned no executable route for this pair. | -| `skipped` | Filtered out: `dust`, `below_reserve` (native row), or `no_price`. | +| `skipped` | Filtered out: `dust` (below `--min-value`), `above_max` (above `--max-value`), `below_reserve` (native row), or `no_price`. | The totals line shows: `N ready, M blocked, K skipped, expected ~X TARGET (~$Y)`. @@ -138,7 +145,8 @@ By default the plan **excludes**: 2. The chain's native gas token (use `--include-native` to opt in). 3. Stablecoins (use `--include-stables` to opt in, or answer the interactive prompt yes). 4. Positions below `--min-value` (default $1, marked `skipped: dust`). -5. All non-wallet positions — `deposit`, `loan`, `staked`, `locked`, `reward`, `investment`. These never appear in the plan. +5. Positions above `--max-value` if set (no default — uncapped; marked `skipped: above_max`). +6. All non-wallet positions — `deposit`, `loan`, `staked`, `locked`, `reward`, `investment`. These never appear in the plan. `--include ` overrides exclusions 2 and 3 (still subject to dust). `--exclude ` adds further symbol exclusions. @@ -194,10 +202,11 @@ By default the plan **excludes**: | `unsupported_chain` | Invalid chain | `zerion chains` | | `target_token_not_found` | Target is neither a curated symbol on this chain nor a contract address | Pass a contract address (`0x…` / Solana base58), or use one of the curated symbols named in the error | | `invalid_min_value` | `--min-value` is NaN or negative | Pass a non-negative number, e.g. `--min-value 1` | +| `invalid_max_value` | `--max-value` is NaN, zero, or negative | Pass a positive number, e.g. `--max-value 10` | | `invalid_max_loss` | `--max-loss` is NaN, negative, or > 100 | Use percent (`5`) or fraction (`0.05`); see Dual form above | | `invalid_gas_reserve` | `--gas-reserve` is NaN or negative | Pass a non-negative native-units number | | `invalid_concurrency` | `--concurrency` is NaN, non-integer, < 1, or > 10 | Pass an integer in `1..10`, e.g. `--concurrency 5` | -| `conflicting_flags` | `--gas-reserve` without `--include-native`, or `--include-stables` with `--exclude-stables` | Pass `--include-native` to opt in, or pick one stables flag | +| `conflicting_flags` | `--gas-reserve` without `--include-native`, `--include-stables` with `--exclude-stables`, or `--max-value < --min-value` | Pass `--include-native` to opt in, pick one stables flag, or widen the band | | `invalid_flag_value` | Bare boolean flag got a non-positional consumed as value | Pass the boolean flag last, or use `--flag=true` / `--no-flag` | | `invalid_slippage` | `--slippage` not in 0–100 | `--slippage 2` | | `no_agent_token` | Trading needs an agent token | See `zerion-agent-management` | @@ -209,15 +218,16 @@ How common natural-language requests map to invocations. | User prompt | Invocation | |---|---| -| `clear dust tokens on base for default wallet` | `zerion consolidate base USDC --min-value 5` (USDC chosen as a sensible stable target on base; raise `--min-value` to widen what counts as dust) | +| `clear dust tokens on base for default wallet` | `zerion consolidate base USDC --max-value 10` (sweep rows up to $10; main bags above $10 stay put. `--min-value` defaults to $1 so genuinely-tiny rows still skip as `dust`.) | | `consolidate everything on arbitrum into USDC` | `zerion consolidate arbitrum USDC` (dry-run first, then re-run with `--execute`) | -| `sweep dust on polygon into USDC including the small stables` | `zerion consolidate polygon USDC --include-stables --min-value 2` | +| `sweep dust on polygon into USDC including the small stables` | `zerion consolidate polygon USDC --include-stables --max-value 10` | | `convert all my base tokens to ETH and keep some for gas` | `zerion consolidate base ETH --include-native --gas-reserve 0.002` | -| `dust cleanup on optimism but be conservative on slippage` | `zerion consolidate optimism USDC --max-loss 2 --slippage 1` | +| `dust cleanup on optimism but be conservative on slippage` | `zerion consolidate optimism USDC --max-value 10 --max-loss 2 --slippage 1` | | `treasury sweep on ethereum into USDC for wallet treasury-1` | `zerion consolidate ethereum USDC --wallet treasury-1` | | `consolidate solana wallet into SOL` | `zerion consolidate solana SOL --include-native` | -| `move all my polygon tokens to <0x…>` | `zerion consolidate polygon 0x… --min-value 1` (curated symbols don't cover this token; pass the address) | -| `clear dust but skip WETH and WBTC` | `zerion consolidate base USDC --exclude WETH,WBTC --min-value 5` | +| `move all my polygon tokens to <0x…>` | `zerion consolidate polygon 0x… ` (curated symbols don't cover this token; pass the address) | +| `clear dust but skip WETH and WBTC` | `zerion consolidate base USDC --exclude WETH,WBTC --max-value 10` | +| `sweep only mid-size positions on base ($5–$50) into USDC` | `zerion consolidate base USDC --min-value 5 --max-value 50` (band: rows below $5 → `dust`, above $50 → `above_max`) | Always start in dry-run (omit `--execute`) so the operator can read the plan before broadcasting. Surface the totals line (`N ready, M blocked, K skipped`) before suggesting `--execute`. From 27b8540db165308b5648571fd7a85e3d1f0da4d6 Mon Sep 17 00:00:00 2001 From: Alex Bash Date: Fri, 22 May 2026 18:25:34 +0100 Subject: [PATCH 11/12] PLT-676: trim STABLE_SYMBOLS to the seven that wallets actually hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: 19 symbols, including a long tail of niche / declining stables (USDD, GUSD, USDB, FRAX, LUSD, BOLD, USDY, …). Most just bloated the auto-exclude blast radius; legacy DAI also fell into "rarely worth auto-protecting" since USDS supersedes it. After: USDC, USDT, USDC.e, USDT0, USDS, TUSD, USDe. - Bridged variants (USDC.e, USDT0) stay because they're the user-visible symbols on positions; without them the operator gets surprised sweeps. - Anything else is now a sweep candidate by default — operators add legacy bags to `--exclude` when they want to protect them. Code, SKILL.md parenthetical, the interactive TTY prompt example list, and the test fixtures (positive list + non-stable list + bridge-variant suite) are all synced. --- cli/commands/trading/consolidate.js | 2 +- .../cli/utils/trading/consolidate.test.mjs | 62 ++++++++++--------- cli/utils/trading/consolidate.js | 20 ++---- skills/zerion-consolidate/SKILL.md | 4 +- 4 files changed, 40 insertions(+), 48 deletions(-) diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js index 7074fcf9..c3c9a67d 100644 --- a/cli/commands/trading/consolidate.js +++ b/cli/commands/trading/consolidate.js @@ -201,7 +201,7 @@ export default async function consolidate(args, flags) { includeStables = false; } else if (process.stdin.isTTY) { includeStables = await confirm( - "Include stables (USDC/USDT/DAI/...) in this sweep? [y/N] ", + "Include stables (USDC/USDT/USDS/...) in this sweep? [y/N] ", { defaultYes: false }, ); } else { diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index eb7bc5e7..0ba01ca8 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -88,26 +88,32 @@ function baseCtx(overrides = {}) { describe("STABLE_SYMBOLS coverage", () => { it("matches the documented stablecoin set case-insensitively", () => { - // The acceptance criteria lists these exact symbols (case-insensitive). + // The set is intentionally small — covers what wallets predominantly hold + // (USDC/USDT variants, USDS, USDe). Anything not on this list is a sweep + // candidate by default; users add legacy/niche stables via --exclude. const documented = [ - "USDC", "USDT", "DAI", "USDS", "FRAX", "TUSD", "USDD", "PYUSD", - "LUSD", "GUSD", "USDe", "RLUSD", "FDUSD", "USDB", "crvUSD", + "USDC", "USDT", "USDC.e", "USDT0", "USDS", "TUSD", "USDe", ]; for (const sym of documented) { assert.equal(isStable(sym), true, `${sym} should match`); assert.equal(isStable(sym.toLowerCase()), true, `${sym.toLowerCase()} should match`); assert.equal(isStable(sym.toUpperCase()), true, `${sym.toUpperCase()} should match`); } - // Mixed-case forms also match — guards against a regression where the set - // was stored upper-case and lower-case input would silently fail. - for (const variant of ["Usdc", "uSdC", "Usde", "CrvUsd", "PyUsd"]) { + // Mixed-case forms also match. + for (const variant of ["Usdc", "uSdC", "Usde", "UsDc.E", "Tusd"]) { assert.equal(isStable(variant), true, `${variant} should match`); } }); - it("does not flag non-stable symbols", () => { - for (const sym of ["ETH", "BTC", "MATIC", "SOL", "MON", "USD"]) { - // "USD" is not in the documented list — exact-symbol match only. + it("does not flag non-stable symbols (incl. legacy stables removed from the curated set)", () => { + // Legacy/niche stables (DAI, FRAX, PYUSD, etc.) are explicitly NOT stables + // for sweep purposes — they get swept like any other token. Operators can + // protect them with --exclude if they hold legacy bags. + for (const sym of [ + "ETH", "BTC", "MATIC", "SOL", "MON", "USD", + "DAI", "FRAX", "PYUSD", "FDUSD", "crvUSD", "LUSD", "GUSD", "USDD", + "RLUSD", "USDB", "USD0", "BOLD", "USDY", + ]) { assert.equal(isStable(sym), false, `${sym} should not match`); } }); @@ -373,11 +379,12 @@ describe("filterCandidates — position type, stables, native, dust", () => { }); it("excludes stables by default", () => { + // USDe is a representative non-target stable on the curated list. const row = walletPosition({ - symbol: "DAI", + symbol: "USDe", value: 50, quantity: 50, - address: "0xdai", + address: "0xusde", }); const { candidates } = filterCandidates([row], baseCtx()); assert.equal(candidates.length, 0); @@ -385,14 +392,14 @@ describe("filterCandidates — position type, stables, native, dust", () => { it("--include-stables opts stables back in", () => { const row = walletPosition({ - symbol: "DAI", + symbol: "USDe", value: 50, quantity: 50, - address: "0xdai", + address: "0xusde", }); const { candidates } = filterCandidates([row], baseCtx({ includeStables: true })); assert.equal(candidates.length, 1); - assert.equal(candidates[0].symbol, "DAI"); + assert.equal(candidates[0].symbol, "USDE"); // classifyPosition uppercases the symbol }); it("dust positions land on the skippedDust list (still emit a plan row)", () => { @@ -505,12 +512,12 @@ describe("filterCandidates — --include / --exclude overrides", () => { it("--include overrides the stables exclusion", () => { const row = walletPosition({ - symbol: "DAI", + symbol: "TUSD", value: 50, quantity: 50, - address: "0xdai", + address: "0xtusd", }); - const { candidates } = filterCandidates([row], baseCtx({ includeSet: new Set(["DAI"]) })); + const { candidates } = filterCandidates([row], baseCtx({ includeSet: new Set(["TUSD"]) })); assert.equal(candidates.length, 1); }); @@ -1555,28 +1562,25 @@ describe("classifyPosition — precise quantity threads through (AC 23)", () => }); // --------------------------------------------------------------------------- -// AC 24 — STABLE_SYMBOLS additions. USDT0 was the user-blocking case from a -// real local sweep. Also added: USD0 (Usual), BOLD (Liquity v2), USDY (Ondo). -// All are bona-fide stables; recognising them by default avoids the user -// having to remember `--exclude USDT0,USD0,BOLD,USDY` on every sweep. +// Bridge-variant stables. USDT0 (LayerZero-bridged USDT) and USDC.e (bridged +// USDC) are the two bridged forms recognised as stables — without them, the +// operator would have to remember `--exclude USDT0,USDC.E` on every sweep. // --------------------------------------------------------------------------- -describe("STABLE_SYMBOLS — new additions (AC 24)", () => { - it("recognises USDT0 in every casing (the user-blocking variant)", () => { +describe("STABLE_SYMBOLS — bridged variants", () => { + it("recognises USDT0 in every casing", () => { assert.equal(isStable("USDT0"), true); assert.equal(isStable("usdt0"), true); assert.equal(isStable("Usdt0"), true); }); - it("recognises USD0, BOLD, USDY (additional bona-fide stables)", () => { - for (const sym of ["USD0", "usd0", "BOLD", "bold", "USDY", "usdy"]) { + it("recognises USDC.e in every casing (the dot is part of the symbol)", () => { + for (const sym of ["USDC.e", "USDC.E", "usdc.e", "Usdc.E"]) { assert.equal(isStable(sym), true, `${sym} should match`); } }); - it("does NOT match similar-looking non-stables (defence against false positives)", () => { - // No "USDT00" / "BOLDED" / "USD000" — these would be malformed but the - // O(1) Set.has check is exact-match, so we just confirm the contract. - for (const sym of ["USDT00", "BOLDED", "USD000", "USDT01", "USDS0"]) { + it("exact-match only: no false positives on lookalikes", () => { + for (const sym of ["USDT00", "USDC.f", "USDCe", "USDC_E", "USDS0"]) { assert.equal(isStable(sym), false, `${sym} should NOT match`); } }); diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js index 885ef6bc..06e21a94 100644 --- a/cli/utils/trading/consolidate.js +++ b/cli/utils/trading/consolidate.js @@ -19,23 +19,11 @@ import { isSolana } from "../chain/registry.js"; export const STABLE_SYMBOLS = new Set([ "usdc", "usdt", - "usdt0", // LayerZero-bridged USDT (e.g. base) — user-blocking discovery in local test - "dai", - "usds", - "frax", + "usdc.e", // bridged USDC (e.g. polygon, arbitrum, optimism) — treated as USDC for filter purposes + "usdt0", // LayerZero-bridged USDT (e.g. base) + "usds", // Sky / Maker rebrand of DAI "tusd", - "usdd", - "pyusd", - "lusd", - "gusd", - "usde", - "rlusd", - "fdusd", - "usdb", - "crvusd", - "usd0", // Usual (USD0) - "bold", // Liquity v2 - "usdy", // Ondo Yield-bearing USD + "usde", // Ethena ]); export function isStable(symbol) { diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md index 89a46e44..0d24f3c9 100644 --- a/skills/zerion-consolidate/SKILL.md +++ b/skills/zerion-consolidate/SKILL.md @@ -48,7 +48,7 @@ Anything else fails with `target_token_not_found` and the error names the curate | `--min-value ` | `1` | Skip positions below this USD value (marked `skipped: dust`). | | `--max-value ` | _(no cap)_ | Skip positions above this USD value (marked `skipped: above_max`). Pair with `--min-value` to sweep only a band — useful for "clear dust, keep main bags". | | `--max-loss ` | `5` | Reject quotes losing more than this fraction vs current value. Dual form: values > 1 treated as percent (`5` → 5%), values ≤ 1 as fraction (`0.05` → 5%). | -| `--include-stables` | _(off)_ | Include stablecoins (USDC, USDT, DAI, USDS, FRAX, TUSD, USDD, PYUSD, LUSD, GUSD, USDe, RLUSD, FDUSD, USDB, crvUSD). | +| `--include-stables` | _(off)_ | Include stablecoins (USDC, USDT, USDC.e, USDT0, USDS, TUSD, USDe). Match is case-insensitive; bridged USDC.e is treated as a stable so it isn't unintentionally swept. | | `--exclude-stables` | _(off)_ | Force-exclude stables, no prompt. | | `--include ` | _(none)_ | Comma-separated symbols to force-include even if filtered (case-insensitive). | | `--exclude ` | _(none)_ | Comma-separated extra exclusions on top of defaults. | @@ -156,7 +156,7 @@ By default the plan **excludes**: |---|---| | `--include-stables` set | Include, no prompt. | | `--exclude-stables` set | Exclude, no prompt. | -| Neither set, TTY | Prompt: `Include stables (USDC/USDT/DAI/...) in this sweep? [y/N]` (default No). | +| Neither set, TTY | Prompt: `Include stables (USDC/USDT/USDS/...) in this sweep? [y/N]` (default No). | | Neither set, non-TTY (pipe / agent invocation) | Default to **exclude**, no prompt — agents never block on stdin. | ## Native gas-token handling From 0d028ef98c36a71972c1627e781406b0986cf408 Mon Sep 17 00:00:00 2001 From: Grayson Ho Date: Tue, 26 May 2026 09:58:23 +0100 Subject: [PATCH 12/12] =?UTF-8?q?PLT-676:=20address=20PR=20#83=20review=20?= =?UTF-8?q?=E2=80=94=20nonce=20thread,=20loss-cell,=20doc=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from PR review: - H1 (swap.js): when allowance already covers the swap and no approval tx is sent, swap signing now falls through to the caller's approvalNonceOverride instead of undefined. Two back-to-back allowance-covered rows in a consolidate batch no longer race RPC `latest`. Source-pinned in consolidate.test.mjs. - M1 (consolidate.js executeReadyRows): after a row throws, invalidate the tracked nextNonce instead of re-reading `pending` — `pending` can transiently include a failed submission for several seconds and over-shoot the counter. Next row falls back to the signer default. Test updated to match the new contract. - L1 (format.js lossCell): a gain (loss_pct < 0) previously rendered as `+-2.50%` because toFixed preserved the negative sign. Now displays as `+2.50%` using Math.abs. Test added for both gain and loss cases. - H2 / H3 (SKILL.md): the "fresh quotes on --execute" bullet contradicted the implementation — quotes are taken from the plan phase. Rewritten to describe actual behavior + staleness window. Added a Safety bullet explaining that loss_pct uses the quote's expected output, not the on-chain `outputMin` floor. - M3 (SKILL.md + consolidate.js): --slippage table cell now states the real 0–100 range and warns that values above ~5 compound across an N-position sweep. The command shell also prints a stderr warning when slippage > 5. 299/299 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/commands/trading/consolidate.js | 10 ++ .../cli/utils/trading/consolidate.test.mjs | 102 ++++++++++++++++-- cli/utils/common/format.js | 7 +- cli/utils/trading/consolidate.js | 17 ++- cli/utils/trading/swap.js | 8 +- skills/zerion-consolidate/SKILL.md | 5 +- 6 files changed, 123 insertions(+), 26 deletions(-) diff --git a/cli/commands/trading/consolidate.js b/cli/commands/trading/consolidate.js index c3c9a67d..11d080a1 100644 --- a/cli/commands/trading/consolidate.js +++ b/cli/commands/trading/consolidate.js @@ -139,6 +139,16 @@ export default async function consolidate(args, flags) { } const slippage = parseSlippage(flags.slippage); + // High-slippage warning. parseSlippage allows 0–100 (shared across + // swap/bridge), but consolidate iterates over N positions so an aggressive + // value compounds. Surface a stderr warning above 5% to nudge operators + // toward tightening before `--execute`. + if (slippage != null && slippage > 5) { + process.stderr.write( + `Warning: --slippage ${slippage} is high; across an N-position sweep this can ` + + `realize a large absolute loss. Consider --slippage 2 (default) for liquid targets.\n`, + ); + } const includeSet = parseSymbolList(flags.include); const excludeSet = parseSymbolList(flags.exclude); diff --git a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs index 0ba01ca8..1a737b15 100644 --- a/cli/tests/unit/cli/utils/trading/consolidate.test.mjs +++ b/cli/tests/unit/cli/utils/trading/consolidate.test.mjs @@ -1368,16 +1368,19 @@ describe("executeReadyRows — nonce tracking (AC 22)", () => { assert.deepEqual(overrides, [10, 12, 13, 15, 16]); }); - it("re-reads pending nonce after a row throws (so the next override isn't stale)", async () => { + it("invalidates the tracked nonce after a row throws — next row falls back to RPC `latest`", async () => { + // After a row throws, `pending` may transiently include the failed + // submission for several seconds. Trusting it can over-shoot the counter + // and surface as `replacement underpriced` / `nonce too low` on the very + // next row. The recovery contract: null out the tracked counter and let + // the next row fall back to the signer's default — we lose per-row batch + // protection for one row but don't compound a wrong counter. const overrides = []; let pendingReads = 0; const factory = async () => ({ getTransactionCount: async () => { pendingReads++; - // First call (start of batch) returns 100; after the throw, returns - // 102 (e.g. the approval-only tx landed but the swap reverted in - // simulation, advancing the chain's pending count). - return pendingReads === 1 ? 100n : 102n; + return 100n; }, }); @@ -1399,14 +1402,15 @@ describe("executeReadyRows — nonce tracking (AC 22)", () => { clientFactory: factory, }); - // ROW1: start at 100, success +2. ROW2: tried with 102, throws → re-fetch - // pending → 102. ROW3: tried with 102 from the re-fetch. + // ROW1: tracked nonce 100, success +2 → 102. ROW2: tried with 102, throws + // → counter invalidated. ROW3: tried with `undefined` (signer falls back + // to RPC latest). assert.equal(overrides[0], 100); assert.equal(overrides[1], 102); - assert.equal(overrides[2], 102); + assert.equal(overrides[2], undefined, "after throw, next row gets no override"); assert.equal(summary.succeeded, 2); assert.equal(summary.failed, 1); - assert.equal(pendingReads, 2, "pending nonce must be re-read after the throw"); + assert.equal(pendingReads, 1, "no `pending` re-read after the throw — counter is invalidated instead"); }); it("skips nonce tracking on Solana (no EVM nonce concept) — no approvalNonceOverride passed", async () => { @@ -1467,6 +1471,86 @@ describe("executeReadyRows — nonce tracking (AC 22)", () => { process.stderr.write = origStderrWrite; } }); + + // Source-pin: when the allowance already covers the swap, no approval tx is + // sent and `approvalNonce` stays null in executeEvmSwap. The swap must + // still use the batch's tracked override — otherwise two back-to-back + // allowance-covered rows race RPC `latest`. Pinning the expression because + // mocking signSwapTransaction's nonce flow end-to-end is heavy. + it("executeEvmSwap forwards approvalNonceOverride to the swap when approval is skipped", async () => { + const { readFile } = await import("node:fs/promises"); + const { resolve, dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const here = dirname(fileURLToPath(import.meta.url)); + const swapPath = resolve(here, "../../../../../..", "cli/utils/trading/swap.js"); + const src = await readFile(swapPath, "utf8"); + + // The fallback when approvalNonce is null MUST be `approvalNonceOverride` + // (the caller's tracked counter), not `undefined`. + assert.match( + src, + /approvalNonce\s*!=\s*null\s*\?\s*approvalNonce\s*\+\s*1\s*:\s*approvalNonceOverride/, + "swapNonceOverride must fall through to approvalNonceOverride when approval is skipped", + ); + + // Guard against a future regression that reverts to undefined. + assert.equal( + /approvalNonce\s*!=\s*null\s*\?\s*approvalNonce\s*\+\s*1\s*:\s*undefined/.test(src), + false, + "swapNonceOverride must not silently fall back to undefined", + ); + }); +}); + +// --------------------------------------------------------------------------- +// lossCell — sign + magnitude rendering. Loss positive → red, no sign. Gain +// (negative loss_pct, quote returns *more* USD than the source) → green +// with a `+` prefix on the absolute magnitude. The previous form printed +// `+-2.50%` for gains because `toFixed` preserved the negative sign. +// --------------------------------------------------------------------------- +describe("formatConsolidatePlan — loss/gain cell rendering", () => { + it("renders a gain as `+2.50%` (not `+-2.50%`)", async () => { + const { formatConsolidatePlan } = await import("#zerion/utils/common/format.js"); + const out = formatConsolidatePlan({ + chain: "base", + toToken: "USDC", + walletAddress: "0x" + "a".repeat(40), + rows: [{ + symbol: "FOO", + quantity: 1, + value_usd: 100, + expected_output: 102.5, + expected_output_usd: 102.5, + loss_pct: -0.025, + status: "ready", + }], + totals: { ready: 1, blocked: 0, skipped: 0, no_route: 0, expected_output: 102.5, expected_output_usd: 102.5 }, + }); + assert.ok(out.includes("+2.50%"), `expected "+2.50%" in plan output, got:\n${out}`); + assert.ok(!out.includes("+-2.50%"), `must not render "+-2.50%" in plan output`); + }); + + it("renders a loss as `2.50%` (no sign, red)", async () => { + const { formatConsolidatePlan } = await import("#zerion/utils/common/format.js"); + const out = formatConsolidatePlan({ + chain: "base", + toToken: "USDC", + walletAddress: "0x" + "a".repeat(40), + rows: [{ + symbol: "FOO", + quantity: 1, + value_usd: 100, + expected_output: 97.5, + expected_output_usd: 97.5, + loss_pct: 0.025, + status: "ready", + }], + totals: { ready: 1, blocked: 0, skipped: 0, no_route: 0, expected_output: 97.5, expected_output_usd: 97.5 }, + }); + assert.ok(out.includes("2.50%"), `expected "2.50%" in plan output, got:\n${out}`); + assert.ok(!out.includes("+2.50%"), "loss must not be prefixed with +"); + assert.ok(!out.includes("-2.50%"), "loss must not render with a negative sign"); + }); }); // --------------------------------------------------------------------------- diff --git a/cli/utils/common/format.js b/cli/utils/common/format.js index e7d620df..d25cb7e4 100644 --- a/cli/utils/common/format.js +++ b/cli/utils/common/format.js @@ -291,14 +291,15 @@ function formatStatusCell(row) { function lossCell(value) { if (value == null) return "-"; - // Loss is reported as a fraction (0.05 = 5% loss). Render as a positive - // percent in red when > 0, green when ≤ 0 (the quote returns *more* USD). + // Loss is reported as a fraction (0.05 = 5% loss). Loss positive → red. + // Gain (negative loss) → green, displayed with a `+` prefix and the + // absolute magnitude so it reads as `+2.50%` rather than `+-2.50%`. const n = Number(value); if (!Number.isFinite(n)) return "-"; const pctValue = n * 100; const color = pctValue > 0 ? RED : GREEN; const sign = pctValue >= 0 ? "" : "+"; - return `${color}${sign}${pctValue.toFixed(2)}%${RESET}`; + return `${color}${sign}${Math.abs(pctValue).toFixed(2)}%${RESET}`; } export function formatConsolidatePlan(data) { diff --git a/cli/utils/trading/consolidate.js b/cli/utils/trading/consolidate.js index 06e21a94..71509dd1 100644 --- a/cli/utils/trading/consolidate.js +++ b/cli/utils/trading/consolidate.js @@ -771,17 +771,12 @@ export async function executeReadyRows( error: err?.message || String(err), }); // Recovery: we don't know how far into the approve/swap pair we got - // before the throw. Re-fetch `pending` so the next iteration's override - // is at least no worse than the original RPC-`latest` behaviour. - if (client) { - try { - nextNonce = Number( - await client.getTransactionCount({ address: walletAddress, blockTag: "pending" }), - ); - } catch { - // Leave nextNonce as-is; the loop continues. - } - } + // before the throw, and `pending` may transiently include the failed + // submission for several seconds. Invalidate the tracked counter so + // the next row falls back to RPC `latest` via the signer's default — + // we lose per-row batch protection for one row, but we don't compound + // a wrong counter across the rest of the batch. + nextNonce = null; } } return { results, summary: { succeeded, failed } }; diff --git a/cli/utils/trading/swap.js b/cli/utils/trading/swap.js index fbf61b35..afda833e 100644 --- a/cli/utils/trading/swap.js +++ b/cli/utils/trading/swap.js @@ -429,7 +429,13 @@ async function executeEvmSwap(quote, walletName, passphrase, zerionChainId, { ti data: swapTx.data, }); - const swapNonceOverride = approvalNonce != null ? approvalNonce + 1 : undefined; + // When approval is needed, the swap's nonce is approvalNonce + 1. When the + // allowance already covers the swap (no approval tx), the swap consumes the + // same nonce the batch reserved for the approval — fall through to the + // caller's override directly so back-to-back allowance-covered rows don't + // race RPC `latest`. + const swapNonceOverride = + approvalNonce != null ? approvalNonce + 1 : approvalNonceOverride; const { signedTxHex, client } = await signSwapTransaction( swapTx, zerionChainId, diff --git a/skills/zerion-consolidate/SKILL.md b/skills/zerion-consolidate/SKILL.md index 0d24f3c9..1ba2c162 100644 --- a/skills/zerion-consolidate/SKILL.md +++ b/skills/zerion-consolidate/SKILL.md @@ -54,7 +54,7 @@ Anything else fails with `target_token_not_found` and the error names the curate | `--exclude ` | _(none)_ | Comma-separated extra exclusions on top of defaults. | | `--include-native` | _(off)_ | Sweep the chain's native gas token (ETH/SOL/etc). | | `--gas-reserve ` | per-chain default | Native units to reserve when `--include-native` is on. Requires `--include-native`. | -| `--slippage ` | `2` | Per-quote slippage tolerance (max 3, same as swap). | +| `--slippage ` | `2` | Per-quote slippage tolerance, percent. Accepts 0–100; **values above ~5 burn substantial money across an N-position sweep** — keep low unless you know the destination is illiquid. | | `--concurrency ` | tier-aware (paid → 5, dev → 1) | Plan-phase quote-fetch concurrency. Integer `1..10`. Does NOT affect `--execute`; the broadcast phase is always sequential. | | `--wallet ` | default | Source wallet. | | `--timeout ` | `120` | Per-swap confirmation timeout. | @@ -191,7 +191,8 @@ By default the plan **excludes**: - **Max-loss filter is a backstop, not a cap.** A row marked `blocked: max_loss` will NOT be broadcast even with `--execute`. Tighten the filter for low-liquidity tokens with `--max-loss 2`. - **Sequential broadcast — no atomicity, regardless of `--concurrency`.** Plan-phase quote fetches may run in parallel (paid keys), but the `--execute` broadcast phase is always serial — parallel signed broadcasts would race EVM nonces. Each row's swap is an independent on-chain transaction. - **Partial success is the only mode.** Per-row failures during `--execute` are recorded with their full error string and the batch continues to the next row — one failing quote does not gate the rest of a sweep. The final result lists which tokens succeeded and which failed under a `Failures:` block; correlate by symbol back to the table above. -- **Fresh quotes on `--execute`.** The execute path re-fetches quotes at run time — but it does not re-prompt the operator. Treat `--execute` as a commitment to broadcast every ready row. +- **Quotes are taken from the plan phase, not re-fetched on `--execute`.** The execute path broadcasts the same quotes the plan just showed you, so the operator's read of the plan is what's signed. Staleness is bounded by `--slippage` and the on-chain `outputMin` returned by the quote API. Treat `--execute` as a commitment to broadcast every ready row. +- **`loss_pct` reports the quote's expected loss, not the on-chain floor.** Realized output is bounded below by `outputMin` (= `minimum_output_amount.quantity`), which sits ~`--slippage`% below the expected output. A row at `loss_pct = 4.9%` with default `--slippage 2` can land at ~6.9% realized loss. Tighten `--max-loss` for low-liquidity sweeps. - **One passphrase prompt for the whole batch.** The agent token is read once up-front; if you abort mid-batch, the remaining swaps will simply not run. ## Common errors