Skip to content

feat: add best-rate lending router action provider - #2

Closed
zaryab2000 wants to merge 4 commits into
feat/lendingRouterfrom
claude/dazzling-hypatia-jVytO
Closed

zaryab2000 wants to merge 4 commits into
feat/lendingRouterfrom
claude/dazzling-hypatia-jVytO

Conversation

@zaryab2000

Copy link
Copy Markdown
Owner

Summary

  • New ActionProvider: lendingRouter — a cross-protocol lending router for Base mainnet (chain ID 8453)
  • Compares live supply/borrow APY across Compound III, Aave v3, Moonwell, and Morpho Blue, then routes deposits and borrows to the optimal venue
  • 5 actions: compare_lending_rates, get_aggregated_position, route_supply, route_borrow, rebalance

What's included

File Purpose
lendingRouterActionProvider.ts Provider class with 5 @CreateAction methods
schemas.ts Zod v4 schemas with .describe() on every field
constants.ts Per-protocol addresses, ABIs, scaling constants
adapters/compound.ts Compound III on-chain rate/position reader
adapters/aave.ts Aave v3 on-chain rate/position reader + tx encoding
adapters/moonwell.ts Moonwell on-chain rate/position reader + mint encoding
adapters/morpho.ts Morpho Blue GraphQL rate reader + position stub
utils.ts Rate ranking, health aggregation, APY formatting
lendingRouterActionProvider.test.ts 29 Jest unit tests (mocked wallet)
index.ts Re-exports
README.md Per-provider documentation
Changeset Patch: "Added a best-rate lending router action provider"

v1 Protocol Coverage

Protocol Rate Reads Position Reads Supply Borrow
Compound III ✅ on-chain ✅ on-chain ✅ ✅
Aave v3 ✅ on-chain ✅ on-chain ✅ ✅
Moonwell ✅ on-chain ✅ on-chain ✅ mint ❌ v2
Morpho Blue ✅ GraphQL ⚠️ stub ❌ v2 ❌ v2

v1 Limitations

  • Borrow routing: Compound and Aave only (Moonwell/Morpho borrow actions don't exist in AgentKit yet)
  • Morpho position tracking is minimal (full support requires market enumeration)
  • Rebalance is advisory — returns a plan rather than auto-executing
  • Base mainnet only (chainId === "8453")

Test plan

  • pnpm build — clean
  • pnpm test — 29/29 passed
  • pnpm run lint — clean
  • pnpm run format — clean
  • Factory re-exported from src/action-providers/index.ts
  • Changeset added (patch, past tense)
  • supportsNetwork returns true only for Base mainnet
  • All actions return Promise<string>; errors returned not thrown
  • Zod schemas: .describe() on every field, no .strip()

https://claude.ai/code/session_01H9og3kM8YwPCcUCHnDxTxw


Generated by Claude Code

Cross-protocol lending router that compares live supply/borrow APY across
Compound III, Aave v3, Moonwell, and Morpho Blue on Base mainnet (8453),
then routes deposits and borrows to the optimal venue.

Actions:
- compare_lending_rates: Read live APY from all 4 protocols, rank best-first
- get_aggregated_position: Cross-protocol portfolio view with health factors
- route_supply: Auto-route supply to best-rate venue (all 4 protocols)
- route_borrow: Auto-route borrow to cheapest venue (Compound/Aave v1)
- rebalance: Advisory rebalance plan when rates drift

v1 limitations: borrow routing is Compound/Aave only; Morpho position
tracking is minimal; rebalance is advisory.

https://claude.ai/code/session_01H9og3kM8YwPCcUCHnDxTxw
@github-actions github-actions Bot added documentation Improvements or additions to documentation typescript action provider labels Jun 5, 2026
@zaryab2000

Copy link
Copy Markdown
Owner Author

@claude start review

@claude

claude Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

Claude finished @zaryab2000's task in 2m 45s —— View job


Code Review — lendingRouter ActionProvider

Todo list:

  • Gather context and read PR files
  • Review main provider (lendingRouterActionProvider.ts)
  • Review schemas (schemas.ts)
  • Review adapters (compound, aave, moonwell, morpho)
  • Review constants, utils, index, README
  • Review tests (lendingRouterActionProvider.test.ts)
  • Check index.ts re-export and changeset
  • Post consolidated review findings

Overall this is a well-structured provider — good separation into adapters, correct use of @CreateAction, Promise<string> returns, errors caught and returned (not thrown), Zod v4 schemas with .describe() everywhere, supportsNetwork scoped correctly to Base mainnet. The checklist items in the PR description are all satisfied. Below are findings that should be addressed before merging upstream.


Critical Bugs

1. Compound always queries the USDC Comet regardless of asset

COMPOUND_COMET_ADDRESS is hardcoded to the single USDC Comet (0xb125…). Compound III has separate Comet deployments per base asset (USDC, WETH, cbBTC, etc.). When you call getCompoundRates(wallet, "WETH", "supply"), the code reads from the USDC Comet and returns a USDC supply rate labeled as if it were for WETH. This makes the rate-comparison and routing actively wrong for any asset other than USDC.

[adapters/compound.ts:42-67] — Fix this →


2. Moonwell position reads use 18 decimals for all tokens

getMoonwellPosition calls formatUnits(supplyBal, 18) and formatUnits(borrowBal, 18) unconditionally. balanceOfUnderlying returns the underlying token amount, which is in that token's own decimals (6 for USDC, 18 for WETH). For a USDC position, this would display the balance as 1 / 10^12 times the true value.

[adapters/moonwell.ts:98-115] — Fix this →


3. Moonwell health factor is not comparable with other protocols

getAccountLiquidity returns USD liquidity surplus (not a ratio). The code synthesizes a health factor as 1 + liquidity where liquidity is a dollar amount. For a user with $1 million in surplus liquidity this gives healthFactor = 1_000_001, while Aave would give a ratio like 1.5. The findLowestHealth comparison across protocols is therefore meaningless and will always flag the Moonwell position regardless of actual risk.

[adapters/moonwell.ts:75-83]

A correct approach is to use (liquidity + totalBorrowValue) / totalBorrowValue — but the Comptroller doesn't expose those separately. The best v1 fix is to document this limitation clearly and return healthSource: "liquidity-surplus-usd (not comparable)", or to cap it at some sentinel rather than 1 + dollarAmount.


4. encodeMoonwellMint has an unused mToken parameter

The function accepts mToken: Address but never references it in the body — the encodeFunctionData call only uses the ABI and function name. The actual to address is correctly set in the provider's sendTransaction call, but the dead parameter is confusing.

[adapters/moonwell.ts:139-145] — Fix this →


5. rebalance doesn't read user positions — it compares protocol rates only

The rebalance action compares the highest-rate protocol against the lowest-rate protocol across all four, regardless of where the user actually has funds. If the user has no funds in the "worst" protocol, the advisory plan is irrelevant. The action should call getAggregatedPosition first to find which protocols the user is actively supplying to, then compare those against the best available rate.

[lendingRouterActionProvider.ts:357-407]


6. Morpho marketAddress is a bytes32 market key, not an address

best.uniqueKey is a bytes32 market identifier (e.g. 0xabc...32hex...chars), not a 20-byte Ethereum address. Casting it as Address and returning it in the marketAddress field (which the provider uses as an address in routing) will cause any future Morpho supply execution to send a transaction to an invalid address.

[adapters/morpho.ts:88]


Notable Issues

7. getCompoundRates notes field hardcodes "Comet USDC market"

Even after fixing issue #1, notes should reflect the actual asset/market being queried rather than always saying USDC. [adapters/compound.ts:66]

8. No health-factor pre-check for Compound borrows

routeBorrow checks Aave health before executing, but not Compound. Compound III will revert on-chain, but the UX is better if the pre-check catches it and returns a readable message. [lendingRouterActionProvider.ts:299-304]

9. Sequential RPC calls in getCompoundPosition

The collateral loop in getCompoundPosition makes 3-4 sequential readContract calls per collateral asset. For a market with many collateral types this can be very slow. Batching with publicClient.multicall would improve this. Not a correctness issue, but worth noting for a protocol integration that will see real use. [adapters/compound.ts:111-151]

10. cbeth mToken address looks incorrect

MOONWELL_MTOKEN_ADDRESSES.cbeth = "0x3bf93770f2d4a0F9a5a5F3d1a1e3a0c9B6C7E3e5". This address ends in a suspicious repeated pattern and does not match any known Moonwell cbETH market. The Moonwell protocol's official docs/contracts list the correct addresses; this one should be verified. [constants.ts:239]


Minor / Style

11. Variable named apr stored in apy field

In compound.ts and moonwell.ts, a variable called apr (simple linear annualization) is stored in the apy field of RateResult. True APY would use compounding. The math produces a linear APR, not a compound APY. Either rename the field to apr or compute the actual compound APY: ((1 + rate_per_second)^seconds_per_year - 1) * 100. For small rates the difference is minor, but the naming inconsistency could mislead LLM consumers. [adapters/compound.ts:59, adapters/moonwell.ts:46]

12. Test for routeSupply toward Moonwell is missing

The test suite has preferProtocol: "compound" covered but no test for preferProtocol: "moonwell". Given that Moonwell uses a distinct mToken lookup and encode path, that code path has no coverage. [lendingRouterActionProvider.test.ts]

13. getAggregatedPosition with explicit user parameter is not tested

The two tests that exist both omit user and rely on walletProvider.getAddress(). A test passing an explicit address would confirm the address plumbing works. [lendingRouterActionProvider.test.ts:240-275]


Checklist Compliance

Requirement Status
Actions return Promise<string> ✅
Errors returned, not thrown ✅
Zod v4 .describe() on every field, no .strip() ✅
supportsNetwork correct (Base mainnet only) ✅
Factory exported + re-exported from index.ts ✅
Per-provider README.md ✅
Changeset (patch, past tense) ✅
pnpm test / pnpm build / lint / format passing ✅ (per PR)

Summary: 3 of the 6 critical issues (#1 Compound multi-market, #2 Moonwell decimals, #6 Morpho address cast) are correctness bugs that produce wrong outputs or would cause runtime failures for non-USDC assets or future Morpho execution. These should be fixed before merging. Issues #3 (health factor comparability) and #5 (rebalance reads positions, not just rates) are design-level and worth addressing even if they require a bit more work.

…h checks

- Replace single COMPOUND_COMET_ADDRESS with COMPOUND_COMET_ADDRESSES map (USDC + WETH Comets)
- Fix executeSupply/executeBorrow to look up correct Comet by asset symbol
- Add Compound health pre-check in routeBorrow alongside existing Aave check
- Rewrite rebalance to read user positions and compare against current protocol rate
- Fix RateResult.marketAddress → marketId (string, not Address — supports Morpho bytes32)
- Add PositionResult.healthComparable flag; skip non-comparable in findLowestHealth
- Fix encodeMoonwellMint signature (remove unused mToken param)
- Fix Moonwell position to use per-token decimals and non-comparable health
- Fix Morpho position user param type and add healthComparable: false
- Fix cbeth/cbbtc mToken addresses in constants
- Update tests for multi-Comet mocks and position-aware rebalance

https://claude.ai/code/session_01H9og3kM8YwPCcUCHnDxTxw
@zaryab2000

Copy link
Copy Markdown
Owner Author

@claude review the fixes

@claude

claude Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

Claude finished @zaryab2000's task in 1m 59s —— View job


Fix Review — lendingRouter ActionProvider

Todo list:

  • Load tools and initialize
  • Read current state of adapter files
  • Check critical bug fixes from prior review
  • Check notable/minor issue fixes
  • Post consolidated findings

All 6 critical bugs from the first review are fixed. Here's the full scorecard:


Critical Bugs — All Fixed ✅

1. Compound multi-Comet (was: always queried USDC Comet) — ✅ Fixed
COMPOUND_COMET_ADDRESSES is now a Record<string, Address> mapping usdc and weth to their respective Comets. getCometForAsset() returns null for unsupported assets, causing getCompoundRates() to return null (skipped from results). Both executeSupply() and executeBorrow() look up the correct Comet by assetSymbol.toLowerCase() and throw a clear error if not found. [constants.ts:22-25, adapters/compound.ts:34-76]

2. Moonwell decimals (was: hardcoded 18 for all tokens) — ✅ Fixed
MOONWELL_UNDERLYING_DECIMALS in constants.ts maps each symbol to its correct decimals (usdc: 6, cbbtc: 8, others: 18). getMoonwellPosition() now passes MOONWELL_UNDERLYING_DECIMALS[symbol] ?? 18 to formatUnits. [constants.ts:293-299, adapters/moonwell.ts:92]

3. Moonwell health factor not comparable across protocols — ✅ Fixed
healthFactor now stores the raw USD liquidity surplus (not 1 + liquidity). More importantly, healthComparable: false means findLowestHealth() skips Moonwell entirely, and healthSource is set to "Comptroller liquidity surplus (USD, not a ratio)" so LLM consumers see the caveat. [adapters/moonwell.ts:80-85, utils.ts:44]

4. Unused mToken parameter in encodeMoonwellMint — ✅ Fixed
Signature is now encodeMoonwellMint(amount: bigint). Call site in executeSupply() passes only amount. [adapters/moonwell.ts:143, lendingRouterActionProvider.ts:531]

5. rebalance read rates-only, ignored user positions — ✅ Fixed
Rebalance now fetches rates and positions in parallel via Promise.all. It filters activeProtocols (positions with a supply of the target asset), returns a "no-op" with suggestion if none are found, and computes improvementBps relative to the user's actual current protocol. [lendingRouterActionProvider.ts:370-442]

6. Morpho marketId cast as Address (was: bytes32 key treated as 20-byte address) — ✅ Fixed
RateResult.marketId is typed as string in utils.ts. getMorphoRates() assigns best.uniqueKey directly without casting. The field name marketId is correctly scoped (not called marketAddress). [utils.ts:4, adapters/morpho.ts:88]


Notable Issues — All Fixed ✅

7. Compound notes hardcoded "Comet USDC market" — ✅ Fixed
Now uses Comet ${asset.toUpperCase()} market. [adapters/compound.ts:75]

8. No Compound health-factor pre-check before borrow — ✅ Fixed
routeBorrow() now checks both Aave (chosen.protocol === "aave") and Compound (chosen.protocol === "compound") health before executing. [lendingRouterActionProvider.ts:299-309]


Remaining Items (Minor — Not Blocking)

9. Sequential RPC calls in getCompoundPosition — Still present
The collateral loop makes multiple sequential readContract calls per collateral asset. Not a correctness issue; multicall batching is the improvement path for v2. [adapters/compound.ts:124-163]

10. cbeth mToken address updated but not verified — Needs confirmation
Address changed from 0x3bf93770f2d4a0F9a5a5F3d1a1e3a0c9B6C7E3e5 (suspicious repeated pattern) to 0x3bf93770f2d4a794c3d9EBEfBAeBAE2a8f09A5E5. The suspicious pattern is gone, but this should be cross-checked against Moonwell's official deployment registry before the PR is merged upstream. [constants.ts:243]

11. APR/APY field naming — Still present
getCompoundRates() and getMoonwellRates() compute a linear APR and store it in the apy field. The naming could mislead consumers. Not a correctness bug for v1; worth noting in v2. [adapters/compound.ts:68-72, adapters/moonwell.ts:47-50]

12. routeSupply → Moonwell path has no test — Still missing
There is no test with preferProtocol: "moonwell". The mToken lookup and encodeMoonwellMint code path remains uncovered. [lendingRouterActionProvider.test.ts]

13. getAggregatedPosition explicit user param not tested — Still missing
Both getAggregatedPosition tests omit the user argument. [lendingRouterActionProvider.test.ts:247-282]


Summary

All 6 critical correctness bugs and both notable issues are fixed. The rebalance redesign and Morpho type fix are particularly well done. Items 12–13 (test coverage) and item 10 (address verification) are the only open points worth addressing before merging upstream, though they don't block the build or tests.

@zaryab2000

Copy link
Copy Markdown
Owner Author

@claude conduct final review and confirm if the PR is accurate now?

@claude

claude Bot commented Jun 11, 2026 •

Copy link
Copy Markdown

Claude finished @zaryab2000's task in 3m 23s —— View job


Final Review — lendingRouter ActionProvider

Todo list:


All Critical Bugs — Fixed ✅

All 6 critical correctness bugs and both notable issues from earlier rounds are confirmed fixed. Quick summary:

# Issue Status
1 Compound always queried USDC Comet ✅ COMPOUND_COMET_ADDRESSES record + getCometForAsset()
2 Moonwell decimals hardcoded to 18 ✅ MOONWELL_UNDERLYING_DECIMALS map used in getMoonwellPosition
3 Moonwell health factor not comparable ✅ healthComparable: false, raw USD surplus stored, skipped in findLowestHealth
4 Unused mToken param in encodeMoonwellMint ✅ Removed; call site updated
5 Rebalance read rates only, ignored positions ✅ Promise.all for rates + positions; activeProtocols filter applied
6 Morpho marketAddress bytes32 cast as Address ✅ marketId: string in RateResult, no cast
7 Compound notes hardcoded "Comet USDC" ✅ Dynamically uses asset.toUpperCase()
8 No Compound health pre-check before borrow ✅ Both Aave and Compound health checked before executing borrow

Remaining Open Items (Minor, Non-Blocking)

coinbase#9 — Sequential RPC calls in getCompoundPosition (unchanged)

The collateral loop at [adapters/compound.ts:124-164] makes 4–5 sequential readContract calls per collateral asset. For a user with many collateral positions this is slow but functionally correct. multicall batching is the v2 improvement path; not a merge blocker.


coinbase#10 — cbeth mToken address still needs verification [constants.ts:243]

Address is 0x3bf93770f2d4a794c3d9EBEfBAeBAE2a8f09A5E5. The suspicious repeated-byte pattern from the original is gone, but this address has never been cross-checked against Moonwell's official deployment registry. Please verify this address before the upstream PR is submitted. An incorrect mToken address would silently fail at runtime for cbETH suppliers.


coinbase#11 — apy field stores a linear APR, not a compound APY (unchanged) [adapters/compound.ts:68, adapters/moonwell.ts:47]

getCompoundRates and getMoonwellRates compute rate_per_second × seconds_per_year × 100. This is a linear APR annualization, not compound APY. The field is named apy in RateResult. For a v1 router the difference is small (a few basis points), but it may mislead LLM consumers comparing against Aave (which reads the actual WAD-encoded interest rate, also linear). Worth renaming the field apr in v2 or computing true APY ((1 + rate)^n - 1).


coinbase#12 — No test for routeSupply → Moonwell path (still missing) [lendingRouterActionProvider.test.ts]

There is no test with preferProtocol: "moonwell". The MOONWELL_MTOKEN_ADDRESSES lookup and encodeMoonwellMint call site ([lendingRouterActionProvider.ts:527-534]) remain uncovered. Since encodeMoonwellMint was changed during the fix cycle (the dead mToken param was removed), a test verifying this path would confirm the call site is correct. Fix this →


coinbase#13 — getAggregatedPosition explicit user param not tested (still missing) [lendingRouterActionProvider.test.ts:247-282]

All three getAggregatedPosition tests omit user and rely on walletProvider.getAddress(). A test passing user: "0xsome...address" would cover the args.user ?? walletProvider.getAddress() branch ([lendingRouterActionProvider.ts:119]) and confirm address plumbing. Fix this →


New Observation: Aave "aggregate" match in rebalance (design note)

[lendingRouterActionProvider.ts:405-408]

p.supplies.some(
  s => s.asset.toLowerCase() === args.asset.toLowerCase() || s.asset === "aggregate",
)

Because getAavePosition returns asset: "aggregate" for all positions (it can only read totals via getUserAccountData), any Aave position will match the s.asset === "aggregate" condition regardless of which asset is actually being supplied. A user with WETH supplied to Aave but no USDC would be flagged as having an active USDC Aave position when running rebalance({ asset: "USDC" }). This is a documented v1 limitation (Aave aggregate reads are by design), but it could produce surprising advisory output. Consider noting it in the rebalance action description.


Checklist Compliance — Final

Requirement Status
Actions return Promise<string> ✅
Errors returned, not thrown ✅
Zod v4 .describe() on every field, no .strip() ✅
supportsNetwork correct (Base mainnet "8453" only) ✅
Factory exported + re-exported from src/action-providers/index.ts ✅
Per-provider README.md ✅
Changeset: patch, past tense, correct package ✅
pnpm test / pnpm build / lint / format (per PR) ✅

Verdict

The PR is correct and ready for upstream submission with one action item before merging: verify the cbeth mToken address (constants.ts:243) against Moonwell's deployment registry. Items coinbase#12 and coinbase#13 (missing test paths) are recommended additions but are not merge-blocking given the existing 29-test suite and the fact that both paths exercise code already unit-verified at the module level.

- Add test for routeSupply with preferProtocol: "moonwell" covering
  mToken lookup and encodeMoonwellMint call path
- Add test for getAggregatedPosition with explicit user address
  covering the args.user ?? walletProvider.getAddress() branch

https://claude.ai/code/session_01H9og3kM8YwPCcUCHnDxTxw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action provider documentation Improvements or additions to documentation typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants