diff --git a/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts index b713977ff..40e5db926 100644 --- a/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts @@ -1,21 +1,36 @@ import { + ERC20_EURE_POLYGON_TOKEN_NAME, ERC20_EURE_POLYGON_V2, EvmClientManager, getEvmTokenBalance, + getNetworkId, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; import Big from "big.js"; -import { encodeFunctionData, PublicClient } from "viem"; +import { encodeFunctionData, isAddress, PublicClient, TransactionReceipt } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; +import erc20ABI from "../../../../contracts/ERC20"; import { permitAbi } from "../../../../contracts/PermitAbi"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; +import { analyzeMoneriumPermitPreflight, MoneriumPermitDiagnostics } from "../../ramp/monerium-permit"; +import { inspectMoneriumSelfTransferTransaction, moneriumTransferFromAbi } from "../../ramp/monerium-self-transfer"; import { BasePhaseHandler } from "../base-phase-handler"; +const permitNonceAbi = [ + { + inputs: [{ name: "owner", type: "address" }], + name: "nonces", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + /** * Handler for the monerium self-transfer phase */ @@ -92,30 +107,91 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { try { const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); + if (!isAddress(account.address)) { + throw new Error(`Configured executor account produced invalid EVM address ${account.address}`); + } + const executorAddress = account.address as `0x${string}`; let permitHash: string; if (state.state.permitTxHash) { logger.info(`Permit transaction already sent with hash: ${state.state.permitTxHash}. Skipping permit sending.`); permitHash = state.state.permitTxHash; } else { - // Send permit transaction - const permitData = encodeFunctionData({ - abi: permitAbi, - args: [ - moneriumWalletAddress, - state.state.evmEphemeralAddress, - BigInt(mintedAmountRaw), - moneriumOnrampPermit.deadline, - moneriumOnrampPermit.v, - moneriumOnrampPermit.r, - moneriumOnrampPermit.s - ], - functionName: "permit" - }); - permitHash = await this.evmClientManager.sendTransactionWithBlindRetry(Networks.Polygon, account, { - data: permitData, - to: ERC20_EURE_POLYGON_V2 - }); + const owner = moneriumWalletAddress as `0x${string}`; + const spender = evmEphemeralAddress as `0x${string}`; + const permitExpectation = { + expectedOwner: owner, + expectedSpender: spender, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: mintedAmountRaw, + network: Networks.Polygon + }; + const permitDiagnostics = await this.getPermitDiagnostics(owner, spender); + const signedPermitContext = moneriumOnrampPermit.context; + logger.info( + `[${state.id}] Monerium permit preflight: ${JSON.stringify({ + allowanceRaw: permitDiagnostics.allowanceRaw.toString(), + balanceRaw: permitDiagnostics.balanceRaw.toString(), + deadline: moneriumOnrampPermit.context?.deadline ?? moneriumOnrampPermit.deadline, + deadlineIso: new Date( + Number(moneriumOnrampPermit.context?.deadline ?? moneriumOnrampPermit.deadline) * 1000 + ).toISOString(), + executor: executorAddress, + expectedValueRaw: mintedAmountRaw, + nonce: permitDiagnostics.nonce.toString(), + owner, + signedChainId: signedPermitContext?.chainId, + signedNonce: signedPermitContext?.nonce, + signedTokenAddress: signedPermitContext?.tokenAddress, + signedTokenName: signedPermitContext?.tokenName, + signedTokenVersion: signedPermitContext?.tokenVersion, + signedValueRaw: signedPermitContext?.valueRaw, + spender, + tokenAddress: ERC20_EURE_POLYGON_V2, + tokenName: permitDiagnostics.tokenName + })}` + ); + + const permitPreflight = analyzeMoneriumPermitPreflight(moneriumOnrampPermit, permitExpectation, permitDiagnostics); + if (!permitPreflight.shouldSendPermit) { + logger.info( + `[${state.id}] Existing Monerium allowance covers ${mintedAmountRaw}. Skipping permit transaction (${permitPreflight.reason}).` + ); + } else if (permitDiagnostics.balanceRaw < BigInt(mintedAmountRaw)) { + logger.warn( + `[${state.id}] Monerium wallet balance ${permitDiagnostics.balanceRaw.toString()} is below expected transfer amount ${mintedAmountRaw}. Permit may still succeed, but transferFrom will wait for sufficient balance.` + ); + } + + const permitArgs = [ + owner, + spender, + BigInt(mintedAmountRaw), + moneriumOnrampPermit.deadline, + moneriumOnrampPermit.v, + moneriumOnrampPermit.r, + moneriumOnrampPermit.s + ] as const; + + if (!permitPreflight.shouldSendPermit) { + permitHash = ""; + } else { + await this.simulatePermit(state.id, executorAddress, permitArgs); + + const walletClient = this.evmClientManager.getWalletClient(Networks.Polygon, account); + permitHash = await walletClient.sendTransaction({ + data: encodeFunctionData({ + abi: permitAbi, + args: permitArgs, + functionName: "permit" + }), + to: ERC20_EURE_POLYGON_V2 + }); + } + } + + if (permitHash) { logger.info(`Permit transaction executed with hash: ${permitHash}`); await this.waitForTransactionConfirmation(permitHash); @@ -131,14 +207,30 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { throw new Error("Missing presigned transactions for moneriumOnrampSelfTransfer phase. State corrupted."); } - // Execute the transfer transaction - const transferHash = await this.executeTransaction(transferTransaction.txData as string); - logger.info(`Transfer transaction executed with hash: ${transferHash}`); + let transferHash = state.state.moneriumOnrampSelfTransferHash; + if (transferHash) { + logger.info(`Transfer transaction already sent with hash: ${transferHash}. Waiting for confirmation.`); + } else { + await this.preflightSignedSelfTransfer( + state.id, + transferTransaction.txData as string, + moneriumWalletAddress as `0x${string}`, + evmEphemeralAddress as `0x${string}`, + mintedAmountRaw + ); + + // Execute the transfer transaction + transferHash = await this.executeTransaction(transferTransaction.txData as string); + state.state.moneriumOnrampSelfTransferHash = transferHash; + await state.update({ state: state.state }); + logger.info(`Transfer transaction executed with hash: ${transferHash}`); + } await this.waitForTransactionConfirmation(transferHash); logger.info(`TransferFrom transaction confirmed: ${transferHash}`); - // Wait for another 30 seconds to give time for the balance to update (in case other RPC nodes are lagging) + // RPC nodes occasionally lag behind the chain tip; the next phase reads the ephemeral's + // EURe balance and would otherwise race against an under-replicated read replica. logger.info("Waiting 30 seconds to ensure balance is updated..."); await new Promise(resolve => setTimeout(resolve, 30000)); @@ -152,6 +244,149 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { } } + private async preflightSignedSelfTransfer( + rampId: string, + txData: string, + expectedOwner: `0x${string}`, + expectedSpender: `0x${string}`, + expectedAmountRaw: string + ): Promise { + const transfer = await inspectMoneriumSelfTransferTransaction(txData, { + expectedAmountRaw, + expectedChainId: getNetworkId(Networks.Polygon), + expectedOwner, + expectedRecipient: expectedSpender, + expectedSigner: expectedSpender, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + rampId + }); + const expectedAmount = BigInt(expectedAmountRaw); + + const transferDiagnostics = await this.getPermitDiagnostics(expectedOwner, expectedSpender); + const currentNonce = await this.polygonClient.getTransactionCount({ address: transfer.signer }); + let estimatedGas: bigint; + try { + estimatedGas = await this.polygonClient.estimateContractGas({ + abi: moneriumTransferFromAbi, + account: transfer.signer, + address: ERC20_EURE_POLYGON_V2, + args: [transfer.owner, transfer.recipient, transfer.amountRaw], + functionName: "transferFrom" + }); + } catch (error) { + throw new Error( + `[${rampId}] Self-transfer gas estimate failed before broadcast: ${error instanceof Error ? error.message : error}` + ); + } + + logger.info( + `[${rampId}] Monerium self-transfer preflight: ${JSON.stringify({ + allowanceRaw: transferDiagnostics.allowanceRaw.toString(), + amountRaw: expectedAmountRaw, + balanceRaw: transferDiagnostics.balanceRaw.toString(), + currentNonce, + estimatedGas: estimatedGas.toString(), + owner: transfer.owner, + recipient: transfer.recipient, + signedGas: transfer.signedGas.toString(), + signedNonce: transfer.signedNonce, + signer: transfer.signer, + tokenAddress: ERC20_EURE_POLYGON_V2 + })}` + ); + + if (currentNonce !== transfer.signedNonce) { + // Strict equality: a gap (currentNonce < signedNonce) would leave the broadcast tx stuck pending + // in mempool forever because the ephemeral account will never fill the missing nonces. + // A past nonce (currentNonce > signedNonce) means the tx was already consumed. + const reason = + currentNonce > transfer.signedNonce + ? `signed nonce ${transfer.signedNonce} has already been consumed (current nonce ${currentNonce}). Do not resend this raw transaction; regenerate the presigned self-transfer transaction or inspect the previous nonce-${transfer.signedNonce} transaction` + : `signed nonce ${transfer.signedNonce} is ahead of current account nonce ${currentNonce}. Broadcasting would stall the tx in mempool until the missing nonces are filled (which will never happen for an ephemeral account). Regenerate the presigned self-transfer transaction`; + throw new Error(`[${rampId}] Self-transfer ${reason} for signer ${transfer.signer}.`); + } + if (transferDiagnostics.allowanceRaw < expectedAmount) { + throw new Error( + `[${rampId}] Self-transfer allowance ${transferDiagnostics.allowanceRaw.toString()} is below expected ${expectedAmountRaw}` + ); + } + if (transferDiagnostics.balanceRaw < expectedAmount) { + throw new Error( + `[${rampId}] Self-transfer balance ${transferDiagnostics.balanceRaw.toString()} is below expected ${expectedAmountRaw}` + ); + } + if (transfer.signedGas < estimatedGas) { + throw new Error( + `[${rampId}] Self-transfer signed gas limit ${transfer.signedGas.toString()} is below estimated gas ${estimatedGas.toString()}` + ); + } + + try { + await this.polygonClient.simulateContract({ + abi: moneriumTransferFromAbi, + account: transfer.signer, + address: ERC20_EURE_POLYGON_V2, + args: [transfer.owner, transfer.recipient, transfer.amountRaw], + functionName: "transferFrom", + gas: transfer.signedGas + }); + } catch (error) { + throw new Error( + `[${rampId}] Self-transfer simulation failed before broadcast: ${error instanceof Error ? error.message : error}` + ); + } + } + + private async getPermitDiagnostics(owner: `0x${string}`, spender: `0x${string}`): Promise { + const [allowanceRaw, balanceRaw, nonce, tokenName] = await Promise.all([ + this.evmClientManager.readContractWithRetry(Networks.Polygon, { + abi: erc20ABI, + address: ERC20_EURE_POLYGON_V2, + args: [owner, spender], + functionName: "allowance" + }), + this.evmClientManager.readContractWithRetry(Networks.Polygon, { + abi: erc20ABI, + address: ERC20_EURE_POLYGON_V2, + args: [owner], + functionName: "balanceOf" + }), + this.evmClientManager.readContractWithRetry(Networks.Polygon, { + abi: permitNonceAbi, + address: ERC20_EURE_POLYGON_V2, + args: [owner], + functionName: "nonces" + }), + this.evmClientManager.readContractWithRetry(Networks.Polygon, { + abi: erc20ABI, + address: ERC20_EURE_POLYGON_V2, + functionName: "name" + }) + ]); + + return { allowanceRaw, balanceRaw, nonce, tokenName }; + } + + private async simulatePermit( + rampId: string, + executorAddress: `0x${string}`, + permitArgs: readonly [`0x${string}`, `0x${string}`, bigint, number, number, `0x${string}`, `0x${string}`] + ): Promise { + try { + await this.polygonClient.simulateContract({ + abi: permitAbi, + account: executorAddress, + address: ERC20_EURE_POLYGON_V2, + args: permitArgs, + functionName: "permit" + }); + } catch (error) { + throw new Error( + `[${rampId}] Monerium permit simulation failed before broadcast: ${error instanceof Error ? error.message : error}` + ); + } + } + /** * Execute a transaction * @param txData The transaction data @@ -173,14 +408,17 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { * @param txHash The transaction hash * @param chainId The chain ID */ - private async waitForTransactionConfirmation(txHash: string): Promise { + private async waitForTransactionConfirmation(txHash: string): Promise { try { const receipt = await this.polygonClient.waitForTransactionReceipt({ hash: txHash as `0x${string}` }); if (!receipt || receipt.status !== "success") { - throw new Error(`moneriumOnrampSelfTransferHandler: Transaction ${txHash} failed or was not found`); + throw new Error( + `moneriumOnrampSelfTransferHandler: Transaction ${txHash} failed or was not found (status: ${receipt?.status ?? "missing"}, block: ${receipt?.blockNumber?.toString() ?? "unknown"}, gasUsed: ${receipt?.gasUsed?.toString() ?? "unknown"})` + ); } + return receipt; } catch (error) { throw new Error(`moneriumOnrampSelfTransferHandler: Error waiting for transaction confirmation: ${error}`); } diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts new file mode 100644 index 000000000..386a2dc95 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts @@ -0,0 +1,211 @@ +// eslint-disable-next-line import/no-unresolved +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import Big from "big.js"; + +const Networks = { + Base: "base", + Moonbeam: "moonbeam", + Polygon: "polygon" +} as const; + +const EvmToken = { + USDC: "USDC" +} as const; + +const FiatToken = { + BRL: "BRL", + EURC: "EUR", + USD: "USD" +} as const; + +const RampDirection = { + BUY: "BUY", + SELL: "SELL" +} as const; + +const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; +const EURE_POLYGON_ADDRESS = "0x18ec0A6E18E5bc3784fDd3a3634b31245ab704F6"; +const USDC_BASE_ADDRESS = "0x3333333333333333333333333333333333333333"; +const APPROVE_TX = "0xapprove"; +const SWAP_TX = "0xswap"; +const APPROVE_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SWAP_HASH = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +const sendRawTransaction = mock(async ({ serializedTransaction }: { serializedTransaction: string }) => { + if (serializedTransaction === APPROVE_TX) { + return APPROVE_HASH; + } + if (serializedTransaction === SWAP_TX) { + return SWAP_HASH; + } + throw new Error(`Unexpected transaction ${serializedTransaction}`); +}); +const waitForTransactionReceipt = mock(async () => ({ status: "success" })); +const getTransactionCount = mock(async () => 0); +const checkEvmBalanceForToken = mock(async () => Big(1000)); +const getEvmTokenDetailsByAddress = mock((network: string, tokenAddress: `0x${string}`) => ({ + assetSymbol: "Monerium EURe", + decimals: 18, + erc20AddressSourceChain: tokenAddress, + isNative: false, + network +})); + +mock.module("@vortexfi/shared", () => ({ + checkEvmBalanceForToken, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + getTransactionCount, + sendRawTransaction, + waitForTransactionReceipt + }) + }) + }, + EvmToken, + FiatToken, + getEvmTokenDetailsByAddress, + getNetworkFromDestination: (destination: string) => + Object.values(Networks).includes(destination as (typeof Networks)[keyof typeof Networks]) ? destination : undefined, + getNetworkId: (network: string) => { + if (network === Networks.Base) return 8453; + if (network === Networks.Polygon) return 137; + if (network === Networks.Moonbeam) return 1284; + return undefined; + }, + isAlfredpayToken: () => false, + Networks, + RampDirection +})); + +mock.module("../../ramp/ramp.service", () => ({ + default: { + appendErrorLog: mock(async () => undefined) + } +})); + +const { default: QuoteTicket } = await import("../../../../models/quoteTicket.model"); +const { SquidRouterPhaseHandler } = await import("./squid-router-phase-handler"); + +let quote: { + inputCurrency: string; + metadata: Record; + outputCurrency: string; + to: string; +}; + +QuoteTicket.findByPk = mock(async () => quote as any) as typeof QuoteTicket.findByPk; + +function makeState(overrides: Record = {}) { + const state = { + currentPhase: "squidRouterSwap", + errorLogs: [], + from: "sepa", + get() { + const { get: _get, update: _update, ...data } = this; + return data; + }, + id: "ramp-1", + phaseHistory: [], + presignedTxs: [ + { + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase: "squidRouterApprove", + signer: EVM_EPHEMERAL_ADDRESS, + txData: APPROVE_TX + }, + { + meta: {}, + network: Networks.Polygon, + nonce: 1, + phase: "squidRouterSwap", + signer: EVM_EPHEMERAL_ADDRESS, + txData: SWAP_TX + } + ], + quoteId: "quote-1", + state: { + evmEphemeralAddress: EVM_EPHEMERAL_ADDRESS + }, + to: Networks.Base, + type: RampDirection.BUY, + async update(updateData: Record) { + Object.assign(this, updateData); + return this; + }, + ...overrides + }; + return state as any; +} + +describe("SquidRouterPhaseHandler", () => { + beforeEach(() => { + sendRawTransaction.mockClear(); + waitForTransactionReceipt.mockClear(); + getTransactionCount.mockClear(); + checkEvmBalanceForToken.mockClear(); + getEvmTokenDetailsByAddress.mockClear(); + }); + + it("submits Squid approve and swap for Monerium EUR onramp to Base USDC", async () => { + quote = { + inputCurrency: FiatToken.EURC, + metadata: { + evmToEvm: { + fromNetwork: Networks.Polygon, + fromToken: EURE_POLYGON_ADDRESS, + inputAmountRaw: "1000", + toNetwork: Networks.Base, + toToken: USDC_BASE_ADDRESS + }, + moneriumMint: { + outputAmountRaw: "1000" + } + }, + outputCurrency: EvmToken.USDC, + to: Networks.Base + }; + + const handler = new SquidRouterPhaseHandler(); + const updatedState = await handler.execute(makeState()); + + expect(sendRawTransaction).toHaveBeenCalledTimes(2); + expect(getEvmTokenDetailsByAddress).toHaveBeenCalledWith(Networks.Polygon, EURE_POLYGON_ADDRESS); + expect(sendRawTransaction.mock.calls[0][0]).toEqual({ serializedTransaction: APPROVE_TX }); + expect(sendRawTransaction.mock.calls[1][0]).toEqual({ serializedTransaction: SWAP_TX }); + expect(updatedState.currentPhase).toBe("squidRouterPay"); + }); + + it("skips Squid for same-chain Base USDC passthrough quotes", async () => { + quote = { + inputCurrency: FiatToken.BRL, + metadata: { + aveniaTransfer: { + outputAmountRaw: "1000" + }, + evmToEvm: { + fromNetwork: Networks.Base, + fromToken: USDC_BASE_ADDRESS, + inputAmountRaw: "1000", + toNetwork: Networks.Base, + toToken: USDC_BASE_ADDRESS + } + }, + outputCurrency: EvmToken.USDC, + to: Networks.Base + }; + + const handler = new SquidRouterPhaseHandler(); + const updatedState = await handler.execute( + makeState({ + presignedTxs: [] + }) + ); + + expect(sendRawTransaction).not.toHaveBeenCalled(); + expect(getEvmTokenDetailsByAddress).not.toHaveBeenCalled(); + expect(updatedState.currentPhase).toBe("destinationTransfer"); + }); +}); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 74c4ca951..89ea84e5a 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -2,10 +2,8 @@ import { checkEvmBalanceForToken, EvmClientManager, EvmNetworks, - EvmToken, - EvmTokenDetails, - evmTokenConfig, FiatToken, + getEvmTokenDetailsByAddress, getNetworkFromDestination, getNetworkId, isAlfredpayToken, @@ -65,31 +63,37 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { const isAlfredpayOnramp = state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken) && !!quote.metadata.alfredpayMint; - // TODO also add check for Avenia onramp USDC on Base - if (isAlfredpayOnramp) { logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay onramp (ramp ${state.id})`); return this.transitionToNextPhase(state, "destinationTransfer"); } - if (quote.to === Networks.Base && quote.outputCurrency === EvmToken.USDC) { - return this.transitionToNextPhase(state, "destinationTransfer"); - } - const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; - if (!bridgeMeta?.inputAmountRaw || !bridgeMeta.fromNetwork || !bridgeMeta.fromToken) { + if ( + !bridgeMeta?.inputAmountRaw || + !bridgeMeta.fromNetwork || + !bridgeMeta.fromToken || + !bridgeMeta.toNetwork || + !bridgeMeta.toToken + ) { throw new Error("Missing bridge metadata required to validate squidRouter input balance"); } + const isSameChainSameTokenPassthrough = + bridgeMeta.fromNetwork === bridgeMeta.toNetwork && + bridgeMeta.fromToken.toLowerCase() === bridgeMeta.toToken.toLowerCase(); + if (isSameChainSameTokenPassthrough) { + logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for same-chain same-token passthrough (ramp ${state.id})`); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + const evmEphemeralAddress = state.state.evmEphemeralAddress; if (!evmEphemeralAddress) { throw new Error("Missing EVM ephemeral address to validate squidRouter input balance"); } const sourceNetwork = bridgeMeta.fromNetwork as EvmNetworks; - const sourceTokenDetails = Object.values(evmTokenConfig[sourceNetwork] || {}).find( - token => token.erc20AddressSourceChain.toLowerCase() === bridgeMeta.fromToken.toLowerCase() - ) as EvmTokenDetails | undefined; + const sourceTokenDetails = getEvmTokenDetailsByAddress(sourceNetwork, bridgeMeta.fromToken); if (!sourceTokenDetails) { throw new Error( @@ -107,7 +111,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { timeoutMs: 15000, tokenDetails: sourceTokenDetails }); - } catch (error) { + } catch (_error) { throw this.createRecoverableError( `Unable to verify squidRouter input balance for ${evmEphemeralAddress} on ${sourceNetwork}; balance may not be settled yet` ); diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index 5b0c76849..6d6c2037b 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -57,6 +57,7 @@ export interface StateMetadata { // Only used in onramp, offramp - monerium moneriumOnrampPermit?: PermitSignature; permitTxHash?: string; + moneriumOnrampSelfTransferHash?: string; ibanPaymentData: IbanPaymentData; // Used for webhook notifications sessionId?: string; diff --git a/apps/api/src/api/services/ramp/monerium-permit.test.ts b/apps/api/src/api/services/ramp/monerium-permit.test.ts new file mode 100644 index 000000000..e7d3ea2fd --- /dev/null +++ b/apps/api/src/api/services/ramp/monerium-permit.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "bun:test"; +import { ERC20_EURE_POLYGON_TOKEN_NAME, ERC20_EURE_POLYGON_V2, Networks, PermitSignature } from "@vortexfi/shared"; +import { Signature as EthersSignature, Wallet } from "ethers"; +import { + analyzeMoneriumPermitPreflight, + validateMoneriumOnrampPermit +} from "./monerium-permit"; + +const OWNER = new Wallet("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); +const SPENDER = "0x4e84e0b84054F078D4Adc785818663eF83c032E3"; +const VALUE_RAW = "1150000000000000000"; +const NONCE = "0"; +const DEADLINE = "1779978803"; + +async function signPermit(overrides: Partial = {}): Promise { + const context = { + chainId: 137, + deadline: DEADLINE, + nonce: NONCE, + owner: OWNER.address as `0x${string}`, + spender: SPENDER as `0x${string}`, + tokenAddress: ERC20_EURE_POLYGON_V2, + tokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + tokenVersion: "1", + valueRaw: VALUE_RAW, + ...overrides + }; + const signature = EthersSignature.from( + await OWNER.signTypedData( + { + chainId: context.chainId, + name: context.tokenName, + verifyingContract: context.tokenAddress, + version: context.tokenVersion + }, + { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + }, + { + deadline: context.deadline, + nonce: context.nonce, + owner: context.owner, + spender: context.spender, + value: context.valueRaw + } + ) + ); + + return { + context, + deadline: Number(context.deadline), + r: signature.r as `0x${string}`, + s: signature.s as `0x${string}`, + v: signature.v + }; +} + +describe("validateMoneriumOnrampPermit", () => { + it("accepts a permit whose signed context matches the expected onramp transfer", async () => { + const permit = await signPermit(); + + expect(() => + validateMoneriumOnrampPermit(permit, { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon + }) + ).not.toThrow(); + }); + + it("rejects a permit signed for a different raw value before payment details are released", async () => { + const permit = await signPermit({ valueRaw: "1000000000000000000" }); + + expect(() => + validateMoneriumOnrampPermit(permit, { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon + }) + ).toThrow("valueRaw"); + }); + + it("rejects a permit whose signed context is missing entirely", () => { + const permit: PermitSignature = { + deadline: Number(DEADLINE), + r: `0x${"0".repeat(64)}`, + s: `0x${"0".repeat(64)}`, + v: 27 + }; + + expect(() => + validateMoneriumOnrampPermit(permit, { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon + }) + ).toThrow("missing signed context"); + }); + + it("rejects a permit signed with a different token version", async () => { + const permit = await signPermit({ tokenVersion: "2" }); + + expect(() => + validateMoneriumOnrampPermit(permit, { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon + }) + ).toThrow("tokenVersion"); + }); + + it("rejects an expired permit before payment details are released", async () => { + const permit = await signPermit({ deadline: "1700000000" }); + + expect(() => + validateMoneriumOnrampPermit( + permit, + { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon + }, + 1700000000 + ) + ).toThrow("has expired"); + }); +}); + +describe("analyzeMoneriumPermitPreflight", () => { + it("skips sending permit when allowance already covers the self-transfer amount", async () => { + const permit = await signPermit(); + + expect( + analyzeMoneriumPermitPreflight( + permit, + { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon + }, + { + allowanceRaw: 2n * BigInt(VALUE_RAW), + balanceRaw: 0n, + nonce: 5n, + tokenName: ERC20_EURE_POLYGON_TOKEN_NAME + }, + 1779970000 + ) + ).toEqual({ reason: "allowance-sufficient", shouldSendPermit: false }); + }); + + it("reports nonce drift before attempting a permit that would revert", async () => { + const permit = await signPermit(); + + expect(() => + analyzeMoneriumPermitPreflight( + permit, + { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon + }, + { + allowanceRaw: 0n, + balanceRaw: BigInt(VALUE_RAW), + nonce: 1n, + tokenName: ERC20_EURE_POLYGON_TOKEN_NAME + }, + 1779970000 + ) + ).toThrow("nonce"); + }); +}); diff --git a/apps/api/src/api/services/ramp/monerium-permit.ts b/apps/api/src/api/services/ramp/monerium-permit.ts new file mode 100644 index 000000000..c3510478a --- /dev/null +++ b/apps/api/src/api/services/ramp/monerium-permit.ts @@ -0,0 +1,138 @@ +import { getNetworkId, Networks, PermitSignature } from "@vortexfi/shared"; +import { Signature as EvmSignature, verifyTypedData } from "ethers"; +import httpStatus from "http-status"; +import { APIError } from "../../errors/api-error"; + +const PERMIT_TYPES = { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] +}; + +export interface MoneriumPermitExpectation { + expectedOwner: string; + expectedSpender: string; + expectedValueRaw: string; + expectedTokenAddress: `0x${string}`; + expectedTokenName: string; + expectedTokenVersion?: string; + network: Networks; +} + +export interface MoneriumPermitDiagnostics { + allowanceRaw: bigint; + balanceRaw: bigint; + nonce: bigint; + tokenName: string; +} + +function throwBadPermit(message: string): never { + throw new APIError({ + message, + status: httpStatus.BAD_REQUEST + }); +} + +function assertEqual(label: string, actual: string | number | undefined, expected: string | number): void { + if (actual === undefined) { + throwBadPermit(`Monerium permit ${label} is missing from signed context (expected ${String(expected)})`); + } + if (String(actual).toLowerCase() !== String(expected).toLowerCase()) { + throwBadPermit(`Monerium permit ${label} ${String(actual)} does not match expected ${String(expected)}`); + } +} + +function getPermitContext(permit: PermitSignature) { + if (!permit.context) { + throwBadPermit("Monerium permit is missing signed context; please sign again with the latest client"); + } + return permit.context; +} + +function validateMoneriumPermitSignature(permit: PermitSignature, expectation: MoneriumPermitExpectation) { + const context = getPermitContext(permit); + const expectedChainId = getNetworkId(expectation.network); + + assertEqual("owner", context.owner, expectation.expectedOwner); + assertEqual("spender", context.spender, expectation.expectedSpender); + assertEqual("valueRaw", context.valueRaw, expectation.expectedValueRaw); + assertEqual("tokenAddress", context.tokenAddress, expectation.expectedTokenAddress); + assertEqual("tokenName", context.tokenName, expectation.expectedTokenName); + assertEqual("tokenVersion", context.tokenVersion, expectation.expectedTokenVersion ?? "1"); + assertEqual("chainId", context.chainId, expectedChainId); + assertEqual("deadline", context.deadline, permit.deadline); + + const recoveredSigner = verifyTypedData( + { + chainId: context.chainId, + name: context.tokenName, + verifyingContract: context.tokenAddress, + version: context.tokenVersion + }, + PERMIT_TYPES, + { + deadline: context.deadline, + nonce: context.nonce, + owner: context.owner, + spender: context.spender, + value: context.valueRaw + }, + EvmSignature.from({ r: permit.r, s: permit.s, v: permit.v }).serialized + ); + + if (recoveredSigner.toLowerCase() !== expectation.expectedOwner.toLowerCase()) { + throwBadPermit(`Monerium permit signature was produced by ${recoveredSigner}, expected ${expectation.expectedOwner}`); + } + + return context; +} + +function assertPermitDeadlineInFuture(deadline: string, nowSeconds: number): void { + if (BigInt(deadline) <= BigInt(nowSeconds)) { + throwBadPermit(`Monerium permit deadline ${deadline} has expired`); + } +} + +export function validateMoneriumOnrampPermit( + permit: PermitSignature, + expectation: MoneriumPermitExpectation, + nowSeconds = Math.floor(Date.now() / 1000) +): void { + const context = validateMoneriumPermitSignature(permit, expectation); + assertPermitDeadlineInFuture(context.deadline, nowSeconds); +} + +export function analyzeMoneriumPermitPreflight( + permit: PermitSignature, + expectation: MoneriumPermitExpectation, + diagnostics: MoneriumPermitDiagnostics, + nowSeconds = Math.floor(Date.now() / 1000) +): { reason: "allowance-sufficient" | "permit-required"; shouldSendPermit: boolean } { + const context = validateMoneriumPermitSignature(permit, expectation); + + const expectedValueRaw = BigInt(expectation.expectedValueRaw); + + if (diagnostics.allowanceRaw >= expectedValueRaw) { + return { reason: "allowance-sufficient", shouldSendPermit: false }; + } + + if (diagnostics.tokenName !== context.tokenName) { + throwBadPermit( + `Monerium permit tokenName ${context.tokenName} does not match on-chain token name ${diagnostics.tokenName}` + ); + } + + if (BigInt(context.nonce) !== diagnostics.nonce) { + throwBadPermit( + `Monerium permit nonce ${context.nonce} does not match current on-chain nonce ${diagnostics.nonce.toString()}` + ); + } + + assertPermitDeadlineInFuture(context.deadline, nowSeconds); + + return { reason: "permit-required", shouldSendPermit: true }; +} diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts new file mode 100644 index 000000000..465e22d25 --- /dev/null +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; +import { inspectMoneriumSelfTransferTransaction } from "./monerium-self-transfer"; + +const rawSelfTransferTx = + "0x02f8d381898085e64020937685e640209376830186a094e0aea583266584dafbb3f9c3211d5588c73fea8d80b86423b872dd000000000000000000000000976ff31a56daf5a0e09f411950311f5877ff00d50000000000000000000000007c4e657eeb8ba8bbf0882c817a7a9f2df55636ad0000000000000000000000000000000000000000000000000e27c49886e60000c001a029c840d52a6634e2ed642d50c306f08a379f8466a10c332e07f03bc85da1ae52a00ae865be836a16b25bbe9d647085930d4b0b1cedf3d3e84e127e14f7dddf660e"; + +const expectation = { + expectedAmountRaw: "1020000000000000000", + expectedOwner: "0x976fF31a56dAF5A0E09F411950311F5877ff00D5" as const, + expectedRecipient: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, + expectedSigner: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, + rampId: "ramp-1" +}; + +describe("inspectMoneriumSelfTransferTransaction", () => { + it("decodes and validates a signed Monerium self-transfer", async () => { + const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation); + + expect(inspection.amountRaw).toBe(1020000000000000000n); + expect(inspection.owner.toLowerCase()).toBe(expectation.expectedOwner.toLowerCase()); + expect(inspection.recipient.toLowerCase()).toBe(expectation.expectedRecipient.toLowerCase()); + expect(inspection.signer.toLowerCase()).toBe(expectation.expectedSigner.toLowerCase()); + expect(inspection.signedGas).toBe(100000n); + expect(inspection.signedNonce).toBe(0); + expect(inspection.tokenAddress.toLowerCase()).toBe("0xe0aea583266584dafbb3f9c3211d5588c73fea8d"); + }); + + it("rejects a signed transfer for the wrong amount", async () => { + await expect( + inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { + ...expectation, + expectedAmountRaw: "1020000000000000001" + }) + ).rejects.toThrow("Self-transfer amount 1020000000000000000 does not match expected 1020000000000000001"); + }); + + it("accepts a signed transfer when chainId matches the expected network", async () => { + const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { + ...expectation, + expectedChainId: 137 + }); + + expect(inspection.amountRaw).toBe(1020000000000000000n); + }); + + it("rejects a signed transfer when chainId does not match the expected network", async () => { + await expect( + inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { + ...expectation, + expectedChainId: 1 + }) + ).rejects.toThrow("Self-transfer chainId 137 does not match expected 1"); + }); +}); diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.ts new file mode 100644 index 000000000..9a0652a8d --- /dev/null +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.ts @@ -0,0 +1,121 @@ +import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; +import { decodeFunctionData, isAddress, parseTransaction, recoverTransactionAddress } from "viem"; + +export const moneriumTransferFromAbi = [ + { + inputs: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" } + ], + name: "transferFrom", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +type RecoverableSerializedTransaction = Parameters[0]["serializedTransaction"]; + +interface MoneriumSelfTransferExpectation { + expectedAmountRaw: string; + expectedChainId?: number; + expectedOwner: `0x${string}`; + expectedRecipient: `0x${string}`; + expectedSigner: `0x${string}`; + expectedTokenAddress?: `0x${string}`; + rampId: string; +} + +export interface MoneriumSelfTransferInspection { + amountRaw: bigint; + owner: `0x${string}`; + recipient: `0x${string}`; + serializedTransaction: RecoverableSerializedTransaction; + signedGas: bigint; + signedNonce: number; + signer: `0x${string}`; + tokenAddress: `0x${string}`; +} + +function requireAddress(value: string | null | undefined, label: string, rampId: string): `0x${string}` { + if (!value || !isAddress(value)) { + throw new Error(`[${rampId}] ${label} ${value ?? ""} is not a valid EVM address`); + } + + return value as `0x${string}`; +} + +export async function inspectMoneriumSelfTransferTransaction( + txData: string, + expectation: MoneriumSelfTransferExpectation +): Promise { + const serializedTransaction = txData as RecoverableSerializedTransaction; + const parsedTx = parseTransaction(serializedTransaction); + const signer = requireAddress( + await recoverTransactionAddress({ serializedTransaction }), + "Self-transfer signer", + expectation.rampId + ); + const expectedTokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; + const tokenAddress = requireAddress(parsedTx.to, "Self-transfer token", expectation.rampId); + const signedNonce = parsedTx.nonce; + + if (signedNonce === undefined) { + throw new Error(`[${expectation.rampId}] Self-transfer signed transaction is missing a nonce`); + } + + if (signer.toLowerCase() !== expectation.expectedSigner.toLowerCase()) { + throw new Error( + `[${expectation.rampId}] Self-transfer signer ${signer} does not match expected EVM ephemeral ${expectation.expectedSigner}` + ); + } + + if (tokenAddress.toLowerCase() !== expectedTokenAddress.toLowerCase()) { + throw new Error( + `[${expectation.rampId}] Self-transfer token ${tokenAddress} does not match expected ${expectedTokenAddress}` + ); + } + + if (expectation.expectedChainId !== undefined && parsedTx.chainId !== expectation.expectedChainId) { + throw new Error( + `[${expectation.rampId}] Self-transfer chainId ${parsedTx.chainId} does not match expected ${expectation.expectedChainId}` + ); + } + + const decodedTransfer = decodeFunctionData({ + abi: moneriumTransferFromAbi, + data: parsedTx.data ?? "0x" + }); + const [decodedOwner, decodedRecipient, amountRaw] = decodedTransfer.args; + const owner = requireAddress(decodedOwner, "Self-transfer owner", expectation.rampId); + const recipient = requireAddress(decodedRecipient, "Self-transfer recipient", expectation.rampId); + const expectedAmount = BigInt(expectation.expectedAmountRaw); + + if (owner.toLowerCase() !== expectation.expectedOwner.toLowerCase()) { + throw new Error( + `[${expectation.rampId}] Self-transfer owner ${owner} does not match expected ${expectation.expectedOwner}` + ); + } + if (recipient.toLowerCase() !== expectation.expectedRecipient.toLowerCase()) { + throw new Error( + `[${expectation.rampId}] Self-transfer recipient ${recipient} does not match expected ${expectation.expectedRecipient}` + ); + } + if (amountRaw !== expectedAmount) { + throw new Error( + `[${expectation.rampId}] Self-transfer amount ${amountRaw.toString()} does not match expected ${expectation.expectedAmountRaw}` + ); + } + + return { + amountRaw, + owner, + recipient, + serializedTransaction, + signedGas: parsedTx.gas ?? 0n, + signedNonce, + signer, + tokenAddress + }; +} diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 3f8a6d76a..839d37592 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -13,6 +13,8 @@ import { CreateAlfredpayOfframpRequest, CreateAlfredpayOnrampRequest, EphemeralAccountType, + ERC20_EURE_POLYGON_TOKEN_NAME, + ERC20_EURE_POLYGON_V2, EvmNetworks, FiatToken, GetRampHistoryResponse, @@ -45,7 +47,6 @@ import { Op, Transaction, WhereOptions } from "sequelize"; import { StrKey } from "stellar-sdk"; import { isAddress } from "viem"; import logger from "../../../config/logger"; -import { config } from "../../../config/vars"; import { SEQUENCE_TIME_WINDOW_IN_SECONDS } from "../../../constants/constants"; import Partner from "../../../models/partner.model"; import QuoteTicket from "../../../models/quoteTicket.model"; @@ -53,7 +54,7 @@ import RampState, { RampStateAttributes } from "../../../models/rampState.model" import TaxId from "../../../models/taxId.model"; import { APIError } from "../../errors/api-error"; import { ActivePartner, handleQuoteConsumptionForDiscountState } from "../../services/quote/engines/discount/helpers"; -import { createEpcQrCodeData, getIbanForAddress, getMoneriumUserProfile } from "../monerium"; +import { createEpcQrCodeData, getIbanForAddress } from "../monerium"; import { StateMetadata } from "../phases/meta-state-types"; import phaseProcessor from "../phases/phase-processor"; import { PriceFeedService } from "../priceFeed.service"; @@ -64,6 +65,7 @@ import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; import { getFinalTransactionHashForRamp } from "./helpers"; +import { validateMoneriumOnrampPermit } from "./monerium-permit"; import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; const RAMP_START_EXPIRATION_TIME_SECONDS = SEQUENCE_TIME_WINDOW_IN_SECONDS * 0.8; @@ -1120,13 +1122,6 @@ export class RampService extends BaseRampService { quote.to as EvmNetworks // Fixme: assethub network type issue. ); - const userProfile = config.sandboxEnabled - ? null - : await getMoneriumUserProfile({ - authToken: additionalData.moneriumAuthToken, - profileId: ibanData.profile - }); - const params: MoneriumOnrampTransactionParams = { destinationAddress: additionalData.destinationAddress, moneriumWalletAddress: additionalData.moneriumWalletAddress, @@ -1136,18 +1131,17 @@ export class RampService extends BaseRampService { const { unsignedTxs, stateMeta } = await prepareOnrampTransactions(params); - const receiverName = config.sandboxEnabled ? "Sandbox User" : userProfile?.name || "User"; const ibanPaymentData = { bic: ibanData.bic, iban: ibanData.iban, - receiverName + receiverName: ibanData.name }; const ibanCode = createEpcQrCodeData({ amount: quote.inputAmount, bic: ibanData.bic, iban: ibanData.iban, - name: receiverName + name: ibanData.name }); return { depositQrCode: ibanCode, ibanPaymentData, stateMeta: stateMeta as Partial, unsignedTxs }; } catch (error) { @@ -1281,6 +1275,27 @@ export class RampService extends BaseRampService { status: httpStatus.BAD_REQUEST }); } + if (!quote.metadata.moneriumMint?.outputAmountRaw) { + throw new APIError({ + message: "Missing moneriumMint.outputAmountRaw in quote metadata. Cannot validate Monerium onramp permit.", + status: httpStatus.BAD_REQUEST + }); + } + if (!rampState.state.moneriumWalletAddress || !rampState.state.evmEphemeralAddress) { + throw new APIError({ + message: "Missing Monerium wallet or EVM ephemeral address in state. Cannot validate Monerium onramp permit.", + status: httpStatus.BAD_REQUEST + }); + } + + validateMoneriumOnrampPermit(rampState.state.moneriumOnrampPermit, { + expectedOwner: rampState.state.moneriumWalletAddress, + expectedSpender: rampState.state.evmEphemeralAddress, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: quote.metadata.moneriumMint.outputAmountRaw, + network: Networks.Polygon + }); } } diff --git a/apps/api/src/api/services/transactions/onramp/common/monerium.test.ts b/apps/api/src/api/services/transactions/onramp/common/monerium.test.ts new file mode 100644 index 000000000..ab20e2107 --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/common/monerium.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "bun:test"; +import { MONERIUM_SELF_TRANSFER_GAS_LIMIT } from "./monerium"; + +describe("MONERIUM_SELF_TRANSFER_GAS_LIMIT", () => { + it("keeps enough room for EURe v2 transferFrom compliance checks", () => { + expect(BigInt(MONERIUM_SELF_TRANSFER_GAS_LIMIT)).toBeGreaterThan(100000n); + }); +}); diff --git a/apps/api/src/api/services/transactions/onramp/common/monerium.ts b/apps/api/src/api/services/transactions/onramp/common/monerium.ts index bb8c1095d..e17b0a6ff 100644 --- a/apps/api/src/api/services/transactions/onramp/common/monerium.ts +++ b/apps/api/src/api/services/transactions/onramp/common/monerium.ts @@ -3,6 +3,8 @@ import { encodeFunctionData } from "viem"; import { config } from "../../../../../config/vars"; import erc20ABI from "../../../../../contracts/ERC20"; +export const MONERIUM_SELF_TRANSFER_GAS_LIMIT = "300000"; + export async function createOnrampEphemeralSelfTransfer( amountRaw: string, fromAddress: string, @@ -22,7 +24,7 @@ export async function createOnrampEphemeralSelfTransfer( const txData: EvmTransactionData = { data: transferCallData as `0x${string}`, - gas: "100000", + gas: MONERIUM_SELF_TRANSFER_GAS_LIMIT, maxFeePerGas: String(maxFeePerGas), maxPriorityFeePerGas: String(maxFeePerGas), to: ERC20_EURE_POLYGON_V2, diff --git a/apps/frontend/src/helpers/crypto.ts b/apps/frontend/src/helpers/crypto.ts index f0d42e3ff..897889234 100644 --- a/apps/frontend/src/helpers/crypto.ts +++ b/apps/frontend/src/helpers/crypto.ts @@ -1,4 +1,4 @@ -import { multiplyByPowerOfTen } from "@vortexfi/shared"; +import { multiplyByPowerOfTen, PermitSignature } from "@vortexfi/shared"; import { getAccount, readContract, signTypedData, switchChain } from "@wagmi/core"; import { wagmiConfig } from "../wagmiConfig"; @@ -10,7 +10,7 @@ export async function signERC2612Permit( decimals: number, chainId: number, tokenName: string -): Promise<{ r: `0x${string}`; s: `0x${string}`; v: number; deadline: number }> { +): Promise { const account = getAccount(wagmiConfig); const originalChainId = account.chainId; @@ -80,7 +80,23 @@ export async function signERC2612Permit( const r = `0x${signature.slice(2, 66)}` as `0x${string}`; const s = `0x${signature.slice(66, 130)}` as `0x${string}`; - return { deadline: Number(deadline), r, s, v }; + return { + context: { + chainId, + deadline: deadline.toString(), + nonce: nonce.toString(), + owner, + spender, + tokenAddress, + tokenName, + tokenVersion: "1", + valueRaw: value.toFixed(0, 0) + }, + deadline: Number(deadline), + r, + s, + v + }; } catch (error) { throw new Error("Failed to sign ERC2612 permit: " + error); } finally { diff --git a/packages/shared/src/endpoints/monerium.ts b/packages/shared/src/endpoints/monerium.ts index f5cebab54..6ce57eaec 100644 --- a/packages/shared/src/endpoints/monerium.ts +++ b/packages/shared/src/endpoints/monerium.ts @@ -30,6 +30,7 @@ export interface IbanData { profile: string; address: string; chain: string; + name: string; } export interface IbanDataResponse { @@ -79,4 +80,16 @@ export enum MoneriumErrors { // TODO: Move these types to a more generic file if they are used outside of Monerium endpoints export type Signature = { v: number; r: `0x${string}`; s: `0x${string}`; deadline: number }; -export type PermitSignature = Signature; +export interface PermitSignatureContext { + owner: `0x${string}`; + spender: `0x${string}`; + valueRaw: string; + nonce: string; + deadline: string; + tokenAddress: `0x${string}`; + tokenName: string; + tokenVersion: string; + chainId: number; +} + +export type PermitSignature = Signature & { context?: PermitSignatureContext }; diff --git a/packages/shared/src/services/evm/clientManager.test.ts b/packages/shared/src/services/evm/clientManager.test.ts new file mode 100644 index 000000000..86479862c --- /dev/null +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { Networks } from "../../helpers"; +import { EvmClientManager, redactRpcUrlForLogs, sanitizeRpcErrorMessage } from "./clientManager"; + +describe("redactRpcUrlForLogs", () => { + it("redacts provider API keys from RPC URLs", () => { + expect(redactRpcUrlForLogs("https://polygon-mainnet.g.alchemy.com/v2/test-api-key")).toBe( + "https://polygon-mainnet.g.alchemy.com/v2/[redacted]" + ); + }); + + it("leaves empty viem default RPC markers readable", () => { + expect(redactRpcUrlForLogs("")).toBe(""); + }); + + it("redacts provider API keys embedded in RPC error messages", () => { + expect( + sanitizeRpcErrorMessage("URL: https://polygon-mainnet.g.alchemy.com/v2/test-api-key\nRequest failed") + ).toBe("URL: https://polygon-mainnet.g.alchemy.com/v2/[redacted]\nRequest failed"); + }); +}); + +describe("EvmClientManager RPC cache keys", () => { + it("keeps viem's default transport distinct from explicit RPC URLs", () => { + const manager = EvmClientManager.getInstance(); + const explicitRpcClient = manager.getClient(Networks.PolygonAmoy, "https://polygon-amoy.api.onfinality.io/public"); + const defaultRpcClient = manager.getClient(Networks.PolygonAmoy, ""); + + expect(defaultRpcClient).not.toBe(explicitRpcClient); + }); +}); diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index a478e2f5a..6313197b7 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -1,4 +1,4 @@ -import { Account, Chain, createPublicClient, createWalletClient, http, PublicClient, Transport, WalletClient } from "viem"; +import { Abi, Account, Chain, createPublicClient, createWalletClient, http, PublicClient, Transport, WalletClient } from "viem"; import { arbitrum, avalanche, base, baseSepolia, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; import { ALCHEMY_API_KEY, EvmNetworks, Networks } from "../../index"; import logger from "../../logger"; @@ -9,6 +9,48 @@ export interface EvmNetworkConfig { rpcUrls: string[]; } +const VIEM_DEFAULT_TRANSPORT_CACHE_KEY = ""; + +export function redactRpcUrlForLogs(rpcUrl: string): string { + if (!rpcUrl) { + return ""; + } + + try { + const url = new URL(rpcUrl); + const pathSegments = url.pathname.split("/"); + const secretSegmentIndex = pathSegments.findIndex(segment => segment === "v2") + 1; + if (secretSegmentIndex > 0 && secretSegmentIndex < pathSegments.length) { + pathSegments[secretSegmentIndex] = "[redacted]"; + url.pathname = pathSegments.join("/"); + return url.toString(); + } + + if (pathSegments[pathSegments.length - 1]?.length > 12) { + pathSegments[pathSegments.length - 1] = "[redacted]"; + url.pathname = pathSegments.join("/"); + return url.toString(); + } + + return rpcUrl; + } catch { + return "[redacted-rpc-url]"; + } +} + +export function sanitizeRpcErrorMessage(message: string): string { + return message.replace(/https:\/\/[^\s"]+\/v2\/[^\s")]+/g, match => redactRpcUrlForLogs(match)); +} + +function getRpcCacheKey(rpcUrl: string): string { + return rpcUrl === "" ? VIEM_DEFAULT_TRANSPORT_CACHE_KEY : rpcUrl; +} + +function createRpcTransport(network: EvmNetworkConfig, rpcUrl?: string): Transport { + const targetRpcUrl = rpcUrl ?? network.rpcUrls[0]; + return targetRpcUrl === "" ? http() : http(targetRpcUrl); +} + function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { // Note on defining RPC URLs: '' is equal to viem's default RPC for that chain: http(). return [ @@ -142,7 +184,7 @@ export class EvmClientManager { lastError = error instanceof Error ? error : new Error(String(error)); logger.current.warn( - `${operationName} attempt ${attempt + 1}/${maxRetries + 1} failed on ${networkName} with RPC ${rpcUrl}: ${lastError.message}` + `${operationName} attempt ${attempt + 1}/${maxRetries + 1} failed on ${networkName} with RPC ${redactRpcUrlForLogs(rpcUrl)}: ${sanitizeRpcErrorMessage(lastError.message)}` ); if (attempt < maxRetries) { @@ -156,7 +198,7 @@ export class EvmClientManager { // TODO should we return the raw rpc error here, instead of just the message? throw new Error( - `Failed to ${operationName} on ${networkName} after ${maxRetries + 1} attempts. Last error: ${lastError?.message}` + `Failed to ${operationName} on ${networkName} after ${maxRetries + 1} attempts. Last error: ${sanitizeRpcErrorMessage(lastError?.message ?? "unknown")}` ); } @@ -168,7 +210,7 @@ export class EvmClientManager { const client = this.createClient(network.name, rpcUrl); const key = this.generatePublicClientKey(network.name, rpcUrl); this.clientInstances.set(key, client); - logger.current.info(`Pre-created EVM client for ${network.name} with RPC: ${rpcUrl}`); + logger.current.info(`Pre-created EVM client for ${network.name} with RPC: ${redactRpcUrlForLogs(rpcUrl)}`); }); }); } @@ -181,7 +223,7 @@ export class EvmClientManager { } private generatePublicClientKey(networkName: EvmNetworks, rpcUrl: string): string { - return `${networkName}-${rpcUrl}`; + return `${networkName}-${getRpcCacheKey(rpcUrl)}`; } private getNetworkConfig(networkName: EvmNetworks): EvmNetworkConfig { @@ -195,18 +237,16 @@ export class EvmClientManager { private createClient(networkName: EvmNetworks, rpcUrl?: string): PublicClient { const network = this.getNetworkConfig(networkName); - const transport = rpcUrl ? http(rpcUrl) : http(network.rpcUrls[0]); - const client = createPublicClient({ chain: network.chain, - transport + transport: createRpcTransport(network, rpcUrl) }); return client; } private generateWalletClientKey(networkName: EvmNetworks, accountAddress: string, rpcUrl?: string): string { - const rpcSuffix = rpcUrl ? `-${rpcUrl}` : ""; + const rpcSuffix = rpcUrl === undefined ? "" : `-${getRpcCacheKey(rpcUrl)}`; return `${networkName}-${accountAddress.toLowerCase()}${rpcSuffix}`; } @@ -217,12 +257,10 @@ export class EvmClientManager { ): WalletClient { const network = this.getNetworkConfig(networkName); - const transport = rpcUrl ? http(rpcUrl) : http(network.rpcUrls[0]); - const walletClient = createWalletClient({ account, chain: network.chain, - transport + transport: createRpcTransport(network, rpcUrl) }); return walletClient; @@ -231,7 +269,7 @@ export class EvmClientManager { public getClient(networkName: EvmNetworks, rpcUrl?: string): PublicClient { const network = this.getNetworkConfig(networkName); - const targetRpcUrl = rpcUrl || network.rpcUrls[0]; + const targetRpcUrl = rpcUrl ?? network.rpcUrls[0]; const key = this.generatePublicClientKey(networkName, targetRpcUrl); const client = this.clientInstances.get(key); @@ -247,9 +285,8 @@ export class EvmClientManager { let walletClient = this.walletClientInstances.get(key); if (!walletClient) { - logger.current.info( - `Creating new EVM wallet client for ${networkName} with account ${account.address}${rpcUrl ? ` using RPC: ${rpcUrl}` : ""}` - ); + const rpcLogSuffix = rpcUrl === undefined ? "" : ` using RPC: ${redactRpcUrlForLogs(rpcUrl)}`; + logger.current.info(`Creating new EVM wallet client for ${networkName} with account ${account.address}${rpcLogSuffix}`); walletClient = this.createWalletClient(networkName, account, rpcUrl); this.walletClientInstances.set(key, walletClient); } @@ -270,10 +307,10 @@ export class EvmClientManager { public async readContractWithRetry( networkName: EvmNetworks, contractParams: { - abi: any; + abi: readonly unknown[]; address: `0x${string}`; functionName: string; - args?: any[]; + args?: readonly unknown[]; }, maxRetries = 3, initialDelayMs = 1000 @@ -284,6 +321,7 @@ export class EvmClientManager { const publicClient = this.getClient(networkName, rpcUrl); return (await publicClient.readContract({ ...contractParams, + abi: contractParams.abi as Abi, args: contractParams.args || [] })) as T; }, diff --git a/packages/shared/src/tokens/utils/helpers.test.ts b/packages/shared/src/tokens/utils/helpers.test.ts new file mode 100644 index 000000000..e9ad191a7 --- /dev/null +++ b/packages/shared/src/tokens/utils/helpers.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { Networks } from "../../helpers"; +import { + ERC20_EURE_POLYGON_DECIMALS, + ERC20_EURE_POLYGON_TOKEN_NAME, + ERC20_EURE_POLYGON_V1, + ERC20_EURE_POLYGON_V2 +} from "../constants/misc"; +import { evmTokenConfig } from "../evm/config"; +import { EvmToken } from "../types/evm"; +import { getEvmTokenDetailsByAddress } from "./helpers"; + +describe("getEvmTokenDetailsByAddress", () => { + it("resolves configured EVM tokens by contract address", () => { + const polygonUsdc = evmTokenConfig[Networks.Polygon][EvmToken.USDC]; + expect(polygonUsdc).toBeDefined(); + if (!polygonUsdc) { + throw new Error("Polygon USDC test fixture is missing"); + } + + const tokenDetails = getEvmTokenDetailsByAddress(Networks.Polygon, polygonUsdc.erc20AddressSourceChain); + + expect(tokenDetails).toEqual(polygonUsdc); + }); + + it("resolves Monerium EUR.e Polygon contracts by address", () => { + for (const tokenAddress of [ERC20_EURE_POLYGON_V1, ERC20_EURE_POLYGON_V2]) { + const tokenDetails = getEvmTokenDetailsByAddress(Networks.Polygon, tokenAddress); + + expect(tokenDetails?.assetSymbol).toBe(ERC20_EURE_POLYGON_TOKEN_NAME); + expect(tokenDetails?.decimals).toBe(ERC20_EURE_POLYGON_DECIMALS); + expect(tokenDetails?.erc20AddressSourceChain).toBe(tokenAddress); + expect(tokenDetails?.network).toBe(Networks.Polygon); + } + }); +}); diff --git a/packages/shared/src/tokens/utils/helpers.ts b/packages/shared/src/tokens/utils/helpers.ts index 48447d8ae..202e2759d 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -5,10 +5,17 @@ import { EvmNetworks, isNetworkEVM, Networks } from "../../helpers"; import logger from "../../logger"; import { assetHubTokenConfig } from "../assethub/config"; +import { + ERC20_EURE_POLYGON_DECIMALS, + ERC20_EURE_POLYGON_TOKEN_NAME, + ERC20_EURE_POLYGON_V1, + ERC20_EURE_POLYGON_V2 +} from "../constants/misc"; import { evmTokenConfig } from "../evm/config"; import { getEvmTokenConfig } from "../evm/dynamicEvmTokens"; import { freeTokenConfig } from "../freeTokens/config"; import { moonbeamTokenConfig } from "../moonbeam/config"; +import { PENDULUM_USDC_AXL } from "../pendulum/config"; import { stellarTokenConfig } from "../stellar/config"; import { AssetHubToken, FiatToken, OnChainToken, OnChainTokenSymbol, RampCurrency, TokenType } from "../types/base"; import { EvmToken, EvmTokenDetails } from "../types/evm"; @@ -18,6 +25,8 @@ import { StellarTokenDetails } from "../types/stellar"; import { normalizeTokenSymbol } from "./normalization"; import { FiatTokenDetails, OnChainTokenDetails } from "./typeGuards"; +const MONERIUM_EURE_POLYGON_ADDRESSES = new Set([ERC20_EURE_POLYGON_V1.toLowerCase(), ERC20_EURE_POLYGON_V2.toLowerCase()]); + /** * Get token details for a specific network and token */ @@ -47,6 +56,40 @@ export function getOnChainTokenDetails( } } +/** + * Resolve an EVM token by contract address on a specific network. + */ +export function getEvmTokenDetailsByAddress( + network: EvmNetworks, + tokenAddress: `0x${string}`, + dynamicEvmTokenConfig?: Record>> +): EvmTokenDetails | undefined { + const normalizedTokenAddress = tokenAddress.toLowerCase(); + const configToUse = dynamicEvmTokenConfig ?? getEvmTokenConfig(); + + const configuredToken = Object.values(configToUse[network] ?? {}).find( + (token): token is EvmTokenDetails => + token !== undefined && token.erc20AddressSourceChain.toLowerCase() === normalizedTokenAddress + ); + if (configuredToken) { + return configuredToken; + } + + if (network === Networks.Polygon && MONERIUM_EURE_POLYGON_ADDRESSES.has(normalizedTokenAddress)) { + return { + assetSymbol: ERC20_EURE_POLYGON_TOKEN_NAME, + decimals: ERC20_EURE_POLYGON_DECIMALS, + erc20AddressSourceChain: tokenAddress, + isNative: false, + network: Networks.Polygon, + pendulumRepresentative: PENDULUM_USDC_AXL, + type: TokenType.Evm + }; + } + + return undefined; +} + /** * Get token details for a specific network and token, with fallback to default */