diff --git a/CLAUDE.md b/CLAUDE.md index 1dfb639..95a858a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidelines for working with this codebase. ## Language -Always respond in **Brazilian Portuguese (pt-BR)**. +Always respond in **English**. ## Required reading diff --git a/backend/src/config/protocols.ts b/backend/src/config/protocols.ts index fac21c7..10ab68b 100644 --- a/backend/src/config/protocols.ts +++ b/backend/src/config/protocols.ts @@ -69,6 +69,19 @@ const PROTOCOL_REGISTRY: Record = { sAvax: "0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE", }, }, + metronome: { + protocolId: "metronome", + name: "Metronome Synth", + chain: "base", + contracts: { + pool: "0xc614136d6c5AB85bc2aCF0ec2652351642d7F54E", + poolRegistry: "0x4372A2b9304296c06197a823f25Cf03119d2Fd82", + usdcDepositToken: "0xC7F2f79Daa7Ea4FBbF60b45b5D6028BDE2453476", + wethDepositToken: "0x8b581d0013F571a792c3Aa8AF2a0366A309BF51E", + msETH: "0x7Ba6F01772924a82D9626c126347A28299E98c98", + msUSD: "0x526728DBc96689597F85ae4cd716d4f7fCcBAE9d", + }, + }, }; export function registerProtocol(protocolId: string, config: ProtocolConfig): void { diff --git a/backend/src/index.ts b/backend/src/index.ts index e2f04cd..7e365f3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,6 +13,7 @@ import { dcaRoutes } from "./modules/dca/routes/dca.routes"; import { avaxSwapRoutes } from "./modules/avax-swap/routes/avax-swap.routes"; import { avaxLendingRoutes } from "./modules/avax-lending/routes/avax-lending.routes"; import { avaxLiquidStakingRoutes } from "./modules/avax-liquid-staking/routes/avax-liquid-staking.routes"; +import { metronomeRoutes } from "./modules/metronome/routes/metronome.routes"; import { errorHandler } from "./middleware/errorHandler"; import { rateLimiter } from "./middleware/rateLimiter"; import { serializeByUser } from "./middleware/serialize-by-user"; @@ -57,6 +58,7 @@ app.use("/dca", dcaRoutes); app.use("/avax/swap", avaxSwapRoutes); app.use("/avax/lending", avaxLendingRoutes); app.use("/avax/liquid-staking", avaxLiquidStakingRoutes); +app.use("/modules/metronome", metronomeRoutes); app.get("/health", (_req, res) => { res.json({ status: "ok", service: "execution-service", port: PORT }); diff --git a/backend/src/modules/metronome/config/metronome-markets.ts b/backend/src/modules/metronome/config/metronome-markets.ts new file mode 100644 index 0000000..5baf62c --- /dev/null +++ b/backend/src/modules/metronome/config/metronome-markets.ts @@ -0,0 +1,70 @@ +/** + * Metronome Synth markets on Base mainnet (8453). + * + * Architecture note: Metronome uses per-token contracts (not a monolithic Pool). + * - `depositToken` wraps the collateral asset (USDC → USDCDepositToken, etc.) + * - `debtToken` tracks the debt denominated in a synthetic asset (msUSD/msETH) + * + * When preparing a user operation, the frontend picks: + * - A collateral market → depositToken address + * - A synthetic market → debtToken address + */ + +export interface MetronomeCollateralMarket { + symbol: string; + depositToken: string; + underlying: string; + underlyingSymbol: string; + decimals: number; +} + +export interface MetronomeSyntheticMarket { + symbol: string; + debtToken: string; + synth: string; + decimals: number; +} + +export const METRONOME_COLLATERAL_MARKETS: MetronomeCollateralMarket[] = [ + { + symbol: "msdUSDC", + depositToken: "0xC7F2f79Daa7Ea4FBbF60b45b5D6028BDE2453476", + underlying: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // native USDC + underlyingSymbol: "USDC", + decimals: 6, + }, + { + symbol: "msdWETH", + depositToken: "0x8b581d0013F571a792c3Aa8AF2a0366A309BF51E", + underlying: "0x4200000000000000000000000000000000000006", // WETH + underlyingSymbol: "WETH", + decimals: 18, + }, +]; + +export const METRONOME_SYNTHETIC_MARKETS: MetronomeSyntheticMarket[] = [ + { + symbol: "msUSD", + debtToken: "0x7bcC1DEcCaa98D52Bf89485f17a3E8607011cFde", + synth: "0x526728DBc96689597F85ae4cd716d4f7fCcBAE9d", + decimals: 18, + }, + { + symbol: "msETH", + debtToken: "0x6F622b037F9146bdE102db84FC9152dF1042aa98", + synth: "0x7Ba6F01772924a82D9626c126347A28299E98c98", + decimals: 18, + }, +]; + +export function getCollateralMarketByDepositToken(depositToken: string): MetronomeCollateralMarket | undefined { + return METRONOME_COLLATERAL_MARKETS.find( + (m) => m.depositToken.toLowerCase() === depositToken.toLowerCase() + ); +} + +export function getSyntheticMarketByDebtToken(debtToken: string): MetronomeSyntheticMarket | undefined { + return METRONOME_SYNTHETIC_MARKETS.find( + (m) => m.debtToken.toLowerCase() === debtToken.toLowerCase() + ); +} diff --git a/backend/src/modules/metronome/controllers/metronome.controller.ts b/backend/src/modules/metronome/controllers/metronome.controller.ts new file mode 100644 index 0000000..f494ebd --- /dev/null +++ b/backend/src/modules/metronome/controllers/metronome.controller.ts @@ -0,0 +1,73 @@ +import { Request, Response } from "express"; +import { asyncHandler } from "../../../middleware/errorHandler"; +import { executePrepareDeposit } from "../usecases/prepare-deposit.usecase"; +import { executePrepareWithdraw } from "../usecases/prepare-withdraw.usecase"; +import { executePrepareMint } from "../usecases/prepare-mint.usecase"; +import { executePrepareRepay } from "../usecases/prepare-repay.usecase"; +import { executePrepareUnwind } from "../usecases/prepare-unwind.usecase"; +import { executeGetUserPosition } from "../usecases/get-user-position.usecase"; +import { + METRONOME_COLLATERAL_MARKETS, + METRONOME_SYNTHETIC_MARKETS, +} from "../config/metronome-markets"; + +export const getMarkets = asyncHandler(async (_req: Request, res: Response) => { + res.json({ + collateral: METRONOME_COLLATERAL_MARKETS, + synthetic: METRONOME_SYNTHETIC_MARKETS, + }); +}); + +export const getUserPosition = asyncHandler(async (req: Request, res: Response) => { + const result = await executeGetUserPosition(req.params.userAddress); + res.json(result); +}); + +export const prepareDeposit = asyncHandler(async (req: Request, res: Response) => { + const result = await executePrepareDeposit({ + userAddress: req.body.userAddress, + depositTokenAddress: req.body.depositTokenAddress, + amount: req.body.amount, + }); + res.json(result); +}); + +export const prepareWithdraw = asyncHandler(async (req: Request, res: Response) => { + const result = await executePrepareWithdraw({ + userAddress: req.body.userAddress, + depositTokenAddress: req.body.depositTokenAddress, + amount: req.body.amount, + recipient: req.body.recipient, + }); + res.json(result); +}); + +export const prepareMint = asyncHandler(async (req: Request, res: Response) => { + const result = await executePrepareMint({ + userAddress: req.body.userAddress, + debtTokenAddress: req.body.debtTokenAddress, + amount: req.body.amount, + recipient: req.body.recipient, + }); + res.json(result); +}); + +export const prepareRepay = asyncHandler(async (req: Request, res: Response) => { + const result = await executePrepareRepay({ + userAddress: req.body.userAddress, + debtTokenAddress: req.body.debtTokenAddress, + amount: req.body.amount, + }); + res.json(result); +}); + +export const prepareUnwind = asyncHandler(async (req: Request, res: Response) => { + const result = await executePrepareUnwind({ + userAddress: req.body.userAddress, + debtTokenAddress: req.body.debtTokenAddress, + depositTokenAddress: req.body.depositTokenAddress, + synthAmount: req.body.synthAmount, + recipient: req.body.recipient, + }); + res.json(result); +}); diff --git a/backend/src/modules/metronome/routes/metronome.routes.ts b/backend/src/modules/metronome/routes/metronome.routes.ts new file mode 100644 index 0000000..2c17f3d --- /dev/null +++ b/backend/src/modules/metronome/routes/metronome.routes.ts @@ -0,0 +1,106 @@ +import { Router } from "express"; +import { validateAddress, validateAmount, validateRequired } from "../../../middleware/validation"; +import { executionTimeout } from "../../../middleware/execution-timeout"; +import * as ctrl from "../controllers/metronome.controller"; + +export const metronomeRoutes = Router(); + +/** + * GET /modules/metronome/markets + * Returns the catalog of Metronome Synth markets available on Base: + * - collateral markets (DepositToken wrappers: msdUSDC, msdWETH, ...) + * - synthetic markets (DebtToken + synth: msUSD, msETH, ...) + */ +metronomeRoutes.get("/markets", ctrl.getMarkets); + +/** + * GET /modules/metronome/position/:userAddress + * Reads collateral shares + synthetic debt held by the user's deterministic + * Metronome adapter proxy on Base. Returns zeros if the proxy is not yet + * predictable (protocol not registered on executor). + */ +metronomeRoutes.get( + "/position/:userAddress", + validateAddress("userAddress", "params"), + ctrl.getUserPosition +); + +/** + * POST /modules/metronome/prepare-deposit + * Steps: [approve underlying (if needed)] + [executor.execute -> depositCollateral] + * Body: { userAddress, depositTokenAddress, amount } + */ +metronomeRoutes.post( + "/prepare-deposit", + validateRequired("userAddress", "depositTokenAddress", "amount"), + validateAddress("userAddress"), + validateAddress("depositTokenAddress"), + validateAmount("amount"), + executionTimeout(), + ctrl.prepareDeposit +); + +/** + * POST /modules/metronome/prepare-withdraw + * Steps: [executor.execute -> withdrawCollateral(depositToken, amount, recipient)] + * No approve needed — collateral shares already live on the per-user proxy. + * Body: { userAddress, depositTokenAddress, amount, recipient? } + */ +metronomeRoutes.post( + "/prepare-withdraw", + validateRequired("userAddress", "depositTokenAddress", "amount"), + validateAddress("userAddress"), + validateAddress("depositTokenAddress"), + validateAmount("amount"), + executionTimeout(), + ctrl.prepareWithdraw +); + +/** + * POST /modules/metronome/prepare-mint + * Steps: [executor.execute -> mintSynth(debtToken, amount, recipient)] + * No approve needed — draws against collateral already held by the proxy. + * Body: { userAddress, debtTokenAddress, amount, recipient? } + */ +metronomeRoutes.post( + "/prepare-mint", + validateRequired("userAddress", "debtTokenAddress", "amount"), + validateAddress("userAddress"), + validateAddress("debtTokenAddress"), + validateAmount("amount"), + executionTimeout(), + ctrl.prepareMint +); + +/** + * POST /modules/metronome/prepare-repay + * Steps: [approve synth (if needed)] + [executor.execute -> repaySynth(debtToken, amount)] + * Body: { userAddress, debtTokenAddress, amount } + */ +metronomeRoutes.post( + "/prepare-repay", + validateRequired("userAddress", "debtTokenAddress", "amount"), + validateAddress("userAddress"), + validateAddress("debtTokenAddress"), + validateAmount("amount"), + executionTimeout(), + ctrl.prepareRepay +); + +/** + * POST /modules/metronome/prepare-unwind + * Closes a position atomically: repayAll + withdraw all collateral. + * Steps: [approve synth (if needed)] + [executor.execute -> unwind(debtToken, depositToken, recipient)] + * `synthAmount` must cover the full outstanding debt plus protocol fee. + * Body: { userAddress, debtTokenAddress, depositTokenAddress, synthAmount, recipient? } + */ +metronomeRoutes.post( + "/prepare-unwind", + validateRequired("userAddress", "debtTokenAddress", "depositTokenAddress", "synthAmount"), + validateAddress("userAddress"), + validateAddress("debtTokenAddress"), + validateAddress("depositTokenAddress"), + validateAmount("synthAmount"), + executionTimeout(), + ctrl.prepareUnwind +); diff --git a/backend/src/modules/metronome/usecases/get-user-position.usecase.ts b/backend/src/modules/metronome/usecases/get-user-position.usecase.ts new file mode 100644 index 0000000..be583bb --- /dev/null +++ b/backend/src/modules/metronome/usecases/get-user-position.usecase.ts @@ -0,0 +1,74 @@ +import { getContract } from "../../../providers/chain.provider"; +import { ERC20_ABI } from "../../../utils/abi"; +import { getUserAdapterAddress } from "../../../config/protocols"; +import { AppError } from "../../../shared/errorCodes"; +import { + METRONOME_COLLATERAL_MARKETS, + METRONOME_SYNTHETIC_MARKETS, + MetronomeCollateralMarket, + MetronomeSyntheticMarket, +} from "../config/metronome-markets"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +export interface CollateralPosition extends MetronomeCollateralMarket { + shares: string; // deposit-token balance (shares, 18 decimals in Metronome) +} + +export interface DebtPosition extends MetronomeSyntheticMarket { + debt: string; // outstanding debt in synth base units +} + +export interface GetUserPositionResponse { + userAddress: string; + adapterProxy: string; // "" if not yet predictable (protocol unregistered on executor) + collateral: CollateralPosition[]; + debt: DebtPosition[]; +} + +/** + * Reads a user's Metronome positions on Base. + * + * Metronome credits collateral + debt to the per-user BeaconProxy (adapter clone), + * NOT the user's EOA. We resolve the deterministic proxy address via + * `PanoramaExecutorV2.predictUserAdapter(protocolId, user)` and then read + * `balanceOf(proxy)` on each deposit- and debt-token. The proxy does not need to + * be deployed — reads on an empty address return 0. + */ +export async function executeGetUserPosition( + userAddress: string +): Promise { + if (!userAddress) throw new AppError("MISSING_FIELD", "userAddress is required"); + + const adapterProxy = await getUserAdapterAddress(userAddress, "metronome", "base"); + + // Pre-registration / lookup failure: return empty position envelope instead of erroring. + // The frontend can still render "no position" without special-casing. + if (!adapterProxy || adapterProxy === ZERO_ADDRESS) { + return { + userAddress, + adapterProxy: "", + collateral: METRONOME_COLLATERAL_MARKETS.map(m => ({ ...m, shares: "0" })), + debt: METRONOME_SYNTHETIC_MARKETS.map(m => ({ ...m, debt: "0" })), + }; + } + + const [collateral, debt] = await Promise.all([ + Promise.all( + METRONOME_COLLATERAL_MARKETS.map(async (m) => { + const depositToken = getContract(m.depositToken, ERC20_ABI, "base"); + const shares = await depositToken.balanceOf(adapterProxy) as bigint; + return { ...m, shares: shares.toString() }; + }) + ), + Promise.all( + METRONOME_SYNTHETIC_MARKETS.map(async (m) => { + const debtToken = getContract(m.debtToken, ERC20_ABI, "base"); + const bal = await debtToken.balanceOf(adapterProxy) as bigint; + return { ...m, debt: bal.toString() }; + }) + ), + ]); + + return { userAddress, adapterProxy, collateral, debt }; +} diff --git a/backend/src/modules/metronome/usecases/prepare-deposit.usecase.ts b/backend/src/modules/metronome/usecases/prepare-deposit.usecase.ts new file mode 100644 index 0000000..c4f1b10 --- /dev/null +++ b/backend/src/modules/metronome/usecases/prepare-deposit.usecase.ts @@ -0,0 +1,92 @@ +import { ethers } from "ethers"; +import { getChainConfig } from "../../../config/chains"; +import { encodeProtocolId, getDeadline } from "../../../utils/encoding"; +import { BundleBuilder, METRONOME_SELECTORS } from "../../../shared/bundle-builder"; +import { TransactionBundle } from "../../../types/transaction"; +import { AppError } from "../../../shared/errorCodes"; +import { getContract } from "../../../providers/chain.provider"; +import { ERC20_ABI } from "../../../utils/abi"; +import { getCollateralMarketByDepositToken } from "../config/metronome-markets"; + +export interface PrepareDepositRequest { + userAddress: string; + depositTokenAddress: string; // Metronome DepositToken (e.g. USDCDepositToken) + amount: string; // in underlying base units +} + +export interface PrepareDepositResponse { + bundle: TransactionBundle; + metadata: { + action: "deposit_collateral"; + depositToken: string; + depositTokenSymbol: string; + underlyingSymbol: string; + amount: string; + }; +} + +export async function executePrepareDeposit( + req: PrepareDepositRequest +): Promise { + const chain = getChainConfig("base"); + const executorAddr = chain.contracts.panoramaExecutor; + if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Base"); + + const market = getCollateralMarketByDepositToken(req.depositTokenAddress); + if (!market) { + throw new AppError( + "POOL_NOT_FOUND", + `Metronome collateral market not found for depositToken: ${req.depositTokenAddress}` + ); + } + + const amount = BigInt(req.amount); + if (amount <= 0n) throw new AppError("INVALID_AMOUNT", "amount must be positive"); + + const protocolId = encodeProtocolId("metronome"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + // Check underlying allowance toward the executor — executor pulls tokens before + // dispatching to the adapter. + const underlying = getContract(market.underlying, ERC20_ABI, "base"); + const allowance: bigint = await underlying.allowance(req.userAddress, executorAddr); + builder.addApproveIfNeeded( + market.underlying, + executorAddr, + allowance, + amount, + `Approve ${market.underlyingSymbol} for PanoramaExecutor` + ); + + // depositCollateral(address depositToken, uint256 amount) + const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "uint256"], + [req.depositTokenAddress, amount] + ); + + builder.addExecute( + protocolId, + METRONOME_SELECTORS.DEPOSIT_COLLATERAL, + [{ token: market.underlying, amount }], + deadline, + adapterData, + 0n, + executorAddr, + `Deposit ${market.underlyingSymbol} as collateral on Metronome` + ); + + return { + bundle: await builder.buildWithGas( + `Deposit ${market.underlyingSymbol} collateral on Metronome`, + req.userAddress + ), + metadata: { + action: "deposit_collateral", + depositToken: req.depositTokenAddress, + depositTokenSymbol: market.symbol, + underlyingSymbol: market.underlyingSymbol, + amount: amount.toString(), + }, + }; +} diff --git a/backend/src/modules/metronome/usecases/prepare-mint.usecase.ts b/backend/src/modules/metronome/usecases/prepare-mint.usecase.ts new file mode 100644 index 0000000..8f639f0 --- /dev/null +++ b/backend/src/modules/metronome/usecases/prepare-mint.usecase.ts @@ -0,0 +1,83 @@ +import { ethers } from "ethers"; +import { getChainConfig } from "../../../config/chains"; +import { encodeProtocolId, getDeadline } from "../../../utils/encoding"; +import { BundleBuilder, METRONOME_SELECTORS } from "../../../shared/bundle-builder"; +import { TransactionBundle } from "../../../types/transaction"; +import { AppError } from "../../../shared/errorCodes"; +import { getSyntheticMarketByDebtToken } from "../config/metronome-markets"; + +export interface PrepareMintRequest { + userAddress: string; + debtTokenAddress: string; // Metronome DebtToken for the synthetic to mint + amount: string; // synth base units (18 decimals for msUSD/msETH) + recipient?: string; // defaults to userAddress +} + +export interface PrepareMintResponse { + bundle: TransactionBundle; + metadata: { + action: "mint_synth"; + debtToken: string; + synth: string; + synthSymbol: string; + amount: string; + recipient: string; + }; +} + +export async function executePrepareMint( + req: PrepareMintRequest +): Promise { + const chain = getChainConfig("base"); + const executorAddr = chain.contracts.panoramaExecutor; + if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Base"); + + const market = getSyntheticMarketByDebtToken(req.debtTokenAddress); + if (!market) { + throw new AppError( + "POOL_NOT_FOUND", + `Metronome synthetic market not found for debtToken: ${req.debtTokenAddress}` + ); + } + + const amount = BigInt(req.amount); + if (amount <= 0n) throw new AppError("INVALID_AMOUNT", "amount must be positive"); + + const recipient = req.recipient ?? req.userAddress; + const protocolId = encodeProtocolId("metronome"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + // Mint draws against the collateral already held by the proxy — no transfers[] input. + // mintSynth(address debtToken, uint256 amount, address recipient) + const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "uint256", "address"], + [req.debtTokenAddress, amount, recipient] + ); + + builder.addExecute( + protocolId, + METRONOME_SELECTORS.MINT_SYNTH, + [], + deadline, + adapterData, + 0n, + executorAddr, + `Mint ${market.symbol} on Metronome` + ); + + return { + bundle: await builder.buildWithGas( + `Mint ${market.symbol} on Metronome`, + req.userAddress + ), + metadata: { + action: "mint_synth", + debtToken: req.debtTokenAddress, + synth: market.synth, + synthSymbol: market.symbol, + amount: amount.toString(), + recipient, + }, + }; +} diff --git a/backend/src/modules/metronome/usecases/prepare-repay.usecase.ts b/backend/src/modules/metronome/usecases/prepare-repay.usecase.ts new file mode 100644 index 0000000..38f49be --- /dev/null +++ b/backend/src/modules/metronome/usecases/prepare-repay.usecase.ts @@ -0,0 +1,91 @@ +import { ethers } from "ethers"; +import { getChainConfig } from "../../../config/chains"; +import { encodeProtocolId, getDeadline } from "../../../utils/encoding"; +import { BundleBuilder, METRONOME_SELECTORS } from "../../../shared/bundle-builder"; +import { TransactionBundle } from "../../../types/transaction"; +import { AppError } from "../../../shared/errorCodes"; +import { getContract } from "../../../providers/chain.provider"; +import { ERC20_ABI } from "../../../utils/abi"; +import { getSyntheticMarketByDebtToken } from "../config/metronome-markets"; + +export interface PrepareRepayRequest { + userAddress: string; + debtTokenAddress: string; // Metronome DebtToken for the synthetic being repaid + amount: string; // synth base units to burn +} + +export interface PrepareRepayResponse { + bundle: TransactionBundle; + metadata: { + action: "repay_synth"; + debtToken: string; + synth: string; + synthSymbol: string; + amount: string; + }; +} + +export async function executePrepareRepay( + req: PrepareRepayRequest +): Promise { + const chain = getChainConfig("base"); + const executorAddr = chain.contracts.panoramaExecutor; + if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Base"); + + const market = getSyntheticMarketByDebtToken(req.debtTokenAddress); + if (!market) { + throw new AppError( + "POOL_NOT_FOUND", + `Metronome synthetic market not found for debtToken: ${req.debtTokenAddress}` + ); + } + + const amount = BigInt(req.amount); + if (amount <= 0n) throw new AppError("INVALID_AMOUNT", "amount must be positive"); + + const protocolId = encodeProtocolId("metronome"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + // Executor pulls synthetic from the user into the proxy before dispatching. + const synth = getContract(market.synth, ERC20_ABI, "base"); + const allowance: bigint = await synth.allowance(req.userAddress, executorAddr); + builder.addApproveIfNeeded( + market.synth, + executorAddr, + allowance, + amount, + `Approve ${market.symbol} for PanoramaExecutor` + ); + + // repaySynth(address debtToken, uint256 amount) + const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "uint256"], + [req.debtTokenAddress, amount] + ); + + builder.addExecute( + protocolId, + METRONOME_SELECTORS.REPAY_SYNTH, + [{ token: market.synth, amount }], + deadline, + adapterData, + 0n, + executorAddr, + `Repay ${market.symbol} debt on Metronome` + ); + + return { + bundle: await builder.buildWithGas( + `Repay ${market.symbol} debt on Metronome`, + req.userAddress + ), + metadata: { + action: "repay_synth", + debtToken: req.debtTokenAddress, + synth: market.synth, + synthSymbol: market.symbol, + amount: amount.toString(), + }, + }; +} diff --git a/backend/src/modules/metronome/usecases/prepare-unwind.usecase.ts b/backend/src/modules/metronome/usecases/prepare-unwind.usecase.ts new file mode 100644 index 0000000..ecd13c6 --- /dev/null +++ b/backend/src/modules/metronome/usecases/prepare-unwind.usecase.ts @@ -0,0 +1,114 @@ +import { ethers } from "ethers"; +import { getChainConfig } from "../../../config/chains"; +import { encodeProtocolId, getDeadline } from "../../../utils/encoding"; +import { BundleBuilder, METRONOME_SELECTORS } from "../../../shared/bundle-builder"; +import { TransactionBundle } from "../../../types/transaction"; +import { AppError } from "../../../shared/errorCodes"; +import { getContract } from "../../../providers/chain.provider"; +import { ERC20_ABI } from "../../../utils/abi"; +import { + getCollateralMarketByDepositToken, + getSyntheticMarketByDebtToken, +} from "../config/metronome-markets"; + +export interface PrepareUnwindRequest { + userAddress: string; + debtTokenAddress: string; // Metronome DebtToken (synth side) + depositTokenAddress: string; // Metronome DepositToken (collateral side) + synthAmount: string; // synth base units the user funds — must cover full debt + fee + recipient?: string; // defaults to userAddress (gets collateral + synth dust) +} + +export interface PrepareUnwindResponse { + bundle: TransactionBundle; + metadata: { + action: "unwind"; + debtToken: string; + synth: string; + synthSymbol: string; + depositToken: string; + depositTokenSymbol: string; + underlyingSymbol: string; + synthAmount: string; + recipient: string; + }; +} + +export async function executePrepareUnwind( + req: PrepareUnwindRequest +): Promise { + const chain = getChainConfig("base"); + const executorAddr = chain.contracts.panoramaExecutor; + if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Base"); + + const synthMarket = getSyntheticMarketByDebtToken(req.debtTokenAddress); + if (!synthMarket) { + throw new AppError( + "POOL_NOT_FOUND", + `Metronome synthetic market not found for debtToken: ${req.debtTokenAddress}` + ); + } + + const collateralMarket = getCollateralMarketByDepositToken(req.depositTokenAddress); + if (!collateralMarket) { + throw new AppError( + "POOL_NOT_FOUND", + `Metronome collateral market not found for depositToken: ${req.depositTokenAddress}` + ); + } + + const synthAmount = BigInt(req.synthAmount); + if (synthAmount <= 0n) throw new AppError("INVALID_AMOUNT", "synthAmount must be positive"); + + const recipient = req.recipient ?? req.userAddress; + const protocolId = encodeProtocolId("metronome"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + // Executor pulls the synthetic from the user so the proxy has enough to burn + // against the full debt inside `unwind`. + const synth = getContract(synthMarket.synth, ERC20_ABI, "base"); + const allowance: bigint = await synth.allowance(req.userAddress, executorAddr); + builder.addApproveIfNeeded( + synthMarket.synth, + executorAddr, + allowance, + synthAmount, + `Approve ${synthMarket.symbol} for PanoramaExecutor` + ); + + // unwind(address debtToken, address depositToken, address recipient) + const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "address", "address"], + [req.debtTokenAddress, req.depositTokenAddress, recipient] + ); + + builder.addExecute( + protocolId, + METRONOME_SELECTORS.UNWIND, + [{ token: synthMarket.synth, amount: synthAmount }], + deadline, + adapterData, + 0n, + executorAddr, + `Unwind ${synthMarket.symbol} / ${collateralMarket.underlyingSymbol} position on Metronome` + ); + + return { + bundle: await builder.buildWithGas( + `Unwind ${synthMarket.symbol} / ${collateralMarket.underlyingSymbol} position on Metronome`, + req.userAddress + ), + metadata: { + action: "unwind", + debtToken: req.debtTokenAddress, + synth: synthMarket.synth, + synthSymbol: synthMarket.symbol, + depositToken: req.depositTokenAddress, + depositTokenSymbol: collateralMarket.symbol, + underlyingSymbol: collateralMarket.underlyingSymbol, + synthAmount: synthAmount.toString(), + recipient, + }, + }; +} diff --git a/backend/src/modules/metronome/usecases/prepare-withdraw.usecase.ts b/backend/src/modules/metronome/usecases/prepare-withdraw.usecase.ts new file mode 100644 index 0000000..6459d0b --- /dev/null +++ b/backend/src/modules/metronome/usecases/prepare-withdraw.usecase.ts @@ -0,0 +1,83 @@ +import { ethers } from "ethers"; +import { getChainConfig } from "../../../config/chains"; +import { encodeProtocolId, getDeadline } from "../../../utils/encoding"; +import { BundleBuilder, METRONOME_SELECTORS } from "../../../shared/bundle-builder"; +import { TransactionBundle } from "../../../types/transaction"; +import { AppError } from "../../../shared/errorCodes"; +import { getCollateralMarketByDepositToken } from "../config/metronome-markets"; + +export interface PrepareWithdrawRequest { + userAddress: string; + depositTokenAddress: string; // Metronome DepositToken (e.g. USDCDepositToken) + amount: string; // share units of the deposit-token + recipient?: string; // defaults to userAddress +} + +export interface PrepareWithdrawResponse { + bundle: TransactionBundle; + metadata: { + action: "withdraw_collateral"; + depositToken: string; + depositTokenSymbol: string; + underlyingSymbol: string; + amount: string; + recipient: string; + }; +} + +export async function executePrepareWithdraw( + req: PrepareWithdrawRequest +): Promise { + const chain = getChainConfig("base"); + const executorAddr = chain.contracts.panoramaExecutor; + if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Base"); + + const market = getCollateralMarketByDepositToken(req.depositTokenAddress); + if (!market) { + throw new AppError( + "POOL_NOT_FOUND", + `Metronome collateral market not found for depositToken: ${req.depositTokenAddress}` + ); + } + + const amount = BigInt(req.amount); + if (amount <= 0n) throw new AppError("INVALID_AMOUNT", "amount must be positive"); + + const recipient = req.recipient ?? req.userAddress; + const protocolId = encodeProtocolId("metronome"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + // Collateral shares already live on the per-user proxy — no approve / no transfers[]. + // withdrawCollateral(address depositToken, uint256 amount, address recipient) + const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "uint256", "address"], + [req.depositTokenAddress, amount, recipient] + ); + + builder.addExecute( + protocolId, + METRONOME_SELECTORS.WITHDRAW_COLLATERAL, + [], + deadline, + adapterData, + 0n, + executorAddr, + `Withdraw ${market.underlyingSymbol} collateral from Metronome` + ); + + return { + bundle: await builder.buildWithGas( + `Withdraw ${market.underlyingSymbol} collateral from Metronome`, + req.userAddress + ), + metadata: { + action: "withdraw_collateral", + depositToken: req.depositTokenAddress, + depositTokenSymbol: market.symbol, + underlyingSymbol: market.underlyingSymbol, + amount: amount.toString(), + recipient, + }, + }; +} diff --git a/backend/src/shared/bundle-builder.ts b/backend/src/shared/bundle-builder.ts index 72f2e66..e95d104 100644 --- a/backend/src/shared/bundle-builder.ts +++ b/backend/src/shared/bundle-builder.ts @@ -45,6 +45,15 @@ export const SAVAX_SELECTORS = { REDEEM: ethers.id("redeem(uint256,address)").slice(0, 10), } as const; +// ── Base — MetronomeAdapter selectors (ISynthMintAdapter family) ──────────── +export const METRONOME_SELECTORS = { + DEPOSIT_COLLATERAL: ethers.id("depositCollateral(address,uint256)").slice(0, 10), + WITHDRAW_COLLATERAL: ethers.id("withdrawCollateral(address,uint256,address)").slice(0, 10), + MINT_SYNTH: ethers.id("mintSynth(address,uint256,address)").slice(0, 10), + REPAY_SYNTH: ethers.id("repaySynth(address,uint256)").slice(0, 10), + UNWIND: ethers.id("unwind(address,address,address)").slice(0, 10), +} as const; + export const PANORAMA_EXECUTOR_ABI_EXECUTE = [ "function execute(bytes32 protocolId, bytes4 action, (address token, uint256 amount)[] transfers, uint256 deadline, bytes data) external payable returns (bytes result)", ] as const; diff --git a/contracts/aerodrome/adapters/AerodromeAdapterV2.sol b/contracts/aerodrome/adapters/AerodromeAdapterV2.sol index 6674985..563d0d7 100644 --- a/contracts/aerodrome/adapters/AerodromeAdapterV2.sol +++ b/contracts/aerodrome/adapters/AerodromeAdapterV2.sol @@ -7,6 +7,8 @@ import {IAerodromeGauge, IAerodromeVoter} from "../interfaces/IAerodromeGauge.so import {IERC20} from "../interfaces/IERC20.sol"; import {SafeTransferLib} from "../libraries/SafeTransferLib.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {ISwapAdapter} from "../../interfaces/ISwapAdapter.sol"; +import {ILPAdapter} from "../../interfaces/ILPAdapter.sol"; /** * @title AerodromeAdapterV2 @@ -28,7 +30,7 @@ import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.s * slot 4: executor (address) * slots 5-54: __gap (50 reserved slots for future use) */ -contract AerodromeAdapterV2 is IProtocolAdapter, Initializable { +contract AerodromeAdapterV2 is IProtocolAdapter, ISwapAdapter, ILPAdapter, Initializable { using SafeTransferLib for address; // ========== STORAGE (was immutable in V1) ========== diff --git a/contracts/avax/adapters/BenqiLendAdapter.sol b/contracts/avax/adapters/BenqiLendAdapter.sol index 25550c9..e370651 100644 --- a/contracts/avax/adapters/BenqiLendAdapter.sol +++ b/contracts/avax/adapters/BenqiLendAdapter.sol @@ -5,6 +5,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {IBenqiToken, IBenqiAVAX, IComptroller} from "../interfaces/IBenqiToken.sol"; +import {ILendAdapter} from "../../interfaces/ILendAdapter.sol"; /** * @title BenqiLendAdapter @@ -26,7 +27,7 @@ import {IBenqiToken, IBenqiAVAX, IComptroller} from "../interfaces/IBenqiToken.s * slot 2: executor (address) * slots 3-52: __gap (50 reserved) */ -contract BenqiLendAdapter is Initializable { +contract BenqiLendAdapter is Initializable, ILendAdapter { using SafeERC20 for IERC20; // ========== STORAGE ========== @@ -84,7 +85,7 @@ contract BenqiLendAdapter is Initializable { address qToken, uint256 amount, address recipient - ) external onlyExecutor returns (uint256 qTokensMinted) { + ) external override onlyExecutor returns (uint256 qTokensMinted) { if (amount == 0) revert ZeroAmount(); address underlying = IBenqiToken(qToken).underlying(); @@ -111,7 +112,7 @@ contract BenqiLendAdapter is Initializable { address qToken, uint256 qTokenAmount, address recipient - ) external onlyExecutor returns (uint256 underlyingReceived) { + ) external override onlyExecutor returns (uint256 underlyingReceived) { if (qTokenAmount == 0) revert ZeroAmount(); address underlying = IBenqiToken(qToken).underlying(); @@ -139,7 +140,7 @@ contract BenqiLendAdapter is Initializable { address qToken, uint256 amount, address recipient - ) external onlyExecutor { + ) external override onlyExecutor { if (amount == 0) revert ZeroAmount(); address[] memory markets = new address[](1); @@ -161,7 +162,7 @@ contract BenqiLendAdapter is Initializable { function repay( address qToken, uint256 amount - ) external onlyExecutor { + ) external override onlyExecutor { if (amount == 0) revert ZeroAmount(); address underlying = IBenqiToken(qToken).underlying(); @@ -238,7 +239,7 @@ contract BenqiLendAdapter is Initializable { * @notice Enter markets to enable qTokens as collateral. * @param qTokens Array of qToken addresses to enter. */ - function enterMarkets(address[] calldata qTokens) external onlyExecutor { + function enterMarkets(address[] calldata qTokens) external override onlyExecutor { comptroller.enterMarkets(qTokens); } @@ -246,7 +247,7 @@ contract BenqiLendAdapter is Initializable { * @notice Exit a market (stop using as collateral). * @param qToken The qToken address to exit. */ - function exitMarket(address qToken) external onlyExecutor { + function exitMarket(address qToken) external override onlyExecutor { comptroller.exitMarket(qToken); } diff --git a/contracts/avax/adapters/SAVAXAdapter.sol b/contracts/avax/adapters/SAVAXAdapter.sol index 851cb89..0e506cc 100644 --- a/contracts/avax/adapters/SAVAXAdapter.sol +++ b/contracts/avax/adapters/SAVAXAdapter.sol @@ -5,6 +5,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {IStakedAvax} from "../interfaces/IStakedAvax.sol"; +import {IStakeAdapter} from "../../interfaces/IStakeAdapter.sol"; /** * @title SAVAXAdapter @@ -24,7 +25,7 @@ import {IStakedAvax} from "../interfaces/IStakedAvax.sol"; * slot 2: _unlockIndices (dynamic array — contract-level unlock indices) * slots 3-52: __gap (50 reserved) */ -contract SAVAXAdapter is Initializable { +contract SAVAXAdapter is Initializable, IStakeAdapter { using SafeERC20 for IERC20; // ========== STORAGE ========== @@ -76,7 +77,7 @@ contract SAVAXAdapter is Initializable { * @param recipient Address to receive sAVAX tokens. * @return sAvaxReceived Amount of sAVAX minted. */ - function stake(address recipient) external payable onlyExecutor returns (uint256 sAvaxReceived) { + function stake(address recipient) external payable override onlyExecutor returns (uint256 sAvaxReceived) { if (msg.value == 0) revert ZeroAmount(); sAvaxReceived = sAvax.submit{value: msg.value}(); @@ -93,7 +94,7 @@ contract SAVAXAdapter is Initializable { * @param sAvaxAmount Amount of sAVAX shares to unlock. * @return unlockIndex Index in this proxy's unlock list (use for redeem). */ - function requestUnlock(uint256 sAvaxAmount) external onlyExecutor returns (uint256 unlockIndex) { + function requestUnlock(uint256 sAvaxAmount) external override onlyExecutor returns (uint256 unlockIndex) { if (sAvaxAmount == 0) revert ZeroAmount(); IERC20(address(sAvax)).forceApprove(address(sAvax), sAvaxAmount); @@ -113,7 +114,7 @@ contract SAVAXAdapter is Initializable { * @param unlockIndex Index in this proxy's unlock list (returned by requestUnlock). * @param recipient Address to receive redeemed AVAX. */ - function redeem(uint256 unlockIndex, address recipient) external onlyExecutor { + function redeem(uint256 unlockIndex, address recipient) external override onlyExecutor { if (unlockIndex >= _unlockIndices.length) revert InvalidUnlockIndex(); uint256 contractIndex = _unlockIndices[unlockIndex]; diff --git a/contracts/avax/adapters/TraderJoeAdapter.sol b/contracts/avax/adapters/TraderJoeAdapter.sol index af811b5..3f55438 100644 --- a/contracts/avax/adapters/TraderJoeAdapter.sol +++ b/contracts/avax/adapters/TraderJoeAdapter.sol @@ -5,6 +5,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {ITraderJoeRouter} from "../interfaces/ITraderJoeRouter.sol"; +import {ISwapAdapter} from "../../interfaces/ISwapAdapter.sol"; /** * @title TraderJoeAdapter @@ -26,7 +27,7 @@ import {ITraderJoeRouter} from "../interfaces/ITraderJoeRouter.sol"; * slot 2: executor (address) * slots 3-52: __gap (50 reserved) */ -contract TraderJoeAdapter is Initializable { +contract TraderJoeAdapter is Initializable, ISwapAdapter { using SafeERC20 for IERC20; // ========== STORAGE ========== diff --git a/contracts/base/adapters/MetronomeAdapter.sol b/contracts/base/adapters/MetronomeAdapter.sol new file mode 100644 index 0000000..26b9991 --- /dev/null +++ b/contracts/base/adapters/MetronomeAdapter.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import { + IMetronomeDepositToken, + IMetronomeDebtToken, + IMetronomePool +} from "../interfaces/IMetronome.sol"; +import {ISynthMintAdapter} from "../../interfaces/ISynthMintAdapter.sol"; + +/** + * @title MetronomeAdapter + * @notice Proxy-compatible adapter for Metronome Synth on Base. + * @dev Implements `ISynthMintAdapter` (see ADR 0002). + * + * Architecture note: Metronome is a CDP / synthetic-mint protocol. Operations + * run through per-token contracts, not a monolithic Pool: + * - `IMetronomeDepositToken` handles collateral (one per collateral asset) + * - `IMetronomeDebtToken` handles synthetic issuance (one per synthetic) + * - `IMetronomePool` orchestrates (we only use it for existence checks) + * + * Each user gets their own BeaconProxy of this adapter — isolates position state. + * The proxy is the accountable address for both collateral and debt in Metronome. + * + * Base mainnet addresses (stored at init time via initArgs): + * - Pool: 0xc614136d6c5AB85bc2aCF0ec2652351642d7F54E + * - PoolRegistry: 0x4372A2b9304296c06197a823f25Cf03119d2Fd82 + * + * Storage layout (append-only — never reorder): + * slot 0: pool (IMetronomePool) + * slot 1: poolRegistry (address) + * slot 2: executor (address) + * slots 3-52: __gap (50 reserved) + */ +contract MetronomeAdapter is Initializable, ISynthMintAdapter { + using SafeERC20 for IERC20; + + // ========== STORAGE ========== + + IMetronomePool public pool; + address public poolRegistry; + address public executor; + + // ========== STORAGE GAP ========== + + uint256[50] private __gap; + + // ========== ERRORS ========== + + error OnlyExecutor(); + error ZeroAmount(); + error UnregisteredMarket(); + error NoDebtToRepay(); + + // ========== MODIFIERS ========== + + modifier onlyExecutor() { + if (msg.sender != executor) revert OnlyExecutor(); + _; + } + + // ========== INITIALIZER ========== + + /** + * @notice Initialize the adapter proxy. + * @dev initArgs = abi.encode(poolAddress, poolRegistryAddress). + * + * @param _executor The PanoramaExecutorV2 contract address. + * @param _initArgs ABI-encoded: (address pool, address poolRegistry). + */ + function initializeFull(address _executor, bytes calldata _initArgs) external initializer { + executor = _executor; + (address _pool, address _poolRegistry) = abi.decode(_initArgs, (address, address)); + pool = IMetronomePool(_pool); + poolRegistry = _poolRegistry; + } + + // ========== SYNTH MINT ADAPTER (STRICT) ========== + + /** + * @notice Deposit underlying collateral into Metronome. + * @dev The proxy becomes the credited collateral holder in the deposit-token + * contract. Executor already pulled `amount` of the underlying token into + * this proxy before calling. + */ + function depositCollateral(address depositToken, uint256 amount) + external + override + onlyExecutor + returns (uint256 deposited) + { + if (amount == 0) revert ZeroAmount(); + if (!pool.doesDepositTokenExist(depositToken)) revert UnregisteredMarket(); + + address underlying = IMetronomeDepositToken(depositToken).underlying(); + IERC20(underlying).forceApprove(depositToken, amount); + + (deposited,) = IMetronomeDepositToken(depositToken).deposit(amount, address(this)); + } + + /** + * @notice Withdraw underlying collateral to `recipient`. + * @dev Reverts in Metronome if the withdrawal would leave the position under- + * collateralized. We do not re-check here — Metronome's on-chain guard is + * authoritative. + */ + function withdrawCollateral(address depositToken, uint256 amount, address recipient) + external + override + onlyExecutor + returns (uint256 withdrawn) + { + if (amount == 0) revert ZeroAmount(); + if (!pool.doesDepositTokenExist(depositToken)) revert UnregisteredMarket(); + + address underlying = IMetronomeDepositToken(depositToken).underlying(); + uint256 balBefore = IERC20(underlying).balanceOf(address(this)); + + (withdrawn,) = IMetronomeDepositToken(depositToken).withdraw(amount, address(this)); + + // Forward to recipient — adapter proxy never keeps user funds. + uint256 balAfter = IERC20(underlying).balanceOf(address(this)); + uint256 received = balAfter - balBefore; + if (received > 0) { + IERC20(underlying).safeTransfer(recipient, received); + } + } + + /** + * @notice Mint synthetic against the proxy's collateral. + * @dev Synthetic ERC20 is sent directly to `recipient` by the debt-token contract. + */ + function mintSynth(address debtToken, uint256 amount, address recipient) + external + override + onlyExecutor + returns (uint256 minted) + { + if (amount == 0) revert ZeroAmount(); + if (!pool.doesDebtTokenExist(debtToken)) revert UnregisteredMarket(); + + (minted,) = IMetronomeDebtToken(debtToken).issue(amount, recipient); + } + + /** + * @notice Burn synthetic to repay the proxy's debt. + * @dev The synthetic must already live in this proxy (executor transferred it in). + */ + function repaySynth(address debtToken, uint256 amount) + external + override + onlyExecutor + returns (uint256 repaid) + { + if (amount == 0) revert ZeroAmount(); + if (!pool.doesDebtTokenExist(debtToken)) revert UnregisteredMarket(); + + address synth = IMetronomeDebtToken(debtToken).syntheticToken(); + IERC20(synth).forceApprove(debtToken, amount); + + (repaid,) = IMetronomeDebtToken(debtToken).repay(address(this), amount); + } + + // ========== ADAPTER-SPECIFIC CONVENIENCE ========== + + /** + * @notice Close a position atomically: repay all debt + withdraw all collateral. + * @dev Adapter-specific (see ADR 0002 §why-no-unwind). Calls Metronome's native + * `repayAll` for gas efficiency, then withdraws the full deposit-token + * balance of this proxy. + * + * Executor must have transferred enough synthetic into this proxy to cover + * the full debt before calling. If the proxy has excess synth, it's refunded + * to `recipient`. + * + * @param debtToken Metronome debt-token for the synthetic to burn. + * @param depositToken Metronome deposit-token for the collateral to release. + * @param recipient Address to receive released collateral + any synth dust. + */ + function unwind(address debtToken, address depositToken, address recipient) + external + onlyExecutor + returns (uint256 repaid, uint256 withdrawn) + { + if (!pool.doesDebtTokenExist(debtToken)) revert UnregisteredMarket(); + if (!pool.doesDepositTokenExist(depositToken)) revert UnregisteredMarket(); + + address synth = IMetronomeDebtToken(debtToken).syntheticToken(); + uint256 synthBalBefore = IERC20(synth).balanceOf(address(this)); + if (synthBalBefore == 0) revert NoDebtToRepay(); + + IERC20(synth).forceApprove(debtToken, synthBalBefore); + (repaid,) = IMetronomeDebtToken(debtToken).repayAll(address(this)); + + // Refund any synth the user over-funded. + uint256 synthBalAfter = IERC20(synth).balanceOf(address(this)); + if (synthBalAfter > 0) { + IERC20(synth).safeTransfer(recipient, synthBalAfter); + } + + // Withdraw the full deposit-token balance → forward underlying to recipient. + uint256 depShares = IMetronomeDepositToken(depositToken).balanceOf(address(this)); + if (depShares > 0) { + address underlying = IMetronomeDepositToken(depositToken).underlying(); + uint256 undBefore = IERC20(underlying).balanceOf(address(this)); + (withdrawn,) = IMetronomeDepositToken(depositToken).withdraw(depShares, address(this)); + uint256 undAfter = IERC20(underlying).balanceOf(address(this)); + uint256 received = undAfter - undBefore; + if (received > 0) { + IERC20(underlying).safeTransfer(recipient, received); + } + } + } + + // ========== VIEW ========== + + /// @notice Current collateral balance (in deposit-token shares) of this proxy. + function collateralBalance(address depositToken) external view returns (uint256) { + return IMetronomeDepositToken(depositToken).balanceOf(address(this)); + } + + /// @notice Current debt balance (in synthetic units) of this proxy. + function debtBalance(address debtToken) external view returns (uint256) { + return IMetronomeDebtToken(debtToken).balanceOf(address(this)); + } + + // ========== FALLBACK ========== + + receive() external payable {} +} diff --git a/contracts/base/interfaces/IMetronome.sol b/contracts/base/interfaces/IMetronome.sol new file mode 100644 index 0000000..e26c3f6 --- /dev/null +++ b/contracts/base/interfaces/IMetronome.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title Metronome Synth protocol interfaces (Base) + * @notice Minimal external surface we actually call. Full ABIs live at + * https://github.com/autonomoussoftware/metronome-synth-public. + * + * Deployed Base addresses (mainnet 8453): + * - Pool: 0xc614136d6c5AB85bc2aCF0ec2652351642d7F54E + * - PoolRegistry: 0x4372A2b9304296c06197a823f25Cf03119d2Fd82 + * - USDCDepositToken: 0xC7F2f79Daa7Ea4FBbF60b45b5D6028BDE2453476 + * - WETHDepositToken: 0x8b581d0013F571a792c3Aa8AF2a0366A309BF51E + * - msETH: 0x7Ba6F01772924a82D9626c126347A28299E98c98 + * - msUSD: 0x526728DBc96689597F85ae4cd716d4f7fCcBAE9d + * + * Note — operations go through per-token contracts, NOT the Pool. + * Pool is the orchestrator (liquidations, synth-to-synth swaps). + */ + +/** + * @notice DepositToken — one per collateral asset. Wraps the underlying and + * tracks the user's collateral position. + */ +interface IMetronomeDepositToken { + /// @notice Pull underlying from msg.sender, credit a deposit position to `onBehalfOf_`. + /// @return _deposited Net amount credited after protocol fee. + /// @return _fee Protocol fee taken. + function deposit(uint256 amount_, address onBehalfOf_) + external + returns (uint256 _deposited, uint256 _fee); + + /// @notice Burn msg.sender's deposit tokens, send underlying to `to_`. + /// @return _withdrawn Net amount sent after protocol fee. + /// @return _fee Protocol fee taken. + function withdraw(uint256 amount_, address to_) + external + returns (uint256 _withdrawn, uint256 _fee); + + /// @notice Underlying ERC20 wrapped by this deposit token. + function underlying() external view returns (address); + + /// @notice ERC20 balance of deposit-token shares (not underlying). + function balanceOf(address account) external view returns (uint256); +} + +/** + * @notice DebtToken — one per synthetic asset. Tracks the user's debt position + * and mediates issuance / repayment of the corresponding synthetic. + */ +interface IMetronomeDebtToken { + /// @notice Mint synthetic to `to_`, create debt on msg.sender. + /// @return _issued Net synthetic sent to `to_` after protocol fee. + /// @return _fee Protocol fee taken. + function issue(uint256 amount_, address to_) + external + returns (uint256 _issued, uint256 _fee); + + /// @notice Burn synthetic from msg.sender, reduce debt on `onBehalfOf_`. + /// @return _repaid Net debt reduction after protocol fee. + /// @return _fee Protocol fee taken. + function repay(address onBehalfOf_, uint256 amount_) + external + returns (uint256 _repaid, uint256 _fee); + + /// @notice Repay the entire debt of `onBehalfOf_`. Amount is sourced from msg.sender. + /// @return _repaid Net debt cleared after protocol fee. + /// @return _fee Protocol fee taken. + function repayAll(address onBehalfOf_) external returns (uint256 _repaid, uint256 _fee); + + /// @notice The synthetic ERC20 this debt token tracks (e.g. msUSD, msETH). + function syntheticToken() external view returns (address); + + /// @notice Current debt balance of `account` in synthetic units. + function balanceOf(address account) external view returns (uint256); +} + +/** + * @notice Pool — orchestrator. We only need it to look up whether a market is + * registered and to fetch the debt/collateral caps if we want to + * pre-validate on the backend. No execution-side calls go here. + */ +interface IMetronomePool { + /// @notice Returns whether this deposit-token address is registered in the pool. + function doesDepositTokenExist(address depositToken_) external view returns (bool); + + /// @notice Returns whether this debt-token address is registered in the pool. + function doesDebtTokenExist(address debtToken_) external view returns (bool); +} diff --git a/contracts/interfaces/ILPAdapter.sol b/contracts/interfaces/ILPAdapter.sol new file mode 100644 index 0000000..78fd79e --- /dev/null +++ b/contracts/interfaces/ILPAdapter.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ILPAdapter + * @notice Family marker interface for protocol adapters that expose liquidity-pool + * actions (add/remove liquidity, gauge staking, reward claiming). + * @dev This interface is intentionally a marker (no functions) because LP surfaces + * diverge between protocol families: + * + * - Solidly-style (Aerodrome): `addLiquidity(...,bool stable,...)`, + * `stake(lpToken, amount, gauge)`, `claimRewards(lpToken, recipient, gauge)`. + * - UniswapV2-style (Trader Joe V1): no `stable` flag, farm staking via + * `MasterChef`-shaped calls. + * - Concentrated liquidity (UniV3, TraderJoe V2.1 liquidity book): ranges + + * NFT positions — incompatible with either of the above. + * + * Enforcing a single strict Solidity signature would either require refactoring + * deployed adapters or exclude protocols whose LP surface does not match. This + * marker declares family membership; the concrete selectors are registered in the + * backend Protocol Registry per adapter. + * + * Expected Solidly-style selectors (registered per adapter, not enforced on-chain): + * - `addLiquidity(address,address,bool,uint256,uint256,uint256,uint256,address)` + * - `removeLiquidity(address,address,bool,uint256,uint256,uint256,address,address)` + * - `stake(address,uint256,address)` + * - `unstake(address,uint256,address,address)` + * - `claimRewards(address,address,address)` + */ +interface ILPAdapter { + // Intentionally empty — marker interface. +} diff --git a/contracts/interfaces/ILendAdapter.sol b/contracts/interfaces/ILendAdapter.sol new file mode 100644 index 0000000..3b50c0a --- /dev/null +++ b/contracts/interfaces/ILendAdapter.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ILendAdapter + * @notice Strict interface for Compound-fork lending adapters (Benqi, Moonwell, etc.). + * @dev The Compound V2 money-market surface is stable across forks, so this interface + * enforces the shared ERC-20 action shape. Chain-native variants (supply native + * AVAX on Benqi, supply native ETH on Moonwell) live on the concrete adapter and + * are NOT declared here — their selectors differ per chain and would fragment the + * interface. + * + * All methods here are `external` — the adapter implementations add + * `onlyExecutor` and any custom error handling (e.g. non-zero Compound error + * codes translated to revert). + * + * Canonical selectors: + * - `supply(address,uint256,address)` + * - `redeem(address,uint256,address)` + * - `borrow(address,uint256,address)` + * - `repay(address,uint256)` + * - `enterMarkets(address[])` + * - `exitMarket(address)` + */ +interface ILendAdapter { + /// @notice Supply ERC-20 to the lending market, receiving a Compound-style + /// receipt token (cToken / qToken / mToken). + /// @param market The market/cToken address. + /// @param amount Amount of the underlying ERC-20 to supply. + /// @param recipient Where the minted receipt tokens should end up. + /// @return receiptMinted The amount of receipt tokens minted. + function supply( + address market, + uint256 amount, + address recipient + ) external returns (uint256 receiptMinted); + + /// @notice Redeem receipt tokens back for the underlying ERC-20. + /// @param market The market/cToken address. + /// @param receiptAmount Amount of receipt tokens to redeem. + /// @param recipient Where the redeemed underlying should be sent. + /// @return underlyingReceived The amount of underlying transferred to `recipient`. + function redeem( + address market, + uint256 receiptAmount, + address recipient + ) external returns (uint256 underlyingReceived); + + /// @notice Borrow ERC-20 from the market against prior collateral in this adapter. + /// @dev Implementations typically auto-enter the market to enable the borrow. + /// @param market Market to borrow from. + /// @param amount Amount of underlying to borrow. + /// @param recipient Where the borrowed tokens should be forwarded. + function borrow( + address market, + uint256 amount, + address recipient + ) external; + + /// @notice Repay an outstanding ERC-20 borrow. + /// @param market The market where the debt exists. + /// @param amount Amount of underlying to repay. + function repay( + address market, + uint256 amount + ) external; + + /// @notice Enable a set of markets as collateral for subsequent borrows. + function enterMarkets(address[] calldata markets) external; + + /// @notice Disable a market as collateral. + function exitMarket(address market) external; +} diff --git a/contracts/interfaces/IStakeAdapter.sol b/contracts/interfaces/IStakeAdapter.sol new file mode 100644 index 0000000..2c02281 --- /dev/null +++ b/contracts/interfaces/IStakeAdapter.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IStakeAdapter + * @notice Strict interface for liquid-staking adapters whose unstake flow has a + * cooldown window (Benqi sAVAX, Lido stETH queue, etc.). + * @dev The three core actions — stake, request unlock, redeem after cooldown — are + * consistent across this class of protocols. Protocols with instantaneous + * unstake (e.g. synchronous liquid staking derivatives) can still implement + * `requestUnlock` + `redeem` as a single-step no-op if needed, but in practice + * those should get their own interface (e.g. `IInstantStakeAdapter`) rather than + * pretend to cooldown. + * + * Canonical selectors: + * - `stake(address)` + * - `requestUnlock(uint256)` + * - `redeem(uint256,address)` + */ +interface IStakeAdapter { + /// @notice Stake native chain asset and receive the liquid-staking receipt token. + /// @param recipient Where the minted receipt tokens should end up. + /// @return sharesReceived The amount of receipt tokens minted. + function stake(address recipient) external payable returns (uint256 sharesReceived); + + /// @notice Queue an unlock request for previously-staked shares. + /// @dev Starts the protocol's cooldown period. Shares must already live in the + /// adapter proxy — the executor transfers them in via a `Transfer`. + /// @param sharesAmount Amount of receipt tokens to queue for unlock. + /// @return unlockIndex Local index used to identify this unlock when redeeming. + function requestUnlock(uint256 sharesAmount) external returns (uint256 unlockIndex); + + /// @notice Redeem unlocked native asset after the cooldown has elapsed. + /// @param unlockIndex Local index returned by `requestUnlock`. + /// @param recipient Where the redeemed native asset should be sent. + function redeem(uint256 unlockIndex, address recipient) external; +} diff --git a/contracts/interfaces/ISwapAdapter.sol b/contracts/interfaces/ISwapAdapter.sol new file mode 100644 index 0000000..e0dfca0 --- /dev/null +++ b/contracts/interfaces/ISwapAdapter.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ISwapAdapter + * @notice Family marker interface for protocol adapters that expose token-swap actions. + * @dev This interface is intentionally a marker (no functions) because swap surfaces + * diverge between protocol families: + * + * - Solidly-style (Aerodrome, Velodrome): swap has `bool stable` to select + * correlated vs volatile pool routing. + * - UniswapV2-style (Trader Joe V1): swap takes no `stable` flag and exposes + * `swapWithPath(...)` for multi-hop routing. + * - Concentrated-liquidity / aggregator styles will introduce yet more shapes. + * + * Enforcing a single strict Solidity signature would force every adapter into one + * flavour and break existing deployed V2 contracts. Instead, this marker declares + * family membership; the concrete swap selector lives in each adapter and is + * registered via the backend `ADAPTER_SELECTORS` map + Protocol Registry. + * + * Expected selectors (registered per adapter, not enforced on-chain): + * - `swap(address,address,uint256,uint256,address,bool)` — Solidly style + * - `swap(address,address,uint256,uint256,address)` — UniV2 style + * - `swapWithPath(uint256,uint256,address[],address)` — UniV2 multi-hop + * + * Events and errors intentionally live on the adapter — declaring them here would + * force storage-adjacent changes on already-deployed implementations. + */ +interface ISwapAdapter { + // Intentionally empty — marker interface. +} diff --git a/contracts/interfaces/ISynthMintAdapter.sol b/contracts/interfaces/ISynthMintAdapter.sol new file mode 100644 index 0000000..af67998 --- /dev/null +++ b/contracts/interfaces/ISynthMintAdapter.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ISynthMintAdapter + * @notice Strict interface for synthetic-asset / CDP protocols (Metronome, Liquity, + * Alchemix, MakerDAO). The four primitives below compose every user flow: + * open position, leverage, partial repay/withdraw, close. + * + * Adapter-specific methods — `unwind`, `flashIssue`, `leveragedDeposit`, + * cross-pool synth swap — live on the concrete adapter. They are not part of + * this family because they are compositions of the four primitives (or + * protocol-specific optimizations) and protocols do not implement them + * consistently. + * + * Canonical selectors (full Solidity signature → `ethers.id(sig).slice(0, 10)`): + * - `depositCollateral(address,uint256)` + * - `withdrawCollateral(address,uint256,address)` + * - `mintSynth(address,uint256,address)` + * - `repaySynth(address,uint256)` + * + * See ADR 0002 for the full design rationale. + */ +interface ISynthMintAdapter { + /** + * @notice Deposit collateral into a CDP position. + * @dev The collateral is pulled from the adapter proxy (executor already + * transferred it in via a `Transfer`). Proxy is the accountable address + * for the deposited collateral — matches BeaconProxy-per-user isolation. + * @param depositToken Protocol's per-collateral deposit token (e.g. msdWETH on + * Metronome). NOT the raw underlying — the deposit-token + * wrapper that tracks the position. + * @param amount Amount of underlying collateral to deposit. + * @return deposited Net amount credited after protocol fees. `deposited <= amount`. + */ + function depositCollateral(address depositToken, uint256 amount) + external + returns (uint256 deposited); + + /** + * @notice Withdraw collateral from a CDP position. + * @dev Reverts if the remaining position would drop below the protocol's + * minimum collateralization ratio. Protocols enforce this on-chain; we + * do not re-validate. + * @param depositToken Same as `depositCollateral`. + * @param amount Amount of underlying collateral to withdraw. + * @param recipient Where the underlying collateral lands. + * @return withdrawn Net amount sent to `recipient` after fees. + */ + function withdrawCollateral(address depositToken, uint256 amount, address recipient) + external + returns (uint256 withdrawn); + + /** + * @notice Mint (issue) a synthetic asset against the proxy's collateral. + * @dev Reverts if the resulting position is under-collateralized per the + * protocol's rules. The proxy is the debt holder of record. + * @param debtToken Protocol's per-synthetic debt token (e.g. msdUSD-debt on + * Metronome). The actual synthetic ERC20 (msUSD) is derived + * by the protocol and sent to `recipient`. + * @param amount Amount of synthetic to mint. + * @param recipient Where the minted synthetic lands. + * @return minted Net amount sent to `recipient` after fees. + */ + function mintSynth(address debtToken, uint256 amount, address recipient) + external + returns (uint256 minted); + + /** + * @notice Burn synthetic assets to reduce the proxy's debt. + * @dev The synthetic must already live in the proxy (executor transferred it + * in via a `Transfer`). `repay` here is a misnomer inherited from the + * Compound lexicon — the underlying operation is a *burn* against the + * user's debt position. + * @param debtToken Same as `mintSynth`. + * @param amount Amount of synthetic to burn. + * @return repaid Net debt reduction after protocol fees. Typically equals + * `amount`, but protocols may charge a repay fee. + */ + function repaySynth(address debtToken, uint256 amount) + external + returns (uint256 repaid); +} diff --git a/docs/00-overview/products-deep-dive.md b/docs/00-overview/products-deep-dive.md new file mode 100644 index 0000000..3f14c97 --- /dev/null +++ b/docs/00-overview/products-deep-dive.md @@ -0,0 +1,897 @@ +# PanoramaBlock Products — Deep Technical Dive + +**Audience:** engineers joining the team who need a genuine, end-to-end understanding of every +product we ship, how it maps onto the underlying DeFi primitive, which contracts execute it, and +what the data flow looks like from the user's finger to the Ethereum state root. + +**Scope:** all active products on Base (8453) and Avalanche C-Chain (43114), plus the common +infrastructure that hosts them (`PanoramaExecutorV2`, `BeaconProxy`, `BundleBuilder`, +non-custodial signing flow). + +**How to read this:** section 1 is the shared foundation — read it once. Sections 2–6 are +per-product deep dives, each self-contained. Section 7 covers cross-cutting concerns +(gas, slippage, deadlines, approvals) that apply to every product. + +--- + +## Table of Contents + +1. [Foundation — how any transaction gets on-chain](#1-foundation) +2. [Product: Swap](#2-swap) +3. [Product: Liquidity Pools](#3-liquidity-pools) +4. [Product: Lending](#4-lending) +5. [Product: Liquid Staking](#5-liquid-staking) +6. [Product: DCA (Dollar-Cost Averaging)](#6-dca) +7. [Cross-cutting concerns](#7-cross-cutting) +8. [Planned products (roadmap-aware summary)](#8-planned) + +--- + + +## 1. Foundation — how any transaction gets on-chain + +### 1.1 The non-custodial contract + +Every single user action in PanoramaBlock obeys the same rule: **we never hold user keys, and +we never sign transactions on the user's behalf.** The backend only *prepares* transaction +bundles; the user's wallet signs and broadcasts them. This is non-negotiable and shapes every +design decision downstream. + +This matters because it: + +- Eliminates regulatory custodian obligations. +- Makes us immune to the "backend operator steals funds" attack class. +- Forces us to produce bundles that are auditable offline by the user's wallet. + +### 1.2 The three-layer transaction pipeline + +``` +┌────────────────┐ 1. HTTP request ┌─────────────────┐ +│ Frontend │ ───────────────────────▶ │ Backend │ +│ (miniapp / │ │ (execution- │ +│ Telegram UI) │ ◀─────────────────────── │ layer API) │ +│ │ 2. PreparedBundle │ │ +└───────┬────────┘ └─────────────────┘ + │ + │ 3. user signs each tx in order + │ (ethers / ThirdWeb / MetaMask) + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ Blockchain │ +│ ┌──────────────────┐ ┌───────────────────────────┐ │ +│ │ PanoramaExecutor │ ───▶ │ BeaconProxy (per user, │ │ +│ │ V2 │ │ per protocol) │ │ +│ └──────────────────┘ └──────────────┬────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ Underlying DeFi protocol │ │ +│ │ (Aerodrome, Benqi, …) │ │ +│ └─────────────────────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +Every bundle is a **sequence of `PreparedTransaction`s**. A typical one has two: + +1. `approve(spender, amount)` — only if current allowance < required (`addApproveIfNeeded`) +2. `execute(protocolId, action, transfers, deadline, data)` — the real operation + +Some products (Swap ERC20→ETH) only need step 2 because the approve was done previously. Some +products need three or four steps (LP: approve tokenA, approve tokenB, addLiquidity, stake in +gauge). + +### 1.3 `PanoramaExecutorV2` — the only entry point + +```solidity +function execute( + bytes32 protocolId, + bytes4 action, + Transfer[] calldata transfers, + uint256 deadline, + bytes calldata data +) external payable returns (bytes memory result) +``` + +The executor is **protocol-neutral by design**. It knows nothing about swaps, lending, or +staking. It only does three things: + +1. **Resolve or create** the user's `BeaconProxy` for `protocolId`. Addresses are deterministic + — `CREATE2` with salt = `keccak256(user, protocolId)`. The same user + protocol always + resolves to the same proxy. +2. **Pull tokens** from the user into their proxy via `transferFrom` (the user already + approved the executor in step 1 of the bundle). +3. **Low-level call** `proxy.call(action ++ data)` — blind dispatch. + +This design means: *adding a new protocol never requires changing the executor*. You deploy the +adapter, deploy an `UpgradeableBeacon`, call `registerBeacon()`, and the executor serves it +immediately. + +### 1.4 `BeaconProxy` and why we use it (not EIP-1167 clones) + +Each protocol has **one `UpgradeableBeacon`**. The beacon stores a single address: the current +adapter implementation. Each user has **one `BeaconProxy` per protocol** they've touched. That +proxy delegates every call to `beacon.implementation()`. + +Upgrading from adapter v1 to adapter v2 is **one transaction**: + +```solidity +beacon.upgradeTo(newAdapterImplementation); +``` + +All user proxies for that protocol immediately point to the new implementation. No user +migration, no gas cost per user. + +Why not EIP-1167 (minimal proxy clones, our V1 approach)? EIP-1167 hardcodes the +implementation address into the proxy's bytecode. Upgrading would require **redeploying every +user's proxy** — impossible at scale. BeaconProxy costs ~30 gas more per call (one extra +`SLOAD`), in exchange for total upgrade flexibility. + +### 1.5 Adapter conventions (V2) + +Every adapter MUST: + +- Inherit `Initializable` from OpenZeppelin (prevents double-init). +- Expose `function initializeFull(address _executor, bytes calldata _initArgs)`. +- Have `modifier onlyExecutor` guarding every state-mutating external function. +- Reserve `uint256[50] private __gap` at the tail of storage. +- Accept native asset: `receive() external payable {}`. + +Why `__gap[50]`? So we can add new state variables in V3 without shifting existing slots. +Storage-layout compatibility is the single hardest constraint in upgradeable contracts — reorder +one slot and every user's proxy reads garbage. + +### 1.6 `BundleBuilder` — the only place bundles are assembled + +Location: [`backend/src/shared/bundle-builder.ts`](../../backend/src/shared/bundle-builder.ts). + +```typescript +const bundle = new BundleBuilder(chainId) + .addApproveIfNeeded(token, spender, currentAllowance, required, "Approve USDC") + .addExecute( + protocolId, + ADAPTER_SELECTORS.SWAP, + transfers, + deadline, + adapterData, + msgValue, + executorAddress, + "Swap USDC for AVAX" + ) + .build("Swap 100 USDC → AVAX on Trader Joe"); +``` + +Rule: **never construct a `PreparedTransaction` manually anywhere else.** This is what keeps +the bundles auditable. Every module goes through the same builder, so the frontend always sees +the same shape: `{ transactions: [...], summary, metadata }`. + +The selector comes from a registry: + +```typescript +ADAPTER_SELECTORS.SWAP_AERODROME = ethers.id( + "swap(address,address,uint256,uint256,address,bool)" +).slice(0, 10); +``` + +Full Solidity signature is mandatory — `ethers.id("swap")` is a different hash. + +### 1.7 `adapterData` — tight ABI encoding, no selector + +Adapter `data` is the ABI-encoded argument tuple of the adapter function, **without** the +4-byte selector (the executor already passed the selector via `action`): + +```typescript +const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "address", "uint256", "uint256", "address", "bool"], + [tokenIn, tokenOut, amountIn, amountOutMin, recipient, stable] +); +``` + +Inside `execute()`: + +```solidity +(bool ok, bytes memory result) = proxy.call(abi.encodePacked(action, data)); +``` + +Note `encodePacked` — the selector is prepended to the encoded args. The proxy receives a +standard Solidity calldata layout. + +--- + + +## 2. Product: Swap + +### 2.1 DeFi primitive explained + +An **AMM** (Automated Market Maker) swap replaces traditional order books with a liquidity +pool. Each pool holds reserves of two tokens and enforces an invariant: + +- **Constant product (volatile pools):** `x * y = k`. Uniswap V2, Aerodrome volatile, Trader + Joe V1. Price slips quadratically with trade size. +- **Stable pools (StableSwap curve):** `x + y = k` approximation at equilibrium, transitions to + constant product at extremes. Curve, Aerodrome stable pools. Minimal slippage for correlated + assets (USDC↔USDT, ETH↔wstETH). + +The user gets no price guarantee — they specify `amountOutMin`, and the trade reverts if the +actual output would be below this threshold. Slippage protection is the user's job (and we +calculate it for them). + +### 2.2 Swap on Base — Aerodrome Finance + +**Protocol:** Aerodrome Finance (Velodrome V2 fork on Base, VE(3,3) model). + +**Router:** `0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43` + +**Our adapter:** [`AerodromeAdapterV2`](../../contracts/aerodrome/adapters/AerodromeAdapterV2.sol) +→ implements `ISwapAdapter` (marker) + `ILPAdapter` (marker). + +**Adapter function:** + +```solidity +function swap( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + address recipient, + bool stable // <— Aerodrome-specific: picks stable vs volatile pool +) external payable onlyExecutor returns (uint256 amountOut) +``` + +Internally it builds a single-hop `IAerodromeRouter.Route`: + +```solidity +routes[0] = IAerodromeRouter.Route({ + from: tokenIn, + to: tokenOut, + stable: stable, // routes through either x*y=k or StableSwap + factory: factory // Aerodrome pool factory +}); +``` + +If `tokenIn == address(0)`, adapter uses `swapExactETHForTokens` (msg.value-based). If +`tokenOut == address(0)`, uses `swapExactTokensForETH`. Else `swapExactTokensForTokens`. + +**Backend module:** [`modules/swap/`](../../backend/src/modules/swap/). + +**Use cases:** +- `get-quote.usecase.ts` — calls `router.getAmountsOut(amountIn, routes)`, returns expected output +- `get-swap-pairs.usecase.ts` — lists all available volatile + stable pairs +- `prepare-swap.usecase.ts` — builds the bundle + +**End-to-end flow (user wants to swap 100 USDC → AERO on Base):** + +1. **Frontend** calls `POST /modules/swap/quote` with `{ chainId: 8453, tokenIn: USDC, tokenOut: AERO, amountIn: 100e6 }`. +2. **Backend** queries `AerodromeRouter.getAmountsOut()` for both volatile and stable pools, + picks the pool with better output, calculates `amountOutMin = expected * (1 - slippageBps/10000)`. +3. **Frontend** shows quote, user clicks confirm. +4. **Frontend** calls `POST /modules/swap/prepare-swap` with the user's address and signed + slippage tolerance. +5. **Backend** checks current USDC→executor allowance on-chain. If insufficient, `BundleBuilder` + prepends an `approve` transaction. Then builds the `execute` transaction. +6. **Frontend** receives a bundle with 1 or 2 transactions. +7. **User signs** the first (approve) in their wallet → broadcasts → wait for confirmation. +8. **User signs** the second (execute) → broadcasts. +9. `PanoramaExecutorV2.execute()` pulls 100 USDC from user → user's AerodromeAdapterV2 proxy → `adapter.swap()` → `Router.swapExactTokensForTokens()` → AERO is sent directly to `recipient` (the user's wallet), never passes back through the proxy. +10. **Frontend** polls the tx hash, shows success. + +### 2.3 Swap on Avalanche — Trader Joe V1 + +**Protocol:** Trader Joe V1 (classic Uniswap V2 fork, constant-product only). + +**Router:** `0x60aE616a2155Ee3d9A68541Ba4544862310933d4` + +**Our adapter:** [`TraderJoeAdapter`](../../contracts/avax/adapters/TraderJoeAdapter.sol) +→ implements `ISwapAdapter` (marker). + +**Key difference from Aerodrome:** no `bool stable` — Trader Joe V1 has only volatile pools. +Instead, it has **path-based routing**: if neither token is WAVAX, the adapter builds a 3-hop +path (`tokenIn → WAVAX → tokenOut`). + +```solidity +address[] memory path; +if (resolvedIn == wavax || resolvedOut == wavax) { + path = new address[](2); + path[0] = resolvedIn; + path[1] = resolvedOut; +} else { + path = new address[](3); + path[0] = resolvedIn; + path[1] = wavax; // WAVAX as hub liquidity + path[2] = resolvedOut; +} +``` + +For true multi-hop routes (like USDC → WAVAX → USDT), the adapter exposes +`swapWithPath(amountIn, amountOutMin, path[], recipient)`. + +**Backend module:** [`modules/avax-swap/`](../../backend/src/modules/avax-swap/). + +**Same selector pattern, different signature** — the backend registers a different selector: + +```typescript +ADAPTER_SELECTORS.SWAP_TRADERJOE = ethers.id( + "swap(address,address,uint256,uint256,address)" // no `bool` +).slice(0, 10); +``` + +Notice the signature is different from Aerodrome's — that's why `ISwapAdapter` is a *marker* +and not strict. See [ADR 0001](../adr/0001-action-families.md) for the full rationale. + +### 2.4 Security edges to know + +- **Sandwich attacks:** a MEV bot front-runs the user's trade to move the price up, then + back-runs to sell. Mitigation: low `amountOutMin` tolerance (typical: 0.5%), small trades. +- **Deadline:** `block.timestamp + 300` in the adapter. After 5 min, the trade is no longer + valid — prevents a pending tx from executing at a stale price. +- **Price impact** on low-liquidity pools: we calculate it in the backend quote and show it in + the UI so the user can abort. + +--- + + +## 3. Product: Liquidity Pools + +### 3.1 DeFi primitive explained + +**LP (Liquidity Provisioning):** deposit equal-value amounts of two tokens into a pool. In +return, receive **LP tokens** representing your share of the pool. You earn: + +- **Trading fees:** every swap routes through the pool; a fee (0.05%–1%) accrues to LPs + proportional to their share. +- **Reward emissions** (optional): in ve(3,3) systems like Aerodrome, gauges emit protocol + tokens (AERO) to LPs who **stake their LP tokens in the gauge**. This is additional yield on + top of trading fees. + +**The three-step flow:** + +1. `addLiquidity(tokenA, tokenB, amountA, amountB)` → receives LP tokens +2. `gauge.deposit(lpTokens)` → stakes them in the gauge → earns AERO emissions +3. Later: `gauge.getReward()` → claims accumulated AERO + +**Impermanent loss (IL):** if token A moons and B stays flat, the pool rebalances and you end +up with less A than if you had just held. The pool fees need to outweigh IL for LP to be +profitable. This is the user's risk, not ours. + +### 3.2 LP on Base — Aerodrome + +**Contracts involved (real Aerodrome infrastructure):** + +- **Router2:** enters `addLiquidity` / `removeLiquidity` +- **Pool factory:** deploys pools deterministically from `(tokenA, tokenB, stable)` +- **Voter:** maps `pool → gauge` address; also handles AERO emissions +- **Gauge:** per-pool staking contract that emits AERO rewards to stakers + +**Our adapter functions** (`AerodromeAdapterV2`): + +```solidity +// Mint LP tokens +function addLiquidity( + address tokenA, address tokenB, bool stable, + uint256 amountADesired, uint256 amountBDesired, + uint256 amountAMin, uint256 amountBMin, + address recipient +) external payable onlyExecutor returns (uint256 liquidity); + +// Burn LP tokens +function removeLiquidity( + address tokenA, address tokenB, bool stable, + uint256 liquidity, + uint256 amountAMin, uint256 amountBMin, + address recipient, address pool +) external onlyExecutor returns (uint256 amountA, uint256 amountB); + +// Stake LP in gauge to earn AERO +function stake(address lpToken, uint256 amount, address gauge) + external onlyExecutor returns (bool); + +// Unstake from gauge +function unstake(address lpToken, uint256 amount, address gauge, address recipient) + external onlyExecutor returns (bool); + +// Claim pending AERO +function claimRewards(address lpToken, address recipient, address gauge) + external onlyExecutor returns (uint256 rewardAmount); +``` + +**Key implementation detail — dust refunds:** + +`addLiquidity` takes `desired` amounts; the router uses only `used` amounts (depends on the +current pool ratio). The adapter **refunds the unused portion** to the user: + +```solidity +(uint256 usedA, uint256 usedB, uint256 lp) = router.addLiquidity(...); +_refundIfExcess(tokenA, amountADesired, usedA, recipient); +_refundIfExcess(tokenB, amountBDesired, usedB, recipient); +``` + +Without this, dust accumulates in the proxy forever — tiny amounts on every addLiquidity. + +**Gauge lookup:** if the frontend doesn't know the gauge address, it passes `address(0)` and +the adapter resolves via `voter.gauges(lpToken)`. + +**Backend module:** [`modules/liquid-staking/`](../../backend/src/modules/liquid-staking/) — +historically named "liquid-staking" because Aerodrome gauges were the first staking-like +product we shipped, but it's semantically LP. Slated for rename to `modules/base-lp/`. + +Use cases: +- `get-staking-pools.usecase.ts` — enumerates all active gauges +- `get-position.usecase.ts` — returns (LP balance, gauge balance, pending AERO) +- `prepare-enter-strategy.usecase.ts` — addLiquidity → stake in gauge (two-step bundle) +- `prepare-exit-strategy.usecase.ts` — unstake → removeLiquidity +- `prepare-claim-rewards.usecase.ts` — claim AERO + +### 3.3 LP on Avalanche — Trader Joe (planned) + +Trader Joe V1 has LP pools but the current `TraderJoeAdapter` only ships swap. Adding LP is +task #XXX (Rizzi) and will mirror the Aerodrome shape minus the `bool stable` and minus the +gauge layer. Trader Joe V1 has no native VE-style emissions; yield comes from trading fees +only. V2 Liquidity Book has **bin-based concentrated liquidity** — a different beast. We're +scoping V1 for the first pass. + +### 3.4 Risks to communicate + +- **IL:** front-end must show estimated IL at current price vs. baseline. +- **Rebase tokens** (stETH-like): unless protocol supports them, they will break LP math. + Aerodrome does NOT. We filter them out of the pair list. +- **Gauge-less pools:** some pools exist but have no gauge — no AERO emissions. We label them. + +--- + + +## 4. Product: Lending + +### 4.1 DeFi primitive explained + +**Money market** = pooled lending. Suppliers deposit tokens into a shared pool, borrowers +take from the same pool, and rates adjust algorithmically based on utilization. + +Three mechanics to internalize: + +1. **Receipt tokens (cTokens, qTokens, mTokens):** when you supply USDC to Benqi, you get + `qiUSDC`. It's rebasing-free: the quantity stays constant but its exchange rate with + underlying USDC goes up over time (1 qiUSDC → 1.02 USDC after a year of interest). This is + called a "rate-based" rebase, vs. Aave's "token-based" rebase (aUSDC balance itself grows). +2. **Collateral factor:** each market has a max borrow power (e.g., 75% for USDC, 60% for + AVAX). If you supply $1000 USDC, you can borrow up to $750 against it. +3. **Liquidations:** if your account falls below the collateral factor threshold, anyone can + call `liquidateBorrow()` on your position, buying your collateral at a discount (5–8%). + +Interest accrues **per block** via `accrueInterest()` — usually called automatically by +`supply`/`borrow`/`repay` (they all trigger it first). + +### 4.2 Lending on Avalanche — Benqi Finance + +**Protocol:** Benqi Finance (Compound V2 fork). + +**Comptroller:** `0x486Af39519B4Dc9a7fCcd318217352830E8AD9b4` — risk engine, holds collateral +factors, decides who can borrow how much, triggers liquidations. + +**qTokens:** +- `qiAVAX`: `0x5C0401e81Bc07Ca70fAD469b451682c0d747Ef1c` (special — native AVAX) +- `qiUSDC`, `qiUSDT`, `qiDAI`, `qiBTC.b`, `qiETH` (normal ERC20 qTokens) + +Native AVAX requires a separate ABI (`IBenqiAVAX`) because `mint()` is `payable` with +`msg.value` instead of an `amount` parameter. + +**Our adapter:** [`BenqiLendAdapter`](../../contracts/avax/adapters/BenqiLendAdapter.sol) → +implements `ILendAdapter` **strict**. + +**Strict interface methods:** + +```solidity +function supply(address qToken, uint256 amount, address recipient) returns (uint256 qTokensMinted); +function redeem(address qToken, uint256 qTokenAmount, address recipient) returns (uint256 underlyingReceived); +function borrow(address qToken, uint256 amount, address recipient); +function repay(address qToken, uint256 amount); +function enterMarkets(address[] calldata qTokens); +function exitMarket(address qToken); +``` + +**Adapter-specific (native AVAX) — NOT in the interface, because every Compound fork wraps the +native side differently:** + +```solidity +function supplyAVAX(address recipient) external payable returns (uint256 qTokensMinted); +function redeemAVAX(uint256 qTokenAmount, address recipient) external; +function borrowAVAX(uint256 amount, address recipient) external; +function repayAVAX() external payable; +``` + +**Where qTokens live:** + +The user's **proxy** holds the qTokens — NOT the user's wallet. Why? Because qTokens act as +collateral via the Comptroller's `enterMarkets()`, and `msg.sender` must be the address that +borrows. If qTokens were in the user's wallet, the adapter couldn't borrow against them. + +Concretely: + +``` +User → Executor → BenqiLendAdapter proxy + └── holds qiUSDC (collateral) + └── called enterMarkets (marks qiUSDC as collateral) + └── borrow qiAVAX (proxy is the borrower of record) + └── transfer AVAX to user wallet (via recipient) +``` + +This is the *whole reason* each user has their own proxy. A shared adapter would mean every +user's collateral is entangled. + +**Backend module:** [`modules/avax-lending/`](../../backend/src/modules/avax-lending/). + +Use cases: `prepare-supply`, `prepare-redeem`, `prepare-borrow`, `prepare-repay`. + +**Bundle example — user borrows 1 AVAX against 2000 USDC supplied:** + +``` +Tx1: approve(USDC, executor, 2000 USDC) [if allowance < 2000] +Tx2: execute(benqi, SUPPLY, [{USDC, 2000}], ...) → qiUSDC minted into proxy +Tx3: execute(benqi, ENTER_MARKETS, [], ..., [qiUSDC, qiAVAX]) +Tx4: execute(benqi, BORROW_AVAX, [], ..., amount=1 AVAX) → native AVAX to user +``` + +Steps 2–4 can be combined into one `supply → enterMarkets → borrow` bundle, but each is its +own `execute()` call (the executor is stateless). + +### 4.3 Lending on Base — Moonwell (planned) + +**Issue #XXX (Rizzi).** Moonwell is a Compound V2 fork on Base + Moonbeam. The adapter will be +a 1:1 copy of `BenqiLendAdapter` with different addresses — because both protocols inherit the +same ABI. That's exactly what `ILendAdapter` strict was designed for. + +**Moonwell Comptroller:** `0xfBb21d0380beE3312B33c4353c8936a0F13EF26C`. +**mTokens:** `mUSDC`, `mETH`, `mcbETH`, `mwstETH`. + +### 4.4 Risk primer + +- **Liquidation cascade:** if market prices move fast, liquidations chain: your health factor + drops below 1 → liquidator takes 5–8% of your collateral → your position shrinks → remaining + health ratio might still be bad → another liquidation. +- **Oracle risk:** Benqi uses Chainlink. If the oracle flash-updates, everyone's health + recalculates in one block. A 10% price move can trigger thousands of liquidations. +- **Utilization cap:** if utilization = 100% (nobody can withdraw), the interest rate spikes to + double-digits/day. Common reason: borrowed tokens are being held as collateral somewhere + else. + +--- + + +## 5. Product: Liquid Staking + +### 5.1 DeFi primitive explained + +"Liquid staking" = stake native asset with a validator / protocol, and receive a +**transferable receipt token** representing your staked position. The receipt token can be +used in DeFi (as collateral, in LP pools) while the underlying is still earning staking +rewards. + +Two sub-types: + +1. **Rebase tokens** (stETH, rETH): balance grows with accrued rewards. Share count stays + constant. Harder for DeFi protocols to support (balance changes without a `Transfer` event). +2. **Rate-based tokens** (sAVAX, cbETH, wstETH): balance stays constant, exchange rate grows. + Easier to integrate. + +Both have **cooldown periods** for unstaking (protocols need to exit validators): +- Lido (ETH): 1–5 days via the withdrawal queue +- sAVAX: **15 days** for unstake + 2 days for redemption window +- Rocket Pool: 0–28 days + +### 5.2 Liquid Staking on Avalanche — BENQI sAVAX + +**Protocol:** BENQI Liquid Staked AVAX. + +**sAVAX contract:** `0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE` — rate-based ERC20. 1 sAVAX +redeems for more AVAX over time. + +**Our adapter:** [`SAVAXAdapter`](../../contracts/avax/adapters/SAVAXAdapter.sol) → +implements `IStakeAdapter` **strict**. + +**Strict interface methods:** + +```solidity +function stake(address recipient) external payable returns (uint256 sharesReceived); +function requestUnlock(uint256 sharesAmount) external returns (uint256 unlockIndex); +function redeem(uint256 unlockIndex, address recipient) external; +``` + +**The 15-day cooldown — why it shapes the contract:** + +A naive design would be: +1. User transfers sAVAX to adapter +2. Adapter calls `sAVAX.requestUnlock(amount)` → sAVAX is queued for redemption +3. Wait 15 days +4. Adapter calls `sAVAX.redeem(index)` → gets AVAX back + +Problem: sAVAX's internal unlock index is **per-caller** (i.e., `address(adapter)`). If many +users share one adapter, their unlock queues overlap. User A's unlock index 3 might redeem +user B's AVAX. + +Our solution: **one BeaconProxy per user**. Since each user has their own proxy, each proxy +has its own unlock index space. The adapter stores a local `uint256[] _unlockIndices` array +and maps user-level indices to protocol-level indices: + +```solidity +uint256 contractIndex = sAvax.getUnlockRequestCount(address(this)); +sAvax.requestUnlock(sAvaxAmount); +unlockIndex = _unlockIndices.length; +_unlockIndices.push(contractIndex); // our local index → protocol's internal index +``` + +On redeem, we swap-and-pop the local array to avoid gaps. This is the *structural* reason +BeaconProxy matters for liquid staking: sharing state would be catastrophic. + +**Backend module:** [`modules/avax-liquid-staking/`](../../backend/src/modules/avax-liquid-staking/). + +Use cases: `prepare-stake`, `prepare-request-unlock`, `prepare-redeem`. + +**Full user flow (stake 10 AVAX, wait 15 days, get AVAX back):** + +1. User calls `POST /modules/avax-liquid-staking/prepare-stake` with `amount: 10 AVAX`. +2. Bundle: **one** tx → `execute(sAvax, STAKE, [], deadline, abi.encode(user))` with `msg.value = 10 AVAX`. +3. `SAVAXAdapter.stake(recipient=user)` calls `sAvax.submit{value: 10 AVAX}()` → mints ~9.8 sAVAX (AVAX accrues yield even while staked, exchange rate > 1). +4. Adapter transfers 9.8 sAVAX → user wallet directly (`recipient = user` means we don't hold in proxy). +5. **Days later — user wants to unstake.** +6. User calls `POST /modules/avax-liquid-staking/prepare-request-unlock` with `shares: 9.8 sAVAX`. +7. Bundle has two transactions: (a) approve sAVAX to executor, (b) execute → `requestUnlock`. +8. User signs both. Adapter pulls sAVAX into proxy, calls `sAvax.requestUnlock(9.8)`. Protocol queues the unlock, our proxy stores the local index (e.g., `0`). +9. **15-day cooldown starts.** +10. After 15 days: user calls `POST /modules/avax-liquid-staking/prepare-redeem` with `unlockIndex: 0`. +11. Bundle: one tx → `execute(sAvax, REDEEM, [], ..., abi.encode(0, user))`. +12. Adapter calls `sAvax.redeem(contractIndex)` → receives ~10.1 AVAX (15 days of rewards) → forwards to user via `call{value: ...}`. + +### 5.3 Liquid Staking on Base — Aerodrome gauges (current) / Lido (planned) + +On Base, we currently ship **Aerodrome gauge staking** under the "liquid-staking" module name +— semantically this is LP yield farming, not liquid staking. Renaming is queued. + +**Actual Lido stETH / wstETH integration on Base is planned** (via the L2 bridged stETH). +Would be a new adapter implementing `IStakeAdapter`, same shape as `SAVAXAdapter` — the +withdrawal queue works the same (request → cooldown → redeem). + +### 5.4 Risks + +- **Slashing:** underlying validator misbehaves → LST's exchange rate drops. Benqi's sAVAX has + never been slashed (as of 2026-04). +- **De-pegging:** LST secondary market price can diverge from redemption value during stress + (cf. stETH ~94% of ETH during May 2022). Matters if user wants to exit quickly — they'll + swap sAVAX for AVAX rather than wait 15 days. + +--- + + +## 6. Product: DCA (Dollar-Cost Averaging) + +### 6.1 DeFi primitive explained + +**DCA** = automatically swap a fixed amount of tokenIn → tokenOut at a fixed interval, +regardless of price. Classic long-horizon investment strategy (e.g., "buy $50 of ETH every +Monday"). + +Two architectural patterns: + +- **Off-chain scheduling:** a backend cron triggers user swaps. Requires custodial keys OR + delegated signatures. Simple but trust-heavy. +- **On-chain vault:** user deposits tokenIn into a vault, a *permissionless* keeper triggers + execution. The vault checks `interval` elapsed, pulls `amountPerSwap` from balance, calls + the swap router, forwards tokenOut to user. Non-custodial. + +We use **hybrid**: on-chain `DCAVault` holds user balances and enforces interval timing; a +backend keeper (off-chain) calls `execute(orderId)` at each interval. The keeper has no +privileged access to user funds beyond triggering the pre-approved swap. + +### 6.2 DCA on Base — `DCAVault` + +**Contract:** [`DCAVault.sol`](../../contracts/aerodrome/core/DCAVault.sol). Singleton, +UUPS-upgradeable, deployed at `0x...` on Base. + +**Note — not a user-proxy:** the vault is **shared** across all users (singleton). State is +per-order, not per-user-proxy. This is because DCA is stateless from the protocol perspective +— the vault just routes swap intents. No per-user collateral entanglement. + +**Order struct:** + +```solidity +struct Order { + address owner; + address tokenIn; + address tokenOut; + uint256 amountPerSwap; + uint256 interval; + uint256 lastExecuted; + uint256 remainingSwaps; // 0 = unlimited + uint256 balance; + bool stable; + bool active; +} +``` + +**Flow:** + +1. `createOrder(tokenIn, tokenOut, amountPerSwap, interval, remainingSwaps, stable, initialDeposit)` + — user deposits `initialDeposit` tokenIn and registers order `orderId`. +2. `deposit(orderId, amount)` — top up balance (optional). +3. `keeper.execute(orderId)` — callable only by `keeper` address. Checks: + - `order.active == true` + - `block.timestamp >= order.lastExecuted + order.interval` + - `order.balance >= order.amountPerSwap` + - Then: approve executor, call `executor.executeSwapFor(order.owner, ...)`, reduce balance, + update `lastExecuted`, decrement `remainingSwaps` if bounded. +4. `cancel(orderId)` — user marks order inactive. +5. `withdraw(orderId)` — user pulls remaining balance back. + +**Safety mechanisms:** +- Keeper and executor changes use **propose/accept with 1-day delay** (`ADMIN_DELAY`). +- Swap revert reasons bubble up verbatim — keeper sees the actual cause of failure. +- `tokenOut` is sent directly to `order.owner`, never trapped in the vault. + +**Why not BeaconProxy?** DCAVault is a different architectural class — it's an *orchestrator*, +not a per-user proxy. It's UUPS-upgradeable (one contract, owner-triggered upgrade). The +BeaconProxy pattern is for adapters where each user needs isolated state. + +**Special integration with `PanoramaExecutorV2`:** + +The executor has an `authorizedOperators` whitelist. `DCAVault` is on it, which lets the vault +call `executor.executeSwapFor(user, ...)` — a variant of `execute` that specifies *whose* proxy +to use (since the caller isn't the user). + +**Backend module:** [`modules/dca/`](../../backend/src/modules/dca/). + +Use cases: +- `prepare-create-order.usecase.ts` — builds bundle: approve tokenIn → createOrder +- `prepare-cancel-order.usecase.ts` — bundle: cancel → withdraw +- `get-orders.usecase.ts` — read user's active orders (by event scanning or direct call) +- `get-executable-orders.usecase.ts` — for the keeper: lists orders ready to execute + +**The keeper** is a separate process (node-cron) that polls `get-executable-orders`, +constructs the execute tx, signs with its private key, and broadcasts. The keeper wallet needs +AVAX/ETH for gas but cannot steal user funds — `execute()` is parameter-rigid. + +### 6.3 Future: multi-protocol DCA (Masqueico's task) + +The current DCAVault only wraps **Aerodrome swap** on Base. Planned extension: `actionType` +enum on Order struct — `SWAP | LEND_SUPPLY | STAKE`. User can then DCA into Benqi (supply USDC +every week) or sAVAX (stake AVAX monthly) on Avalanche. Requires an Avalanche counterpart to +DCAVault and corresponding backend module changes. + +### 6.4 Risks + +- **Keeper unavailability:** if the keeper process dies, orders won't execute on time. Orders + aren't lost — balance is safe — but swaps are delayed until keeper restart. +- **Price manipulation at keeper call time:** small pools could be sandwiched. We set + `amountOutMin` using `get-quote` at execution time with 0.5% slippage. High-volume pools + only. +- **Batch execution:** if 100 orders are ready at the same tick, keeper calls them serially. + At high scale, we may need a batch executor. + +--- + + +## 7. Cross-cutting concerns + +### 7.1 Approvals (`addApproveIfNeeded`) + +Every ERC20 transfer from user to executor requires prior approval. The `BundleBuilder` method +`addApproveIfNeeded(token, spender, currentAllowance, required, description)` prepends an +`approve` tx **only if `currentAllowance < required`**. + +To minimize future approvals, we approve **max uint256** (`type(uint256).max`). Tradeoffs: +- Pro: one approve lasts forever for that (user, token, spender) tuple. +- Con: if the executor is ever exploited, attacker can drain the user's tokens of that type + against the max approve. Mitigation: we use **two-step ownership + beacon removal delay** + for the executor, and periodic security audits. + +### 7.2 Slippage + +Frontend sends slippage in bps (basis points, 1/100th of 1%). Default 50 bps = 0.5%. Backend +converts: + +```typescript +const amountOutMin = expected * (10000n - slippageBps) / 10000n; +``` + +Applied uniformly across swap, LP addLiquidity (both amountMins), DCA execution. + +### 7.3 Deadlines + +`deadline = block.timestamp + 300` (5 minutes). Adapter reverts if `block.timestamp > deadline`. +Prevents pending transactions from executing at stale prices — a classic MEV trap. + +For DCA keeper triggers, deadline is computed fresh at the moment of keeper call. + +### 7.4 Gas + +Bundle transactions set `gasLimit` conservatively: + +- Swap: ~300k gas +- Supply / Redeem: ~250k gas +- Borrow: ~400k (requires `enterMarkets` interaction) +- Stake (sAVAX): ~200k gas +- Request unlock: ~250k gas +- Redeem (sAVAX): ~150k gas +- LP add + stake: ~600k gas + +Measured via `buildWithGas()` in `BundleBuilder`, which uses `provider.estimateGas()` and adds +a 20% safety buffer. User's wallet can still override if wanted. + +### 7.5 Multi-chain routing + +`backend/src/config/chains.ts` maps `chainId → { rpcUrls, executor, supportedProtocols }`. +Every module resolves `getChainConfig(chainId)` first; there is no global "default" chain. + +Base (8453) and Avalanche (43114) are active. The module directory structure encodes this: + +``` +modules/ +├── swap/ (implicitly Base — legacy naming) +├── liquid-staking/ (implicitly Base — Aerodrome gauges) +├── dca/ (implicitly Base) +├── avax-swap/ (Avalanche) +├── avax-lending/ (Avalanche) +└── avax-liquid-staking/ (Avalanche) +``` + +The `avax-` prefix is a temporary accommodation; plan is to rename Base modules to `base-` for +symmetry. + +### 7.6 Bundle auditability — what the user sees + +Every `PreparedTransaction` has: +- `to`, `data`, `value`, `gasLimit` — the raw tx +- `description` — human-readable string ("Approve USDC", "Swap 100 USDC → AVAX") +- `metadata` — optional structured data (expected amountOut, price impact, etc.) + +The frontend displays descriptions in order. User can see *exactly* what they're signing: +"you're about to approve USDC and then swap 100 USDC for at least 94 AVAX on Trader Joe". + +### 7.7 Error handling + +Adapter errors use custom Solidity errors (`error ZeroAmount()`, `error BenqiError(uint256)`). +The executor bubbles them up. The backend decodes them and returns a 422 with a mapped error +code. Frontend has a translation table. + +--- + + +## 8. Planned products (sprint-aware) + +| Product | Chain | Protocol | Status | Notes | +| --- | --- | --- | --- | --- | +| Moonwell Lending | Base | Moonwell | Planned (#XXX, Rizzi) | `ILendAdapter` strict, Compound fork, same shape as Benqi | +| Metronome Synthetic Mint | Base | Metronome | Planned (#480, Hugo) | Needs **new family** `ISynthMintAdapter` (ADR 0002) | +| TraderJoe LP | Avax | Trader Joe V1 | Planned (#XXX, Rizzi) | Same shape as Aerodrome LP minus `bool stable` and gauges | +| Lido Stake | Base | Lido | Future | `IStakeAdapter` strict — sAVAX-shaped withdrawal queue | +| DCA Multi-Protocol | Both | DCAVault v2 | Planned (Masqueico) | Enum `actionType` on Order struct | +| Community Group Unification | — | Telegram | Planned (Hugo) | Product-side, not contract | + +Moonwell and TraderJoe LP are pure copy-paste from existing adapters. Metronome requires a new +action family because synthetic-mint / CDP is architecturally distinct from lending. See +[ADR 0001 §2](../adr/0001-action-families.md) for the family-vs-family rationale. + +--- + +## Appendix A — Deployed contract addresses (production) + +**Base (8453):** +- `PanoramaExecutorV2`: see latest deploy log in `script/` +- Aerodrome Router2: `0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43` +- Aerodrome Voter: `0x16613524e02ad97eDfeF371bC883F2F5d6C480A5` +- `DCAVault`: see latest deploy log + +**Avalanche (43114):** +- `PanoramaExecutorV2`: see latest deploy log +- Trader Joe V1 Router: `0x60aE616a2155Ee3d9A68541Ba4544862310933d4` +- Benqi Comptroller: `0x486Af39519B4Dc9a7fCcd318217352830E8AD9b4` +- qiAVAX: `0x5C0401e81Bc07Ca70fAD469b451682c0d747Ef1c` +- sAVAX: `0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE` + +## Appendix B — References + +- [ADR 0001 — Action Family Interfaces](../adr/0001-action-families.md) +- [Action Families dev guide](../03-smart-contracts/action-families.md) +- [Root CLAUDE.md](../../CLAUDE.md) — canonical project rules +- [`BundleBuilder`](../../backend/src/shared/bundle-builder.ts) — source of truth for bundle assembly +- [`PanoramaExecutorV2`](../../contracts/aerodrome/core/PanoramaExecutorV2.sol) — single on-chain entry point +- Compound V2 Whitepaper — reference for `ILendAdapter` shape +- Aerodrome Finance docs — reference for VE(3,3) gauge mechanics +- BENQI sAVAX Integration Guide — reference for cooldown-based liquid staking diff --git a/docs/03-smart-contracts/action-families.md b/docs/03-smart-contracts/action-families.md new file mode 100644 index 0000000..619605d --- /dev/null +++ b/docs/03-smart-contracts/action-families.md @@ -0,0 +1,243 @@ +# Action Families — Dev Context + +**Audience:** anyone adding a new protocol adapter, reviewing adapter PRs, or wiring a new +product module on the backend. + +**TL;DR:** every adapter now declares which *action families* it belongs to via Solidity +interface inheritance. Some interfaces enforce a strict signature (lending, cooldown staking), +others are empty markers (swap, LP) because real protocol surfaces diverge. Full rationale in +[ADR 0001](../adr/0001-action-families.md). + +--- + +## 1. Why this exists + +Before, every adapter just inherited `Initializable` and shipped whatever methods its upstream +protocol needed. That works but: + +- Two swap adapters can diverge in subtle ways (parameter order, naming), forcing the + backend's `ADAPTER_SELECTORS` to memorize per-adapter exceptions. +- A new team member reading `BenqiLendAdapter` has no Solidity-level hint that it's a + Compound-fork money market and that `MoonwellAdapter` should mirror its shape. +- Audits have no taxonomy to bucket adapters by capability. + +Interfaces solve the second and third problem cheaply (zero runtime cost for markers, a +vtable entry or two for strict). They do not, on their own, solve the first — consistent +naming across adapters is still a PR-review concern. + +## 2. The four families + +Location: `contracts/interfaces/`. + +| Interface | Kind | Methods | Implementers | +| ----------------- | ------ | ---------------------------------------------------------------- | ----------------------------- | +| `ISwapAdapter` | marker | (none) | Aerodrome, Trader Joe | +| `ILPAdapter` | marker | (none) | Aerodrome | +| `ILendAdapter` | strict | `supply / redeem / borrow / repay / enterMarkets / exitMarket` | Benqi (Moonwell coming) | +| `IStakeAdapter` | strict | `stake / requestUnlock / redeem` | sAVAX (Lido-style queue next) | + +**Marker** = categorization only. You declare `is ISwapAdapter` to tag your adapter, but you +pick whatever swap signature your protocol needs. + +**Strict** = you *must* implement the exact signatures (or the compiler rejects the +contract), and you mark them with `override`. + +### When is something strict vs marker? + +Rule of thumb: if the upstream protocol surface is standardized de facto across the +ecosystem, we lock it down. Compound has been the reference money-market ABI since 2019 — any +Compound fork (Benqi, Moonwell, Venus, Sonne) matches it 1:1. Lido's withdrawal queue has +become the reference cooldown-stake shape for ETH and AVAX liquid staking. + +AMMs do not have this luxury — Uniswap V2, Aerodrome with its `bool stable`, Curve with +stable pools, Trader Joe V1 flat / V2 Liquidity Book — no common signature preserves all of +them without losing information. + +## 3. How to add a new adapter to an existing family + +### 3.1 Lending (strict `ILendAdapter`) + +Example: wiring Moonwell on Base. + +```solidity +import {ILendAdapter} from "../../interfaces/ILendAdapter.sol"; + +contract MoonwellLendAdapter is Initializable, ILendAdapter { + // ... storage, executor, initializeFull ... + + function supply(address mToken, uint256 amount, address recipient) + external override onlyExecutor returns (uint256) { ... } + + function redeem(address mToken, uint256 mTokenAmount, address recipient) + external override onlyExecutor returns (uint256) { ... } + + function borrow(address mToken, uint256 amount, address recipient) + external override onlyExecutor { ... } + + function repay(address mToken, uint256 amount) + external override onlyExecutor { ... } + + function enterMarkets(address[] calldata mTokens) external override onlyExecutor { ... } + function exitMarket(address mToken) external override onlyExecutor { ... } + + // Native variants (supplyETH, borrowETH) stay adapter-specific — no override. + function supplyETH(address recipient) external payable onlyExecutor returns (uint256) { ... } +} +``` + +Forgetting `override` on one of the six will fail compilation. That's the point. + +### 3.2 Stake (strict `IStakeAdapter`) + +Cooldown-based liquid staking. Example signature template: + +```solidity +function stake(address recipient) external payable override onlyExecutor returns (uint256); +function requestUnlock(uint256 sharesAmount) external override onlyExecutor returns (uint256); +function redeem(uint256 unlockIndex, address recipient) external override onlyExecutor; +``` + +Per-user unlock tracking should live in the adapter storage — each user has their own +BeaconProxy, so a contract-level array is effectively per-user. See +[`SAVAXAdapter.sol`](../../contracts/avax/adapters/SAVAXAdapter.sol) for the canonical +pattern. + +### 3.3 Swap (marker `ISwapAdapter`) + +You're free to invent the signature, but please: + +- First arg is `tokenIn` (or `path[0]` when using paths). +- Always take `amountIn`, `amountOutMin`, `recipient`, in that order. +- Accept `tokenIn == address(0)` as native asset (`msg.value`) where applicable. +- Return the actual `amountOut`. +- If your protocol needs a mode flag (stable vs volatile, concentrated vs classic), add it as + the last bool/enum arg. + +Then register the selector in `backend/src/shared/bundle-builder.ts` using the full Solidity +signature: + +```typescript +ADAPTER_SELECTORS.SWAP_AERODROME = ethers.id("swap(address,address,uint256,uint256,address,bool)").slice(0, 10); +ADAPTER_SELECTORS.SWAP_TRADERJOE = ethers.id("swap(address,address,uint256,uint256,address)").slice(0, 10); +``` + +Different selectors per adapter is fine — `PanoramaExecutorV2` dispatches blindly. + +### 3.4 LP (marker `ILPAdapter`) + +Same spirit as swap: declare `is ILPAdapter` to categorize, then model `addLiquidity`, +`removeLiquidity`, `stake`, `unstake` to match your protocol. See `AerodromeAdapterV2` for a +reference on refunds and gauge integration. + +## 4. How to add a brand-new family + +Only do this when a new protocol *class* appears that doesn't fit any existing family — +perpetuals, options, bridges, etc. Steps: + +1. Propose the interface in a new ADR (`docs/adr/000N-.md`). +2. Decide strict vs marker using the rubric in §2. +3. Add the interface file under `contracts/interfaces/IAdapter.sol`. +4. Update this doc's table. +5. First adapter that implements the family inherits it; rinse and repeat for future ones. + +Do **not** retrofit a strict interface onto pre-existing adapters without a version bump — +promoting a family from marker to strict is a signature constraint that can break +already-deployed adapters. + +## 5. Deploy procedure after adapter changes + +Adding `is IXxxAdapter` + `override` does **not** change runtime behavior — only the metadata +hash at the tail of the bytecode changes. Still: + +- Every mutation to an adapter's source requires a new implementation deploy. +- Proxies (user BeaconProxies) do **not** redeploy — they automatically follow the beacon. +- `PanoramaExecutorV2` does **not** redeploy. + +Rollout checklist: + +```bash +# 1. Deploy new implementation +forge script script/UpgradeAdapter.s.sol --rpc-url $BASE_RPC_URL --broadcast + +# 2. Point the beacon at it +# Inside the script or via cast: +cast send $BEACON_ADDRESS "upgradeTo(address)" $NEW_IMPL --private-key $DEPLOYER_KEY + +# 3. Verify one user proxy now reads the new impl +cast call $BEACON_ADDRESS "implementation()(address)" +``` + +All existing user proxies immediately delegate to the new impl. No user action required. + +### Storage-layout changes — the one thing to be careful about + +If you add a new state variable to an adapter: + +- Append at the **end** of storage (never between existing vars). +- Reduce `__gap[50]` by the number of new slots you consumed. +- A new `uint256 foo` → `uint256[49] private __gap;`. + +If you reorder or remove a storage variable: **stop, open a PR, get a review**. This is the +only class of change that can brick existing user proxies on upgrade. + +## 6. Testing expectations + +After any adapter change: + +```bash +# Unit tests, no RPC +forge test -vv --no-match-path "test/fork/*" + +# Fork tests (requires RPC) +BASE_RPC_URL=https://mainnet.base.org forge test --match-path "test/fork/*" -vvv +AVAX_RPC_URL=https://api.avax.network/ext/bc/C/rpc forge test --match-path "test/fork/*" -vvv + +# Backend +cd backend && npm test +``` + +Minimum coverage for a new adapter: + +- One unit test per strict-interface method, using mocks. +- One fork test for the happy path against the real protocol. +- If it implements a marker family, cover at least one swap / LP roundtrip on fork. + +## 7. Backend integration checklist + +When wiring a new adapter on the backend side: + +- [ ] Register the protocol with `registerProtocol()` (id, chain, beacon address, selectors). +- [ ] Add selectors to `ADAPTER_SELECTORS` using full Solidity signatures (not just names). +- [ ] Build every bundle through `BundleBuilder` — never hand-construct `PreparedTransaction`. +- [ ] Encode `adapterData` with `ethers.AbiCoder.defaultAbiCoder().encode(types, values)` — + types must match the adapter function's Solidity types exactly. +- [ ] Add a module under `backend/src/modules//` with `usecases/`, `controllers/`, + `routes/`. + +## 8. FAQ + +**Q: I want to add a new lending adapter but the protocol's `supply` returns nothing — Benqi +returns a uint. Can I change the interface return type?** +A: No, because that breaks every existing implementer. Either return 0 when you have no +useful value, or bring it up for an ADR amendment. + +**Q: My swap adapter needs 3 extra args (slippage curve, feeTier, recipient). Does it still +fit `ISwapAdapter`?** +A: Yes. Marker interfaces impose no signature. Just be consistent about ordering and document +the encoding in the module's `usecases/`. + +**Q: Can one adapter implement two families?** +A: Yes — see `AerodromeAdapterV2 is IProtocolAdapter, ISwapAdapter, ILPAdapter`. Use +judgment: if the two surfaces are genuinely coupled (same router call site), keep them in one +adapter; if they're independent (e.g., lending + staking), split. + +**Q: Do I need `override` on a marker interface?** +A: No — markers have no functions to override. You only write `override` for methods declared +in a strict parent interface. + +## 9. References + +- [ADR 0001 — Action Family Interfaces](../adr/0001-action-families.md) +- [`contracts/interfaces/`](../../contracts/interfaces/) +- Root [`CLAUDE.md`](../../CLAUDE.md) — canonical project rules (selectors, storage, executor) +- [`BundleBuilder`](../../backend/src/shared/bundle-builder.ts) — single bundle assembly point diff --git a/docs/adr/0001-action-families.md b/docs/adr/0001-action-families.md new file mode 100644 index 0000000..5e97e99 --- /dev/null +++ b/docs/adr/0001-action-families.md @@ -0,0 +1,140 @@ +# ADR 0001 — Action Family Interfaces + +- **Status:** Accepted +- **Date:** 2026-04-14 +- **Owner:** Execution Layer +- **Related issue:** #479 + +## Context + +The execution layer hosts a growing set of protocol adapters that each expose their own +external surface to `PanoramaExecutorV2`: + +- Aerodrome (Base) — AMM + gauges +- Trader Joe V1 (Avalanche) — AMM +- Benqi Finance (Avalanche) — Compound-fork money market +- sAVAX (Avalanche) — liquid staking with cooldown +- Moonwell, Metronome, Pharaoh, … (planned) + +Without shared type contracts, every new adapter risks diverging from its siblings — method +names, parameter ordering, return-value conventions — which increases both integration cost +on the backend (`BundleBuilder`, `ADAPTER_SELECTORS`) and review cost for security audits. + +At the same time, the adapters **already work in production** and the executor is strictly +protocol-neutral (blind dispatch via `proxy.call(action ++ data)`). Any standardization effort +must be **purely additive**: it cannot change the executor, cannot reorder storage, and cannot +break the bundle builder. + +## Problem + +How do we codify the taxonomy of *action families* (SWAP, LP, LEND, STAKE) in Solidity +without forcing every existing adapter into a signature straightjacket that its upstream +protocol cannot support? + +Example of friction: + +- `AerodromeAdapterV2.swap(tokenIn, tokenOut, amountIn, amountOutMin, recipient, bool stable)` +- `TraderJoeAdapter.swap(tokenIn, tokenOut, amountIn, amountOutMin, recipient)` + `swapWithPath(...)` + +A strict `ISwapAdapter.swap(...)` interface would have to pick one shape and break the other. +Same story for LP: Aerodrome's `addLiquidity` takes a `bool stable`; Trader Joe V1 does not. + +Conversely, lending (Compound fork) and cooldown-based liquid staking (sAVAX / Lido queue) +have **stable, well-known** surfaces that *should* be locked down: + +- `supply / redeem / borrow / repay / enterMarkets / exitMarket` — Compound has been the + reference for 6+ years +- `stake / requestUnlock / redeem` — every queue-based LSD looks the same + +## Decision + +Adopt a **hybrid strict/marker** strategy for action-family interfaces: + +### Strict interfaces (enforce exact shape) + +Used when the protocol surface is *de facto* standardized by the reference implementation. + +#### `ILendAdapter` — Compound-fork money markets +```solidity +function supply(address market, uint256 amount, address recipient) external returns (uint256); +function redeem(address market, uint256 receiptAmount, address recipient) external returns (uint256); +function borrow(address market, uint256 amount, address recipient) external; +function repay(address market, uint256 amount) external; +function enterMarkets(address[] calldata markets) external; +function exitMarket(address market) external; +``` + +Implemented by `BenqiLendAdapter` today. Moonwell (Compound fork on Base) drops in directly. +Native-asset variants (`supplyAVAX`, `borrowETH`, …) remain adapter-specific since each +Compound fork wraps the native side differently. + +#### `IStakeAdapter` — cooldown-based liquid staking +```solidity +function stake(address recipient) external payable returns (uint256); +function requestUnlock(uint256 sharesAmount) external returns (uint256 unlockIndex); +function redeem(uint256 unlockIndex, address recipient) external; +``` + +Implemented by `SAVAXAdapter`. Future Lido/stETH queue adapter, Rocket Pool, Ankr — all same +shape. + +### Marker interfaces (tag without constraint) + +Used when real protocol surfaces genuinely diverge and a common signature would either lose +information or force awkward overloads. + +#### `ISwapAdapter` +Empty. Tags any adapter that performs token-for-token swaps. Aerodrome's `stable` bool and +Trader Joe's multi-hop `path[]` stay as native, typed methods on the concrete adapter — the +backend (`ADAPTER_SELECTORS`) resolves them via full Solidity selector. + +#### `ILPAdapter` +Empty. Tags any adapter that manages AMM positions (add / remove / stake / unstake). Again, +`bool stable` on Aerodrome and the lack thereof on Trader Joe would require a lowest-common- +denominator signature that loses fidelity. + +## Consequences + +### Positive + +- **Categorization in code.** Every adapter declares its action families via `is IXxxAdapter`, + so static analysis tools, audit checklists, and the backend's type system can bucket them. +- **No big-bang refactor.** All four existing adapters keep their current methods; the only + diff is inheritance + `override` keywords where applicable. +- **Progressive tightening.** If Aerodrome V3 and Trader Joe V2 both move to the same + Uniswap-V4-style router, we can promote `ISwapAdapter` to a strict interface later without + changing the marker-contract history. +- **Room for native-asset variants.** `supplyAVAX`, `supplyETH`, `stake` with `msg.value` — + all stay on concrete adapters without polluting the shared interface. + +### Negative / tradeoffs + +- Marker interfaces alone don't prevent drift between adapters in the same family — a new + swap adapter could invent `swap(bytes)` and still be `is ISwapAdapter`. Mitigation: the + *backend's* `ADAPTER_SELECTORS` + `BundleBuilder` tests act as the functional contract. +- Two strictness tiers means reviewers need to know which family they're in. +- Native-asset operations (`supplyAVAX`, `borrowETH`, `stakeAVAX`) remain adapter-specific. + +### Neutral + +- No storage layout impact — interfaces contribute nothing to slots. +- No executor change — the executor still dispatches blindly. + +## Implementation checklist + +- [x] Create `contracts/interfaces/ISwapAdapter.sol` (marker) +- [x] Create `contracts/interfaces/ILPAdapter.sol` (marker) +- [x] Create `contracts/interfaces/ILendAdapter.sol` (strict) +- [x] Create `contracts/interfaces/IStakeAdapter.sol` (strict) +- [x] `AerodromeAdapterV2 is IProtocolAdapter, ISwapAdapter, ILPAdapter` +- [x] `TraderJoeAdapter is ISwapAdapter` +- [x] `BenqiLendAdapter is ILendAdapter` + `override` on the six strict methods +- [x] `SAVAXAdapter is IStakeAdapter` + `override` on the three strict methods +- [ ] `forge test -vv --no-match-path "test/fork/*"` green +- [ ] `cd backend && npm test` green + +## References + +- OpenZeppelin ERC-165 — precedent for marker interfaces +- Compound v2 Comptroller — reference lending surface +- Lido Withdrawal Queue — reference cooldown-stake surface diff --git a/docs/adr/0002-synth-mint-family.md b/docs/adr/0002-synth-mint-family.md new file mode 100644 index 0000000..4ce7e06 --- /dev/null +++ b/docs/adr/0002-synth-mint-family.md @@ -0,0 +1,182 @@ +# ADR 0002 — ISynthMintAdapter Action Family + +- **Status:** Accepted +- **Date:** 2026-04-17 +- **Owner:** Execution Layer +- **Related issue:** #480 +- **Supersedes:** — (extends ADR 0001) + +## Context + +Issue #480 introduces Metronome Synth as a new Base protocol. The issue's original wording +suggested the adapter should implement `ILendAdapter + IStakeAdapter`, but investigation of +the real Metronome Synth ABI shows it belongs to a distinct protocol class that none of the +existing four action families (`ISwap`, `ILP`, `ILend`, `IStake`) capture honestly. + +Specifically, Metronome is a **CDP / synthetic-asset protocol** (same class as MakerDAO DAI, +Liquity LUSD, Alchemix alAssets). These protocols are architecturally different from Compound- +style money markets: + +| Dimension | Compound money market (`ILendAdapter`) | Synthetic mint CDP (this ADR) | +| -------------------------------- | ---------------------------------------------- | ----------------------------------------------- | +| Where the borrowed token comes from | Other suppliers' deposits (pool) | **Minted from nothing** against collateral | +| Receipt token on deposit | Yes — cToken/qToken, accrues interest | Varies (Metronome: msdTokens; Liquity: none) | +| Collateral yield | Yes — supply APY | Usually no (except Metronome's productive collateral) | +| Debt semantics | Borrow real token from pool | Mint synthetic; repay burns it | +| Multi-position | One position per market | Sometimes multi-collateral/multi-synth per pool | +| Exit | Redeem cToken for underlying | Repay debt → unlock collateral → withdraw | + +Forcing Metronome into `ILendAdapter` would require: +- `supply()` pretending to return a "receiptMinted" that has different semantics. +- `enterMarkets()` / `exitMarket()` as dead no-op functions (Metronome has no entry markets). +- `borrow()` misnamed — it's actually a synthetic *mint*. +- `repay()` misnamed — it's actually a *burn*. + +These are not aesthetic nits. They are *bugs-in-waiting* because the next engineer reads +`supply` and applies a mental model that doesn't match the protocol. + +## Decision + +Create a new **strict** action family: `ISynthMintAdapter`. + +```solidity +interface ISynthMintAdapter { + function depositCollateral( + address depositToken, + uint256 amount + ) external returns (uint256 deposited); + + function withdrawCollateral( + address depositToken, + uint256 amount, + address recipient + ) external returns (uint256 withdrawn); + + function mintSynth( + address debtToken, + uint256 amount, + address recipient + ) external returns (uint256 minted); + + function repaySynth( + address debtToken, + uint256 amount + ) external returns (uint256 repaid); +} +``` + +Four methods. Strict — any implementer **must** provide all four. Convenience operations like +`unwind` (repay-all + withdraw-all in one tx), looped-yield farms, or cross-collateral swaps +stay as adapter-specific methods and do not belong in the base interface. + +### Design rationale + +#### Why strict, not marker? + +ADR 0001 rubric: we make a family strict when the upstream protocol surface is standardized +de facto across the ecosystem. For CDP protocols, the four operations above are **universal**: + +| Protocol | Deposit | Withdraw | Mint | Repay | +| --------- | ----------------- | ------------------ | ----------------- | --------------- | +| Metronome | `deposit` | `withdraw` | `issue` | `repay` | +| Liquity | `openTrove`/`addColl` | `withdrawColl` | `withdrawLUSD` | `repayLUSD` | +| MakerDAO | `join` (Vat) | `exit` | `draw` | `wipe` | +| Alchemix | `deposit` | `withdraw` | `mint` | `burn`/`repay` | + +Different names, same semantic operations. The interface normalizes them. + +#### Why these four and not more? + +Minimal, orthogonal operations. You can compose any user-facing flow from these four: + +- **Open position** = `depositCollateral` + `mintSynth` +- **Partial repay** = `repaySynth` +- **Partial withdraw** = `withdrawCollateral` (safe as long as health factor remains) +- **Close position** = `repaySynth(all)` + `withdrawCollateral(all)` (adapter-specific `unwind` = both in one tx) +- **Leverage up** = mint more synth, swap to collateral elsewhere, deposit again +- **Leverage down** = sell collateral, repay + +Adding `unwind` or `leveragedDeposit` to the strict interface locks in Metronome-specific +composition that Liquity/Maker don't match. + +#### Why return `uint256` on every method? + +Protocols take fees on some operations (Metronome charges deposit/withdraw/issue fees). The +return value is the **net actioned amount** after fees. For protocols with no fees, this +equals the input amount. Uniform return type makes the backend module code symmetric. + +Adapters that want to surface the fee separately can add a view function +`previewFee(address market, Op op, uint256 amount)` — outside the interface. + +#### Why no `unwind` in the interface? + +`unwind` is a composition (`repayAll` + `withdrawAll`), not a primitive. Protocols that +support it atomically (Metronome's `repayAll`, Liquity's `closeTrove`) can expose it as an +adapter-specific method. Protocols that don't will synthesize it client-side (two txs in a +bundle). Neither path belongs in the strict interface. + +#### Native asset handling + +Some synth protocols accept native ETH/AVAX collateral (Liquity). Metronome does not — ETH +must be wrapped to WETH first. Because the pattern varies, native support is **adapter- +specific**, same approach as `ILendAdapter` (`supplyAVAX` lives on `BenqiLendAdapter`, not in +the interface). + +### What this ADR does NOT do + +- **Does not deprecate `ILendAdapter`.** Compound-style lending is still its own distinct + family. Benqi and Moonwell stay under `ILendAdapter`. +- **Does not merge synthetic mint into `ILendAdapter` with an enum flag.** Tried mentally, + rejected — it's the exact "bugs-in-waiting" case that ADR 0001 §2 warns against. +- **Does not constrain the adapter's non-primitive methods.** Metronome's `unwind`, leveraged + flash-issue, cross-pool synth swap — all stay as typed methods on the concrete adapter. + +## Consequences + +### Positive + +- Metronome (#480) implements `ISynthMintAdapter` honestly — no pretend semantics. +- Liquity, Alchemix, Maker integrations in the future drop into the same interface. +- Backend's `ADAPTER_SELECTORS` gets a new family section; existing selectors untouched. +- Taxonomy remains clear: Lend (money market) ≠ SynthMint (CDP) ≠ Stake (liquid staking) + ≠ Swap ≠ LP. + +### Negative / tradeoffs + +- One more interface in the `contracts/interfaces/` folder. +- Reviewers need to know **which family** a new protocol belongs to. Rubric in section 2 of + this ADR plus the `action-families.md` dev guide should cover it. +- Backend modules for Metronome and (future) Benqi borrow share no code — because the shapes + genuinely differ. This is the price of honest interfaces. + +### Neutral + +- No executor change. +- No BundleBuilder change beyond adding selectors. +- No storage layout impact. + +## Implementation checklist + +- [x] Document the family in this ADR. +- [ ] Create `contracts/interfaces/ISynthMintAdapter.sol` (strict, 4 methods). +- [ ] Create `contracts/base/interfaces/IMetronome.sol` for the Metronome ABI. +- [ ] Create `contracts/base/adapters/MetronomeAdapter.sol` implementing `ISynthMintAdapter`. + Add adapter-specific `unwind()` that composes `repayAll` + `withdraw(max)`. +- [ ] Deploy script `script/Deploy_MetronomeBeacon.s.sol`. +- [ ] Register protocol in `backend/src/config/protocols.ts`: id `metronome`, chain `base`. +- [ ] Add `ADAPTER_SELECTORS.METRONOME_DEPOSIT_COLLATERAL`, `_MINT_SYNTH`, `_REPAY_SYNTH`, + `_WITHDRAW_COLLATERAL`, `_UNWIND` using full Solidity signatures. +- [ ] Backend module `backend/src/modules/metronome/` with at minimum `prepare-deposit.usecase.ts`. +- [ ] Unit tests (Foundry) covering the four primitives. +- [ ] Fork test on Base covering deposit → mint → repay → withdraw round-trip. +- [ ] Update `docs/03-smart-contracts/action-families.md` table to include the new family. +- [ ] Update `docs/00-overview/products-deep-dive.md` with a §4.5 or §8 row. + +## References + +- [ADR 0001 — Action Family Interfaces](0001-action-families.md) — parent rubric. +- Metronome Synth Protocol: [docs.metronome.io](https://docs.metronome.io/metronome-synth/metronome-synth-protocol) +- Metronome Base Pool: `0xc614136d6c5AB85bc2aCF0ec2652351642d7F54E` +- Metronome Synth public contracts: [autonomoussoftware/metronome-synth-public](https://github.com/autonomoussoftware/metronome-synth-public) +- Liquity Core: compositional reference for `openTrove` / `closeTrove` +- Alchemix V2: compositional reference for `mint` / `burn` symmetry diff --git a/script/base/Deploy_MetronomeBeacon.s.sol b/script/base/Deploy_MetronomeBeacon.s.sol new file mode 100644 index 0000000..eaa7513 --- /dev/null +++ b/script/base/Deploy_MetronomeBeacon.s.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {MetronomeAdapter} from "../../contracts/base/adapters/MetronomeAdapter.sol"; +import {PanoramaExecutorV2} from "../../contracts/aerodrome/core/PanoramaExecutorV2.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; + +/** + * @title Deploy_MetronomeBeacon + * @notice Adds Metronome as a new protocol on Base to an existing PanoramaExecutorV2. + * @dev Usage: + * source .env + * forge script script/base/Deploy_MetronomeBeacon.s.sol \ + * --rpc-url $BASE_RPC_URL \ + * --broadcast --verify + * + * Required env vars: + * PRIVATE_KEY — deployer key (must also be executor owner) + * EXECUTOR_V2_ADDRESS — already-deployed PanoramaExecutorV2 on Base + * + * Deploys: + * 1. MetronomeAdapter implementation + * 2. UpgradeableBeacon pointing to (1) + * 3. registerBeacon(keccak256("metronome"), beacon, abi.encode(pool, poolRegistry)) + */ +contract Deploy_MetronomeBeacon is Script { + // ── Metronome Synth — Base mainnet (8453) ─────────────────────────────── + address constant METRONOME_POOL = 0xc614136d6c5AB85bc2aCF0ec2652351642d7F54E; + address constant METRONOME_POOL_REGISTRY = 0x4372A2b9304296c06197a823f25Cf03119d2Fd82; + + bytes32 constant METRONOME_ID = keccak256("metronome"); + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + address executorAddress = vm.envAddress("EXECUTOR_V2_ADDRESS"); + + vm.startBroadcast(deployerPrivateKey); + + // 1. Adapter implementation + MetronomeAdapter impl = new MetronomeAdapter(); + console.log("MetronomeAdapter impl:", address(impl)); + + // 2. Beacon (deployer is initial owner — can transfer to executor owner later) + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), deployer); + console.log("Metronome beacon:", address(beacon)); + + // 3. Register with the existing executor + PanoramaExecutorV2 executor = PanoramaExecutorV2(payable(executorAddress)); + executor.registerBeacon( + METRONOME_ID, + address(beacon), + abi.encode(METRONOME_POOL, METRONOME_POOL_REGISTRY) + ); + + vm.stopBroadcast(); + + console.log("\n=== Metronome Registration Summary ==="); + console.log("Chain: Base mainnet (8453)"); + console.log("Executor:", executorAddress); + console.log("Protocol ID:", uint256(METRONOME_ID)); + console.log("Adapter impl:", address(impl)); + console.log("Beacon:", address(beacon)); + console.log("Metronome Pool:", METRONOME_POOL); + console.log("Metronome PoolRegistry:", METRONOME_POOL_REGISTRY); + } +} diff --git a/test/fork/MetronomeAdapter.t.sol b/test/fork/MetronomeAdapter.t.sol new file mode 100644 index 0000000..ea40d6b --- /dev/null +++ b/test/fork/MetronomeAdapter.t.sol @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MetronomeAdapter} from "../../contracts/base/adapters/MetronomeAdapter.sol"; +import { + IMetronomeDepositToken, + IMetronomeDebtToken, + IMetronomePool +} from "../../contracts/base/interfaces/IMetronome.sol"; + +/** + * @title MetronomeAdapterForkTest + * @notice End-to-end tests for MetronomeAdapter against real Metronome Synth + * contracts on Base mainnet. No mocks. + * + * @dev Run with: + * BASE_RPC_URL=https://mainnet.base.org forge test \ + * --match-contract MetronomeAdapterForkTest --evm-version cancun -vvv + * + * `--evm-version cancun` is required because Metronome's live Pool proxy + * uses post-Paris opcodes (TSTORE/MCOPY). The repo default is `paris` + * (foundry.toml) — overriding here does not affect compilation of our own + * adapters, only Foundry's EVM interpreter for forked state. + * + * The adapter is deployed fresh here (no BeaconProxy) — that mirrors the + * post-initialize state of the per-user proxy. Executor is simulated via + * `vm.prank(executor)` so we exercise the exact `onlyExecutor` path. + */ +contract MetronomeAdapterForkTest is Test { + // ── Metronome — Base mainnet (8453) ───────────────────────────────────── + address constant POOL = 0xc614136d6c5AB85bc2aCF0ec2652351642d7F54E; + address constant POOL_REGISTRY = 0x4372A2b9304296c06197a823f25Cf03119d2Fd82; + address constant USDC_DEPOSIT_TOKEN = 0xC7F2f79Daa7Ea4FBbF60b45b5D6028BDE2453476; + address constant MS_USD_DEBT_TOKEN = 0x7bcC1DEcCaa98D52Bf89485f17a3E8607011cFde; + address constant MS_USD = 0x526728DBc96689597F85ae4cd716d4f7fCcBAE9d; + address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + + MetronomeAdapter internal adapter; + address internal executor = makeAddr("executor"); + address internal recipient = makeAddr("recipient"); + + uint256 internal constant DEPOSIT_AMOUNT = 1_000e6; // 1,000 USDC (6 decimals) + + // Pin a recent block so state reads hit Foundry's local cache on re-runs. + // Bump only when Metronome governance ships a material change you want to test. + uint256 internal constant FORK_BLOCK = 44_828_900; + + function setUp() public { + string memory rpcUrl = vm.envOr("BASE_RPC_URL", string("https://mainnet.base.org")); + vm.createSelectFork(rpcUrl, FORK_BLOCK); + + adapter = new MetronomeAdapter(); + adapter.initializeFull(executor, abi.encode(POOL, POOL_REGISTRY)); + } + + // ─── initializeFull ────────────────────────────────────────────────────── + + function test_Fork_Initialize_StoresPoolAndExecutor() public view { + assertEq(address(adapter.pool()), POOL); + assertEq(adapter.poolRegistry(), POOL_REGISTRY); + assertEq(adapter.executor(), executor); + } + + function test_Fork_Pool_ReportsLiveMarkets() public view { + IMetronomePool pool = IMetronomePool(POOL); + assertTrue(pool.doesDepositTokenExist(USDC_DEPOSIT_TOKEN), "USDC deposit token registered"); + assertTrue(pool.doesDebtTokenExist(MS_USD_DEBT_TOKEN), "msUSD debt token registered"); + } + + // ─── depositCollateral ─────────────────────────────────────────────────── + + function test_Fork_DepositCollateral_CreditsSharesToProxy() public { + // Pre-fund the proxy as PanoramaExecutorV2 would (via its `transfers` loop). + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + + uint256 sharesBefore = IERC20(USDC_DEPOSIT_TOKEN).balanceOf(address(adapter)); + + vm.prank(executor); + uint256 deposited = adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + uint256 sharesAfter = IERC20(USDC_DEPOSIT_TOKEN).balanceOf(address(adapter)); + + assertGt(deposited, 0, "deposited > 0 after fee"); + assertEq(sharesAfter - sharesBefore, deposited, "shares credited match return value"); + assertEq(IERC20(USDC).balanceOf(address(adapter)), 0, "proxy shipped all USDC"); + + console.log("USDC deposited:", DEPOSIT_AMOUNT); + console.log("msdUSDC shares credited:", deposited); + } + + function test_Fork_DepositCollateral_RevertIf_NotExecutor() public { + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + + address stranger = makeAddr("stranger"); + vm.prank(stranger); + vm.expectRevert(MetronomeAdapter.OnlyExecutor.selector); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + } + + function test_Fork_DepositCollateral_RevertIf_ZeroAmount() public { + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.ZeroAmount.selector); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, 0); + } + + function test_Fork_DepositCollateral_RevertIf_UnregisteredMarket() public { + address bogus = makeAddr("bogusDepositToken"); + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.UnregisteredMarket.selector); + adapter.depositCollateral(bogus, DEPOSIT_AMOUNT); + } + + // ─── withdrawCollateral ────────────────────────────────────────────────── + + function test_Fork_WithdrawCollateral_ForwardsToRecipient() public { + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + vm.prank(executor); + uint256 deposited = adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + uint256 recipientBefore = IERC20(USDC).balanceOf(recipient); + + vm.prank(executor); + uint256 withdrawn = adapter.withdrawCollateral(USDC_DEPOSIT_TOKEN, deposited, recipient); + + uint256 received = IERC20(USDC).balanceOf(recipient) - recipientBefore; + + assertGt(withdrawn, 0, "withdrew non-zero underlying"); + assertEq(received, withdrawn, "recipient received exactly withdrawn amount"); + assertEq(IERC20(USDC_DEPOSIT_TOKEN).balanceOf(address(adapter)), 0, "all shares burned"); + assertEq(IERC20(USDC).balanceOf(address(adapter)), 0, "proxy forwarded all underlying"); + + console.log("msdUSDC shares burned:", deposited); + console.log("USDC withdrawn to recipient:", received); + } + + function test_Fork_WithdrawCollateral_RevertIf_NotExecutor() public { + address stranger = makeAddr("stranger"); + vm.prank(stranger); + vm.expectRevert(MetronomeAdapter.OnlyExecutor.selector); + adapter.withdrawCollateral(USDC_DEPOSIT_TOKEN, 1, recipient); + } + + function test_Fork_WithdrawCollateral_RevertIf_ZeroAmount() public { + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.ZeroAmount.selector); + adapter.withdrawCollateral(USDC_DEPOSIT_TOKEN, 0, recipient); + } + + function test_Fork_WithdrawCollateral_RevertIf_UnregisteredMarket() public { + address bogus = makeAddr("bogusDepositToken"); + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.UnregisteredMarket.selector); + adapter.withdrawCollateral(bogus, 1, recipient); + } + + // ─── mintSynth ─────────────────────────────────────────────────────────── + + function test_Fork_MintSynth_SendsSynthToRecipient() public { + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + vm.prank(executor); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + // Mint conservatively relative to $1k USDC collateral — Metronome has a + // collateral factor well under 100%, so 100 msUSD keeps us safely above + // the minimum health factor on mainnet parameters. + uint256 mintAmount = 100e18; + + uint256 synthBefore = IERC20(MS_USD).balanceOf(recipient); + + vm.prank(executor); + uint256 minted = adapter.mintSynth(MS_USD_DEBT_TOKEN, mintAmount, recipient); + + uint256 received = IERC20(MS_USD).balanceOf(recipient) - synthBefore; + + assertGt(minted, 0, "minted > 0"); + assertEq(received, minted, "recipient received exactly minted synth"); + assertGt(IERC20(MS_USD_DEBT_TOKEN).balanceOf(address(adapter)), 0, "debt accrues on proxy"); + + console.log("msUSD minted to recipient:", received); + console.log("Debt carried by proxy:", IERC20(MS_USD_DEBT_TOKEN).balanceOf(address(adapter))); + } + + function test_Fork_MintSynth_RevertIf_NotExecutor() public { + address stranger = makeAddr("stranger"); + vm.prank(stranger); + vm.expectRevert(MetronomeAdapter.OnlyExecutor.selector); + adapter.mintSynth(MS_USD_DEBT_TOKEN, 100e18, recipient); + } + + function test_Fork_MintSynth_RevertIf_ZeroAmount() public { + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.ZeroAmount.selector); + adapter.mintSynth(MS_USD_DEBT_TOKEN, 0, recipient); + } + + function test_Fork_MintSynth_RevertIf_UnregisteredMarket() public { + address bogus = makeAddr("bogusDebtToken"); + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.UnregisteredMarket.selector); + adapter.mintSynth(bogus, 100e18, recipient); + } + + // ─── repaySynth ────────────────────────────────────────────────────────── + + function test_Fork_RepaySynth_ClearsDebt() public { + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + vm.prank(executor); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + // Mint msUSD directly into the proxy so the adapter can burn it on repay. + uint256 mintAmount = 100e18; + vm.prank(executor); + adapter.mintSynth(MS_USD_DEBT_TOKEN, mintAmount, address(adapter)); + + uint256 debtBefore = IERC20(MS_USD_DEBT_TOKEN).balanceOf(address(adapter)); + assertGt(debtBefore, 0, "debt was opened"); + + // Repay the full synth balance — the protocol trims to outstanding debt. + uint256 synthBal = IERC20(MS_USD).balanceOf(address(adapter)); + + vm.prank(executor); + uint256 repaid = adapter.repaySynth(MS_USD_DEBT_TOKEN, synthBal); + + uint256 debtAfter = IERC20(MS_USD_DEBT_TOKEN).balanceOf(address(adapter)); + + assertGt(repaid, 0, "repaid > 0"); + assertLt(debtAfter, debtBefore, "debt reduced"); + + console.log("Debt before:", debtBefore); + console.log("Debt after:", debtAfter); + console.log("Repaid:", repaid); + } + + function test_Fork_RepaySynth_RevertIf_NotExecutor() public { + address stranger = makeAddr("stranger"); + vm.prank(stranger); + vm.expectRevert(MetronomeAdapter.OnlyExecutor.selector); + adapter.repaySynth(MS_USD_DEBT_TOKEN, 1e18); + } + + function test_Fork_RepaySynth_RevertIf_ZeroAmount() public { + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.ZeroAmount.selector); + adapter.repaySynth(MS_USD_DEBT_TOKEN, 0); + } + + function test_Fork_RepaySynth_RevertIf_UnregisteredMarket() public { + address bogus = makeAddr("bogusDebtToken"); + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.UnregisteredMarket.selector); + adapter.repaySynth(bogus, 1e18); + } + + // ─── unwind ────────────────────────────────────────────────────────────── + + function test_Fork_Unwind_RepaysFullDebtAndReleasesCollateral() public { + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + vm.prank(executor); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + uint256 mintAmount = 100e18; + vm.prank(executor); + adapter.mintSynth(MS_USD_DEBT_TOKEN, mintAmount, address(adapter)); + + // Top up the proxy so it covers debt + fees when repayAll is called. + uint256 debtNow = IERC20(MS_USD_DEBT_TOKEN).balanceOf(address(adapter)); + uint256 synthNow = IERC20(MS_USD).balanceOf(address(adapter)); + if (synthNow < debtNow) { + deal(MS_USD, address(adapter), debtNow * 11 / 10); + } + + uint256 recipientUsdcBefore = IERC20(USDC).balanceOf(recipient); + + vm.prank(executor); + (uint256 repaid, uint256 withdrawn) = adapter.unwind( + MS_USD_DEBT_TOKEN, USDC_DEPOSIT_TOKEN, recipient + ); + + assertGt(repaid, 0, "repaid non-zero debt"); + assertGt(withdrawn, 0, "withdrew non-zero collateral"); + assertEq(IERC20(MS_USD_DEBT_TOKEN).balanceOf(address(adapter)), 0, "no residual debt"); + assertEq(IERC20(USDC_DEPOSIT_TOKEN).balanceOf(address(adapter)), 0, "no residual shares"); + assertGt(IERC20(USDC).balanceOf(recipient) - recipientUsdcBefore, 0, "underlying forwarded to recipient"); + + console.log("Repaid:", repaid); + console.log("Collateral out:", withdrawn); + } + + function test_Fork_Unwind_RevertIf_NoDebtToRepay() public { + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + vm.prank(executor); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + vm.prank(executor); + vm.expectRevert(MetronomeAdapter.NoDebtToRepay.selector); + adapter.unwind(MS_USD_DEBT_TOKEN, USDC_DEPOSIT_TOKEN, recipient); + } + + // ─── view helpers ──────────────────────────────────────────────────────── + + function test_Fork_CollateralBalance_TracksDepositShares() public { + assertEq(adapter.collateralBalance(USDC_DEPOSIT_TOKEN), 0); + + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + vm.prank(executor); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + assertGt(adapter.collateralBalance(USDC_DEPOSIT_TOKEN), 0); + } + + function test_Fork_DebtBalance_TracksOpenDebt() public { + deal(USDC, address(adapter), DEPOSIT_AMOUNT); + vm.prank(executor); + adapter.depositCollateral(USDC_DEPOSIT_TOKEN, DEPOSIT_AMOUNT); + + vm.prank(executor); + adapter.mintSynth(MS_USD_DEBT_TOKEN, 100e18, recipient); + + assertGt(adapter.debtBalance(MS_USD_DEBT_TOKEN), 0); + } +}