From 5a16a2944aeed9a4fd8a0501eb9329a22a649968 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 21 May 2026 09:20:30 -0300 Subject: [PATCH 01/13] use user name from iban data response --- apps/api/src/api/services/ramp/ramp.service.ts | 12 ++---------- packages/shared/src/endpoints/monerium.ts | 1 + 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 3f8a6d76a..ce667cfae 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1120,13 +1120,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 +1129,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) { diff --git a/packages/shared/src/endpoints/monerium.ts b/packages/shared/src/endpoints/monerium.ts index f5cebab54..2c52b9337 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 { From 2f2e4fbc0b778347916980265be2528c030669a4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 15:38:55 +0200 Subject: [PATCH 02/13] Enhance squidRouterPhaseHandler with additional bridge metadata checks and same-chain passthrough handling --- .../handlers/squid-router-phase-handler.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) 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..6a2629307 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,7 +2,6 @@ import { checkEvmBalanceForToken, EvmClientManager, EvmNetworks, - EvmToken, EvmTokenDetails, evmTokenConfig, FiatToken, @@ -65,22 +64,30 @@ 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"); @@ -107,7 +114,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` ); From acd35b25cf3a946784621d65b31aee7a26c01262 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 16:09:14 +0200 Subject: [PATCH 03/13] Fix issue with token details not found in squidrouter phase handlers --- .../handlers/squid-router-phase-handler.ts | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) 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 6a2629307..a2a412aee 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 @@ -1,5 +1,9 @@ import { checkEvmBalanceForToken, + ERC20_EURE_POLYGON_DECIMALS, + ERC20_EURE_POLYGON_TOKEN_NAME, + ERC20_EURE_POLYGON_V1, + ERC20_EURE_POLYGON_V2, EvmClientManager, EvmNetworks, EvmTokenDetails, @@ -9,8 +13,10 @@ import { getNetworkId, isAlfredpayToken, Networks, + PENDULUM_USDC_AXL, RampDirection, - RampPhase + RampPhase, + TokenType } from "@vortexfi/shared"; import { PublicClient } from "viem"; import logger from "../../../../config/logger"; @@ -18,6 +24,8 @@ import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; +const MONERIUM_EURE_POLYGON_SOURCE_TOKENS = new Set([ERC20_EURE_POLYGON_V1.toLowerCase(), ERC20_EURE_POLYGON_V2.toLowerCase()]); + /** * Handler for the squidRouter phase */ @@ -94,9 +102,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { } 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 = this.getSourceTokenDetails(sourceNetwork, bridgeMeta.fromToken); if (!sourceTokenDetails) { throw new Error( @@ -295,6 +301,29 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { throw this.createRecoverableError("Failed to get transaction nonce"); } } + + private getSourceTokenDetails(sourceNetwork: EvmNetworks, sourceToken: `0x${string}`): EvmTokenDetails | undefined { + const configuredToken = Object.values(evmTokenConfig[sourceNetwork] || {}).find( + token => token.erc20AddressSourceChain.toLowerCase() === sourceToken.toLowerCase() + ) as EvmTokenDetails | undefined; + if (configuredToken) { + return configuredToken; + } + + if (sourceNetwork === Networks.Polygon && MONERIUM_EURE_POLYGON_SOURCE_TOKENS.has(sourceToken.toLowerCase())) { + return { + assetSymbol: ERC20_EURE_POLYGON_TOKEN_NAME, + decimals: ERC20_EURE_POLYGON_DECIMALS, + erc20AddressSourceChain: sourceToken, + isNative: false, + network: Networks.Polygon, + pendulumRepresentative: PENDULUM_USDC_AXL, + type: TokenType.Evm + }; + } + + return undefined; + } } export default new SquidRouterPhaseHandler(); From 49e933ee8b9ace606fd6c9e03df02c06b6640139 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 16:32:43 +0200 Subject: [PATCH 04/13] Refactor code --- .../handlers/squid-router-phase-handler.ts | 38 ++-------------- packages/shared/src/tokens/utils/helpers.ts | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 35 deletions(-) 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 a2a412aee..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 @@ -1,22 +1,15 @@ import { checkEvmBalanceForToken, - ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_TOKEN_NAME, - ERC20_EURE_POLYGON_V1, - ERC20_EURE_POLYGON_V2, EvmClientManager, EvmNetworks, - EvmTokenDetails, - evmTokenConfig, FiatToken, + getEvmTokenDetailsByAddress, getNetworkFromDestination, getNetworkId, isAlfredpayToken, Networks, - PENDULUM_USDC_AXL, RampDirection, - RampPhase, - TokenType + RampPhase } from "@vortexfi/shared"; import { PublicClient } from "viem"; import logger from "../../../../config/logger"; @@ -24,8 +17,6 @@ import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; -const MONERIUM_EURE_POLYGON_SOURCE_TOKENS = new Set([ERC20_EURE_POLYGON_V1.toLowerCase(), ERC20_EURE_POLYGON_V2.toLowerCase()]); - /** * Handler for the squidRouter phase */ @@ -102,7 +93,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { } const sourceNetwork = bridgeMeta.fromNetwork as EvmNetworks; - const sourceTokenDetails = this.getSourceTokenDetails(sourceNetwork, bridgeMeta.fromToken); + const sourceTokenDetails = getEvmTokenDetailsByAddress(sourceNetwork, bridgeMeta.fromToken); if (!sourceTokenDetails) { throw new Error( @@ -301,29 +292,6 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { throw this.createRecoverableError("Failed to get transaction nonce"); } } - - private getSourceTokenDetails(sourceNetwork: EvmNetworks, sourceToken: `0x${string}`): EvmTokenDetails | undefined { - const configuredToken = Object.values(evmTokenConfig[sourceNetwork] || {}).find( - token => token.erc20AddressSourceChain.toLowerCase() === sourceToken.toLowerCase() - ) as EvmTokenDetails | undefined; - if (configuredToken) { - return configuredToken; - } - - if (sourceNetwork === Networks.Polygon && MONERIUM_EURE_POLYGON_SOURCE_TOKENS.has(sourceToken.toLowerCase())) { - return { - assetSymbol: ERC20_EURE_POLYGON_TOKEN_NAME, - decimals: ERC20_EURE_POLYGON_DECIMALS, - erc20AddressSourceChain: sourceToken, - isNative: false, - network: Networks.Polygon, - pendulumRepresentative: PENDULUM_USDC_AXL, - type: TokenType.Evm - }; - } - - return undefined; - } } export default new SquidRouterPhaseHandler(); 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 */ From c37d087b904e0347ae52f825dff031d1d7b956b8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 16:55:49 +0200 Subject: [PATCH 05/13] Implement Monerium permit diagnostics and simulation for onramp transactions --- .../monerium-onramp-self-transfer-handler.ts | 156 ++++++++++++++++-- .../api/src/api/services/ramp/ramp.service.ts | 27 ++- apps/frontend/src/helpers/crypto.ts | 22 ++- packages/shared/src/endpoints/monerium.ts | 14 +- .../shared/src/services/evm/clientManager.ts | 40 ++++- 5 files changed, 229 insertions(+), 30 deletions(-) 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..a5f3002a5 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,4 +1,5 @@ import { + ERC20_EURE_POLYGON_TOKEN_NAME, ERC20_EURE_POLYGON_V2, EvmClientManager, getEvmTokenBalance, @@ -11,11 +12,23 @@ import { encodeFunctionData, PublicClient } 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 { 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 */ @@ -98,24 +111,81 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { 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: account.address, + 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, account.address, 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); @@ -152,6 +222,56 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { } } + 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 diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index ce667cfae..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; @@ -1273,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/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 2c52b9337..6ce57eaec 100644 --- a/packages/shared/src/endpoints/monerium.ts +++ b/packages/shared/src/endpoints/monerium.ts @@ -80,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.ts b/packages/shared/src/services/evm/clientManager.ts index a478e2f5a..9b0a5a37a 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,33 @@ export interface EvmNetworkConfig { rpcUrls: string[]; } +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]"; + } +} + function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { // Note on defining RPC URLs: '' is equal to viem's default RPC for that chain: http(). return [ @@ -142,7 +169,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)}: ${lastError.message}` ); if (attempt < maxRetries) { @@ -168,7 +195,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)}`); }); }); } @@ -248,7 +275,7 @@ export class EvmClientManager { if (!walletClient) { logger.current.info( - `Creating new EVM wallet client for ${networkName} with account ${account.address}${rpcUrl ? ` using RPC: ${rpcUrl}` : ""}` + `Creating new EVM wallet client for ${networkName} with account ${account.address}${rpcUrl ? ` using RPC: ${redactRpcUrlForLogs(rpcUrl)}` : ""}` ); walletClient = this.createWalletClient(networkName, account, rpcUrl); this.walletClientInstances.set(key, walletClient); @@ -270,10 +297,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 +311,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; }, From 06ca1a5a90bb6863393658f1f67273a6674f13fb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 17:29:24 +0200 Subject: [PATCH 06/13] Add preflight checks and diagnostics for Monerium self-transfer transactions --- .../monerium-onramp-self-transfer-handler.ts | 118 ++++++++++++++++- .../api/services/phases/meta-state-types.ts | 1 + .../src/api/services/ramp/monerium-permit.ts | 121 ++++++++++++++++++ .../services/ramp/monerium-self-transfer.ts | 97 ++++++++++++++ .../transactions/onramp/common/monerium.ts | 4 +- .../shared/src/services/evm/clientManager.ts | 8 +- 6 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/api/services/ramp/monerium-permit.ts create mode 100644 apps/api/src/api/services/ramp/monerium-self-transfer.ts 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 a5f3002a5..15e8fda71 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 @@ -8,7 +8,7 @@ import { RampPhase } from "@vortexfi/shared"; import Big from "big.js"; -import { encodeFunctionData, PublicClient } from "viem"; +import { encodeFunctionData, PublicClient, TransactionReceipt } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; @@ -17,6 +17,7 @@ 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 = [ @@ -201,9 +202,24 @@ 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}`); @@ -222,6 +238,93 @@ 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, + 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) { + throw new Error( + `[${rampId}] Self-transfer signed nonce ${transfer.signedNonce} has already been consumed by ${transfer.signer} (current nonce ${currentNonce}). Do not resend this raw transaction; regenerate the presigned self-transfer transaction or inspect the previous nonce-${transfer.signedNonce} transaction.` + ); + } + 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, { @@ -293,14 +396,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/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.ts b/apps/api/src/api/services/ramp/monerium-permit.ts new file mode 100644 index 000000000..da72a9555 --- /dev/null +++ b/apps/api/src/api/services/ramp/monerium-permit.ts @@ -0,0 +1,121 @@ +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 (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; +} + +export function validateMoneriumOnrampPermit(permit: PermitSignature, expectation: MoneriumPermitExpectation): void { + 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}`); + } +} + +export function analyzeMoneriumPermitPreflight( + permit: PermitSignature, + expectation: MoneriumPermitExpectation, + diagnostics: MoneriumPermitDiagnostics, + nowSeconds = Math.floor(Date.now() / 1000) +): { reason: "allowance-sufficient" | "permit-required"; shouldSendPermit: boolean } { + validateMoneriumOnrampPermit(permit, expectation); + + const context = getPermitContext(permit); + 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()}` + ); + } + + if (BigInt(context.deadline) <= BigInt(nowSeconds)) { + throwBadPermit(`Monerium permit deadline ${context.deadline} has expired`); + } + + return { reason: "permit-required", shouldSendPermit: true }; +} 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..5ba0b79cf --- /dev/null +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.ts @@ -0,0 +1,97 @@ +import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; +import { decodeFunctionData, 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; + 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}`; +} + +export async function inspectMoneriumSelfTransferTransaction( + txData: string, + expectation: MoneriumSelfTransferExpectation +): Promise { + const serializedTransaction = txData as RecoverableSerializedTransaction; + const parsedTx = parseTransaction(serializedTransaction); + const signer = await recoverTransactionAddress({ serializedTransaction }); + const tokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; + 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 (parsedTx.to?.toLowerCase() !== tokenAddress.toLowerCase()) { + throw new Error(`[${expectation.rampId}] Self-transfer token ${parsedTx.to} does not match expected ${tokenAddress}`); + } + + const decodedTransfer = decodeFunctionData({ + abi: moneriumTransferFromAbi, + data: parsedTx.data ?? "0x" + }); + const [owner, recipient, amountRaw] = decodedTransfer.args; + 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/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/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 9b0a5a37a..b6f98ea83 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -36,6 +36,10 @@ export function redactRpcUrlForLogs(rpcUrl: string): string { } } +export function sanitizeRpcErrorMessage(message: string): string { + return message.replace(/https:\/\/[^\s"]+\/v2\/[^\s")]+/g, match => redactRpcUrlForLogs(match)); +} + function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { // Note on defining RPC URLs: '' is equal to viem's default RPC for that chain: http(). return [ @@ -169,7 +173,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 ${redactRpcUrlForLogs(rpcUrl)}: ${lastError.message}` + `${operationName} attempt ${attempt + 1}/${maxRetries + 1} failed on ${networkName} with RPC ${redactRpcUrlForLogs(rpcUrl)}: ${sanitizeRpcErrorMessage(lastError.message)}` ); if (attempt < maxRetries) { @@ -183,7 +187,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")}` ); } From 99710cd60bea2e079c870d634da5c479b3b9da24 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 17:43:37 +0200 Subject: [PATCH 07/13] Tighten Monerium self-transfer validation and add tests - Strict nonce equality check with directional error messages to surface both stale and gap-ahead signed nonces on the EVM ephemeral. - Validate chainId of the signed self-transfer against Polygon to reject transactions signed for the wrong network before broadcast. - Reject Monerium permits with a missing signed context explicitly instead of silently coercing undefined to a string mismatch. - Document the 30s post-broadcast settlement wait. - Add unit tests for permit validation, self-transfer inspection, squid-router same-chain passthrough, monerium tx builders, shared EVM client manager redaction, and EURE token helper fallbacks. --- .../monerium-onramp-self-transfer-handler.ts | 18 +- .../squid-router-phase-handler.test.ts | 211 ++++++++++++++++++ .../api/services/ramp/monerium-permit.test.ts | 181 +++++++++++++++ .../src/api/services/ramp/monerium-permit.ts | 3 + .../ramp/monerium-self-transfer.test.ts | 54 +++++ .../services/ramp/monerium-self-transfer.ts | 7 + .../onramp/common/monerium.test.ts | 8 + .../src/services/evm/clientManager.test.ts | 22 ++ .../shared/src/tokens/utils/helpers.test.ts | 36 +++ 9 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts create mode 100644 apps/api/src/api/services/ramp/monerium-permit.test.ts create mode 100644 apps/api/src/api/services/ramp/monerium-self-transfer.test.ts create mode 100644 apps/api/src/api/services/transactions/onramp/common/monerium.test.ts create mode 100644 packages/shared/src/services/evm/clientManager.test.ts create mode 100644 packages/shared/src/tokens/utils/helpers.test.ts 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 15e8fda71..f388f43b7 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 @@ -3,6 +3,7 @@ import { ERC20_EURE_POLYGON_V2, EvmClientManager, getEvmTokenBalance, + getNetworkId, Networks, RampDirection, RampPhase @@ -224,7 +225,8 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { 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)); @@ -247,6 +249,7 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { ): Promise { const transfer = await inspectMoneriumSelfTransferTransaction(txData, { expectedAmountRaw, + expectedChainId: getNetworkId(Networks.Polygon), expectedOwner, expectedRecipient: expectedSpender, expectedSigner: expectedSpender, @@ -288,10 +291,15 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { })}` ); - if (currentNonce > transfer.signedNonce) { - throw new Error( - `[${rampId}] Self-transfer signed nonce ${transfer.signedNonce} has already been consumed by ${transfer.signer} (current nonce ${currentNonce}). Do not resend this raw transaction; regenerate the presigned self-transfer transaction or inspect the previous nonce-${transfer.signedNonce} transaction.` - ); + 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( 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/ramp/monerium-permit.test.ts b/apps/api/src/api/services/ramp/monerium-permit.test.ts new file mode 100644 index 000000000..a49b3dd6f --- /dev/null +++ b/apps/api/src/api/services/ramp/monerium-permit.test.ts @@ -0,0 +1,181 @@ +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"); + }); +}); + +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 index da72a9555..673b49334 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.ts @@ -38,6 +38,9 @@ function throwBadPermit(message: string): never { } 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)}`); } 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 index 5ba0b79cf..84dd99e07 100644 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.ts +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.ts @@ -19,6 +19,7 @@ type RecoverableSerializedTransaction = Parameters { + it("keeps enough room for EURe v2 transferFrom compliance checks", () => { + expect(BigInt(MONERIUM_SELF_TRANSFER_GAS_LIMIT)).toBeGreaterThan(100000n); + }); +}); 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..d35858466 --- /dev/null +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test"; +import { redactRpcUrlForLogs, sanitizeRpcErrorMessage } from "./clientManager"; + +describe("redactRpcUrlForLogs", () => { + it("redacts provider API keys from RPC URLs", () => { + expect(redactRpcUrlForLogs("https://polygon-mainnet.g.alchemy.com/v2/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62")).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/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62\nRequest failed" + ) + ).toBe("URL: https://polygon-mainnet.g.alchemy.com/v2/[redacted]\nRequest failed"); + }); +}); 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); + } + }); +}); From 0641f1c4bdafc93a16da28adec6966d619a29cbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 16:15:36 +0000 Subject: [PATCH 08/13] fix: address Monerium review comments Agent-Logs-Url: https://github.com/pendulum-chain/vortex/sessions/3ed19689-b5e8-40e8-a453-d69a148478dc --- .../api/services/ramp/monerium-permit.test.ts | 90 ++++++--------- .../src/api/services/ramp/monerium-permit.ts | 15 ++- .../ramp/monerium-self-transfer.test.ts | 105 +++++++++++++++++- .../services/ramp/monerium-self-transfer.ts | 34 ++++-- .../src/services/evm/clientManager.test.ts | 20 +++- .../shared/src/services/evm/clientManager.ts | 32 ++++-- packages/shared/src/tokens/constants/misc.ts | 1 + .../shared/src/tokens/utils/helpers.test.ts | 4 +- packages/shared/src/tokens/utils/helpers.ts | 4 +- 9 files changed, 212 insertions(+), 93 deletions(-) diff --git a/apps/api/src/api/services/ramp/monerium-permit.test.ts b/apps/api/src/api/services/ramp/monerium-permit.test.ts index a49b3dd6f..27b361303 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.test.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.test.ts @@ -7,12 +7,24 @@ import { } from "./monerium-permit"; const OWNER = new Wallet("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); +const OTHER_SIGNER = new Wallet("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"); const SPENDER = "0x4e84e0b84054F078D4Adc785818663eF83c032E3"; const VALUE_RAW = "1150000000000000000"; const NONCE = "0"; const DEADLINE = "1779978803"; +const EXPECTATION = { + expectedOwner: OWNER.address, + expectedSpender: SPENDER, + expectedTokenAddress: ERC20_EURE_POLYGON_V2, + expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, + expectedValueRaw: VALUE_RAW, + network: Networks.Polygon +} as const; -async function signPermit(overrides: Partial = {}): Promise { +async function signPermit( + overrides: Partial = {}, + signer: Wallet = OWNER +): Promise { const context = { chainId: 137, deadline: DEADLINE, @@ -26,7 +38,7 @@ async function signPermit(overrides: Partial = {}): ...overrides }; const signature = EthersSignature.from( - await OWNER.signTypedData( + await signer.signTypedData( { chainId: context.chainId, name: context.tokenName, @@ -65,31 +77,13 @@ 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(); + expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).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"); + expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).toThrow("valueRaw"); }); it("rejects a permit whose signed context is missing entirely", () => { @@ -100,31 +94,25 @@ describe("validateMoneriumOnrampPermit", () => { 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"); + expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).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"); + expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).toThrow("tokenVersion"); + }); + + it("rejects a permit whose recovered signer does not match the expected owner", async () => { + const permit = await signPermit({}, OTHER_SIGNER); + + expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).toThrow("signature was produced by"); + }); + + it("rejects an already-expired permit before deposit details are released", async () => { + const permit = await signPermit({ deadline: "1700000000" }); + + expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION, 1700000000)).toThrow("has expired"); }); }); @@ -135,14 +123,7 @@ describe("analyzeMoneriumPermitPreflight", () => { 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 - }, + EXPECTATION, { allowanceRaw: 2n * BigInt(VALUE_RAW), balanceRaw: 0n, @@ -160,14 +141,7 @@ describe("analyzeMoneriumPermitPreflight", () => { 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 - }, + EXPECTATION, { allowanceRaw: 0n, balanceRaw: BigInt(VALUE_RAW), diff --git a/apps/api/src/api/services/ramp/monerium-permit.ts b/apps/api/src/api/services/ramp/monerium-permit.ts index 673b49334..08966ace5 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.ts @@ -53,7 +53,11 @@ function getPermitContext(permit: PermitSignature) { return permit.context; } -export function validateMoneriumOnrampPermit(permit: PermitSignature, expectation: MoneriumPermitExpectation): void { +export function validateMoneriumOnrampPermit( + permit: PermitSignature, + expectation: MoneriumPermitExpectation, + nowSeconds = Math.floor(Date.now() / 1000) +): void { const context = getPermitContext(permit); const expectedChainId = getNetworkId(expectation.network); @@ -65,6 +69,9 @@ export function validateMoneriumOnrampPermit(permit: PermitSignature, expectatio assertEqual("tokenVersion", context.tokenVersion, expectation.expectedTokenVersion ?? "1"); assertEqual("chainId", context.chainId, expectedChainId); assertEqual("deadline", context.deadline, permit.deadline); + if (BigInt(context.deadline) <= BigInt(nowSeconds)) { + throwBadPermit(`Monerium permit deadline ${context.deadline} has expired`); + } const recoveredSigner = verifyTypedData( { @@ -95,7 +102,7 @@ export function analyzeMoneriumPermitPreflight( diagnostics: MoneriumPermitDiagnostics, nowSeconds = Math.floor(Date.now() / 1000) ): { reason: "allowance-sufficient" | "permit-required"; shouldSendPermit: boolean } { - validateMoneriumOnrampPermit(permit, expectation); + validateMoneriumOnrampPermit(permit, expectation, nowSeconds); const context = getPermitContext(permit); const expectedValueRaw = BigInt(expectation.expectedValueRaw); @@ -116,9 +123,5 @@ export function analyzeMoneriumPermitPreflight( ); } - if (BigInt(context.deadline) <= BigInt(nowSeconds)) { - throwBadPermit(`Monerium permit deadline ${context.deadline} has expired`); - } - 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 index 465e22d25..2f62624d7 100644 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts @@ -1,19 +1,57 @@ import { describe, expect, it } from "bun:test"; import { inspectMoneriumSelfTransferTransaction } from "./monerium-self-transfer"; +import { Interface, Wallet } from "ethers"; +import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; -const rawSelfTransferTx = - "0x02f8d381898085e64020937685e640209376830186a094e0aea583266584dafbb3f9c3211d5588c73fea8d80b86423b872dd000000000000000000000000976ff31a56daf5a0e09f411950311f5877ff00d50000000000000000000000007c4e657eeb8ba8bbf0882c817a7a9f2df55636ad0000000000000000000000000000000000000000000000000e27c49886e60000c001a029c840d52a6634e2ed642d50c306f08a379f8466a10c332e07f03bc85da1ae52a00ae865be836a16b25bbe9d647085930d4b0b1cedf3d3e84e127e14f7dddf660e"; +const transferFromInterface = new Interface(["function transferFrom(address from,address to,uint256 value)"]); +const OWNER = new Wallet("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); +const SIGNER = new Wallet("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); +const OTHER_RECIPIENT = new Wallet("0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"); +const OTHER_TOKEN = "0x1111111111111111111111111111111111111111" as const; const expectation = { expectedAmountRaw: "1020000000000000000", - expectedOwner: "0x976fF31a56dAF5A0E09F411950311F5877ff00D5" as const, - expectedRecipient: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, - expectedSigner: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, + expectedOwner: OWNER.address as `0x${string}`, + expectedRecipient: SIGNER.address as `0x${string}`, + expectedSigner: SIGNER.address as `0x${string}`, rampId: "ramp-1" }; +async function signSelfTransferTx({ + chainId = 137, + data, + gasLimit = 100000n, + nonce = 0, + to = ERC20_EURE_POLYGON_V2 +}: { + chainId?: number; + data?: `0x${string}`; + gasLimit?: bigint; + nonce?: number | undefined; + to?: `0x${string}`; +} = {}): Promise { + return SIGNER.signTransaction({ + chainId, + data: + data ?? + (transferFromInterface.encodeFunctionData("transferFrom", [ + expectation.expectedOwner, + expectation.expectedRecipient, + BigInt(expectation.expectedAmountRaw) + ]) as `0x${string}`), + gasLimit, + maxFeePerGas: 10_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce, + to, + type: 2, + value: 0 + }); +} + describe("inspectMoneriumSelfTransferTransaction", () => { it("decodes and validates a signed Monerium self-transfer", async () => { + const rawSelfTransferTx = await signSelfTransferTx(); const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation); expect(inspection.amountRaw).toBe(1020000000000000000n); @@ -22,10 +60,11 @@ describe("inspectMoneriumSelfTransferTransaction", () => { expect(inspection.signer.toLowerCase()).toBe(expectation.expectedSigner.toLowerCase()); expect(inspection.signedGas).toBe(100000n); expect(inspection.signedNonce).toBe(0); - expect(inspection.tokenAddress.toLowerCase()).toBe("0xe0aea583266584dafbb3f9c3211d5588c73fea8d"); + expect(inspection.tokenAddress.toLowerCase()).toBe(ERC20_EURE_POLYGON_V2.toLowerCase()); }); it("rejects a signed transfer for the wrong amount", async () => { + const rawSelfTransferTx = await signSelfTransferTx(); await expect( inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { ...expectation, @@ -35,6 +74,7 @@ describe("inspectMoneriumSelfTransferTransaction", () => { }); it("accepts a signed transfer when chainId matches the expected network", async () => { + const rawSelfTransferTx = await signSelfTransferTx(); const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { ...expectation, expectedChainId: 137 @@ -44,6 +84,7 @@ describe("inspectMoneriumSelfTransferTransaction", () => { }); it("rejects a signed transfer when chainId does not match the expected network", async () => { + const rawSelfTransferTx = await signSelfTransferTx(); await expect( inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { ...expectation, @@ -51,4 +92,56 @@ describe("inspectMoneriumSelfTransferTransaction", () => { }) ).rejects.toThrow("Self-transfer chainId 137 does not match expected 1"); }); + + it("rejects a signed transfer for the wrong token contract", async () => { + const rawSelfTransferTx = await signSelfTransferTx({ to: OTHER_TOKEN }); + + await expect( + inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { + ...expectation, + expectedTokenAddress: ERC20_EURE_POLYGON_V2 + }) + ).rejects.toThrow(`Self-transfer token ${OTHER_TOKEN} does not match expected ${ERC20_EURE_POLYGON_V2}`); + }); + + it("rejects a signed transfer for the wrong recipient", async () => { + const rawSelfTransferTx = await signSelfTransferTx({ + data: transferFromInterface.encodeFunctionData("transferFrom", [ + expectation.expectedOwner, + OTHER_RECIPIENT.address, + BigInt(expectation.expectedAmountRaw) + ]) as `0x${string}` + }); + + await expect(inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation)).rejects.toThrow( + `Self-transfer recipient ${OTHER_RECIPIENT.address} does not match expected ${expectation.expectedRecipient}` + ); + }); + + it("rejects a signed transfer with invalid calldata", async () => { + const rawSelfTransferTx = await signSelfTransferTx({ data: "0x1234" }); + + await expect(inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation)).rejects.toThrow( + "Self-transfer calldata is not a valid transferFrom payload" + ); + }); + + it("rejects a signed transfer without a nonce", async () => { + await expect( + inspectMoneriumSelfTransferTransaction("0xdeadbeef", expectation, { + decodeFunctionData: () => ({ + args: [expectation.expectedOwner, expectation.expectedRecipient, BigInt(expectation.expectedAmountRaw)] + }), + parseTransaction: () => + ({ + chainId: 137, + data: "0x23b872dd", + gas: 100000n, + nonce: undefined, + to: ERC20_EURE_POLYGON_V2 + }) as ReturnType, + recoverTransactionAddress: async () => expectation.expectedSigner + }) + ).rejects.toThrow("Self-transfer signed transaction is missing a nonce"); + }); }); diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.ts index 84dd99e07..679edd8d6 100644 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.ts +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.ts @@ -38,13 +38,26 @@ export interface MoneriumSelfTransferInspection { tokenAddress: `0x${string}`; } +interface MoneriumSelfTransferInspectionDependencies { + decodeFunctionData: typeof decodeFunctionData; + parseTransaction: typeof parseTransaction; + recoverTransactionAddress: typeof recoverTransactionAddress; +} + +const defaultMoneriumSelfTransferInspectionDependencies: MoneriumSelfTransferInspectionDependencies = { + decodeFunctionData, + parseTransaction, + recoverTransactionAddress +}; + export async function inspectMoneriumSelfTransferTransaction( txData: string, - expectation: MoneriumSelfTransferExpectation + expectation: MoneriumSelfTransferExpectation, + dependencies: MoneriumSelfTransferInspectionDependencies = defaultMoneriumSelfTransferInspectionDependencies ): Promise { const serializedTransaction = txData as RecoverableSerializedTransaction; - const parsedTx = parseTransaction(serializedTransaction); - const signer = await recoverTransactionAddress({ serializedTransaction }); + const parsedTx = dependencies.parseTransaction(serializedTransaction); + const signer = await dependencies.recoverTransactionAddress({ serializedTransaction }); const tokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; const signedNonce = parsedTx.nonce; @@ -68,10 +81,17 @@ export async function inspectMoneriumSelfTransferTransaction( ); } - const decodedTransfer = decodeFunctionData({ - abi: moneriumTransferFromAbi, - data: parsedTx.data ?? "0x" - }); + let decodedTransfer; + try { + decodedTransfer = dependencies.decodeFunctionData({ + abi: moneriumTransferFromAbi, + data: parsedTx.data ?? "0x" + }); + } catch (error) { + throw new Error( + `[${expectation.rampId}] Self-transfer calldata is not a valid transferFrom payload: ${error instanceof Error ? error.message : error}` + ); + } const [owner, recipient, amountRaw] = decodedTransfer.args; const expectedAmount = BigInt(expectation.expectedAmountRaw); diff --git a/packages/shared/src/services/evm/clientManager.test.ts b/packages/shared/src/services/evm/clientManager.test.ts index d35858466..845587d1e 100644 --- a/packages/shared/src/services/evm/clientManager.test.ts +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { redactRpcUrlForLogs, sanitizeRpcErrorMessage } from "./clientManager"; +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/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62")).toBe( + expect(redactRpcUrlForLogs("https://polygon-mainnet.g.alchemy.com/v2/test-api-key")).toBe( "https://polygon-mainnet.g.alchemy.com/v2/[redacted]" ); }); @@ -15,8 +16,21 @@ describe("redactRpcUrlForLogs", () => { it("redacts provider API keys embedded in RPC error messages", () => { expect( sanitizeRpcErrorMessage( - "URL: https://polygon-mainnet.g.alchemy.com/v2/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62\nRequest failed" + "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 the viem default transport distinct from explicit RPC URLs", () => { + const manager = EvmClientManager.getInstance() as unknown as { + generatePublicClientKey: (networkName: Networks, rpcUrl: string) => string; + }; + + expect(manager.generatePublicClientKey(Networks.Polygon, "")).toBe(`${Networks.Polygon}-`); + expect(manager.generatePublicClientKey(Networks.Polygon, "")).not.toBe( + manager.generatePublicClientKey(Networks.Polygon, "https://polygon-mainnet.g.alchemy.com/v2/test-api-key") + ); + }); +}); diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index b6f98ea83..5f7375ef9 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -9,6 +9,8 @@ export interface EvmNetworkConfig { rpcUrls: string[]; } +const DEFAULT_RPC_CACHE_KEY = ""; + export function redactRpcUrlForLogs(rpcUrl: string): string { if (!rpcUrl) { return ""; @@ -40,6 +42,22 @@ export function sanitizeRpcErrorMessage(message: string): string { return message.replace(/https:\/\/[^\s"]+\/v2\/[^\s")]+/g, match => redactRpcUrlForLogs(match)); } +function getRpcCacheKey(rpcUrl: string): string { + return rpcUrl === "" ? DEFAULT_RPC_CACHE_KEY : rpcUrl; +} + +function createRpcTransport(network: EvmNetworkConfig, rpcUrl?: string): Transport { + if (rpcUrl === "") { + return http(); + } + if (rpcUrl !== undefined) { + return http(rpcUrl); + } + + const defaultRpcUrl = network.rpcUrls[0]; + return defaultRpcUrl === "" ? http() : http(defaultRpcUrl); +} + function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { // Note on defining RPC URLs: '' is equal to viem's default RPC for that chain: http(). return [ @@ -212,7 +230,7 @@ export class EvmClientManager { } private generatePublicClientKey(networkName: EvmNetworks, rpcUrl: string): string { - return `${networkName}-${rpcUrl}`; + return `${networkName}-${getRpcCacheKey(rpcUrl)}`; } private getNetworkConfig(networkName: EvmNetworks): EvmNetworkConfig { @@ -226,18 +244,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}`; } @@ -248,12 +264,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; @@ -262,7 +276,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); diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 3165bc5fa..8e8a08603 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -30,6 +30,7 @@ export const ERC20_USDT_POLYGON: `0x${string}` = "0xc2132d05d31c914a87c6611c1074 // V2 is used for the permit - transferFrom flow. // The token balances are synced between both contracts. export const ERC20_EURE_POLYGON_V2: `0x${string}` = "0xE0aEa583266584DafBB3f9C3211d5588c73fEa8d"; // EUR.e on Polygon V2 +export const ERC20_EURE_POLYGON_SYMBOL = "EURe"; export const ERC20_EURE_POLYGON_TOKEN_NAME = "Monerium EURe"; export const ERC20_EURE_POLYGON_DECIMALS = 18; // EUR.e on Polygon has 18 decimals diff --git a/packages/shared/src/tokens/utils/helpers.test.ts b/packages/shared/src/tokens/utils/helpers.test.ts index e9ad191a7..677d6cf33 100644 --- a/packages/shared/src/tokens/utils/helpers.test.ts +++ b/packages/shared/src/tokens/utils/helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { Networks } from "../../helpers"; import { ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_TOKEN_NAME, + ERC20_EURE_POLYGON_SYMBOL, ERC20_EURE_POLYGON_V1, ERC20_EURE_POLYGON_V2 } from "../constants/misc"; @@ -27,7 +27,7 @@ describe("getEvmTokenDetailsByAddress", () => { 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?.assetSymbol).toBe(ERC20_EURE_POLYGON_SYMBOL); 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 202e2759d..67dc08f24 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -7,7 +7,7 @@ import logger from "../../logger"; import { assetHubTokenConfig } from "../assethub/config"; import { ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_TOKEN_NAME, + ERC20_EURE_POLYGON_SYMBOL, ERC20_EURE_POLYGON_V1, ERC20_EURE_POLYGON_V2 } from "../constants/misc"; @@ -77,7 +77,7 @@ export function getEvmTokenDetailsByAddress( if (network === Networks.Polygon && MONERIUM_EURE_POLYGON_ADDRESSES.has(normalizedTokenAddress)) { return { - assetSymbol: ERC20_EURE_POLYGON_TOKEN_NAME, + assetSymbol: ERC20_EURE_POLYGON_SYMBOL, decimals: ERC20_EURE_POLYGON_DECIMALS, erc20AddressSourceChain: tokenAddress, isNative: false, From f0c82b8d05622d9f35726e01c3bb25db64359032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 16:18:32 +0000 Subject: [PATCH 09/13] fix: align permit expiry and RPC sanitization Agent-Logs-Url: https://github.com/pendulum-chain/vortex/sessions/3ed19689-b5e8-40e8-a453-d69a148478dc --- .../api/services/ramp/monerium-permit.test.ts | 2 +- .../src/api/services/ramp/monerium-permit.ts | 2 +- .../shared/src/services/evm/clientManager.ts | 27 ++++++++++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/ramp/monerium-permit.test.ts b/apps/api/src/api/services/ramp/monerium-permit.test.ts index 27b361303..db390ace0 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.test.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.test.ts @@ -112,7 +112,7 @@ describe("validateMoneriumOnrampPermit", () => { it("rejects an already-expired permit before deposit details are released", async () => { const permit = await signPermit({ deadline: "1700000000" }); - expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION, 1700000000)).toThrow("has expired"); + expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION, 1700000001)).toThrow("has expired"); }); }); diff --git a/apps/api/src/api/services/ramp/monerium-permit.ts b/apps/api/src/api/services/ramp/monerium-permit.ts index 08966ace5..25ad9a8ea 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.ts @@ -69,7 +69,7 @@ export function validateMoneriumOnrampPermit( assertEqual("tokenVersion", context.tokenVersion, expectation.expectedTokenVersion ?? "1"); assertEqual("chainId", context.chainId, expectedChainId); assertEqual("deadline", context.deadline, permit.deadline); - if (BigInt(context.deadline) <= BigInt(nowSeconds)) { + if (BigInt(context.deadline) < BigInt(nowSeconds)) { throwBadPermit(`Monerium permit deadline ${context.deadline} has expired`); } diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 5f7375ef9..7ee1d41c3 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -9,7 +9,7 @@ export interface EvmNetworkConfig { rpcUrls: string[]; } -const DEFAULT_RPC_CACHE_KEY = ""; +const VIEM_DEFAULT_TRANSPORT_CACHE_KEY = ""; export function redactRpcUrlForLogs(rpcUrl: string): string { if (!rpcUrl) { @@ -39,11 +39,32 @@ export function redactRpcUrlForLogs(rpcUrl: string): string { } export function sanitizeRpcErrorMessage(message: string): string { - return message.replace(/https:\/\/[^\s"]+\/v2\/[^\s")]+/g, match => redactRpcUrlForLogs(match)); + const urlTerminators = new Set([" ", "\n", "\r", "\t", '"', ")"]); + let sanitizedMessage = ""; + let currentIndex = 0; + + while (currentIndex < message.length) { + const nextUrlIndex = message.indexOf("https://", currentIndex); + if (nextUrlIndex === -1) { + sanitizedMessage += message.slice(currentIndex); + break; + } + + sanitizedMessage += message.slice(currentIndex, nextUrlIndex); + let urlEndIndex = nextUrlIndex; + while (urlEndIndex < message.length && !urlTerminators.has(message[urlEndIndex] ?? "")) { + urlEndIndex++; + } + + sanitizedMessage += redactRpcUrlForLogs(message.slice(nextUrlIndex, urlEndIndex)); + currentIndex = urlEndIndex; + } + + return sanitizedMessage; } function getRpcCacheKey(rpcUrl: string): string { - return rpcUrl === "" ? DEFAULT_RPC_CACHE_KEY : rpcUrl; + return rpcUrl === "" ? VIEM_DEFAULT_TRANSPORT_CACHE_KEY : redactRpcUrlForLogs(rpcUrl); } function createRpcTransport(network: EvmNetworkConfig, rpcUrl?: string): Transport { From d1cebc9ce0fda408523eb8843c751d2b225d1a11 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 17:59:18 +0200 Subject: [PATCH 10/13] Fix type issues --- .../monerium-onramp-self-transfer-handler.ts | 10 ++++-- .../services/ramp/monerium-self-transfer.ts | 31 ++++++++++++++----- 2 files changed, 31 insertions(+), 10 deletions(-) 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 f388f43b7..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 @@ -9,7 +9,7 @@ import { RampPhase } from "@vortexfi/shared"; import Big from "big.js"; -import { encodeFunctionData, PublicClient, TransactionReceipt } from "viem"; +import { encodeFunctionData, isAddress, PublicClient, TransactionReceipt } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; @@ -107,6 +107,10 @@ 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) { @@ -133,7 +137,7 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { deadlineIso: new Date( Number(moneriumOnrampPermit.context?.deadline ?? moneriumOnrampPermit.deadline) * 1000 ).toISOString(), - executor: account.address, + executor: executorAddress, expectedValueRaw: mintedAmountRaw, nonce: permitDiagnostics.nonce.toString(), owner, @@ -173,7 +177,7 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { if (!permitPreflight.shouldSendPermit) { permitHash = ""; } else { - await this.simulatePermit(state.id, account.address, permitArgs); + await this.simulatePermit(state.id, executorAddress, permitArgs); const walletClient = this.evmClientManager.getWalletClient(Networks.Polygon, account); permitHash = await walletClient.sendTransaction({ diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.ts index 679edd8d6..6eb7fcaa5 100644 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.ts +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.ts @@ -1,5 +1,5 @@ import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; -import { decodeFunctionData, parseTransaction, recoverTransactionAddress } from "viem"; +import { decodeFunctionData, isAddress, parseTransaction, recoverTransactionAddress } from "viem"; export const moneriumTransferFromAbi = [ { @@ -38,6 +38,14 @@ export interface MoneriumSelfTransferInspection { 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}`; +} + interface MoneriumSelfTransferInspectionDependencies { decodeFunctionData: typeof decodeFunctionData; parseTransaction: typeof parseTransaction; @@ -57,8 +65,13 @@ export async function inspectMoneriumSelfTransferTransaction( ): Promise { const serializedTransaction = txData as RecoverableSerializedTransaction; const parsedTx = dependencies.parseTransaction(serializedTransaction); - const signer = await dependencies.recoverTransactionAddress({ serializedTransaction }); - const tokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; + const signer = requireAddress( + await dependencies.recoverTransactionAddress({ serializedTransaction }), + "Self-transfer signer", + expectation.rampId + ); + const expectedTokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; + const transactionTokenAddress = requireAddress(parsedTx.to, "Self-transfer token", expectation.rampId); const signedNonce = parsedTx.nonce; if (signedNonce === undefined) { @@ -71,8 +84,10 @@ export async function inspectMoneriumSelfTransferTransaction( ); } - if (parsedTx.to?.toLowerCase() !== tokenAddress.toLowerCase()) { - throw new Error(`[${expectation.rampId}] Self-transfer token ${parsedTx.to} does not match expected ${tokenAddress}`); + if (transactionTokenAddress.toLowerCase() !== expectedTokenAddress.toLowerCase()) { + throw new Error( + `[${expectation.rampId}] Self-transfer token ${transactionTokenAddress} does not match expected ${expectedTokenAddress}` + ); } if (expectation.expectedChainId !== undefined && parsedTx.chainId !== expectation.expectedChainId) { @@ -92,7 +107,9 @@ export async function inspectMoneriumSelfTransferTransaction( `[${expectation.rampId}] Self-transfer calldata is not a valid transferFrom payload: ${error instanceof Error ? error.message : error}` ); } - const [owner, recipient, amountRaw] = decodedTransfer.args; + 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()) { @@ -119,6 +136,6 @@ export async function inspectMoneriumSelfTransferTransaction( signedGas: parsedTx.gas ?? 0n, signedNonce, signer, - tokenAddress + tokenAddress: transactionTokenAddress }; } From 15a64ee6966eb24fc11448c368db811a0e786397 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 16:20:37 +0000 Subject: [PATCH 11/13] fix: clarify RPC sanitizer loop bounds Agent-Logs-Url: https://github.com/pendulum-chain/vortex/sessions/3ed19689-b5e8-40e8-a453-d69a148478dc --- packages/shared/src/services/evm/clientManager.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 7ee1d41c3..c80084b6d 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -52,7 +52,12 @@ export function sanitizeRpcErrorMessage(message: string): string { sanitizedMessage += message.slice(currentIndex, nextUrlIndex); let urlEndIndex = nextUrlIndex; - while (urlEndIndex < message.length && !urlTerminators.has(message[urlEndIndex] ?? "")) { + while (urlEndIndex < message.length) { + const currentCharacter = message[urlEndIndex]; + if (currentCharacter === undefined || urlTerminators.has(currentCharacter)) { + break; + } + urlEndIndex++; } From 4f02b5ba52219ac7aef0264253e386d78c4118a7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 18:28:56 +0200 Subject: [PATCH 12/13] Revert "fix: address Monerium review comments" This reverts commit 0641f1c4bdafc93a16da28adec6966d619a29cbd. --- .../api/services/ramp/monerium-permit.test.ts | 90 +++++++++------ .../src/api/services/ramp/monerium-permit.ts | 15 +-- .../ramp/monerium-self-transfer.test.ts | 105 +----------------- .../services/ramp/monerium-self-transfer.ts | 63 +++-------- .../src/services/evm/clientManager.test.ts | 20 +--- .../shared/src/services/evm/clientManager.ts | 60 ++-------- packages/shared/src/tokens/constants/misc.ts | 1 - .../shared/src/tokens/utils/helpers.test.ts | 4 +- packages/shared/src/tokens/utils/helpers.ts | 4 +- 9 files changed, 100 insertions(+), 262 deletions(-) diff --git a/apps/api/src/api/services/ramp/monerium-permit.test.ts b/apps/api/src/api/services/ramp/monerium-permit.test.ts index db390ace0..a49b3dd6f 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.test.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.test.ts @@ -7,24 +7,12 @@ import { } from "./monerium-permit"; const OWNER = new Wallet("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); -const OTHER_SIGNER = new Wallet("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"); const SPENDER = "0x4e84e0b84054F078D4Adc785818663eF83c032E3"; const VALUE_RAW = "1150000000000000000"; const NONCE = "0"; const DEADLINE = "1779978803"; -const EXPECTATION = { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon -} as const; -async function signPermit( - overrides: Partial = {}, - signer: Wallet = OWNER -): Promise { +async function signPermit(overrides: Partial = {}): Promise { const context = { chainId: 137, deadline: DEADLINE, @@ -38,7 +26,7 @@ async function signPermit( ...overrides }; const signature = EthersSignature.from( - await signer.signTypedData( + await OWNER.signTypedData( { chainId: context.chainId, name: context.tokenName, @@ -77,13 +65,31 @@ describe("validateMoneriumOnrampPermit", () => { it("accepts a permit whose signed context matches the expected onramp transfer", async () => { const permit = await signPermit(); - expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).not.toThrow(); + 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, EXPECTATION)).toThrow("valueRaw"); + 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", () => { @@ -94,25 +100,31 @@ describe("validateMoneriumOnrampPermit", () => { v: 27 }; - expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).toThrow("missing signed context"); + 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, EXPECTATION)).toThrow("tokenVersion"); - }); - - it("rejects a permit whose recovered signer does not match the expected owner", async () => { - const permit = await signPermit({}, OTHER_SIGNER); - - expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION)).toThrow("signature was produced by"); - }); - - it("rejects an already-expired permit before deposit details are released", async () => { - const permit = await signPermit({ deadline: "1700000000" }); - - expect(() => validateMoneriumOnrampPermit(permit, EXPECTATION, 1700000001)).toThrow("has expired"); + 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"); }); }); @@ -123,7 +135,14 @@ describe("analyzeMoneriumPermitPreflight", () => { expect( analyzeMoneriumPermitPreflight( permit, - EXPECTATION, + { + 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, @@ -141,7 +160,14 @@ describe("analyzeMoneriumPermitPreflight", () => { expect(() => analyzeMoneriumPermitPreflight( permit, - EXPECTATION, + { + 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), diff --git a/apps/api/src/api/services/ramp/monerium-permit.ts b/apps/api/src/api/services/ramp/monerium-permit.ts index 25ad9a8ea..673b49334 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.ts @@ -53,11 +53,7 @@ function getPermitContext(permit: PermitSignature) { return permit.context; } -export function validateMoneriumOnrampPermit( - permit: PermitSignature, - expectation: MoneriumPermitExpectation, - nowSeconds = Math.floor(Date.now() / 1000) -): void { +export function validateMoneriumOnrampPermit(permit: PermitSignature, expectation: MoneriumPermitExpectation): void { const context = getPermitContext(permit); const expectedChainId = getNetworkId(expectation.network); @@ -69,9 +65,6 @@ export function validateMoneriumOnrampPermit( assertEqual("tokenVersion", context.tokenVersion, expectation.expectedTokenVersion ?? "1"); assertEqual("chainId", context.chainId, expectedChainId); assertEqual("deadline", context.deadline, permit.deadline); - if (BigInt(context.deadline) < BigInt(nowSeconds)) { - throwBadPermit(`Monerium permit deadline ${context.deadline} has expired`); - } const recoveredSigner = verifyTypedData( { @@ -102,7 +95,7 @@ export function analyzeMoneriumPermitPreflight( diagnostics: MoneriumPermitDiagnostics, nowSeconds = Math.floor(Date.now() / 1000) ): { reason: "allowance-sufficient" | "permit-required"; shouldSendPermit: boolean } { - validateMoneriumOnrampPermit(permit, expectation, nowSeconds); + validateMoneriumOnrampPermit(permit, expectation); const context = getPermitContext(permit); const expectedValueRaw = BigInt(expectation.expectedValueRaw); @@ -123,5 +116,9 @@ export function analyzeMoneriumPermitPreflight( ); } + if (BigInt(context.deadline) <= BigInt(nowSeconds)) { + throwBadPermit(`Monerium permit deadline ${context.deadline} has expired`); + } + 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 index 2f62624d7..465e22d25 100644 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts @@ -1,57 +1,19 @@ import { describe, expect, it } from "bun:test"; import { inspectMoneriumSelfTransferTransaction } from "./monerium-self-transfer"; -import { Interface, Wallet } from "ethers"; -import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; -const transferFromInterface = new Interface(["function transferFrom(address from,address to,uint256 value)"]); -const OWNER = new Wallet("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); -const SIGNER = new Wallet("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); -const OTHER_RECIPIENT = new Wallet("0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"); -const OTHER_TOKEN = "0x1111111111111111111111111111111111111111" as const; +const rawSelfTransferTx = + "0x02f8d381898085e64020937685e640209376830186a094e0aea583266584dafbb3f9c3211d5588c73fea8d80b86423b872dd000000000000000000000000976ff31a56daf5a0e09f411950311f5877ff00d50000000000000000000000007c4e657eeb8ba8bbf0882c817a7a9f2df55636ad0000000000000000000000000000000000000000000000000e27c49886e60000c001a029c840d52a6634e2ed642d50c306f08a379f8466a10c332e07f03bc85da1ae52a00ae865be836a16b25bbe9d647085930d4b0b1cedf3d3e84e127e14f7dddf660e"; const expectation = { expectedAmountRaw: "1020000000000000000", - expectedOwner: OWNER.address as `0x${string}`, - expectedRecipient: SIGNER.address as `0x${string}`, - expectedSigner: SIGNER.address as `0x${string}`, + expectedOwner: "0x976fF31a56dAF5A0E09F411950311F5877ff00D5" as const, + expectedRecipient: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, + expectedSigner: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, rampId: "ramp-1" }; -async function signSelfTransferTx({ - chainId = 137, - data, - gasLimit = 100000n, - nonce = 0, - to = ERC20_EURE_POLYGON_V2 -}: { - chainId?: number; - data?: `0x${string}`; - gasLimit?: bigint; - nonce?: number | undefined; - to?: `0x${string}`; -} = {}): Promise { - return SIGNER.signTransaction({ - chainId, - data: - data ?? - (transferFromInterface.encodeFunctionData("transferFrom", [ - expectation.expectedOwner, - expectation.expectedRecipient, - BigInt(expectation.expectedAmountRaw) - ]) as `0x${string}`), - gasLimit, - maxFeePerGas: 10_000_000_000n, - maxPriorityFeePerGas: 1_000_000_000n, - nonce, - to, - type: 2, - value: 0 - }); -} - describe("inspectMoneriumSelfTransferTransaction", () => { it("decodes and validates a signed Monerium self-transfer", async () => { - const rawSelfTransferTx = await signSelfTransferTx(); const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation); expect(inspection.amountRaw).toBe(1020000000000000000n); @@ -60,11 +22,10 @@ describe("inspectMoneriumSelfTransferTransaction", () => { expect(inspection.signer.toLowerCase()).toBe(expectation.expectedSigner.toLowerCase()); expect(inspection.signedGas).toBe(100000n); expect(inspection.signedNonce).toBe(0); - expect(inspection.tokenAddress.toLowerCase()).toBe(ERC20_EURE_POLYGON_V2.toLowerCase()); + expect(inspection.tokenAddress.toLowerCase()).toBe("0xe0aea583266584dafbb3f9c3211d5588c73fea8d"); }); it("rejects a signed transfer for the wrong amount", async () => { - const rawSelfTransferTx = await signSelfTransferTx(); await expect( inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { ...expectation, @@ -74,7 +35,6 @@ describe("inspectMoneriumSelfTransferTransaction", () => { }); it("accepts a signed transfer when chainId matches the expected network", async () => { - const rawSelfTransferTx = await signSelfTransferTx(); const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { ...expectation, expectedChainId: 137 @@ -84,7 +44,6 @@ describe("inspectMoneriumSelfTransferTransaction", () => { }); it("rejects a signed transfer when chainId does not match the expected network", async () => { - const rawSelfTransferTx = await signSelfTransferTx(); await expect( inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { ...expectation, @@ -92,56 +51,4 @@ describe("inspectMoneriumSelfTransferTransaction", () => { }) ).rejects.toThrow("Self-transfer chainId 137 does not match expected 1"); }); - - it("rejects a signed transfer for the wrong token contract", async () => { - const rawSelfTransferTx = await signSelfTransferTx({ to: OTHER_TOKEN }); - - await expect( - inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { - ...expectation, - expectedTokenAddress: ERC20_EURE_POLYGON_V2 - }) - ).rejects.toThrow(`Self-transfer token ${OTHER_TOKEN} does not match expected ${ERC20_EURE_POLYGON_V2}`); - }); - - it("rejects a signed transfer for the wrong recipient", async () => { - const rawSelfTransferTx = await signSelfTransferTx({ - data: transferFromInterface.encodeFunctionData("transferFrom", [ - expectation.expectedOwner, - OTHER_RECIPIENT.address, - BigInt(expectation.expectedAmountRaw) - ]) as `0x${string}` - }); - - await expect(inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation)).rejects.toThrow( - `Self-transfer recipient ${OTHER_RECIPIENT.address} does not match expected ${expectation.expectedRecipient}` - ); - }); - - it("rejects a signed transfer with invalid calldata", async () => { - const rawSelfTransferTx = await signSelfTransferTx({ data: "0x1234" }); - - await expect(inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation)).rejects.toThrow( - "Self-transfer calldata is not a valid transferFrom payload" - ); - }); - - it("rejects a signed transfer without a nonce", async () => { - await expect( - inspectMoneriumSelfTransferTransaction("0xdeadbeef", expectation, { - decodeFunctionData: () => ({ - args: [expectation.expectedOwner, expectation.expectedRecipient, BigInt(expectation.expectedAmountRaw)] - }), - parseTransaction: () => - ({ - chainId: 137, - data: "0x23b872dd", - gas: 100000n, - nonce: undefined, - to: ERC20_EURE_POLYGON_V2 - }) as ReturnType, - recoverTransactionAddress: async () => expectation.expectedSigner - }) - ).rejects.toThrow("Self-transfer signed transaction is missing a nonce"); - }); }); diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.ts index 6eb7fcaa5..84dd99e07 100644 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.ts +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.ts @@ -1,5 +1,5 @@ import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; -import { decodeFunctionData, isAddress, parseTransaction, recoverTransactionAddress } from "viem"; +import { decodeFunctionData, parseTransaction, recoverTransactionAddress } from "viem"; export const moneriumTransferFromAbi = [ { @@ -38,40 +38,14 @@ export interface MoneriumSelfTransferInspection { 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}`; -} - -interface MoneriumSelfTransferInspectionDependencies { - decodeFunctionData: typeof decodeFunctionData; - parseTransaction: typeof parseTransaction; - recoverTransactionAddress: typeof recoverTransactionAddress; -} - -const defaultMoneriumSelfTransferInspectionDependencies: MoneriumSelfTransferInspectionDependencies = { - decodeFunctionData, - parseTransaction, - recoverTransactionAddress -}; - export async function inspectMoneriumSelfTransferTransaction( txData: string, - expectation: MoneriumSelfTransferExpectation, - dependencies: MoneriumSelfTransferInspectionDependencies = defaultMoneriumSelfTransferInspectionDependencies + expectation: MoneriumSelfTransferExpectation ): Promise { const serializedTransaction = txData as RecoverableSerializedTransaction; - const parsedTx = dependencies.parseTransaction(serializedTransaction); - const signer = requireAddress( - await dependencies.recoverTransactionAddress({ serializedTransaction }), - "Self-transfer signer", - expectation.rampId - ); - const expectedTokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; - const transactionTokenAddress = requireAddress(parsedTx.to, "Self-transfer token", expectation.rampId); + const parsedTx = parseTransaction(serializedTransaction); + const signer = await recoverTransactionAddress({ serializedTransaction }); + const tokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; const signedNonce = parsedTx.nonce; if (signedNonce === undefined) { @@ -84,10 +58,8 @@ export async function inspectMoneriumSelfTransferTransaction( ); } - if (transactionTokenAddress.toLowerCase() !== expectedTokenAddress.toLowerCase()) { - throw new Error( - `[${expectation.rampId}] Self-transfer token ${transactionTokenAddress} does not match expected ${expectedTokenAddress}` - ); + if (parsedTx.to?.toLowerCase() !== tokenAddress.toLowerCase()) { + throw new Error(`[${expectation.rampId}] Self-transfer token ${parsedTx.to} does not match expected ${tokenAddress}`); } if (expectation.expectedChainId !== undefined && parsedTx.chainId !== expectation.expectedChainId) { @@ -96,20 +68,11 @@ export async function inspectMoneriumSelfTransferTransaction( ); } - let decodedTransfer; - try { - decodedTransfer = dependencies.decodeFunctionData({ - abi: moneriumTransferFromAbi, - data: parsedTx.data ?? "0x" - }); - } catch (error) { - throw new Error( - `[${expectation.rampId}] Self-transfer calldata is not a valid transferFrom payload: ${error instanceof Error ? error.message : error}` - ); - } - 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 decodedTransfer = decodeFunctionData({ + abi: moneriumTransferFromAbi, + data: parsedTx.data ?? "0x" + }); + const [owner, recipient, amountRaw] = decodedTransfer.args; const expectedAmount = BigInt(expectation.expectedAmountRaw); if (owner.toLowerCase() !== expectation.expectedOwner.toLowerCase()) { @@ -136,6 +99,6 @@ export async function inspectMoneriumSelfTransferTransaction( signedGas: parsedTx.gas ?? 0n, signedNonce, signer, - tokenAddress: transactionTokenAddress + tokenAddress }; } diff --git a/packages/shared/src/services/evm/clientManager.test.ts b/packages/shared/src/services/evm/clientManager.test.ts index 845587d1e..d35858466 100644 --- a/packages/shared/src/services/evm/clientManager.test.ts +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { Networks } from "../../helpers"; -import { EvmClientManager, redactRpcUrlForLogs, sanitizeRpcErrorMessage } from "./clientManager"; +import { 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( + expect(redactRpcUrlForLogs("https://polygon-mainnet.g.alchemy.com/v2/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62")).toBe( "https://polygon-mainnet.g.alchemy.com/v2/[redacted]" ); }); @@ -16,21 +15,8 @@ describe("redactRpcUrlForLogs", () => { 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" + "URL: https://polygon-mainnet.g.alchemy.com/v2/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62\nRequest failed" ) ).toBe("URL: https://polygon-mainnet.g.alchemy.com/v2/[redacted]\nRequest failed"); }); }); - -describe("EvmClientManager RPC cache keys", () => { - it("keeps the viem default transport distinct from explicit RPC URLs", () => { - const manager = EvmClientManager.getInstance() as unknown as { - generatePublicClientKey: (networkName: Networks, rpcUrl: string) => string; - }; - - expect(manager.generatePublicClientKey(Networks.Polygon, "")).toBe(`${Networks.Polygon}-`); - expect(manager.generatePublicClientKey(Networks.Polygon, "")).not.toBe( - manager.generatePublicClientKey(Networks.Polygon, "https://polygon-mainnet.g.alchemy.com/v2/test-api-key") - ); - }); -}); diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index c80084b6d..b6f98ea83 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -9,8 +9,6 @@ export interface EvmNetworkConfig { rpcUrls: string[]; } -const VIEM_DEFAULT_TRANSPORT_CACHE_KEY = ""; - export function redactRpcUrlForLogs(rpcUrl: string): string { if (!rpcUrl) { return ""; @@ -39,49 +37,7 @@ export function redactRpcUrlForLogs(rpcUrl: string): string { } export function sanitizeRpcErrorMessage(message: string): string { - const urlTerminators = new Set([" ", "\n", "\r", "\t", '"', ")"]); - let sanitizedMessage = ""; - let currentIndex = 0; - - while (currentIndex < message.length) { - const nextUrlIndex = message.indexOf("https://", currentIndex); - if (nextUrlIndex === -1) { - sanitizedMessage += message.slice(currentIndex); - break; - } - - sanitizedMessage += message.slice(currentIndex, nextUrlIndex); - let urlEndIndex = nextUrlIndex; - while (urlEndIndex < message.length) { - const currentCharacter = message[urlEndIndex]; - if (currentCharacter === undefined || urlTerminators.has(currentCharacter)) { - break; - } - - urlEndIndex++; - } - - sanitizedMessage += redactRpcUrlForLogs(message.slice(nextUrlIndex, urlEndIndex)); - currentIndex = urlEndIndex; - } - - return sanitizedMessage; -} - -function getRpcCacheKey(rpcUrl: string): string { - return rpcUrl === "" ? VIEM_DEFAULT_TRANSPORT_CACHE_KEY : redactRpcUrlForLogs(rpcUrl); -} - -function createRpcTransport(network: EvmNetworkConfig, rpcUrl?: string): Transport { - if (rpcUrl === "") { - return http(); - } - if (rpcUrl !== undefined) { - return http(rpcUrl); - } - - const defaultRpcUrl = network.rpcUrls[0]; - return defaultRpcUrl === "" ? http() : http(defaultRpcUrl); + return message.replace(/https:\/\/[^\s"]+\/v2\/[^\s")]+/g, match => redactRpcUrlForLogs(match)); } function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { @@ -256,7 +212,7 @@ export class EvmClientManager { } private generatePublicClientKey(networkName: EvmNetworks, rpcUrl: string): string { - return `${networkName}-${getRpcCacheKey(rpcUrl)}`; + return `${networkName}-${rpcUrl}`; } private getNetworkConfig(networkName: EvmNetworks): EvmNetworkConfig { @@ -270,16 +226,18 @@ 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: createRpcTransport(network, rpcUrl) + transport }); return client; } private generateWalletClientKey(networkName: EvmNetworks, accountAddress: string, rpcUrl?: string): string { - const rpcSuffix = rpcUrl !== undefined ? `-${getRpcCacheKey(rpcUrl)}` : ""; + const rpcSuffix = rpcUrl ? `-${rpcUrl}` : ""; return `${networkName}-${accountAddress.toLowerCase()}${rpcSuffix}`; } @@ -290,10 +248,12 @@ 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: createRpcTransport(network, rpcUrl) + transport }); return walletClient; @@ -302,7 +262,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); diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 8e8a08603..3165bc5fa 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -30,7 +30,6 @@ export const ERC20_USDT_POLYGON: `0x${string}` = "0xc2132d05d31c914a87c6611c1074 // V2 is used for the permit - transferFrom flow. // The token balances are synced between both contracts. export const ERC20_EURE_POLYGON_V2: `0x${string}` = "0xE0aEa583266584DafBB3f9C3211d5588c73fEa8d"; // EUR.e on Polygon V2 -export const ERC20_EURE_POLYGON_SYMBOL = "EURe"; export const ERC20_EURE_POLYGON_TOKEN_NAME = "Monerium EURe"; export const ERC20_EURE_POLYGON_DECIMALS = 18; // EUR.e on Polygon has 18 decimals diff --git a/packages/shared/src/tokens/utils/helpers.test.ts b/packages/shared/src/tokens/utils/helpers.test.ts index 677d6cf33..e9ad191a7 100644 --- a/packages/shared/src/tokens/utils/helpers.test.ts +++ b/packages/shared/src/tokens/utils/helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { Networks } from "../../helpers"; import { ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_SYMBOL, + ERC20_EURE_POLYGON_TOKEN_NAME, ERC20_EURE_POLYGON_V1, ERC20_EURE_POLYGON_V2 } from "../constants/misc"; @@ -27,7 +27,7 @@ describe("getEvmTokenDetailsByAddress", () => { 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_SYMBOL); + 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 67dc08f24..202e2759d 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -7,7 +7,7 @@ import logger from "../../logger"; import { assetHubTokenConfig } from "../assethub/config"; import { ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_SYMBOL, + ERC20_EURE_POLYGON_TOKEN_NAME, ERC20_EURE_POLYGON_V1, ERC20_EURE_POLYGON_V2 } from "../constants/misc"; @@ -77,7 +77,7 @@ export function getEvmTokenDetailsByAddress( if (network === Networks.Polygon && MONERIUM_EURE_POLYGON_ADDRESSES.has(normalizedTokenAddress)) { return { - assetSymbol: ERC20_EURE_POLYGON_SYMBOL, + assetSymbol: ERC20_EURE_POLYGON_TOKEN_NAME, decimals: ERC20_EURE_POLYGON_DECIMALS, erc20AddressSourceChain: tokenAddress, isNative: false, From e96b57a398068edff8174aa5f59885539b436a0f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 21 May 2026 18:36:37 +0200 Subject: [PATCH 13/13] Address Monerium review comments cleanly --- .../api/services/ramp/monerium-permit.test.ts | 19 ++++++++++++ .../src/api/services/ramp/monerium-permit.ts | 26 ++++++++++++---- .../services/ramp/monerium-self-transfer.ts | 29 ++++++++++++++---- .../src/services/evm/clientManager.test.ts | 19 ++++++++---- .../shared/src/services/evm/clientManager.ts | 30 +++++++++++-------- 5 files changed, 94 insertions(+), 29 deletions(-) diff --git a/apps/api/src/api/services/ramp/monerium-permit.test.ts b/apps/api/src/api/services/ramp/monerium-permit.test.ts index a49b3dd6f..e7d3ea2fd 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.test.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.test.ts @@ -126,6 +126,25 @@ describe("validateMoneriumOnrampPermit", () => { }) ).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", () => { diff --git a/apps/api/src/api/services/ramp/monerium-permit.ts b/apps/api/src/api/services/ramp/monerium-permit.ts index 673b49334..c3510478a 100644 --- a/apps/api/src/api/services/ramp/monerium-permit.ts +++ b/apps/api/src/api/services/ramp/monerium-permit.ts @@ -53,7 +53,7 @@ function getPermitContext(permit: PermitSignature) { return permit.context; } -export function validateMoneriumOnrampPermit(permit: PermitSignature, expectation: MoneriumPermitExpectation): void { +function validateMoneriumPermitSignature(permit: PermitSignature, expectation: MoneriumPermitExpectation) { const context = getPermitContext(permit); const expectedChainId = getNetworkId(expectation.network); @@ -87,6 +87,23 @@ export function validateMoneriumOnrampPermit(permit: PermitSignature, expectatio 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( @@ -95,9 +112,8 @@ export function analyzeMoneriumPermitPreflight( diagnostics: MoneriumPermitDiagnostics, nowSeconds = Math.floor(Date.now() / 1000) ): { reason: "allowance-sufficient" | "permit-required"; shouldSendPermit: boolean } { - validateMoneriumOnrampPermit(permit, expectation); + const context = validateMoneriumPermitSignature(permit, expectation); - const context = getPermitContext(permit); const expectedValueRaw = BigInt(expectation.expectedValueRaw); if (diagnostics.allowanceRaw >= expectedValueRaw) { @@ -116,9 +132,7 @@ export function analyzeMoneriumPermitPreflight( ); } - if (BigInt(context.deadline) <= BigInt(nowSeconds)) { - throwBadPermit(`Monerium permit deadline ${context.deadline} has expired`); - } + assertPermitDeadlineInFuture(context.deadline, nowSeconds); return { reason: "permit-required", shouldSendPermit: true }; } diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.ts index 84dd99e07..9a0652a8d 100644 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.ts +++ b/apps/api/src/api/services/ramp/monerium-self-transfer.ts @@ -1,5 +1,5 @@ import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; -import { decodeFunctionData, parseTransaction, recoverTransactionAddress } from "viem"; +import { decodeFunctionData, isAddress, parseTransaction, recoverTransactionAddress } from "viem"; export const moneriumTransferFromAbi = [ { @@ -38,14 +38,27 @@ export interface MoneriumSelfTransferInspection { 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 = await recoverTransactionAddress({ serializedTransaction }); - const tokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; + 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) { @@ -58,8 +71,10 @@ export async function inspectMoneriumSelfTransferTransaction( ); } - if (parsedTx.to?.toLowerCase() !== tokenAddress.toLowerCase()) { - throw new Error(`[${expectation.rampId}] Self-transfer token ${parsedTx.to} does not match expected ${tokenAddress}`); + 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) { @@ -72,7 +87,9 @@ export async function inspectMoneriumSelfTransferTransaction( abi: moneriumTransferFromAbi, data: parsedTx.data ?? "0x" }); - const [owner, recipient, amountRaw] = decodedTransfer.args; + 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()) { diff --git a/packages/shared/src/services/evm/clientManager.test.ts b/packages/shared/src/services/evm/clientManager.test.ts index d35858466..86479862c 100644 --- a/packages/shared/src/services/evm/clientManager.test.ts +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { redactRpcUrlForLogs, sanitizeRpcErrorMessage } from "./clientManager"; +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/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62")).toBe( + expect(redactRpcUrlForLogs("https://polygon-mainnet.g.alchemy.com/v2/test-api-key")).toBe( "https://polygon-mainnet.g.alchemy.com/v2/[redacted]" ); }); @@ -14,9 +15,17 @@ describe("redactRpcUrlForLogs", () => { it("redacts provider API keys embedded in RPC error messages", () => { expect( - sanitizeRpcErrorMessage( - "URL: https://polygon-mainnet.g.alchemy.com/v2/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62\nRequest failed" - ) + 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 b6f98ea83..6313197b7 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -9,6 +9,8 @@ export interface EvmNetworkConfig { rpcUrls: string[]; } +const VIEM_DEFAULT_TRANSPORT_CACHE_KEY = ""; + export function redactRpcUrlForLogs(rpcUrl: string): string { if (!rpcUrl) { return ""; @@ -40,6 +42,15 @@ 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 [ @@ -212,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 { @@ -226,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}`; } @@ -248,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; @@ -262,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); @@ -278,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: ${redactRpcUrlForLogs(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); }