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
50 changes: 50 additions & 0 deletions ts/packages/defi-cli/src/commands/lending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,56 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make
printOutput(result, getOpts());
});

lending.command("enter-markets")
.description("Compound V2 (Venus): enter supplied assets as collateral via Comptroller.enterMarkets")
.requiredOption("--protocol <protocol>", "Protocol slug (must be a Compound V2 fork)")
.requiredOption("--asset <token>", "Underlying asset symbol or address (resolved to its cToken)")
.action(async (opts) => {
const executor = makeExecutor();
const ctx = resolveContext(parent, getOpts, opts.protocol);
if (!ctx) return;
const adapter = createLending(ctx.protocol!, ctx.rpcUrl);
if (typeof adapter.buildEnterMarkets !== "function") {
printOutput({
error: `[${ctx.protocol!.name}] adapter does not implement buildEnterMarkets. ` +
`This is a Compound V2 family operation; Aave V3 uses toggle-collateral instead.`,
}, getOpts());
return;
}
// Resolve underlying asset → cToken via the protocol entry's contracts.
// Compound V2 vTokens are registered under names like vusdt/vusdc/vbnb.
const asset = resolveTokenAddress(ctx.registry, ctx.chainName, opts.asset);
const contracts = (ctx.protocol!.contracts ?? {}) as Record<string, Address>;
// Try matching by underlying address via the adapter's internal cache —
// expose via an opt-in cast (the cache is private, so we resort to a
// straightforward grep over the registered vTokens).
const vTokenEntries = Object.entries(contracts).filter(([k]) => /^v[a-z][a-z0-9]*$/i.test(k));
if (vTokenEntries.length === 0) {
printOutput({ error: `[${ctx.protocol!.name}] no vTokens registered in TOML` }, getOpts());
return;
}
// We can't introspect vToken.underlying() without RPC; require the user to
// pass --asset matching one of the vToken keys (e.g. --asset USDT → vusdt).
// For convenience, try matching the asset symbol against vToken keys.
const symbol = (opts.asset as string).toLowerCase();
const matchedKey = vTokenEntries.find(([k]) => k.toLowerCase() === `v${symbol}`);
const vToken = matchedKey ? (matchedKey[1] as Address) : undefined;
if (!vToken) {
printOutput({
error: `[${ctx.protocol!.name}] could not resolve a vToken for '${opts.asset}'. ` +
`Registered vTokens: ${vTokenEntries.map(([k]) => k).join(", ")}. ` +
`Pass --asset matching the symbol after the 'v' prefix (e.g. USDT for vusdt).`,
}, getOpts());
return;
}
// asset arg silenced (used only for symbol matching above); reference it
// with a noop so the unused-var lint is happy without disabling the rule.
void asset;
const tx = await adapter.buildEnterMarkets([vToken]);
const result = await executor.execute(tx);
printOutput(result, getOpts());
});

lending.command("supply-collateral")
.description("Supply the collateral side of a Morpho Blue market (different selector from supply)")
.requiredOption("--protocol <protocol>", "Protocol slug (must be a Morpho Blue adapter)")
Expand Down
9 changes: 9 additions & 0 deletions ts/packages/defi-core/src/traits/lending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,13 @@ export interface ILending {
* Aave V3 leaves this undefined; Morpho Blue requires market_id.
*/
buildWithdrawCollateral?(params: WithdrawCollateralParams): Promise<DeFiTx>;

/**
* Optional — Compound V2 forks (Venus etc.) require an explicit
* `Comptroller.enterMarkets([cToken])` call before a supplied asset
* is counted as collateral for borrowing. Aave V3 / Compound V3 /
* Morpho Blue use different mechanisms; their adapters leave this
* undefined.
*/
buildEnterMarkets?(cTokens: Address[]): Promise<DeFiTx>;
}
9 changes: 9 additions & 0 deletions ts/packages/defi-protocols/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@ declare class CompoundV2Adapter implements ILending {
private readonly protocolName;
private readonly defaultVtoken;
private readonly vTokenCandidates;
private readonly comptroller;
private readonly rpcUrl?;
private vTokenByAsset;
private nativeVtoken;
Expand All @@ -745,6 +746,14 @@ declare class CompoundV2Adapter implements ILending {
buildBorrow(params: BorrowParams): Promise<DeFiTx>;
buildRepay(params: RepayParams): Promise<DeFiTx>;
buildWithdraw(params: WithdrawParams): Promise<DeFiTx>;
/**
* Compound V2 family: enter cTokens as collateral via Comptroller.
* Without this call, supplied assets sit dormant in the Comptroller's
* accountAssets[] and `getAccountLiquidity` reports zero collateral —
* any borrow then reverts. Mirrors the role of Aave V3's
* setUserUseReserveAsCollateral, but the API is batch-by-cToken.
*/
buildEnterMarkets(cTokens: Address[]): Promise<DeFiTx>;
getRates(asset: Address): Promise<LendingRates>;
getUserPosition(user: Address): Promise<UserPosition>;
}
Expand Down
47 changes: 47 additions & 0 deletions ts/packages/defi-protocols/src/lending/compound_v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,53 @@ describe("CompoundV2Adapter — v1.0.5 per-asset getRates routing", () => {
});
});

describe("CompoundV2Adapter — buildEnterMarkets (Comptroller toggle)", () => {
beforeEach(() => readContractMock.mockReset());

it("encodes Comptroller.enterMarkets([vToken]) and targets the registered comptroller", async () => {
const { CompoundV2Adapter } = await import("./compound_v2.js");
const adapter = new CompoundV2Adapter(makeEntry(), "https://example/bnb");
const tx = await adapter.buildEnterMarkets!([VUSDT]);
// Tx target must be the Comptroller, not the cToken — entering markets is
// an account-level Comptroller call, not a per-cToken state mutation.
expect(tx.to).toBe(COMPTROLLER);
expect(tx.value).toBe(0n);
const decoded = decodeFunctionData({
abi: parseAbi(["function enterMarkets(address[] cTokens)"]),
data: tx.data,
});
expect(decoded.functionName).toBe("enterMarkets");
expect((decoded.args![0] as readonly Address[])).toEqual([VUSDT]);
});

it("supports batch enterMarkets across multiple vTokens", async () => {
const { CompoundV2Adapter } = await import("./compound_v2.js");
const adapter = new CompoundV2Adapter(makeEntry(), "https://example/bnb");
const tx = await adapter.buildEnterMarkets!([VUSDT, VUSDC, VBNB]);
const decoded = decodeFunctionData({
abi: parseAbi(["function enterMarkets(address[] cTokens)"]),
data: tx.data,
});
expect((decoded.args![0] as readonly Address[]).length).toBe(3);
});

it("rejects empty cTokens[] (Comptroller would no-op)", async () => {
const { CompoundV2Adapter } = await import("./compound_v2.js");
const adapter = new CompoundV2Adapter(makeEntry(), "https://example/bnb");
await expect(adapter.buildEnterMarkets!([])).rejects.toThrow(/at least one cToken/i);
});

it("rejects when comptroller is missing from the protocol entry", async () => {
const { CompoundV2Adapter } = await import("./compound_v2.js");
const entryNoComptroller = {
...makeEntry(),
contracts: { vusdt: VUSDT, vusdc: VUSDC, vbnb: VBNB }, // no comptroller
} as ProtocolEntry;
const adapter = new CompoundV2Adapter(entryNoComptroller, "https://example/bnb");
await expect(adapter.buildEnterMarkets!([VUSDT])).rejects.toThrow(/Comptroller/);
});
});

describe("CompoundV2Adapter — v1.0.6 utilization unit conversion", () => {
beforeEach(() => readContractMock.mockReset());

Expand Down
47 changes: 47 additions & 0 deletions ts/packages/defi-protocols/src/lending/compound_v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ const NATIVE_CTOKEN_ABI = parseAbi([
"function repayBorrow() external payable",
]);

// Comptroller toggle. enterMarkets is the Compound V2 family equivalent
// of Aave V3's setUserUseReserveAsCollateral — without an explicit
// enterMarkets call, supplied assets DO NOT count as collateral and
// any borrow reverts with `getAccountLiquidity` shortfall.
// enterMarkets(address[]) -> 0xc2998238
const COMPTROLLER_ABI = parseAbi([
"function enterMarkets(address[] cTokens) external returns (uint256[])",
"function exitMarket(address cToken) external returns (uint256)",
]);

// defi-cli's internal sentinel for native gas tokens (registry uses 0x0
// for HYPE / MNT / ETH / BNB / MON in tokens/*.toml).
const NATIVE_SENTINEL = "0x0000000000000000000000000000000000000000" as const;
Expand All @@ -50,6 +60,7 @@ export class CompoundV2Adapter implements ILending {
private readonly protocolName: string;
private readonly defaultVtoken: Address;
private readonly vTokenCandidates: Address[];
private readonly comptroller: Address | undefined;
private readonly rpcUrl?: string;
// Lazy cache: underlying asset address (lowercased) → vToken address.
// The native sentinel (0x0…) is mapped to the cETH/vBNB-style vToken
Expand All @@ -72,6 +83,9 @@ export class CompoundV2Adapter implements ILending {
contracts["comptroller"];
if (!vtoken) throw DefiError.contractError("Missing vToken or comptroller address");
this.defaultVtoken = vtoken;
// Comptroller is required for `buildEnterMarkets`. Optional otherwise
// (rates / position / supply / withdraw / repay / borrow don't need it).
this.comptroller = contracts["comptroller"];
// Collect all keys that look like vTokens (`v<symbol>`) — used by getRates
// to resolve the per-asset market. Falls back to defaultVtoken if empty.
this.vTokenCandidates = Object.entries(contracts)
Expand Down Expand Up @@ -259,6 +273,39 @@ export class CompoundV2Adapter implements ILending {
};
}

/**
* Compound V2 family: enter cTokens as collateral via Comptroller.
* Without this call, supplied assets sit dormant in the Comptroller's
* accountAssets[] and `getAccountLiquidity` reports zero collateral —
* any borrow then reverts. Mirrors the role of Aave V3's
* setUserUseReserveAsCollateral, but the API is batch-by-cToken.
*/
async buildEnterMarkets(cTokens: Address[]): Promise<DeFiTx> {
if (!this.comptroller) {
throw DefiError.contractError(
`[${this.protocolName}] enterMarkets requires the Comptroller address ` +
`to be registered under [protocol.contracts] as 'comptroller'.`,
);
}
if (cTokens.length === 0) {
throw DefiError.invalidParam(
`[${this.protocolName}] enterMarkets requires at least one cToken address.`,
);
}
const data = encodeFunctionData({
abi: COMPTROLLER_ABI,
functionName: "enterMarkets",
args: [cTokens],
});
return {
description: `[${this.protocolName}] Enter ${cTokens.length} market(s) as collateral`,
to: this.comptroller,
data,
value: 0n,
gas_estimate: 200_000,
};
}

async getRates(asset: Address): Promise<LendingRates> {
if (!this.rpcUrl) throw DefiError.rpcError("No RPC URL configured");
const client = createPublicClient({ transport: http(this.rpcUrl) });
Expand Down