Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions ts/config/protocols/lending/felix_morpho.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,19 @@ feusde = "0x835FEBF893c6DdDee5CF762B0f8e31C5B06938ab"
feusdhl = "0x9c59a9389D8f72DE2CdAf1126F36EA4790E2275e"
feusdt0_frontier = "0x9896a8605763106e57A51aa0a97Fe8099E806bb3"
feusdhl_frontier = "0x66c71204B70aE27BE6dC3eb41F9aF5868E68fDb6"

# Named-market shortcuts so direct Morpho Blue ops can use a friendly name
# instead of the 32-byte hex. Source: blue-api.morpho.org, verified 2026-05-07.
[[protocol.markets]]
name = "WHYPE-USDT0"
id = "0xd5a9fba2309a0b85972a96f2cc45f9784e786d712944d8fc0b31a6d9cb4f21d3"
loan_asset = "USDT0"
collateral_asset = "WHYPE"
lltv = "770000000000000000"

[[protocol.markets]]
name = "wstHYPE-USDT0"
id = "0xf7d557e9a88edda08758188faf12aba046168de4a7abdb3a7919a019d884f9ba"
loan_asset = "USDT0"
collateral_asset = "wstHYPE"
lltv = "620000000000000000"
17 changes: 17 additions & 0 deletions ts/config/protocols/lending/morpho_blue_monad.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,20 @@ description = "Morpho Blue on Monad — permissionless lending primitive"

[protocol.contracts]
morpho_blue = "0xD5D960E8C380B724a48AC59E2DfF1b2CB4a1eAee"

# Named-market shortcuts so the CLI doesn't need a 32-byte hex on every call.
# Resolved against `markets[]` by MorphoBlueAdapter.resolveMarketIdByName().
# Source: blue-api.morpho.org, verified 2026-05-07.
[[protocol.markets]]
name = "WMON-AUSD"
id = "0xfa0b720389b546fcf8562c18cda8c00460072b63776add7fbfe8cd4f06d7c3ba"
loan_asset = "AUSD"
collateral_asset = "WMON"
lltv = "770000000000000000"

[[protocol.markets]]
name = "TETH-TUSD"
id = "0xfdfec1ced463198bba499c38a43c04a5919cab0472a298c4c68041b225d16563"
loan_asset = "TUSD"
collateral_asset = "TETH"
lltv = "860000000000000000"
16 changes: 16 additions & 0 deletions ts/packages/defi-cli/config/protocols/lending/felix_morpho.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,19 @@ feusde = "0x835FEBF893c6DdDee5CF762B0f8e31C5B06938ab"
feusdhl = "0x9c59a9389D8f72DE2CdAf1126F36EA4790E2275e"
feusdt0_frontier = "0x9896a8605763106e57A51aa0a97Fe8099E806bb3"
feusdhl_frontier = "0x66c71204B70aE27BE6dC3eb41F9aF5868E68fDb6"

# Named-market shortcuts so direct Morpho Blue ops can use a friendly name
# instead of the 32-byte hex. Source: blue-api.morpho.org, verified 2026-05-07.
[[protocol.markets]]
name = "WHYPE-USDT0"
id = "0xd5a9fba2309a0b85972a96f2cc45f9784e786d712944d8fc0b31a6d9cb4f21d3"
loan_asset = "USDT0"
collateral_asset = "WHYPE"
lltv = "770000000000000000"

[[protocol.markets]]
name = "wstHYPE-USDT0"
id = "0xf7d557e9a88edda08758188faf12aba046168de4a7abdb3a7919a019d884f9ba"
loan_asset = "USDT0"
collateral_asset = "wstHYPE"
lltv = "620000000000000000"
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,20 @@ description = "Morpho Blue on Monad — permissionless lending primitive"

[protocol.contracts]
morpho_blue = "0xD5D960E8C380B724a48AC59E2DfF1b2CB4a1eAee"

# Named-market shortcuts so the CLI doesn't need a 32-byte hex on every call.
# Resolved against `markets[]` by MorphoBlueAdapter.resolveMarketIdByName().
# Source: blue-api.morpho.org, verified 2026-05-07.
[[protocol.markets]]
name = "WMON-AUSD"
id = "0xfa0b720389b546fcf8562c18cda8c00460072b63776add7fbfe8cd4f06d7c3ba"
loan_asset = "AUSD"
collateral_asset = "WMON"
lltv = "770000000000000000"

[[protocol.markets]]
name = "TETH-TUSD"
id = "0xfdfec1ced463198bba499c38a43c04a5919cab0472a298c4c68041b225d16563"
loan_asset = "TUSD"
collateral_asset = "TETH"
lltv = "860000000000000000"
43 changes: 37 additions & 6 deletions ts/packages/defi-cli/src/commands/lending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,37 @@ function parseAmount(s: string): bigint {
return BigInt(s);
}

/**
* Resolve `--market` input — either a 32-byte hex marketId or a friendly
* name registered under `[[protocol.markets]]` in the protocol TOML. Names
* are case-insensitive (`WMON-AUSD` ≡ `wmon-ausd`). When the name doesn't
* match a registered market, surface a helpful error listing the valid
* choices instead of forwarding garbage to the adapter (which would later
* fail with a generic `idToMarketParams reverted`).
*/
function resolveMarketInput(
adapter: { resolveMarketIdByName?: (n: string) => `0x${string}` | null;
listNamedMarkets?: () => ReadonlyArray<{ name: string }> },
raw: string | undefined,
): MorphoMarketId | undefined {
if (!raw) return undefined;
// 32-byte hex passes through verbatim (66 chars including 0x).
if (/^0x[0-9a-fA-F]{64}$/.test(raw)) return raw as MorphoMarketId;
if (typeof adapter.resolveMarketIdByName !== "function") {
throw new Error(
`--market must be a 32-byte hex value; got '${raw}'. ` +
`This adapter does not support named-market lookup.`,
);
}
const id = adapter.resolveMarketIdByName(raw);
if (id) return id as MorphoMarketId;
const known = adapter.listNamedMarkets?.()?.map((m) => m.name).join(", ") ?? "(none)";
throw new Error(
`--market '${raw}' is not a 32-byte hex and is not registered on this protocol. ` +
`Known markets: ${known}.`,
);
}

export function registerLending(parent: Command, getOpts: () => OutputMode, makeExecutor: () => Executor): void {
const lending = parent.command("lending").description("Lending operations: supply, borrow, repay, withdraw, rates, position");

Expand Down Expand Up @@ -70,7 +101,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make
const onBehalfOf = resolveWallet(opts.onBehalfOf);
const tx = await adapter.buildSupply({
protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), on_behalf_of: onBehalfOf,
market_id: opts.market as MorphoMarketId | undefined,
market_id: resolveMarketInput(adapter as never, opts.market as string | undefined),
});
const result = await executor.execute(tx);
printOutput(result, getOpts());
Expand All @@ -95,7 +126,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make
protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount),
interest_rate_mode: opts.rateMode === "stable" ? InterestRateMode.Stable : InterestRateMode.Variable,
on_behalf_of: onBehalfOf,
market_id: opts.market as MorphoMarketId | undefined,
market_id: resolveMarketInput(adapter as never, opts.market as string | undefined),
});
const result = await executor.execute(tx);
printOutput(result, getOpts());
Expand All @@ -120,7 +151,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make
protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount),
interest_rate_mode: opts.rateMode === "stable" ? InterestRateMode.Stable : InterestRateMode.Variable,
on_behalf_of: onBehalfOf,
market_id: opts.market as MorphoMarketId | undefined,
market_id: resolveMarketInput(adapter as never, opts.market as string | undefined),
});
const result = await executor.execute(tx);
printOutput(result, getOpts());
Expand All @@ -142,7 +173,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make
const to = resolveWallet(opts.to);
const tx = await adapter.buildWithdraw({
protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), to,
market_id: opts.market as MorphoMarketId | undefined,
market_id: resolveMarketInput(adapter as never, opts.market as string | undefined),
});
const result = await executor.execute(tx);
printOutput(result, getOpts());
Expand Down Expand Up @@ -281,7 +312,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make
asset,
amount: parseAmount(opts.amount),
on_behalf_of: onBehalfOf,
market_id: opts.market as MorphoMarketId,
market_id: resolveMarketInput(adapter as never, opts.market as string)!,
});
const result = await executor.execute(tx);
printOutput(result, getOpts());
Expand Down Expand Up @@ -312,7 +343,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make
asset,
amount: parseAmount(opts.amount),
to,
market_id: opts.market as MorphoMarketId,
market_id: resolveMarketInput(adapter as never, opts.market as string)!,
});
const result = await executor.execute(tx);
printOutput(result, getOpts());
Expand Down
107 changes: 107 additions & 0 deletions ts/packages/defi-cli/src/commands/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,113 @@ describe("defi token transfer", () => {
});
});

describe("defi token wrap / unwrap — WETH9-shape calldata", () => {
// WETH9 fork selectors. Pinning these on every chain we support — pre-PR,
// the CLI had no way to drive deposit() / withdraw() at all and users had
// to hand-craft transactions. Bug-class: missing surface, silent fail.
const DEPOSIT_SELECTOR = "0xd0e30db0";
const WITHDRAW_SELECTOR = "0x2e1a7d4d";

it("wrap encodes deposit() with selector 0xd0e30db0 and routes value to wrapped_native", async () => {
const program = buildProgram();
const { capture, restore } = captureConsole();
try {
await program.parseAsync([
"node",
"defi",
"--json",
"--chain",
"monad",
"token",
"wrap",
"--amount",
"1000000000000000000",
]);
} finally {
restore();
}
const data = JSON.parse(capture.json.join("\n")) as {
details?: { to: string; data: Hex; value: string };
status?: string;
};
expect(data.status).toBe("dry_run");
// value carries the native amount (deposit() is payable; the chain
// credits the caller with 1:1 wrapped token via msg.value).
expect(data.details?.value).toBe("1000000000000000000");
expect(data.details?.data.toLowerCase()).toBe(DEPOSIT_SELECTOR);
// Target must be Monad's WMON, not the 0x0 sentinel.
expect(data.details?.to.toLowerCase()).toMatch(/^0x[0-9a-f]{40}$/);
expect(data.details?.to.toLowerCase()).not.toBe(
"0x0000000000000000000000000000000000000000",
);
});

it("unwrap encodes withdraw(uint256) with selector 0x2e1a7d4d and the exact amount", async () => {
const program = buildProgram();
const { capture, restore } = captureConsole();
try {
await program.parseAsync([
"node",
"defi",
"--json",
"--chain",
"monad",
"token",
"unwrap",
"--amount",
"1000000000000000000",
]);
} finally {
restore();
}
const data = JSON.parse(capture.json.join("\n")) as {
details?: { data: Hex; value: string };
};
// unwrap is non-payable: value MUST be 0 — sending native to withdraw()
// wastes gas and is a footgun; pin it.
expect(data.details?.value).toBe("0");
expect(data.details?.data.toLowerCase().slice(0, 10)).toBe(WITHDRAW_SELECTOR);
const decoded = decodeFunctionData({
abi: parseAbi(["function withdraw(uint256 amount)"]),
data: data.details!.data,
});
expect(decoded.functionName).toBe("withdraw");
const args = decoded.args as readonly unknown[];
expect(args[0]).toBe(1_000_000_000_000_000_000n);
});

it("wrap targets each chain's registered wrapped_native (not a hard-coded address)", async () => {
// Same flow on a different chain → different `to`. Validates the
// chain-registry lookup actually drives the target rather than a
// baked-in address.
const captured: Record<string, string> = {};
for (const chain of ["monad", "hyperevm", "bnb"]) {
const program = buildProgram();
const { capture, restore } = captureConsole();
try {
await program.parseAsync([
"node",
"defi",
"--json",
"--chain",
chain,
"token",
"wrap",
"--amount",
"1",
]);
} finally {
restore();
}
const data = JSON.parse(capture.json.join("\n")) as { details?: { to: string } };
captured[chain] = data.details!.to.toLowerCase();
}
expect(captured.monad).not.toBe(captured.hyperevm);
expect(captured.hyperevm).not.toBe(captured.bnb);
expect(captured.monad).not.toBe(captured.bnb);
});
});

describe("defi token balance / allowance — pre-RPC guards", () => {
it("balance: errors when neither --owner nor DEFI_WALLET_ADDRESS is set", async () => {
const program = buildProgram();
Expand Down
64 changes: 63 additions & 1 deletion ts/packages/defi-cli/src/commands/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { OutputMode } from "../output.js";
import type { Executor } from "../executor.js";
import { printOutput } from "../output.js";
import { Registry, buildApprove, buildTransfer, erc20Abi } from "@hypurrquant/defi-core";
import { createPublicClient, http, maxUint256 } from "viem";
import { createPublicClient, encodeFunctionData, http, maxUint256, parseAbi } from "viem";
import type { Address } from "viem";
import { requireChain, resolveTokenAddress } from "../utils.js";

Expand Down Expand Up @@ -85,6 +85,68 @@ export function registerToken(parent: Command, getOpts: () => OutputMode, makeEx
printOutput({ token: tokenAddr, owner, spender: opts.spender, allowance }, getOpts());
});

// WETH9-shape wrapped-native interface (deposit/withdraw). Every chain we
// support exposes `wrapped_native` in chains.toml as the WETH9 fork's
// address; selectors below are stable across that family:
// deposit() → 0xd0e30db0
// withdraw(uint256) → 0x2e1a7d4d
const WETH9_ABI = parseAbi([
"function deposit() payable",
"function withdraw(uint256 amount)",
]);

token
.command("wrap")
.description("Wrap native gas token into its ERC-20 wrapped form (WrappedNative.deposit())")
.requiredOption("--amount <amount>", "Amount of native token to wrap (in wei)")
.action(async (opts) => {
const executor = makeExecutor();
const chainName = requireChain(parent, getOpts);
if (!chainName) return;
const registry = Registry.loadEmbedded();
const chain = registry.getChain(chainName);
if (!chain.wrapped_native) {
printOutput({ error: `[${chainName}] no wrapped_native registered in chains.toml` }, getOpts());
return;
}
const amount = BigInt(opts.amount);
const data = encodeFunctionData({ abi: WETH9_ABI, functionName: "deposit" });
const result = await executor.execute({
description: `[${chainName}] Wrap ${opts.amount} ${chain.native_token} → W${chain.native_token}`,
to: chain.wrapped_native as Address,
data,
value: amount,
gas_estimate: 80_000,
});
printOutput(result, getOpts());
});

token
.command("unwrap")
.description("Unwrap wrapped-native ERC-20 back into native gas token (WrappedNative.withdraw(amount))")
.requiredOption("--amount <amount>", "Amount of wrapped token to unwrap (in wei)")
.action(async (opts) => {
const executor = makeExecutor();
const chainName = requireChain(parent, getOpts);
if (!chainName) return;
const registry = Registry.loadEmbedded();
const chain = registry.getChain(chainName);
if (!chain.wrapped_native) {
printOutput({ error: `[${chainName}] no wrapped_native registered in chains.toml` }, getOpts());
return;
}
const amount = BigInt(opts.amount);
const data = encodeFunctionData({ abi: WETH9_ABI, functionName: "withdraw", args: [amount] });
const result = await executor.execute({
description: `[${chainName}] Unwrap ${opts.amount} W${chain.native_token} → ${chain.native_token}`,
to: chain.wrapped_native as Address,
data,
value: 0n,
gas_estimate: 80_000,
});
printOutput(result, getOpts());
});

token
.command("transfer")
.description("Transfer tokens to an address")
Expand Down
Loading