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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { swapRoutes } from "./modules/swap/routes/swap.routes";
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 { avaxLpRoutes } from "./modules/avax-lp/routes/avax-lp.routes";
import { avaxLiquidStakingRoutes } from "./modules/avax-liquid-staking/routes/avax-liquid-staking.routes";
import { moonwellLendingRoutes } from "./modules/moonwell-lending/routes/moonwell-lending.routes";
import { errorHandler } from "./middleware/errorHandler";
Expand Down Expand Up @@ -57,6 +58,7 @@ app.use("/swap", swapRoutes);
app.use("/dca", dcaRoutes);
app.use("/avax/swap", avaxSwapRoutes);
app.use("/avax/lending", avaxLendingRoutes);
app.use("/avax/lp", avaxLpRoutes);
app.use("/avax/liquid-staking", avaxLiquidStakingRoutes);
app.use("/base/lending", moonwellLendingRoutes);

Expand All @@ -68,6 +70,7 @@ app.use("/execution/swap", swapRoutes);
app.use("/execution/dca", dcaRoutes);
app.use("/execution/avax/swap", avaxSwapRoutes);
app.use("/execution/avax/lending", avaxLendingRoutes);
app.use("/execution/avax/lp", avaxLpRoutes);
app.use("/execution/avax/liquid-staking", avaxLiquidStakingRoutes);
app.use("/execution/base/lending", moonwellLendingRoutes);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import { avaxService } from "../../../shared/services/avax.service"
import { getContract } from "../../../providers/chain.provider";
import { BENQI_TOKEN_ABI } from "../../../utils/abi";

const SECONDS_PER_YEAR = 31_536_000n;
const MANTISSA = 1_000_000_000_000_000_000n;

function rateToApyBps(ratePerTimestamp: bigint): number {
// Simple APY: rate/s * seconds_per_year, scaled to BPS (1 BPS = 0.01%)
return Number(ratePerTimestamp * SECONDS_PER_YEAR * 10_000n / MANTISSA);
}

export const getMarkets = asyncHandler(async (_req: Request, res: Response) => {
const markets = getEnabledMarkets();

Expand All @@ -18,7 +26,13 @@ export const getMarkets = asyncHandler(async (_req: Request, res: Response) => {
avaxService.getSupplyRate(m.qTokenAddress),
avaxService.getBorrowRate(m.qTokenAddress),
]);
return { ...m, supplyRatePerTimestamp: supplyRate.toString(), borrowRatePerTimestamp: borrowRate.toString() };
return {
...m,
supplyRatePerTimestamp: supplyRate.toString(),
borrowRatePerTimestamp: borrowRate.toString(),
supplyApyBps: rateToApyBps(supplyRate),
borrowApyBps: rateToApyBps(borrowRate),
};
})
);

Expand Down
70 changes: 70 additions & 0 deletions backend/src/modules/avax-lp/config/avax-lp-pools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { AVAX_TOKENS } from "../../../shared/services/avax.service";

export interface LpPool {
id: string;
tokenA: { symbol: string; address: string; decimals: number };
tokenB: { symbol: string; address: string; decimals: number };
pairAddress: string;
// MasterChefJoeV3 pool id — null if no active farm emission
farmPid: number | null;
enabled: boolean;
}

// TraderJoe V1 Factory: 0x9Ad6C38BE94206cA50bb0d90783171662CD1e917
// MasterChefJoeV3: 0x188bED1968b795d5c9022F6a0bb5931Ac4c18F00
export const AVAX_LP_POOLS: LpPool[] = [
{
id: "wavax-usdc.e",
tokenA: AVAX_TOKENS.WAVAX,
tokenB: AVAX_TOKENS.USDCe,
pairAddress: "0xA389f9430876455C36478DeEa9769B7Ca4E3DDB1",
farmPid: 42,
enabled: true,
},
{
id: "wavax-usdt",
tokenA: AVAX_TOKENS.WAVAX,
tokenB: AVAX_TOKENS.USDT,
pairAddress: "0xbb4646a764358ee93c2a9c4a147537f9cf7f2Bc5",
farmPid: null,
enabled: true,
},
{
id: "wavax-joe",
tokenA: AVAX_TOKENS.WAVAX,
tokenB: { symbol: "JOE", address: "0x6e84a6216eA6dACC71eE8E6b0a5B7322EEbC0fDd", decimals: 18 },
pairAddress: "0x454E67025631C065d3cFAD6d71E6892f74487a15",
farmPid: 0,
enabled: true,
},
{
id: "wavax-weth.e",
tokenA: AVAX_TOKENS.WAVAX,
tokenB: AVAX_TOKENS.WETH,
pairAddress: "0xFE15c2695F1F920da45C30AAE47d11dE51007AF9",
farmPid: null,
enabled: true,
},
];

export function getEnabledPools(): LpPool[] {
return AVAX_LP_POOLS.filter(p => p.enabled);
}

export function getPoolById(id: string): LpPool | undefined {
return AVAX_LP_POOLS.find(p => p.id === id);
}

export function getPoolByPair(tokenA: string, tokenB: string): LpPool | undefined {
const a = tokenA.toLowerCase();
const b = tokenB.toLowerCase();
return AVAX_LP_POOLS.find(
p =>
(p.tokenA.address.toLowerCase() === a && p.tokenB.address.toLowerCase() === b) ||
(p.tokenA.address.toLowerCase() === b && p.tokenB.address.toLowerCase() === a)
);
}

export function getPoolByPid(pid: number): LpPool | undefined {
return AVAX_LP_POOLS.find(p => p.farmPid === pid);
}
97 changes: 97 additions & 0 deletions backend/src/modules/avax-lp/controllers/avax-lp.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Request, Response } from "express";
import { asyncHandler } from "../../../middleware/errorHandler";
import { executePrepareAddLiquidity } from "../usecases/prepare-add-liquidity.usecase";
import { executePrepareRemoveLiquidity } from "../usecases/prepare-remove-liquidity.usecase";
import { executePrepareStake } from "../usecases/prepare-stake.usecase";
import { executePrepareUnstake } from "../usecases/prepare-unstake.usecase";
import { executePrepareClaimRewards } from "../usecases/prepare-claim-rewards.usecase";
import { getEnabledPools, getPoolById } from "../config/avax-lp-pools";
import { avaxLpService } from "../../../shared/services/avax-lp.service";

export const getPools = asyncHandler(async (_req: Request, res: Response) => {
res.json({ pools: getEnabledPools() });
});

export const getUserPosition = asyncHandler(async (req: Request, res: Response) => {
const { userAddress } = req.params;
const { proxyAddress } = req.query as { proxyAddress?: string };

const pools = getEnabledPools();
const results = await Promise.all(
pools.map(async (pool) => {
const lpBalance = await avaxLpService.getLpBalance(pool.pairAddress, userAddress);
const farmInfo = proxyAddress && pool.farmPid !== null
? await avaxLpService.getUserFarmInfo(pool.farmPid, proxyAddress)
: null;
const pendingJoe = proxyAddress && pool.farmPid !== null
? await avaxLpService.getPendingRewards(pool.farmPid, proxyAddress)
: 0n;

return {
poolId: pool.id,
pairAddress: pool.pairAddress,
symbolA: pool.tokenA.symbol,
symbolB: pool.tokenB.symbol,
farmPid: pool.farmPid,
lpBalance: lpBalance.toString(),
stakedAmount: farmInfo?.amount.toString() ?? "0",
pendingJoe: pendingJoe.toString(),
};
})
);

const active = results.filter(r => BigInt(r.lpBalance) > 0n || BigInt(r.stakedAmount) > 0n);
res.json({ userAddress, positions: active });
});

export const prepareAddLiquidity = asyncHandler(async (req: Request, res: Response) => {
const result = await executePrepareAddLiquidity({
userAddress: req.body.userAddress,
tokenA: req.body.tokenA,
tokenB: req.body.tokenB,
amountADesired: req.body.amountADesired,
amountBDesired: req.body.amountBDesired,
slippageBps: req.body.slippageBps,
});
res.json(result);
});

export const prepareRemoveLiquidity = asyncHandler(async (req: Request, res: Response) => {
const result = await executePrepareRemoveLiquidity({
userAddress: req.body.userAddress,
tokenA: req.body.tokenA,
tokenB: req.body.tokenB,
lpAmount: req.body.lpAmount,
amountAMin: req.body.amountAMin,
amountBMin: req.body.amountBMin,
});
res.json(result);
});

export const prepareStake = asyncHandler(async (req: Request, res: Response) => {
const result = await executePrepareStake({
userAddress: req.body.userAddress,
poolId: req.body.poolId,
lpAmount: req.body.lpAmount,
});
res.json(result);
});

export const prepareUnstake = asyncHandler(async (req: Request, res: Response) => {
const result = await executePrepareUnstake({
userAddress: req.body.userAddress,
poolId: req.body.poolId,
lpAmount: req.body.lpAmount,
proxyAddress: req.body.proxyAddress,
});
res.json(result);
});

export const prepareClaimRewards = asyncHandler(async (req: Request, res: Response) => {
const result = await executePrepareClaimRewards({
userAddress: req.body.userAddress,
poolId: req.body.poolId,
proxyAddress: req.body.proxyAddress,
});
res.json(result);
});
116 changes: 116 additions & 0 deletions backend/src/modules/avax-lp/routes/avax-lp.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Router } from "express";
import {
validateAddress,
validateAmount,
validateRequired,
validateSlippage,
} from "../../../middleware/validation";
import { executionTimeout } from "../../../middleware/execution-timeout";
import * as ctrl from "../controllers/avax-lp.controller";

export const avaxLpRoutes = Router();

/**
* GET /avax/lp/pools
* Returns all enabled TraderJoe V1 LP pools with farm metadata.
*/
avaxLpRoutes.get("/pools", ctrl.getPools);

/**
* GET /avax/lp/position/:userAddress?proxyAddress=0x...
* Returns user's LP balances and staked farm positions.
* Optionally pass proxyAddress to fetch on-chain farm data.
*/
avaxLpRoutes.get(
"/position/:userAddress",
validateAddress("userAddress", "params"),
ctrl.getUserPosition
);

/**
* POST /avax/lp/prepare-add-liquidity
* Builds a TransactionBundle to add Token-Token liquidity on TraderJoe V1.
*
* Body: { userAddress, tokenA, tokenB, amountADesired, amountBDesired, slippageBps? }
* Returns: [approve tokenA?] + [approve tokenB?] + [addLiquidity via executor]
*/
avaxLpRoutes.post(
"/prepare-add-liquidity",
validateRequired("userAddress", "tokenA", "tokenB", "amountADesired", "amountBDesired"),
validateAddress("userAddress"),
validateAddress("tokenA"),
validateAddress("tokenB"),
validateAmount("amountADesired"),
validateAmount("amountBDesired"),
validateSlippage(),
executionTimeout(),
ctrl.prepareAddLiquidity
);

/**
* POST /avax/lp/prepare-remove-liquidity
* Builds a TransactionBundle to remove liquidity and receive underlying tokens.
*
* Body: { userAddress, tokenA, tokenB, lpAmount, amountAMin?, amountBMin? }
* Returns: [approve LP token] + [removeLiquidity via executor]
*/
avaxLpRoutes.post(
"/prepare-remove-liquidity",
validateRequired("userAddress", "tokenA", "tokenB", "lpAmount"),
validateAddress("userAddress"),
validateAddress("tokenA"),
validateAddress("tokenB"),
validateAmount("lpAmount"),
executionTimeout(),
ctrl.prepareRemoveLiquidity
);

/**
* POST /avax/lp/prepare-stake
* Builds a TransactionBundle to stake LP tokens into a MasterChefJoeV3 farm.
*
* Body: { userAddress, poolId, lpAmount }
* Returns: [approve LP token] + [stake via executor]
*/
avaxLpRoutes.post(
"/prepare-stake",
validateRequired("userAddress", "poolId", "lpAmount"),
validateAddress("userAddress"),
validateAmount("lpAmount"),
executionTimeout(),
ctrl.prepareStake
);

/**
* POST /avax/lp/prepare-unstake
* Builds a TransactionBundle to withdraw LP tokens from a MasterChefJoeV3 farm.
* Also claims pending JOE rewards automatically (MCV3 behavior).
*
* Body: { userAddress, poolId, lpAmount, proxyAddress }
* Returns: [unstake via executor]
*/
avaxLpRoutes.post(
"/prepare-unstake",
validateRequired("userAddress", "poolId", "lpAmount", "proxyAddress"),
validateAddress("userAddress"),
validateAddress("proxyAddress"),
executionTimeout(),
ctrl.prepareUnstake
);

/**
* POST /avax/lp/prepare-claim-rewards
* Builds a TransactionBundle to harvest pending JOE rewards from a farm.
* Uses the deposit(pid, 0) harvest pattern of MasterChefJoeV3.
*
* Body: { userAddress, poolId, proxyAddress }
* Returns: [claimRewards via executor]
*/
avaxLpRoutes.post(
"/prepare-claim-rewards",
validateRequired("userAddress", "poolId", "proxyAddress"),
validateAddress("userAddress"),
validateAddress("proxyAddress"),
executionTimeout(),
ctrl.prepareClaimRewards
);
Loading
Loading