Skip to content

Commit dfa42d3

Browse files
committed
add anago support
1 parent c10fc41 commit dfa42d3

File tree

9 files changed

+354
-35
lines changed

9 files changed

+354
-35
lines changed

packages/backend/src/config/chains.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export const CHAINS = {
2929
rpcUrl: process.env.BASE_RPC_URL?.replace('/base', '/monad') || 'https://rpc.monad.xyz',
3030
usdcAddress: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', // USDC on Monad
3131
wmonAddress: '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A', // WMON on Monad
32+
anagoAddress: '0x99ae2dc76c43979e3bcc0ae8d69f1fca077c8888', // ANAGO on Monad
3233
universalRouterAddress: '0x0D97Dc33264bfC1c226207428A79b26757fb9dc3', // Uniswap Universal Router on Monad
34+
quoterV2Address: '0x661E93cca42AfacB172121EF892830cA3b70F08d', // Uniswap QuoterV2 on Monad
3335
permit2Address: '0x000000000022D473030F116dDEE9F6B43aC78BA3', // Permit2 (same on all chains)
3436
cctpMessageTransmitter: '0x81D40F21F12A8F0E3252Bccb954D722d4c464B64', // CCTP v2 MessageTransmitter on Monad
3537
chainId: 143,

packages/backend/src/config/uniswap-abis.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,25 @@ export enum UniversalRouterCommand {
7171
UNWRAP_WETH = 0x0c,
7272
PERMIT2_TRANSFER_FROM_BATCH = 0x0d,
7373
}
74+
75+
// Uniswap V3 QuoterV2 ABI for quoting swaps
76+
export const QUOTER_V2_ABI = [
77+
{
78+
inputs: [
79+
{ name: 'path', type: 'bytes' },
80+
{ name: 'amountIn', type: 'uint256' },
81+
],
82+
name: 'quoteExactInput',
83+
outputs: [
84+
{ name: 'amountOut', type: 'uint256' },
85+
{ name: 'sqrtPriceX96AfterList', type: 'uint160[]' },
86+
{ name: 'initializedTicksCrossedList', type: 'uint32[]' },
87+
{ name: 'gasEstimate', type: 'uint256' },
88+
],
89+
stateMutability: 'nonpayable',
90+
type: 'function',
91+
},
92+
] as const;
93+
94+
// Common fee tiers to try (in order of preference)
95+
export const FEE_TIERS = [500, 3000, 10000, 100] as const; // 0.05%, 0.3%, 1%, 0.01%

packages/backend/src/services/claim-queue-processor.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,14 +259,20 @@ export class ClaimQueueProcessor {
259259
throw new Error(`Unsupported destination chain: ${destChain}. Supported: ${Object.keys(KNOWN_CCTP_CONTRACTS).join(', ')}`);
260260
}
261261

262-
// Determine if we need to swap to native token (supported on Base and Monad)
262+
// Determine if we need to swap (supported on Base and Monad)
263+
// - Native tokens: MON, ETH
264+
// - ERC20 tokens: ANAGO
263265
const isNativeTokenSwap = transaction.selectedDestinationToken &&
264266
(transaction.selectedDestinationToken === 'ETH' ||
265267
transaction.selectedDestinationToken === 'ETH_BASE' ||
266268
transaction.selectedDestinationToken === 'MON' ||
267269
transaction.selectedDestinationToken === 'MON_MON');
268270

269-
const shouldSwapToNative = isNativeTokenSwap &&
271+
const isTokenSwap = transaction.selectedDestinationToken &&
272+
(transaction.selectedDestinationToken === 'ANAGO' ||
273+
transaction.selectedDestinationToken === 'ANAGO_MON');
274+
275+
const shouldSwapToNative = (isNativeTokenSwap || isTokenSwap) &&
270276
(destChain === 'base' || destChain === 'monad');
271277

272278
// Update status to processing claim

packages/backend/src/services/swap-queue-processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export class SwapQueueProcessor {
265265
userDestination,
266266
estimatedUsdcAmount,
267267
chain: destChain,
268+
targetToken: transaction.selectedDestinationToken || undefined,
268269
});
269270

270271
const swapTxHash = result.txHash;

packages/backend/src/services/transaction-signer.ts

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { createWalletClient, createPublicClient, defineChain, http, type Hex, ty
22
import { base } from 'viem/chains';
33
import { privateKeyToAccount } from 'viem/accounts';
44
import { CHAINS } from '../config/chains';
5-
import { buildUsdcToNativeSwapCommands } from './uniswap-swap';
6-
import { ERC20_ABI } from '../config/uniswap-abis';
5+
import { buildUsdcToNativeSwapCommands, buildUsdcToAnagoSwapCommands, buildMultiHopPath } from './uniswap-swap';
6+
import { ERC20_ABI, QUOTER_V2_ABI, FEE_TIERS } from '../config/uniswap-abis';
77

88
// Define Monad chain for viem
99
const monad = defineChain({
@@ -63,6 +63,7 @@ interface SwapParams {
6363
userDestination: Hex;
6464
estimatedUsdcAmount: bigint;
6565
chain: 'base' | 'monad';
66+
targetToken?: string; // e.g., 'MON', 'MON_MON', 'ANAGO', 'ANAGO_MON', 'ETH', 'ETH_BASE'
6667
}
6768

6869
interface GasFees {
@@ -257,6 +258,91 @@ export class TransactionSigner {
257258
}
258259
}
259260

261+
// Cache for valid fee tiers (token path -> fee tiers)
262+
// Key format: "token0-token1-token2"
263+
private feeTierCache: Map<string, { feeTiers: number[]; timestamp: number }> = new Map();
264+
private readonly FEE_TIER_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes cache
265+
266+
/**
267+
* Find valid fee tiers for a multi-hop swap by trying different combinations with the quoter
268+
* Returns the first valid fee tier combination that returns a quote
269+
* Results are cached for 30 minutes
270+
*/
271+
async findValidFeeTiers(
272+
tokens: `0x${string}`[],
273+
amountIn: bigint,
274+
chain: 'monad'
275+
): Promise<{ feeTiers: number[]; amountOut: bigint } | null> {
276+
const { publicClient } = this.getClients(chain);
277+
const quoterAddress = CHAINS.monad.quoterV2Address as Hex;
278+
279+
// Check cache first
280+
const cacheKey = tokens.map(t => t.toLowerCase()).join('-');
281+
const now = Date.now();
282+
const cached = this.feeTierCache.get(cacheKey);
283+
284+
if (cached && (now - cached.timestamp) < this.FEE_TIER_CACHE_TTL_MS) {
285+
console.info(`⚡ [TX SIGNER] Using cached fee tiers: ${cached.feeTiers.join('/')}`);
286+
287+
// Still need to get a fresh quote for the amount
288+
try {
289+
const path = buildMultiHopPath(tokens, cached.feeTiers);
290+
const result = await publicClient.simulateContract({
291+
address: quoterAddress,
292+
abi: QUOTER_V2_ABI,
293+
functionName: 'quoteExactInput',
294+
args: [path, amountIn],
295+
});
296+
const amountOut = result.result[0] as bigint;
297+
return { feeTiers: cached.feeTiers, amountOut };
298+
} catch {
299+
// Cache might be stale, clear it and re-discover
300+
console.info(`⚠️ [TX SIGNER] Cached fee tiers no longer valid, re-discovering...`);
301+
this.feeTierCache.delete(cacheKey);
302+
}
303+
}
304+
305+
console.info(`🔍 [TX SIGNER] Finding valid fee tiers for ${tokens.length}-token path...`);
306+
307+
// For a 3-token path (e.g., USDC -> WMON -> ANAGO), we need 2 fee tiers
308+
// Try all combinations of fee tiers
309+
for (const firstFee of FEE_TIERS) {
310+
for (const secondFee of FEE_TIERS) {
311+
try {
312+
const path = buildMultiHopPath(tokens, [firstFee, secondFee]);
313+
314+
// Use simulate instead of call for nonpayable function
315+
const result = await publicClient.simulateContract({
316+
address: quoterAddress,
317+
abi: QUOTER_V2_ABI,
318+
functionName: 'quoteExactInput',
319+
args: [path, amountIn],
320+
});
321+
322+
const amountOut = result.result[0] as bigint;
323+
324+
if (amountOut > 0n) {
325+
console.info(`✅ [TX SIGNER] Found valid path with fee tiers: ${firstFee}/${secondFee}, amountOut: ${amountOut}`);
326+
327+
// Cache the result
328+
this.feeTierCache.set(cacheKey, {
329+
feeTiers: [firstFee, secondFee],
330+
timestamp: now,
331+
});
332+
333+
return { feeTiers: [firstFee, secondFee], amountOut };
334+
}
335+
} catch {
336+
// This fee tier combination doesn't work, try next
337+
continue;
338+
}
339+
}
340+
}
341+
342+
console.error('❌ [TX SIGNER] No valid fee tier combination found');
343+
return null;
344+
}
345+
260346
async signAndSubmitMint(params: SignAndSubmitParams): Promise<{ txHash: Hex; confirmed?: boolean; error?: string }> {
261347
const chainLower = params.chain.toLowerCase();
262348

@@ -486,12 +572,41 @@ export class TransactionSigner {
486572
}) as [bigint, bigint, bigint];
487573
console.info(`🔍 [TX SIGNER] Pre-swap Permit2→Router allowance: ${permit2Allowance[0].toString()}, expires: ${permit2Allowance[1].toString()}`);
488574

489-
// Build Uniswap swap commands
490-
const { commands, inputs, deadline } = buildUsdcToNativeSwapCommands({
491-
amountIn: usdcAmount,
492-
recipient: params.userDestination,
493-
chain: chainName,
494-
});
575+
// Determine if we're swapping to ANAGO or native token
576+
const isAnagoSwap = params.targetToken === 'ANAGO' || params.targetToken === 'ANAGO_MON';
577+
578+
// For ANAGO swaps, find valid fee tiers first (ANAGO only exists on Monad)
579+
let feeTiers: { first: number; second: number } | undefined;
580+
if (isAnagoSwap) {
581+
const monadConfig = CHAINS.monad;
582+
const tokens: `0x${string}`[] = [
583+
monadConfig.usdcAddress as `0x${string}`,
584+
monadConfig.wmonAddress as `0x${string}`,
585+
monadConfig.anagoAddress as `0x${string}`,
586+
];
587+
588+
const validPath = await this.findValidFeeTiers(tokens, usdcAmount, 'monad');
589+
if (validPath) {
590+
feeTiers = { first: validPath.feeTiers[0], second: validPath.feeTiers[1] };
591+
console.info(`💰 [TX SIGNER] Expected output: ${validPath.amountOut} ANAGO (raw)`);
592+
} else {
593+
throw new Error('No valid liquidity pool found for USDC → WMON → ANAGO swap path');
594+
}
595+
}
596+
597+
// Build Uniswap swap commands based on target token
598+
const { commands, inputs, deadline } = isAnagoSwap
599+
? buildUsdcToAnagoSwapCommands({
600+
amountIn: usdcAmount,
601+
recipient: params.userDestination,
602+
targetToken: 'ANAGO',
603+
feeTiers,
604+
})
605+
: buildUsdcToNativeSwapCommands({
606+
amountIn: usdcAmount,
607+
recipient: params.userDestination,
608+
chain: chainName,
609+
});
495610

496611
const universalRouterAddress = chainConfig.universalRouterAddress as Hex;
497612
const universalRouterAbi: Abi = [
@@ -548,7 +663,8 @@ export class TransactionSigner {
548663
throw new Error('Uniswap swap transaction failed');
549664
}
550665

551-
console.info(`✅ [TX SIGNER] Swap completed - ${nativeSymbol} sent to user`);
666+
const swapTargetSymbol = isAnagoSwap ? 'ANAGO' : nativeSymbol;
667+
console.info(`✅ [TX SIGNER] Swap completed - ${swapTargetSymbol} sent to user`);
552668

553669
return { txHash: swapTxHash, confirmed: true };
554670
} catch (error) {

packages/backend/src/services/uniswap-swap.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
1-
import { encodePacked, encodeAbiParameters } from 'viem';
1+
import { encodePacked, encodeAbiParameters, type Hex } from 'viem';
22
import { UniversalRouterCommand } from '../config/uniswap-abis';
33
import { CHAINS } from '../config/chains';
44

5+
/**
6+
* Builds a multi-hop path encoding for Uniswap V3
7+
*/
8+
export function buildMultiHopPath(tokens: `0x${string}`[], feeTiers: number[]): Hex {
9+
if (tokens.length < 2 || feeTiers.length !== tokens.length - 1) {
10+
throw new Error('Invalid path: need at least 2 tokens and feeTiers.length = tokens.length - 1');
11+
}
12+
13+
// Build types and values arrays for encodePacked
14+
const types: string[] = [];
15+
const values: (string | number)[] = [];
16+
17+
for (let i = 0; i < tokens.length; i++) {
18+
types.push('address');
19+
values.push(tokens[i]);
20+
if (i < feeTiers.length) {
21+
types.push('uint24');
22+
values.push(feeTiers[i]);
23+
}
24+
}
25+
26+
return encodePacked(types as any, values as any);
27+
}
28+
529
interface SwapParams {
630
amountIn: bigint; // Amount of USDC to swap (in USDC decimals, typically 6)
731
recipient: `0x${string}`; // Final recipient of native token
832
chain: 'base' | 'monad'; // Destination chain for the swap
933
}
1034

35+
interface TokenSwapParams {
36+
amountIn: bigint; // Amount of USDC to swap (in USDC decimals, typically 6)
37+
recipient: `0x${string}`; // Final recipient of tokens
38+
targetToken: 'ANAGO'; // Target token to swap to
39+
feeTiers?: { first: number; second: number }; // Fee tiers for the two-hop swap
40+
}
41+
1142
// Chain-specific swap configurations
1243
const SWAP_CONFIGS = {
1344
base: {
@@ -111,3 +142,71 @@ export function buildUsdcToEthSwapCommands(params: Omit<SwapParams, 'chain'>) {
111142
return buildUsdcToNativeSwapCommands({ ...params, chain: 'base' });
112143
}
113144

145+
/**
146+
* Builds Universal Router commands for swapping USDC to ANAGO on Monad
147+
*
148+
* Flow (two-hop swap via V3):
149+
* 1. V3_SWAP_EXACT_IN: USDC → WMON → ANAGO (multi-hop path)
150+
* 2. ANAGO is sent directly to recipient
151+
*
152+
* Note: This uses a single V3_SWAP_EXACT_IN with a multi-hop path encoding
153+
*/
154+
export function buildUsdcToAnagoSwapCommands(params: TokenSwapParams) {
155+
const { amountIn, recipient, feeTiers } = params;
156+
157+
const usdcAddress = CHAINS.monad.usdcAddress as `0x${string}`;
158+
const wmonAddress = CHAINS.monad.wmonAddress as `0x${string}`;
159+
const anagoAddress = CHAINS.monad.anagoAddress as `0x${string}`;
160+
161+
// Fee tiers from params or defaults
162+
const usdcWmonFeeTier = feeTiers?.first ?? 500; // Default: 0.05%
163+
const wmonAnagoFeeTier = feeTiers?.second ?? 3000; // Default: 0.3%
164+
165+
console.info(`🔧 [SWAP] Building USDC→WMON→ANAGO path with fee tiers: ${usdcWmonFeeTier}/${wmonAnagoFeeTier}`);
166+
167+
// Minimum amount out with slippage (0 for now, let Uniswap handle it)
168+
const minAmountOut = 0n;
169+
170+
// Multi-hop path encoding: USDC -> WMON -> ANAGO
171+
// Path format: tokenIn + fee + tokenIntermediate + fee + tokenOut
172+
const path = encodePacked(
173+
['address', 'uint24', 'address', 'uint24', 'address'],
174+
[usdcAddress, usdcWmonFeeTier, wmonAddress, wmonAnagoFeeTier, anagoAddress]
175+
);
176+
177+
// V3_SWAP_EXACT_IN command - swap USDC to ANAGO via WMON
178+
const v3SwapInput = encodeAbiParameters(
179+
[
180+
{ name: 'recipient', type: 'address' },
181+
{ name: 'amountIn', type: 'uint256' },
182+
{ name: 'amountOutMin', type: 'uint256' },
183+
{ name: 'path', type: 'bytes' },
184+
{ name: 'payerIsUser', type: 'bool' },
185+
],
186+
[
187+
recipient, // ANAGO goes directly to user
188+
amountIn,
189+
minAmountOut,
190+
path,
191+
true, // Payer is the caller (our processing wallet)
192+
]
193+
);
194+
195+
// Single command: V3_SWAP_EXACT_IN (no unwrap needed since ANAGO is ERC20)
196+
const commands = encodePacked(
197+
['uint8'],
198+
[UniversalRouterCommand.V3_SWAP_EXACT_IN]
199+
);
200+
201+
const inputs = [v3SwapInput];
202+
203+
// Deadline: 20 minutes from now
204+
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200);
205+
206+
return {
207+
commands,
208+
inputs,
209+
deadline,
210+
};
211+
}
212+
148 KB
Loading

packages/frontend/src/components/ToTokenSelector.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const SOLANA_TOKENS: ToTokenOption[] = [
1717

1818
const EVM_TOKENS: ToTokenOption[] = [
1919
{ symbol: 'MON_MON', name: 'MON (Monad)', address: '0x0000000000000000000000000000000000000000', chain: 'evm', iconPath: '/chain_icons/mon.png' },
20+
{ symbol: 'ANAGO_MON', name: 'ANAGO (Monad)', address: '0x99ae2dc76c43979e3bcc0ae8d69f1fca077c8888', chain: 'evm', iconPath: '/chain_icons/anago.png' },
2021
{ symbol: 'USDC_MON', name: 'USDC (Monad)', address: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', chain: 'evm', iconPath: '/chain_icons/usdc.png' },
2122
];
2223

0 commit comments

Comments
 (0)