diff --git a/ts/packages/defi-cli/src/agent.test.ts b/ts/packages/defi-cli/src/agent.test.ts new file mode 100644 index 0000000..2a199c4 --- /dev/null +++ b/ts/packages/defi-cli/src/agent.test.ts @@ -0,0 +1,149 @@ +// Unit tests for agent.ts handleSchema — the only exported entry point +// suitable for direct testing (runAgent reads from process.stdin). The +// schema switch sits at 63.81% line / 14.28% branch coverage in the +// 2026-05-17 sweep because most `case` arms are never exercised; this +// file walks every case so a future schema change can't drop a route +// silently. +import { describe, expect, it } from "vitest"; + +import { handleSchema } from "./agent.js"; + +interface ActionSchema { + action: string; + params: Record; + cli: string; +} + +interface ActionList { + actions: string[]; +} + +describe("agent.handleSchema — happy switch cases", () => { + it("status case returns a stub schema with no params", () => { + const r = handleSchema({ action: "status" }) as ActionSchema; + expect(r.action).toBe("status"); + expect(r.cli).toBe("defi status"); + expect(r.params).toEqual({}); + }); + + it("list_protocols case documents an optional category filter", () => { + const r = handleSchema({ action: "list_protocols" }) as ActionSchema; + expect(r.action).toBe("list_protocols"); + expect(r.params["category"]?.required).toBe(false); + expect(r.params["category"]?.description).toMatch(/category/i); + }); + + it("yield case defaults to USDC asset", () => { + const r = handleSchema({ action: "yield" }) as ActionSchema; + expect(r.action).toBe("yield"); + expect(r.params["asset"]?.default).toBe("USDC"); + expect(r.params["asset"]?.required).toBe(false); + }); + + it("lending.rates case requires chain + protocol + asset", () => { + const r = handleSchema({ action: "lending.rates" }) as ActionSchema; + expect(r.action).toBe("lending.rates"); + expect(r.params["chain"]?.required).toBe(true); + expect(r.params["protocol"]?.required).toBe(true); + expect(r.params["asset"]?.required).toBe(true); + expect(r.cli).toContain("lending rates"); + }); + + it.each([ + ["lending.supply", "supply"], + ["lending.borrow", "borrow"], + ["lending.repay", "repay"], + ["lending.withdraw", "withdraw"], + ])("%s collapses into the shared lending-action schema", (action, sub) => { + const r = handleSchema({ action }) as ActionSchema; + expect(r.action).toBe(action); + expect(r.params["amount"]?.required).toBe(true); + expect(r.cli).toContain(`lending ${sub}`); + }); + + it("lp.discover case requires chain, optional protocol filter", () => { + const r = handleSchema({ action: "lp.discover" }) as ActionSchema; + expect(r.action).toBe("lp.discover"); + expect(r.params["chain"]?.required).toBe(true); + expect(r.params["protocol"]?.required).toBe(false); + }); + + it("swap case defaults provider=kyber and slippage=50 bps", () => { + const r = handleSchema({ action: "swap" }) as ActionSchema; + expect(r.action).toBe("swap"); + expect(r.params["provider"]?.default).toBe("kyber"); + expect(r.params["slippage"]?.default).toBe("50"); + expect(r.cli).toContain("swap --from"); + }); + + // --- The two cases that were uncovered in the 2026-05-17 sweep --- + + it("price case requires chain + asset and emits the documented CLI example", () => { + const r = handleSchema({ action: "price" }) as ActionSchema; + expect(r.action).toBe("price"); + expect(r.params["chain"]?.required).toBe(true); + expect(r.params["asset"]?.required).toBe(true); + // Pin the documented example so agent docs / CLI help stay in sync. + expect(r.cli).toBe("defi --chain hyperevm price --asset WHYPE"); + }); + + it("bridge case requires chain/token/amount/to_chain", () => { + const r = handleSchema({ action: "bridge" }) as ActionSchema; + expect(r.action).toBe("bridge"); + expect(r.params["chain"]?.required).toBe(true); + expect(r.params["token"]?.required).toBe(true); + expect(r.params["amount"]?.required).toBe(true); + expect(r.params["to_chain"]?.required).toBe(true); + expect(r.cli).toContain("bridge --token"); + expect(r.cli).toContain("--to-chain"); + }); +}); + +describe("agent.handleSchema — default branch", () => { + it("returns the full action catalog for unknown actions", () => { + const r = handleSchema({ action: "no-such-action" }) as ActionList; + expect(Array.isArray(r.actions)).toBe(true); + // Must list every case we tested above. + for (const expected of [ + "status", + "list_protocols", + "schema", + "yield", + "lending.rates", + "lending.supply", + "lending.borrow", + "lending.repay", + "lending.withdraw", + "lp.discover", + "lp.add", + "lp.farm", + "lp.claim", + "lp.remove", + "swap", + "price", + "token.balance", + "token.approve", + "token.transfer", + "wallet.balance", + "portfolio.show", + "bridge", + ]) { + expect(r.actions).toContain(expected); + } + }); + + it("returns the catalog when no action is provided at all", () => { + const r = handleSchema({}) as ActionList; + expect(r.actions.length).toBeGreaterThan(0); + expect(r.actions).toContain("price"); + expect(r.actions).toContain("bridge"); + }); + + it("returns the catalog when action is a non-string value", () => { + // Hardens the `typeof params.action === 'string' ? ... : 'all'` guard at + // the top of handleSchema — a numeric / null / boolean action falls + // through to the default branch. + const r = handleSchema({ action: 42 as unknown as string }) as ActionList; + expect(r.actions).toContain("status"); + }); +}); diff --git a/ts/packages/defi-cli/src/commands/bridge-lifi.test.ts b/ts/packages/defi-cli/src/commands/bridge-lifi.test.ts new file mode 100644 index 0000000..9a92c41 --- /dev/null +++ b/ts/packages/defi-cli/src/commands/bridge-lifi.test.ts @@ -0,0 +1,364 @@ +// Unit tests for the LI.FI provider branch of `defi bridge` — +// bridge.ts lines 582-622, the default provider. The path was uncovered +// (61.19% line / 9.09% branch) in the 2026-05-17 sweep because LI.FI +// requires a live HTTP fetch. +// +// We stub globalThis.fetch with a canned quote response per the pattern in +// swap.test.ts and assert: +// 1. native input (token = 0x0…0) → no approvals[] entry +// 2. ERC20 input → approvals[0] populated with quote.estimate.approvalAddress +// 3. approvalAddress fallback to transactionRequest.to when omitted +// 4. quote without transactionRequest → "No LI.FI route found" envelope +// 5. fetch throws → caught + "LI.FI API error" envelope +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { registerBridge } from "./bridge.js"; +import { Executor } from "../executor.js"; +import { parseOutputMode } from "../output.js"; +import { TxStatus } from "@hypurrquant/defi-core"; +import type { DeFiTx } from "@hypurrquant/defi-core"; + +// --------------------------------------------------------------------------- +// Console capture (json/text envelopes are mixed; LI.FI prints to stdout). +// --------------------------------------------------------------------------- + +interface CapturedOutput { + json: string[]; + text: string[]; +} + +function captureConsole(): { capture: CapturedOutput; restore: () => void } { + const originalLog = console.log; + const originalErr = process.stderr.write.bind(process.stderr); + const capture: CapturedOutput = { json: [], text: [] }; + console.log = (msg?: unknown, ...rest: unknown[]) => { + const line = [msg, ...rest] + .map((m) => (typeof m === "string" ? m : JSON.stringify(m))) + .join(" "); + if (line.trim().startsWith("[") || line.trim().startsWith("{")) { + capture.json.push(line); + } else { + capture.text.push(line); + } + }; + process.stderr.write = ((chunk: string | Uint8Array) => { + capture.text.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }) as typeof process.stderr.write; + return { + capture, + restore: () => { + console.log = originalLog; + process.stderr.write = originalErr; + }, + }; +} + +// Executor.execute()'s dry-run result deliberately doesn't include the +// approvals[] the caller asked for — those are consumed by the executor's +// approval handling, which only fires under --broadcast. To assert on the +// approvals branch we wrap the executor in a tiny capture shim that +// records the most recent DeFiTx the handler passed in. +interface CapturedExecutor { + executor: Executor; + lastTx: () => DeFiTx | undefined; +} + +function makeCapturingExecutor(): CapturedExecutor { + let captured: DeFiTx | undefined; + const ex = new Executor(false); + // Override the instance method (shadows the prototype) so the handler's + // `await executor.execute(tx)` lands here. We return a minimal + // ActionResult-shaped object — the handler only forwards it under + // `action` in the printed envelope. + ex.execute = async (tx: DeFiTx) => { + captured = tx; + return { + tx_hash: undefined, + status: TxStatus.DryRun, + gas_used: tx.gas_estimate, + description: tx.description, + details: { + to: tx.to, + data: tx.data, + value: tx.value.toString(), + mode: "dry_run", + }, + }; + }; + return { + executor: ex, + lastTx: () => captured, + }; +} + +function buildProgram(executor: Executor): Command { + const program = new Command(); + program.exitOverride(); + program.option("--chain ", "Target chain"); + program.option("--json", "Output as JSON"); + program.option("--ndjson", "Output as newline-delimited JSON"); + program.option("--fields ", "Filter output fields"); + registerBridge( + program, + () => parseOutputMode(program.opts<{ json?: boolean; ndjson?: boolean; fields?: string }>()), + () => executor, + ); + return program; +} + +const origFetch = globalThis.fetch; +const ENV_KEYS = ["DEFI_WALLET_ADDRESS", "DEFI_PRIVATE_KEY"] as const; +let snapshot: Record = {}; + +beforeEach(() => { + snapshot = {}; + for (const k of ENV_KEYS) { + snapshot[k] = process.env[k]; + delete process.env[k]; + } + process.env["DEFI_WALLET_ADDRESS"] = "0x000000000000000000000000000000000000dEaD"; + globalThis.fetch = origFetch; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } + globalThis.fetch = origFetch; +}); + +function mockFetchOnce(response: unknown): void { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify(response), + json: async () => response, + })) as unknown as typeof fetch; +} + +function mockFetchThrows(error: Error): void { + globalThis.fetch = vi.fn(async () => { + throw error; + }) as unknown as typeof fetch; +} + +const ROUTER = "0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae"; +const APPROVAL_ADDRESS = "0xabcd00000000000000000000000000000000abcd"; +const USDC_HYPEREVM = "0xb88339cb7199b77e23db6e890353e22632ba630f"; +const FAKE_CALLDATA = "0xdeadbeef"; + +// --------------------------------------------------------------------------- +// Native input → empty approvals[] +// --------------------------------------------------------------------------- + +describe("defi bridge — LI.FI native input", () => { + it("passes empty approvals[] to executor when token is the 0x0 native sentinel", async () => { + mockFetchOnce({ + transactionRequest: { + to: ROUTER, + data: FAKE_CALLDATA, + value: "1000000000000000000", + }, + estimate: { toAmount: "999", approvalAddress: APPROVAL_ADDRESS }, + toolDetails: { name: "LI.FI/across" }, + }); + const { executor, lastTx } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "bridge", + "--token", + "0x0000000000000000000000000000000000000000", + "--amount", + "1000000000000000000", + "--to-chain", + "base", + ]); + } finally { + restore(); + } + // approvalAddress was supplied but native input must NOT include an + // approvals[] entry — there's no ERC20 to approve. + const tx = lastTx(); + expect(tx).toBeDefined(); + expect(tx!.approvals).toEqual([]); + expect(tx!.to.toLowerCase()).toBe(ROUTER.toLowerCase()); + expect(tx!.value).toBe(1_000_000_000_000_000_000n); + + const data = JSON.parse(capture.json.join("\n")) as { + bridge?: string; + estimated_output?: string; + }; + expect(data.bridge).toBe("LI.FI/across"); + expect(data.estimated_output).toBe("999"); + }); +}); + +// --------------------------------------------------------------------------- +// ERC20 input → approvals[0] populated with the canonical spender +// --------------------------------------------------------------------------- + +describe("defi bridge — LI.FI ERC20 input", () => { + it("passes approvals[0] with quote.estimate.approvalAddress when present", async () => { + mockFetchOnce({ + transactionRequest: { to: ROUTER, data: FAKE_CALLDATA, value: "0" }, + estimate: { toAmount: "999000", approvalAddress: APPROVAL_ADDRESS }, + toolDetails: { name: "LI.FI/hop" }, + }); + const { executor, lastTx } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "bridge", + "--asset", + "USDC", + "--amount", + "1000000", + "--to-chain", + "base", + ]); + } finally { + restore(); + } + const tx = lastTx(); + expect(tx).toBeDefined(); + expect(tx!.approvals?.length).toBe(1); + expect(tx!.approvals![0]!.token.toLowerCase()).toBe(USDC_HYPEREVM); + expect(tx!.approvals![0]!.spender.toLowerCase()).toBe( + APPROVAL_ADDRESS.toLowerCase(), + ); + expect(tx!.approvals![0]!.amount).toBe(1_000_000n); + + const data = JSON.parse(capture.json.join("\n")) as { + bridge?: string; + estimated_output?: string; + }; + expect(data.bridge).toBe("LI.FI/hop"); + expect(data.estimated_output).toBe("999000"); + }); + + it("uses transactionRequest.to as spender when approvalAddress is missing", async () => { + mockFetchOnce({ + transactionRequest: { to: ROUTER, data: FAKE_CALLDATA, value: "0" }, + // estimate without approvalAddress — pins the `?? quote.transactionRequest.to` + // expression in bridge.ts:598. Without that branch coverage, a refactor + // could silently drop the fallback and start emitting `approvals[0] + // .spender = undefined`, which would crash the executor's approval pass. + estimate: { toAmount: "500000" }, + }); + const { executor, lastTx } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "bridge", + "--token", + "USDC", + "--amount", + "1000000", + "--to-chain", + "base", + ]); + } finally { + restore(); + } + const tx = lastTx(); + expect(tx).toBeDefined(); + expect(tx!.approvals![0]!.spender.toLowerCase()).toBe(ROUTER.toLowerCase()); + + const data = JSON.parse(capture.json.join("\n")) as { bridge?: string }; + // bridge string degrades to bare "LI.FI" when toolDetails.name is absent. + expect(data.bridge).toBe("LI.FI"); + }); +}); + +// --------------------------------------------------------------------------- +// Quote errors +// --------------------------------------------------------------------------- + +describe("defi bridge — LI.FI quote errors", () => { + it("returns 'No LI.FI route found' when quote omits transactionRequest", async () => { + mockFetchOnce({ + // Simulate the LI.FI 'no route' shape — common when src/dst pair + // isn't supported (e.g. an obscure chain). + message: "No route found", + }); + const { executor } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "bridge", + "--token", + "USDC", + "--amount", + "1000000", + "--to-chain", + "base", + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { + error?: string; + details?: { message?: string }; + }; + expect(data.error).toBe("No LI.FI route found"); + // The original quote payload is bubbled up under `details` so the agent + // caller can inspect why LI.FI declined. + expect(data.details?.message).toBe("No route found"); + }); + + it("catches fetch failure and surfaces a 'LI.FI API error' envelope", async () => { + mockFetchThrows(new Error("ECONNREFUSED")); + const { executor } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "bridge", + "--token", + "USDC", + "--amount", + "1000000", + "--to-chain", + "base", + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(data.error).toMatch(/LI\.FI API error/i); + expect(data.error).toMatch(/ECONNREFUSED/); + }); +}); diff --git a/ts/packages/defi-cli/src/commands/lending-collateral.test.ts b/ts/packages/defi-cli/src/commands/lending-collateral.test.ts new file mode 100644 index 0000000..1598a77 --- /dev/null +++ b/ts/packages/defi-cli/src/commands/lending-collateral.test.ts @@ -0,0 +1,399 @@ +// Unit tests for `defi lending supply-collateral` and `defi lending +// withdraw-collateral` — the two Morpho-Blue-only subcommands at +// lending.ts:289-350 that were uncovered (53.28% line coverage) in the +// 2026-05-17 sweep. +// +// Both subcommands feature-detect the adapter: +// 1. if `adapter.buildSupplyCollateral` is not a function → error envelope +// 2. otherwise → build tx + executor.execute + print result +// +// We cover both paths. The default vi.mock returns a Morpho-shaped stub +// that exposes the collateral builders; a negative-path test swaps the +// mock for a stub WITHOUT those methods to exercise the feature-detect +// envelope. +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { maxUint256 } from "viem"; + +import { Executor } from "../executor.js"; +import { parseOutputMode } from "../output.js"; + +// --------------------------------------------------------------------------- +// Default mock: Morpho-Blue-shaped adapter that exposes the collateral +// builders. Each builder echoes the parsed amount + asset back as the +// description so we can pin the parser → adapter wiring without a real RPC. +// --------------------------------------------------------------------------- + +vi.mock("@hypurrquant/defi-protocols", () => { + const morphoStub = { + name: () => "stub-morpho", + async getRates() { + return {} as never; + }, + async getUserPosition() { + return {} as never; + }, + async buildSupply() { + return {} as never; + }, + async buildBorrow() { + return {} as never; + }, + async buildRepay() { + return {} as never; + }, + async buildWithdraw() { + return {} as never; + }, + async buildSupplyCollateral(p: { + amount: bigint; + asset: `0x${string}`; + market_id: `0x${string}`; + }) { + return { + description: `stub supplyCollateral ${p.amount} of ${p.asset} (market ${p.market_id})`, + to: "0x0000000000000000000000000000000000000099" as `0x${string}`, + data: "0x" as `0x${string}`, + value: 0n, + gas_estimate: 200_000, + }; + }, + async buildWithdrawCollateral(p: { + amount: bigint; + asset: `0x${string}`; + market_id: `0x${string}`; + }) { + return { + description: `stub withdrawCollateral ${p.amount} of ${p.asset} (market ${p.market_id})`, + to: "0x0000000000000000000000000000000000000099" as `0x${string}`, + data: "0x" as `0x${string}`, + value: 0n, + gas_estimate: 200_000, + }; + }, + }; + return { + createLending: vi.fn(() => morphoStub), + }; +}); + +const { createLending } = await import("@hypurrquant/defi-protocols"); +const mockedCreateLending = createLending as unknown as ReturnType; + +const { registerLending } = await import("./lending.js"); + +// --------------------------------------------------------------------------- +// Console capture + program builder (same pattern as lending.test.ts). +// --------------------------------------------------------------------------- + +interface CapturedOutput { + json: string[]; + text: string[]; +} + +function captureConsole(): { capture: CapturedOutput; restore: () => void } { + const originalLog = console.log; + const originalErr = process.stderr.write.bind(process.stderr); + const capture: CapturedOutput = { json: [], text: [] }; + console.log = (msg?: unknown, ...rest: unknown[]) => { + const line = [msg, ...rest] + .map((m) => (typeof m === "string" ? m : JSON.stringify(m))) + .join(" "); + if (line.trim().startsWith("[") || line.trim().startsWith("{")) { + capture.json.push(line); + } else { + capture.text.push(line); + } + }; + process.stderr.write = ((chunk: string | Uint8Array) => { + capture.text.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }) as typeof process.stderr.write; + return { + capture, + restore: () => { + console.log = originalLog; + process.stderr.write = originalErr; + }, + }; +} + +function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.option("--chain ", "Target chain"); + program.option("--json", "Output as JSON"); + program.option("--ndjson", "Output as newline-delimited JSON"); + program.option("--fields ", "Filter output fields"); + registerLending( + program, + () => parseOutputMode(program.opts<{ json?: boolean; ndjson?: boolean; fields?: string }>()), + () => new Executor(false), + ); + return program; +} + +const ENV_KEYS = ["DEFI_WALLET_ADDRESS", "DEFI_PRIVATE_KEY"] as const; +let snapshot: Record = {}; + +beforeEach(() => { + snapshot = {}; + for (const k of ENV_KEYS) { + snapshot[k] = process.env[k]; + delete process.env[k]; + } + process.env["DEFI_WALLET_ADDRESS"] = "0x000000000000000000000000000000000000dEaD"; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } + // Restore the default Morpho stub so a test that swapped it in via + // mockReturnValueOnce doesn't leak into the next test. + mockedCreateLending.mockClear(); +}); + +const PROTOCOL = "felix-morpho"; +const ASSET = "0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9"; +// Morpho marketId — must be 32-byte hex per resolveMarketInput; we use a +// deterministic stub value so the regex passes through verbatim. +const MARKET_ID = + "0x1111111111111111111111111111111111111111111111111111111111111111"; + +// --------------------------------------------------------------------------- +// supply-collateral — happy path + edge cases +// --------------------------------------------------------------------------- + +describe("defi lending supply-collateral — happy path", () => { + it("echoes the parsed amount and market_id through buildSupplyCollateral", async () => { + const program = buildProgram(); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "lending", + "supply-collateral", + "--protocol", + PROTOCOL, + "--asset", + ASSET, + "--amount", + "12345", + "--market", + MARKET_ID, + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { description?: string }; + expect(data.description).toContain("12345"); + expect(data.description).toContain(MARKET_ID); + expect(data.description).toMatch(/supplyCollateral/); + }); + + it("supply-collateral --amount max maps to type(uint256).max", async () => { + const program = buildProgram(); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "lending", + "supply-collateral", + "--protocol", + PROTOCOL, + "--asset", + ASSET, + "--amount", + "max", + "--market", + MARKET_ID, + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { description?: string }; + expect(data.description).toContain(String(maxUint256)); + }); + + it("supply-collateral honors --on-behalf-of (no exception even with arbitrary addr)", async () => { + const program = buildProgram(); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "lending", + "supply-collateral", + "--protocol", + PROTOCOL, + "--asset", + ASSET, + "--amount", + "1", + "--market", + MARKET_ID, + "--on-behalf-of", + "0x000000000000000000000000000000000000bEEF", + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { description?: string }; + expect(data.description).toMatch(/supplyCollateral/); + }); +}); + +// --------------------------------------------------------------------------- +// withdraw-collateral — happy path +// --------------------------------------------------------------------------- + +describe("defi lending withdraw-collateral — happy path", () => { + it("echoes the parsed amount and market_id through buildWithdrawCollateral", async () => { + const program = buildProgram(); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "lending", + "withdraw-collateral", + "--protocol", + PROTOCOL, + "--asset", + ASSET, + "--amount", + "99999", + "--market", + MARKET_ID, + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { description?: string }; + expect(data.description).toContain("99999"); + expect(data.description).toContain(MARKET_ID); + expect(data.description).toMatch(/withdrawCollateral/); + }); + + it("withdraw-collateral --amount max maps to type(uint256).max", async () => { + const program = buildProgram(); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "lending", + "withdraw-collateral", + "--protocol", + PROTOCOL, + "--asset", + ASSET, + "--amount", + "max", + "--market", + MARKET_ID, + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { description?: string }; + expect(data.description).toContain(String(maxUint256)); + }); +}); + +// --------------------------------------------------------------------------- +// Feature-detection error path — adapter that does NOT expose the collateral +// builders (e.g. Aave V3 / Compound V2). The handler should print a clear +// error envelope explaining which forks support the subcommand. +// --------------------------------------------------------------------------- + +describe("defi lending supply-collateral — feature detect (non-Morpho adapter)", () => { + it("emits a clear error envelope when adapter lacks buildSupplyCollateral", async () => { + // Swap the default Morpho stub for an Aave-shaped stub that omits the + // collateral builders. The handler's `typeof !== "function"` guard + // should fire and short-circuit before reaching adapter.buildSupplyCollateral. + mockedCreateLending.mockReturnValueOnce({ + name: () => "aave-shaped-stub", + // intentionally NO buildSupplyCollateral / buildWithdrawCollateral + }); + + const program = buildProgram(); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "lending", + "supply-collateral", + "--protocol", + PROTOCOL, + "--asset", + ASSET, + "--amount", + "1", + "--market", + MARKET_ID, + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(data.error).toMatch(/does not implement buildSupplyCollateral/i); + // The error should hint at which forks support it. + expect(data.error).toMatch(/Morpho|Aave|Compound/i); + }); + + it("emits a clear error envelope when adapter lacks buildWithdrawCollateral", async () => { + mockedCreateLending.mockReturnValueOnce({ + name: () => "aave-shaped-stub", + }); + + const program = buildProgram(); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "hyperevm", + "lending", + "withdraw-collateral", + "--protocol", + PROTOCOL, + "--asset", + ASSET, + "--amount", + "1", + "--market", + MARKET_ID, + ]); + } finally { + restore(); + } + const data = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(data.error).toMatch(/does not implement buildWithdrawCollateral/i); + }); +}); diff --git a/ts/packages/defi-cli/src/commands/lp-autopilot.test.ts b/ts/packages/defi-cli/src/commands/lp-autopilot.test.ts new file mode 100644 index 0000000..7fa02e7 --- /dev/null +++ b/ts/packages/defi-cli/src/commands/lp-autopilot.test.ts @@ -0,0 +1,553 @@ +// Unit tests for `defi lp autopilot` — covers lp.ts lines 1595-1891 which +// remained at 31.82% line coverage in the 2026-05-17 sweep. The autopilot +// handler is the largest still-untested handler in defi-cli and combines +// four code paths that all need wiring: +// +// 1. budget / whitelist validation guards +// 2. per-entry yield scan via the protocol adapter constructors +// 3. dry-run allocation plan (sort, max_allocation_pct cap, 20% reserve) +// 4. broadcast execution (lending = supply; LB / gauge / farming = skip) +// +// We vi.mock the whitelist loader and every adapter constructor that the +// scanner / executor reach for, so the test runs offline and deterministically. +// process.exit() in the error guards is intercepted via vi.spyOn — without +// that, the error-path tests would terminate the vitest worker. +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Executor } from "../executor.js"; +import { parseOutputMode } from "../output.js"; + +// --------------------------------------------------------------------------- +// vi.mock surface: must be hoisted, so declared before the dynamic import. +// --------------------------------------------------------------------------- + +vi.mock("../whitelist.js", () => ({ + // Default to empty; individual tests override via the mocked symbol below. + loadWhitelist: vi.fn(() => []), +})); + +vi.mock("@hypurrquant/defi-protocols", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLending: vi.fn(() => ({ + getRates: vi.fn(async () => ({ supply_apy: 0.05, borrow_apy: 0.1 })), + buildSupply: vi.fn(async (p: { asset: `0x${string}`; amount: bigint }) => ({ + description: `stub supply ${p.amount} of ${p.asset}`, + to: "0x0000000000000000000000000000000000000099" as `0x${string}`, + data: "0x" as `0x${string}`, + value: 0n, + gas_estimate: 100_000, + })), + })), + createMerchantMoeLB: vi.fn(() => ({ + discoverRewardedPools: vi.fn(async () => [ + { + pool: "0x000000000000000000000000000000000000abcd", + symbolX: "MOE", + symbolY: "USDC", + aprPercent: 25, + stopped: false, + }, + ]), + })), + createKittenSwapFarming: vi.fn(() => ({ + discoverFarmingPools: vi.fn(async () => [ + { pool: "0x000000000000000000000000000000000000beef", active: true }, + ]), + })), + createGauge: vi.fn(() => ({ + discoverGaugedPools: vi.fn(async () => [ + { + pool: "0x000000000000000000000000000000000000cafe", + token0: "TOK0", + token1: "TOK1", + }, + ]), + })), + }; +}); + +const { loadWhitelist } = await import("../whitelist.js"); +const mockedLoadWhitelist = loadWhitelist as unknown as ReturnType; + +const { registerLP } = await import("./lp.js"); + +// --------------------------------------------------------------------------- +// Console capture + program builder helpers (mirror lp-positions.test.ts). +// --------------------------------------------------------------------------- + +interface CapturedOutput { + json: string[]; + text: string[]; +} + +function captureConsole(): { capture: CapturedOutput; restore: () => void } { + const originalLog = console.log; + const originalErr = process.stderr.write.bind(process.stderr); + const capture: CapturedOutput = { json: [], text: [] }; + console.log = (msg?: unknown, ...rest: unknown[]) => { + const line = [msg, ...rest] + .map((m) => (typeof m === "string" ? m : JSON.stringify(m))) + .join(" "); + if (line.trim().startsWith("[") || line.trim().startsWith("{")) { + capture.json.push(line); + } else { + capture.text.push(line); + } + }; + process.stderr.write = ((chunk: string | Uint8Array) => { + capture.text.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }) as typeof process.stderr.write; + return { + capture, + restore: () => { + console.log = originalLog; + process.stderr.write = originalErr; + }, + }; +} + +function buildProgram(): Command { + // Note: we deliberately do NOT declare `--chain` on the root program here. + // The `lp autopilot` subcommand defines its own `--chain` flag (it filters + // the whitelist, not the executor's target chain), and commander would let + // a root-level `--chain` consume the value before the subcommand sees it. + // Other lp subcommands (positions, etc.) read `parent.opts().chain`, but + // this file only exercises autopilot — so omitting the root flag avoids + // the collision without affecting any production code path. + const program = new Command(); + program.exitOverride(); + program.option("--json", "Output as JSON"); + program.option("--ndjson", "Output as newline-delimited JSON"); + program.option("--fields ", "Filter output fields"); + registerLP( + program, + () => parseOutputMode(program.opts<{ json?: boolean; ndjson?: boolean; fields?: string }>()), + () => new Executor(false), + ); + return program; +} + +// process.exit(1) inside the autopilot guards would terminate the vitest +// worker; intercept it so the test can assert on the captured envelope and +// continue. The thrown sentinel is caught inside the `try`/`finally` below. +class ProcessExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super(`process.exit(${code ?? 0})`); + } +} + +function spyOnExit(): { restore: () => void } { + const spy = vi + .spyOn(process, "exit") + .mockImplementation(((code?: number) => { + throw new ProcessExitSignal(code); + }) as never); + return { + restore: () => spy.mockRestore(), + }; +} + +const ENV_KEYS = ["DEFI_WALLET_ADDRESS", "DEFI_PRIVATE_KEY"] as const; +let snapshot: Record = {}; + +beforeEach(() => { + snapshot = {}; + for (const k of ENV_KEYS) { + snapshot[k] = process.env[k]; + delete process.env[k]; + } + process.env["DEFI_WALLET_ADDRESS"] = "0x000000000000000000000000000000000000dEaD"; + // Reset whitelist mock so each test starts with an empty list unless it + // explicitly overrides via mockedLoadWhitelist.mockReturnValue(...). + mockedLoadWhitelist.mockReturnValue([]); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } +}); + +async function runAutopilot(args: string[]): Promise { + const program = buildProgram(); + const { capture, restore } = captureConsole(); + const exit = spyOnExit(); + try { + try { + await program.parseAsync(["node", "defi", ...args]); + } catch (e) { + if (!(e instanceof ProcessExitSignal)) throw e; + } + } finally { + restore(); + exit.restore(); + } + return capture; +} + +// --------------------------------------------------------------------------- +// Error guards (budget / whitelist / chain filter) +// --------------------------------------------------------------------------- + +describe("defi lp autopilot — guards", () => { + it("rejects an invalid budget (NaN) with a structured error and exits 1", async () => { + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "not-a-number", + ]); + const env = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(env.error).toMatch(/Invalid budget/i); + }); + + it("rejects a zero budget with a structured error and exits 1", async () => { + const capture = await runAutopilot(["--json", "lp", "autopilot", "--budget", "0"]); + const env = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(env.error).toMatch(/Invalid budget/i); + }); + + it("rejects a negative budget", async () => { + const capture = await runAutopilot(["--json", "lp", "autopilot", "--budget", "-100"]); + const env = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(env.error).toMatch(/Invalid budget/i); + }); + + it("errors when the whitelist is empty (no pools.toml)", async () => { + // mockedLoadWhitelist already returns [] via beforeEach reset. + const capture = await runAutopilot(["--json", "lp", "autopilot", "--budget", "1000"]); + const env = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(env.error).toMatch(/No pools whitelisted/i); + }); + + it("errors when --chain filter excludes every whitelist entry", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 50, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + "--chain", + "monad", + ]); + const env = JSON.parse(capture.json.join("\n")) as { error?: string }; + expect(env.error).toMatch(/No whitelisted pools found for chain 'monad'/i); + }); +}); + +// --------------------------------------------------------------------------- +// Happy path: dry-run allocation plan +// --------------------------------------------------------------------------- + +describe("defi lp autopilot — dry-run allocation plan", () => { + it("builds a plan with 20% reserve and lending entry APY scan", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 80, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + ]); + + interface Plan { + budget_usd: number; + deployable_usd: number; + reserve_pct: number; + allocations: Array>; + execution: string; + } + const plan = JSON.parse(capture.json.join("\n")) as Plan; + + expect(plan.budget_usd).toBe(1000); + expect(plan.reserve_pct).toBe(20); + expect(plan.deployable_usd).toBe(800); + expect(plan.execution).toBe("dry_run"); + // Two entries: the lending allocation, then the reserve sentinel. + expect(plan.allocations.length).toBe(2); + + const lending = plan.allocations[0]!; + expect(lending["protocol"]).toBe("felix-morpho"); + expect(lending["type"]).toBe("lending"); + // 80% cap is above the 80% deployable → spends the entire deployable. + expect(lending["amount_usd"]).toBe(800); + expect(lending["apy"]).toBe(0.05); + expect(lending["asset"]).toBe("USDC"); + + const reserve = plan.allocations[1]!; + expect(reserve["reserve"]).toBe(true); + expect(reserve["amount_usd"]).toBe(200); + }); + + it("caps allocation at max_allocation_pct and rolls the remainder into reserve", async () => { + // max_allocation_pct = 30 → can only take $300 of the $800 deployable. + // Remaining $500 falls back into the reserve entry alongside the base 20%. + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 30, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + ]); + const plan = JSON.parse(capture.json.join("\n")) as { + allocations: Array>; + }; + + const lending = plan.allocations[0]!; + expect(lending["amount_usd"]).toBe(300); + + const reserve = plan.allocations.find((a) => a["reserve"] === true)!; + // 20% base reserve ($200) + leftover ($500) = $700 + expect(reserve["amount_usd"]).toBe(700); + }); + + it("attaches scan_error when protocol slug is not registered on the chain", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + protocol: "not-a-real-protocol-slug", + asset: "USDC", + type: "lending", + max_allocation_pct: 50, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + ]); + const plan = JSON.parse(capture.json.join("\n")) as { + allocations: Array>; + }; + const entry = plan.allocations[0]!; + expect(entry["scan_error"]).toMatch(/Protocol not found/i); + }); + + it("attaches scan_error for an unknown chain in the whitelist entry", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "totally-fake-chain", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 50, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + ]); + const plan = JSON.parse(capture.json.join("\n")) as { + allocations: Array>; + }; + const entry = plan.allocations[0]!; + expect(entry["scan_error"]).toMatch(/Unknown chain/i); + }); + + it("--chain filter narrows the plan to entries on the requested chain", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 50, + }, + { + chain: "monad", + protocol: "should-not-appear", + asset: "USDC", + type: "lending", + max_allocation_pct: 50, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + "--chain", + "hyperevm", + ]); + const plan = JSON.parse(capture.json.join("\n")) as { + allocations: Array>; + }; + // 1 lending + 1 reserve entry, the monad row is filtered out. + expect(plan.allocations.length).toBe(2); + expect(plan.allocations[0]!["chain"]).toBe("hyperevm"); + }); + + it("marks unsupported entry types with scan_error", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + // `swap` is not one of the four handled types (lending/lb/farming/gauge) + // so the scanner returns the generic "Unsupported entry type" envelope. + protocol: "anything", + type: "swap" as never, + max_allocation_pct: 50, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + ]); + const plan = JSON.parse(capture.json.join("\n")) as { + allocations: Array>; + }; + const entry = plan.allocations[0]!; + expect(entry["scan_error"]).toMatch(/Unsupported entry type/i); + }); +}); + +// --------------------------------------------------------------------------- +// Broadcast execution path +// --------------------------------------------------------------------------- + +describe("defi lp autopilot — broadcast execution", () => { + it("executes lending allocations by calling buildSupply + executor.execute", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 80, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + "--broadcast", + ]); + + // Two JSON envelopes: the plan, then the execution_results. + expect(capture.json.length).toBeGreaterThanOrEqual(2); + const plan = JSON.parse(capture.json[0]!) as { + execution: string; + }; + expect(plan.execution).toBe("broadcast"); + + const execEnv = JSON.parse(capture.json[capture.json.length - 1]!) as { + execution_results: Array>; + }; + expect(execEnv.execution_results).toBeDefined(); + expect(execEnv.execution_results.length).toBe(1); + const exec = execEnv.execution_results[0]!; + expect(exec["protocol"]).toBe("felix-morpho"); + // Executor is dry-run (broadcast=false on the constructor) → status reflects + // the simulated path, but the important thing is that we reached + // executor.execute and got an exec_status field back. + expect(exec["exec_status"]).toBeDefined(); + }); + + it("warns and skips LB / farming / gauge entries (non-lending types) during broadcast", async () => { + mockedLoadWhitelist.mockReturnValue([ + { + chain: "hyperevm", + protocol: "merchant-moe", + pool: "MOE/USDC", + type: "lb", + max_allocation_pct: 80, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + "--broadcast", + ]); + + const execEnv = JSON.parse(capture.json[capture.json.length - 1]!) as { + execution_results: Array>; + }; + const exec = execEnv.execution_results[0]!; + expect(exec["exec_status"]).toBe("skipped"); + expect(exec["exec_note"]).toMatch(/requires manual token preparation/i); + }); + + it("skips and reports unknown chains during broadcast without aborting the loop", async () => { + mockedLoadWhitelist.mockReturnValue([ + // First entry: unknown chain. Should be reported but not stop the run. + // Second entry: a valid lending row that still gets attempted. + { + chain: "ghost-chain", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 40, + }, + { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + type: "lending", + max_allocation_pct: 40, + }, + ]); + const capture = await runAutopilot([ + "--json", + "lp", + "autopilot", + "--budget", + "1000", + "--broadcast", + ]); + const execEnv = JSON.parse(capture.json[capture.json.length - 1]!) as { + execution_results: Array>; + }; + // 2 allocations attempted; one skipped (unknown chain), one executed. + expect(execEnv.execution_results.length).toBe(2); + const ghost = execEnv.execution_results.find( + (r) => r["chain"] === "ghost-chain", + ); + expect(ghost?.["exec_status"]).toBe("skipped"); + expect(ghost?.["exec_error"]).toMatch(/Unknown chain/i); + }); +}); diff --git a/ts/packages/defi-cli/src/commands/swap-openocean.test.ts b/ts/packages/defi-cli/src/commands/swap-openocean.test.ts new file mode 100644 index 0000000..0e5a74d --- /dev/null +++ b/ts/packages/defi-cli/src/commands/swap-openocean.test.ts @@ -0,0 +1,256 @@ +// Branch coverage tests for `defi swap --provider openocean` — pins the +// `isNativeInput ? {} : { approvals[]: ... }` split at swap.ts:354-365 + +// the symbol-vs-address `--from` lookup at swap.ts:331-333. swap.test.ts +// already covers the native-symbol path (MON → USDC); these tests +// complete the matrix: ERC20→ERC20, 0x-prefixed `--from`, and the +// 0xeeee… sentinel as native input. +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Executor } from "../executor.js"; +import { parseOutputMode } from "../output.js"; +import { registerSwap } from "./swap.js"; +import { TxStatus } from "@hypurrquant/defi-core"; +import type { DeFiTx } from "@hypurrquant/defi-core"; + +interface CapturedOutput { + json: string[]; + text: string[]; +} + +function captureConsole(): { capture: CapturedOutput; restore: () => void } { + const originalLog = console.log; + const originalErr = process.stderr.write.bind(process.stderr); + const capture: CapturedOutput = { json: [], text: [] }; + console.log = (msg?: unknown, ...rest: unknown[]) => { + const line = [msg, ...rest] + .map((m) => (typeof m === "string" ? m : JSON.stringify(m))) + .join(" "); + if (line.trim().startsWith("[") || line.trim().startsWith("{")) { + capture.json.push(line); + } else { + capture.text.push(line); + } + }; + process.stderr.write = ((chunk: string | Uint8Array) => { + capture.text.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }) as typeof process.stderr.write; + return { + capture, + restore: () => { + console.log = originalLog; + process.stderr.write = originalErr; + }, + }; +} + +// As in bridge-lifi.test.ts — Executor.execute() in dry-run mode strips the +// approvals[] from its return shape, so we wrap the executor to capture the +// inbound DeFiTx and assert on its approvals directly. +interface CapturedExecutor { + executor: Executor; + lastTx: () => DeFiTx | undefined; +} + +function makeCapturingExecutor(): CapturedExecutor { + let captured: DeFiTx | undefined; + const ex = new Executor(false); + ex.execute = async (tx: DeFiTx) => { + captured = tx; + return { + tx_hash: undefined, + status: TxStatus.DryRun, + gas_used: tx.gas_estimate, + description: tx.description, + details: { + to: tx.to, + data: tx.data, + value: tx.value.toString(), + mode: "dry_run", + }, + }; + }; + return { executor: ex, lastTx: () => captured }; +} + +function buildProgram(executor: Executor): Command { + const program = new Command(); + program.exitOverride(); + program.option("--chain ", "Target chain"); + program.option("--json", "Output as JSON"); + program.option("--ndjson", "Output as newline-delimited JSON"); + program.option("--fields ", "Filter output fields"); + registerSwap( + program, + () => parseOutputMode(program.opts<{ json?: boolean; ndjson?: boolean; fields?: string }>()), + () => executor, + ); + return program; +} + +const origFetch = globalThis.fetch; +const ENV_KEYS = ["DEFI_WALLET_ADDRESS", "DEFI_PRIVATE_KEY"] as const; +let snapshot: Record = {}; + +beforeEach(() => { + snapshot = {}; + for (const k of ENV_KEYS) { + snapshot[k] = process.env[k]; + delete process.env[k]; + } + process.env["DEFI_WALLET_ADDRESS"] = "0x000000000000000000000000000000000000dEaD"; + globalThis.fetch = origFetch; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } + globalThis.fetch = origFetch; +}); + +function mockOpenoceanResponse(response: Record): void { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify(response), + json: async () => response, + })) as unknown as typeof fetch; +} + +const ROUTER = "0x6352a56caadC4F1E25CD6c75970Fa768A3304e64"; +const FAKE_CALLDATA = "0xdeadbeef"; +const USDC_MONAD = "0x754704bc059f8c67012fed69bc8a327a5aafb603"; +const EEEE_SENTINEL = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + +// --------------------------------------------------------------------------- +// ERC20 input → approvals[] populated (covers swap.ts:364) +// --------------------------------------------------------------------------- + +describe("defi swap --provider openocean — ERC20 input", () => { + it("emits a single approvals[] entry with spender = router when --from is an ERC20", async () => { + mockOpenoceanResponse({ + data: { to: ROUTER, data: FAKE_CALLDATA, value: "0", outAmount: "987654" }, + }); + const { executor, lastTx } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "monad", + "swap", + "--from", + "USDC", + "--to", + "MON", + "--amount", + "1000000", + "--provider", + "openocean", + ]); + } finally { + restore(); + } + const tx = lastTx(); + expect(tx).toBeDefined(); + expect(tx!.approvals?.length).toBe(1); + expect(tx!.approvals![0]!.token.toLowerCase()).toBe(USDC_MONAD); + expect(tx!.approvals![0]!.spender.toLowerCase()).toBe(ROUTER.toLowerCase()); + expect(tx!.approvals![0]!.amount).toBe(1_000_000n); + + const data = JSON.parse(capture.json.join("\n")) as { + provider?: string; + amount_out?: string; + }; + expect(data.provider).toBe("openocean"); + expect(data.amount_out).toBe("987654"); + }); + + it("0x-prefixed --from is looked up by address (covers swap.ts:331-332)", async () => { + mockOpenoceanResponse({ + data: { to: ROUTER, data: FAKE_CALLDATA, value: "0", outAmount: "111" }, + }); + const { executor, lastTx } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "monad", + "swap", + "--from", + USDC_MONAD, // address, not symbol → exercises the startsWith("0x") arm + "--to", + "MON", + "--amount", + "1000000", + "--provider", + "openocean", + ]); + } finally { + restore(); + } + const tx = lastTx(); + expect(tx).toBeDefined(); + // Non-native ERC20 → approvals[] populated. + expect(tx!.approvals?.length).toBe(1); + expect(tx!.approvals![0]!.token.toLowerCase()).toBe(USDC_MONAD); + + const data = JSON.parse(capture.json.join("\n")) as { amount_out?: string }; + expect(data.amount_out).toBe("111"); + }); +}); + +// --------------------------------------------------------------------------- +// 0xeeee… sentinel as native input (covers the second arm of isNativeInput +// at swap.ts:356) — the existing swap.test.ts already covers the symbol +// (MON) path; this pins the alias for callers that pass the canonical +// 1inch/KyberSwap/OpenOcean sentinel directly. +// --------------------------------------------------------------------------- + +describe("defi swap --provider openocean — 0xeeee native sentinel", () => { + it("omits approvals[] when --from is the 0xeeee sentinel", async () => { + mockOpenoceanResponse({ + data: { to: ROUTER, data: FAKE_CALLDATA, value: "0", outAmount: "42" }, + }); + const { executor, lastTx } = makeCapturingExecutor(); + const program = buildProgram(executor); + const { capture, restore } = captureConsole(); + try { + await program.parseAsync([ + "node", + "defi", + "--json", + "--chain", + "monad", + "swap", + "--from", + EEEE_SENTINEL, + "--to", + "USDC", + "--amount", + "1000000000000000000", + "--provider", + "openocean", + ]); + } finally { + restore(); + } + const tx = lastTx(); + expect(tx).toBeDefined(); + // Native input → no approvals. + expect(tx!.approvals).toBeUndefined(); + + const data = JSON.parse(capture.json.join("\n")) as { provider?: string }; + expect(data.provider).toBe("openocean"); + }); +}); diff --git a/ts/packages/defi-cli/src/mcp-server-tools.test.ts b/ts/packages/defi-cli/src/mcp-server-tools.test.ts new file mode 100644 index 0000000..a6b7862 --- /dev/null +++ b/ts/packages/defi-cli/src/mcp-server-tools.test.ts @@ -0,0 +1,455 @@ +// Unit tests for the MCP tool handler bodies in mcp-server.ts. +// +// The pre-existing mcp-server.test.ts only verifies the envelope helpers +// (ok/err) and the server constructor — line coverage for the 22 tool +// handlers themselves was 21.9% at the 2026-05-17 sweep. +// +// We can't easily run a full MCP client in-process to call tools by name, +// so this file intercepts McpServer.tool() at module load via vi.mock, +// captures every registered handler into a Map, and then invokes the +// handlers directly with the same shape an MCP transport would deliver +// (just the destructured params object). +// +// Adapter constructors from @hypurrquant/defi-protocols are mocked so the +// handlers never touch real RPC. viem.createPublicClient is mocked for +// defi_lp_positions (the largest single uncovered handler, lines 1545-1663). +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Capture every registered tool handler so we can invoke them directly. +// --------------------------------------------------------------------------- + +type ToolHandler = (args: Record) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; +}>; + +const registeredTools = new Map(); + +vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => { + return { + McpServer: class { + // The real SDK exposes a `tool(name, desc, schema, handler)` registration + // method. We just stash the handler in our module-scoped map; tests + // dispatch by name via callTool() below. + tool(name: string, _desc: string, _schema: unknown, handler: ToolHandler) { + registeredTools.set(name, handler); + return {} as unknown; + } + async connect() { + /* no-op — the import guard in mcp-server.ts skips connect() under vitest */ + } + }, + }; +}); + +vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ + StdioServerTransport: class {}, +})); + +// --------------------------------------------------------------------------- +// Adapter mocks. These are constructors that the tool handlers call inside +// their try/catch — returning stub adapters keeps the handlers offline. +// --------------------------------------------------------------------------- + +vi.mock("@hypurrquant/defi-protocols", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createLending: vi.fn(() => ({ + name: () => "stub-lending", + getRates: vi.fn(async () => ({ + supply_apy: 0.05, + borrow_apy: 0.1, + utilization: 0.6, + })), + buildSupply: vi.fn( + async (p: { amount: bigint; asset: `0x${string}` }) => ({ + description: `stub supply ${p.amount} of ${p.asset}`, + to: "0x0000000000000000000000000000000000000099" as `0x${string}`, + data: "0x" as `0x${string}`, + value: 0n, + gas_estimate: 100_000, + }), + ), + buildWithdraw: vi.fn( + async (p: { amount: bigint; asset: `0x${string}` }) => ({ + description: `stub withdraw ${p.amount} of ${p.asset}`, + to: "0x0000000000000000000000000000000000000099" as `0x${string}`, + data: "0x" as `0x${string}`, + value: 0n, + gas_estimate: 100_000, + }), + ), + })), + createDex: vi.fn(() => ({ + // amount_out is returned as a string so the MCP envelope's + // JSON.stringify doesn't trip on a bigint. The real adapter + // implementations may return either shape; the handler under test + // just forwards the object, so the choice doesn't constrain prod. + quote: vi.fn(async () => ({ + amount_out: "12345", + price_impact: 0.001, + })), + })), + createMerchantMoeLB: vi.fn(() => ({ + discoverRewardedPools: vi.fn(async () => []), + findUserBinsWithBalance: vi.fn(async () => []), + getUserPositions: vi.fn(async () => []), + getPendingRewards: vi.fn(async () => []), + })), + }; +}); + +// Mock viem so defi_lp_positions's NFT enumeration short-circuits without RPC. +vi.mock("viem", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: () => ({ + readContract: vi.fn(async () => 0n), + }), + http: () => () => ({}), + }; +}); + +// Triggering the import after vi.mock is set up registers all tools into our +// captured map. The module's top-level guard prevents stdio.connect() from +// running under vitest, so this is safe and offline. +await import("./mcp-server.js"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface OkEnvelope { + ok: true; + data: T; + meta?: Record; +} +interface ErrEnvelope { + ok: false; + error: string; + meta?: Record; +} + +async function callTool( + name: string, + args: Record, +): Promise<{ + payload: OkEnvelope | ErrEnvelope; + isError: boolean; +}> { + // We don't return `ok` as a separate field even though it's tempting — + // destructuring `ok` strips the discriminant from the union, leaving + // `payload` untyped at the call site. Forcing callers to read + // `payload.ok` keeps TypeScript narrowing usable + // (`if (!payload.ok) throw …` lets the rest of the block see OkEnvelope). + const handler = registeredTools.get(name); + if (!handler) throw new Error(`tool '${name}' was never registered`); + const result = await handler(args); + const text = result.content[0]?.text ?? ""; + const payload = JSON.parse(text) as OkEnvelope | ErrEnvelope; + return { payload, isError: !!result.isError }; +} + +const ENV_KEYS = ["DEFI_WALLET_ADDRESS", "DEFI_PRIVATE_KEY"] as const; +let snapshot: Record = {}; + +beforeEach(() => { + snapshot = {}; + for (const k of ENV_KEYS) { + snapshot[k] = process.env[k]; + delete process.env[k]; + } + process.env["DEFI_WALLET_ADDRESS"] = + "0x000000000000000000000000000000000000dEaD"; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } +}); + +// --------------------------------------------------------------------------- +// Tool registry assertions — confirm every handler we plan to test is wired up. +// --------------------------------------------------------------------------- + +describe("MCP server registration", () => { + it("registers all 22 tools at module load time", () => { + expect(registeredTools.size).toBeGreaterThanOrEqual(22); + }); + + it.each([ + "defi_status", + "defi_lending_rates", + "defi_lending_supply", + "defi_lending_withdraw", + "defi_dex_quote", + "defi_lp_positions", + ])("includes %s", (toolName) => { + expect(registeredTools.has(toolName)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// defi_status — chain + protocol enumeration +// --------------------------------------------------------------------------- + +describe("defi_status handler", () => { + it("returns chain_id + protocol list for a known chain", async () => { + const { payload } = await callTool<{ + chain: string; + chain_id: number; + protocols: Array<{ slug: string; name: string }>; + summary: { total_protocols: number }; + }>("defi_status", { chain: "hyperevm" }); + + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error("expected ok envelope"); + expect(payload.data.chain).toBe("hyperevm"); + expect(payload.data.chain_id).toBeGreaterThan(0); + expect(payload.data.protocols.length).toBeGreaterThan(0); + expect(payload.data.summary.total_protocols).toBe(payload.data.protocols.length); + }); + + it("defaults to hyperevm when chain is omitted", async () => { + const { payload } = await callTool<{ chain: string }>( + "defi_status", + {}, + ); + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error(); + expect(payload.data.chain).toBe("hyperevm"); + }); + + it("returns an err envelope (isError: true) for an unknown chain", async () => { + const { payload, isError } = await callTool("defi_status", { + chain: "totally-fake-chain-xyz", + }); + expect(payload.ok).toBe(false); + expect(isError).toBe(true); + if (payload.ok) throw new Error("expected err envelope"); + expect(payload.error).toMatch(/chain|unknown|not found/i); + }); +}); + +// --------------------------------------------------------------------------- +// defi_lending_rates — adapter.getRates round-trip +// --------------------------------------------------------------------------- + +describe("defi_lending_rates handler", () => { + it("returns the stub adapter's rates payload", async () => { + const { payload } = await callTool<{ + supply_apy: number; + borrow_apy: number; + }>("defi_lending_rates", { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + }); + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error(); + expect(payload.data.supply_apy).toBe(0.05); + expect(payload.data.borrow_apy).toBe(0.1); + }); + + it("err envelope for unknown protocol", async () => { + const { payload, isError } = await callTool("defi_lending_rates", { + chain: "hyperevm", + protocol: "this-protocol-does-not-exist", + asset: "USDC", + }); + expect(payload.ok).toBe(false); + expect(isError).toBe(true); + if (payload.ok) throw new Error(); + expect(payload.error).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// defi_lending_supply — dry-run round-trip through executor +// --------------------------------------------------------------------------- + +describe("defi_lending_supply handler", () => { + it("returns an executor preview for the stub adapter (broadcast omitted = dry run)", async () => { + const { payload } = await callTool<{ + status: string; + tx?: { description?: string; to?: string }; + }>("defi_lending_supply", { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + amount: "1000000", + }); + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error(); + // Executor.execute in dry-run mode returns an ActionResult whose + // .tx field includes our stub's description. + expect(payload.data.status).toBeDefined(); + }); + + it("threads on_behalf_of into the buildSupply call without throwing", async () => { + const { payload } = await callTool("defi_lending_supply", { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + amount: "500", + on_behalf_of: "0x000000000000000000000000000000000000bEEF", + }); + expect(payload.ok).toBe(true); + }); + + it("err envelope for unknown protocol", async () => { + const { payload, isError } = await callTool("defi_lending_supply", { + chain: "hyperevm", + protocol: "nope", + asset: "USDC", + amount: "1", + }); + expect(payload.ok).toBe(false); + expect(isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// defi_lending_withdraw +// --------------------------------------------------------------------------- + +describe("defi_lending_withdraw handler", () => { + it("returns an executor preview for the withdraw path", async () => { + const { payload } = await callTool<{ status: string }>( + "defi_lending_withdraw", + { + chain: "hyperevm", + protocol: "felix-morpho", + asset: "USDC", + amount: "500000", + }, + ); + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error(); + expect(payload.data.status).toBeDefined(); + }); + + it("err envelope for unknown chain", async () => { + const { payload, isError } = await callTool("defi_lending_withdraw", { + chain: "ghost-chain", + protocol: "felix-morpho", + asset: "USDC", + amount: "1", + }); + expect(payload.ok).toBe(false); + expect(isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// defi_dex_quote — adapter.quote round-trip +// --------------------------------------------------------------------------- + +describe("defi_dex_quote handler", () => { + it("returns the stub adapter's quote (amount_out + price_impact)", async () => { + // Use a known DEX protocol slug on hyperevm. The stub adapter doesn't + // care which slug we use — only that registry.getProtocol(slug) succeeds. + const { payload } = await callTool<{ + amount_out: string; + price_impact: number; + }>("defi_dex_quote", { + chain: "hyperevm", + protocol: "kittenswap", + token_in: "USDC", + token_out: "WHYPE", + amount_in: "1000000", + }); + // The handler may serialise bigint via JSON; we accept either string or + // number representation. The important thing is the round trip succeeded. + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error(); + expect(payload.data.price_impact).toBe(0.001); + }); + + it("err envelope for unknown chain", async () => { + const { payload, isError } = await callTool("defi_dex_quote", { + chain: "ghost-chain", + protocol: "kittenswap", + token_in: "USDC", + token_out: "WHYPE", + amount_in: "1", + }); + expect(payload.ok).toBe(false); + expect(isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// defi_lp_positions — the largest single uncovered handler (lines 1545-1663). +// --------------------------------------------------------------------------- + +describe("defi_lp_positions handler", () => { + it("returns empty positions when every protocol's balanceOf returns 0n", async () => { + const { payload } = await callTool<{ + chain: string; + positions: Array; + total: number; + }>("defi_lp_positions", { + chain: "hyperevm", + address: "0x000000000000000000000000000000000000bEEF", + }); + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error(); + expect(payload.data.chain).toBe("hyperevm"); + expect(payload.data.positions).toEqual([]); + expect(payload.data.total).toBe(0); + // Meta should report the wallet + scanned_protocols count. + expect(payload.meta?.["scanned_protocols"]).toBeGreaterThan(0); + }); + + it("defaults to DEFI_WALLET_ADDRESS when address is omitted", async () => { + // beforeEach sets DEFI_WALLET_ADDRESS, so the handler should resolve and + // succeed even with no `address` arg. (Address-required error path is + // covered by the next test.) + const { payload } = await callTool("defi_lp_positions", { + chain: "hyperevm", + }); + expect(payload.ok).toBe(true); + }); + + it("rejects when no address is given and DEFI_WALLET_ADDRESS is unset", async () => { + delete process.env["DEFI_WALLET_ADDRESS"]; + const { payload, isError } = await callTool("defi_lp_positions", { + chain: "hyperevm", + }); + expect(payload.ok).toBe(false); + expect(isError).toBe(true); + if (payload.ok) throw new Error(); + expect(payload.error).toMatch(/address required/i); + }); + + it("--protocol filter narrows enumeration to a single protocol", async () => { + const { payload } = await callTool<{ + chain: string; + positions: Array; + }>("defi_lp_positions", { + chain: "hyperevm", + protocol: "kittenswap", + address: "0x000000000000000000000000000000000000bEEF", + }); + expect(payload.ok).toBe(true); + if (!payload.ok) throw new Error(); + expect(payload.data.positions).toEqual([]); + expect(payload.meta?.["scanned_protocols"]).toBe(1); + }); + + it("err envelope for unknown chain", async () => { + const { payload, isError } = await callTool("defi_lp_positions", { + chain: "ghost-chain", + address: "0x000000000000000000000000000000000000bEEF", + }); + expect(payload.ok).toBe(false); + expect(isError).toBe(true); + }); +});