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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Guidelines for working with this codebase.

## Language

Always respond in **Brazilian Portuguese (pt-BR)**.
Always respond in **English**.

## Required reading

Expand Down
13 changes: 13 additions & 0 deletions backend/src/config/protocols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ const PROTOCOL_REGISTRY: Record<string, ProtocolConfig> = {
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 {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
70 changes: 70 additions & 0 deletions backend/src/modules/metronome/config/metronome-markets.ts
Original file line number Diff line number Diff line change
@@ -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()
);
}
73 changes: 73 additions & 0 deletions backend/src/modules/metronome/controllers/metronome.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
});
106 changes: 106 additions & 0 deletions backend/src/modules/metronome/routes/metronome.routes.ts
Original file line number Diff line number Diff line change
@@ -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
);
Original file line number Diff line number Diff line change
@@ -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<GetUserPositionResponse> {
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 };
}
Loading