From b45768be3e29f068d21069f5af919a430ddc7fa7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 18:17:09 +0200 Subject: [PATCH 1/5] Implement no-permit fallback for user-submitted transactions --- .../squidrouter-permit-execution-handler.ts | 49 +++ .../api/services/phases/meta-state-types.ts | 7 + .../offramp/routes/evm-to-alfredpay.ts | 298 ++++++++++++------ .../api/services/transactions/validation.ts | 6 + .../src/machines/actors/sign.actor.ts | 17 + apps/frontend/src/pages/progress/index.tsx | 3 + .../src/pages/progress/phaseMessages.ts | 3 + .../shared/src/endpoints/ramp.endpoints.ts | 6 + 8 files changed, 289 insertions(+), 100 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts index 57455415f..a2b38bdfc 100644 --- a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts @@ -114,6 +114,49 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { return this.transitionToNextPhase(updatedState, "fundEphemeral"); } + private async waitForUserHash( + state: RampState, + hash: `0x${string}` | undefined, + fromNetwork: EvmNetworks, + label: string + ): Promise { + if (!hash) { + throw this.createRecoverableError(`${label} hash not yet reported by frontend`); + } + const { publicClient } = this.getExecutorClients(fromNetwork); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`${label} tx failed: ${hash}`); + } + logger.info(`${label} tx confirmed: ${hash}`); + } + + private async executeNoPermitFallback(state: RampState, fromNetwork: EvmNetworks): Promise { + if (state.state.isDirectTransfer) { + await this.waitForUserHash( + state, + state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined, + fromNetwork, + "No-permit direct transfer" + ); + } else { + await this.waitForUserHash( + state, + state.state.squidRouterNoPermitApproveHash as `0x${string}` | undefined, + fromNetwork, + "No-permit approve" + ); + await this.waitForUserHash( + state, + state.state.squidRouterNoPermitSwapHash as `0x${string}` | undefined, + fromNetwork, + "No-permit swap" + ); + } + + return this.transitionToNextPhase(state, "fundEphemeral"); + } + private async executeDirectTransfer( state: RampState, signedTypedDataArray: SignedTypedData[], @@ -212,6 +255,12 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { } try { + // No-permit fallback: the user submitted the substitute transaction(s) from their own + // wallet during the signing step. We just verify their on-chain success and proceed. + if (state.state.isNoPermitFallback) { + return await this.executeNoPermitFallback(state, fromNetwork); + } + const existingHash = state.state.squidRouterPermitExecutionHash || null; if (existingHash) { 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 87f45f78f..491810b5a 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -71,4 +71,11 @@ export interface StateMetadata { squidRouterPermitExecutionHash?: string; squidRouterPermitExecutionValue?: string; isDirectTransfer?: boolean; + // Fallback path used when input ERC20 does not support EIP-2612 permit. + // The user submits the substituting transaction(s) from their own wallet and + // reports back the resulting tx hashes via UpdateRampRequest.additionalData. + isNoPermitFallback?: boolean; + squidRouterNoPermitTransferHash?: string; + squidRouterNoPermitApproveHash?: string; + squidRouterNoPermitSwapHash?: string; } diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index 12797b6f7..b70080eef 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -22,7 +22,7 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; -import { encodeAbiParameters, keccak256, PublicClient, pad, parseAbiParameters, toHex } from "viem"; +import { encodeAbiParameters, encodeFunctionData, keccak256, PublicClient, pad, parseAbiParameters, toHex } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_EXECUTOR_PRIVATE_KEY } from "../../../../../constants/constants"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; @@ -123,6 +123,19 @@ const erc20Abi = [ { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } ]; +const transferAbi = [ + { + inputs: [ + { name: "to", type: "address" }, + { name: "value", type: "uint256" } + ], + name: "transfer", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + /** * Prepares all transactions for an EVM to Alfredpay (USD) offramp. * This route handles: EVM → Polygon (USDC) → Alfredpay (Fiat) @@ -215,57 +228,184 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ const isDirectPolygonTransfer = fromNetwork === Networks.Polygon && inputTokenAddress.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase(); - const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); const publicClient = evmClientManager.getClient(fromNetwork); + const chainId = getNetworkId(fromNetwork)!; + + // Probe EIP-2612 support: tokens that don't implement nonces() (e.g. USDT on Base) revert here. + let userNonce: bigint | null = null; + try { + userNonce = (await publicClient.readContract({ + abi: erc20Abi, + address: inputTokenAddress, + args: [userAddress], + functionName: "nonces" + })) as bigint; + } catch { + userNonce = null; + } + const supportsPermit = userNonce !== null; - const userNonce = (await publicClient.readContract({ - abi: erc20Abi, - address: inputTokenAddress, - args: [userAddress], - functionName: "nonces" - })) as bigint; + if (supportsPermit && userNonce !== null) { + const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); - const tokenName = (await publicClient.readContract({ - abi: erc20Abi, - address: inputTokenAddress, - functionName: "name" - })) as string; + const tokenName = (await publicClient.readContract({ + abi: erc20Abi, + address: inputTokenAddress, + functionName: "name" + })) as string; - const chainId = getNetworkId(fromNetwork)!; - const resolvedDomain = await resolvePermitDomain(publicClient, inputTokenAddress, chainId, tokenName); - - if (isDirectPolygonTransfer) { - // Source is already Polygon USDT — user permits the executor to transferFrom directly. - // The executor has gas; the ephemeral is not yet funded at the squidRouterPermitExecute phase. - const executorAccount = privateKeyToAccount(MOONBEAM_EXECUTOR_PRIVATE_KEY as `0x${string}`); - const permitTypedData: SignedTypedData = { - domain: resolvedDomain, - message: { - deadline: permitDeadline.toString(), - nonce: userNonce.toString(), - owner: userAddress, - spender: executorAccount.address, - value: inputAmountRaw.toString() - }, - primaryType: "Permit", - types: { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; + const resolvedDomain = await resolvePermitDomain(publicClient, inputTokenAddress, chainId, tokenName); + + if (isDirectPolygonTransfer) { + // Source is already Polygon USDT — user permits the executor to transferFrom directly. + // The executor has gas; the ephemeral is not yet funded at the squidRouterPermitExecute phase. + const executorAccount = privateKeyToAccount(MOONBEAM_EXECUTOR_PRIVATE_KEY as `0x${string}`); + const permitTypedData: SignedTypedData = { + domain: resolvedDomain, + message: { + deadline: permitDeadline.toString(), + nonce: userNonce.toString(), + owner: userAddress, + spender: executorAccount.address, + value: inputAmountRaw.toString() + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterPermitExecute", + signer: userAddress, + txData: [permitTypedData] + }); + + stateMeta = { + ...stateMeta, + alfredpayTransactionId: offrampOrder.transactionId, + alfredpayUserId: customer.alfredPayId, + evmEphemeralAddress: evmEphemeralEntry.address, + fiatAccountId, + isDirectTransfer: true, + walletAddress: userAddress + }; + } else { + const bridgeResult = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: userAddress, + fromNetwork, + fromToken: inputTokenAddress, + rawAmount: inputAmountRaw, + toNetwork: Networks.Polygon, + toToken: ALFREDPAY_ERC20_TOKEN + }); + + const permitTypedData: SignedTypedData = { + domain: resolvedDomain, + message: { + deadline: permitDeadline.toString(), + nonce: userNonce.toString(), + owner: userAddress, + spender: RELAYER_ADDRESS, + value: inputAmountRaw.toString() + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + const payloadNonce = BigInt(Math.floor(Date.now() / 1000)); + const payloadDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const payloadTypedData: SignedTypedData = { + domain: { + chainId: getNetworkId(fromNetwork)!, + name: "TokenRelayer", + verifyingContract: RELAYER_ADDRESS, + version: "1" + }, + message: { + data: bridgeResult.swapData.data, + deadline: payloadDeadline.toString(), + destination: bridgeResult.swapData.to, + ethValue: bridgeResult.swapData.value, + nonce: payloadNonce.toString(), + owner: userAddress, + token: inputTokenAddress, + value: inputAmountRaw.toString() + }, + primaryType: "Payload", + types: { + Payload: [ + { name: "destination", type: "address" }, + { name: "owner", type: "address" }, + { name: "token", type: "address" }, + { name: "value", type: "uint256" }, + { name: "data", type: "bytes" }, + { name: "ethValue", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterPermitExecute", + signer: userAddress, + txData: [permitTypedData, payloadTypedData] + }); + + stateMeta = { + ...stateMeta, + alfredpayTransactionId: offrampOrder.transactionId, + alfredpayUserId: customer.alfredPayId, + evmEphemeralAddress: evmEphemeralEntry.address, + fiatAccountId, + squidRouterPermitExecutionValue: bridgeResult.swapData.value, + walletAddress: userAddress + }; + } + } else if (isDirectPolygonTransfer) { + // No permit available, but user already holds USDT on Polygon: user signs a single + // transfer(ephemeral, amount) in their wallet. Funds land directly on the ephemeral. + const transferData = encodeFunctionData({ + abi: transferAbi, + args: [evmEphemeralEntry.address as `0x${string}`, BigInt(inputAmountRaw)], + functionName: "transfer" + }); unsignedTxs.push({ meta: {}, network: fromNetwork, nonce: 0, - phase: "squidRouterPermitExecute", + phase: "squidRouterNoPermitTransfer", signer: userAddress, - txData: [permitTypedData] + txData: { + data: transferData, + gas: "0", + to: inputTokenAddress, + value: "0" + } }); stateMeta = { @@ -275,9 +415,13 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isDirectTransfer: true, + isNoPermitFallback: true, walletAddress: userAddress }; } else { + // Cross-chain fallback: user submits the standard squidRouter approve + swap pair from + // their own wallet, bypassing the relayer (which would require permit). Squid lands the + // bridged USDC on the EVM ephemeral on Polygon, identical to the permit-based flow. const bridgeResult = await createOfframpSquidrouterTransactionsToEvm({ destinationAddress: evmEphemeralEntry.address, fromAddress: userAddress, @@ -288,69 +432,22 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ toToken: ALFREDPAY_ERC20_TOKEN }); - const permitTypedData: SignedTypedData = { - domain: resolvedDomain, - message: { - deadline: permitDeadline.toString(), - nonce: userNonce.toString(), - owner: userAddress, - spender: RELAYER_ADDRESS, - value: inputAmountRaw.toString() - }, - primaryType: "Permit", - types: { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; - - const payloadNonce = BigInt(Math.floor(Date.now() / 1000)); - const payloadDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600); - - const payloadTypedData: SignedTypedData = { - domain: { - chainId: getNetworkId(fromNetwork)!, - name: "TokenRelayer", - verifyingContract: RELAYER_ADDRESS, - version: "1" - }, - message: { - data: bridgeResult.swapData.data, - deadline: payloadDeadline.toString(), - destination: bridgeResult.swapData.to, - ethValue: bridgeResult.swapData.value, - nonce: payloadNonce.toString(), - owner: userAddress, - token: inputTokenAddress, - value: inputAmountRaw.toString() - }, - primaryType: "Payload", - types: { - Payload: [ - { name: "destination", type: "address" }, - { name: "owner", type: "address" }, - { name: "token", type: "address" }, - { name: "value", type: "uint256" }, - { name: "data", type: "bytes" }, - { name: "ethValue", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; - unsignedTxs.push({ meta: {}, network: fromNetwork, nonce: 0, - phase: "squidRouterPermitExecute", + phase: "squidRouterNoPermitApprove", + signer: userAddress, + txData: bridgeResult.approveData + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 1, + phase: "squidRouterNoPermitSwap", signer: userAddress, - txData: [permitTypedData, payloadTypedData] + txData: bridgeResult.swapData }); stateMeta = { @@ -359,6 +456,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ alfredpayUserId: customer.alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, + isNoPermitFallback: true, squidRouterPermitExecutionValue: bridgeResult.swapData.value, walletAddress: userAddress }; diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 95b6cd055..7042066bb 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -91,6 +91,12 @@ export async function validatePresignedTxs( const txType = getTransactionTypeForPhase(tx.phase); if (tx.phase === "moneriumOnrampMint") continue; // Skip validation for this as it's from the user's wallet + if ( + tx.phase === "squidRouterNoPermitTransfer" || + tx.phase === "squidRouterNoPermitApprove" || + tx.phase === "squidRouterNoPermitSwap" + ) + continue; // User-submitted from their own wallet; only the resulting tx hash flows back via additionalData if (direction === RampDirection.SELL && (tx.phase === "squidRouterSwap" || tx.phase === "squidRouterApprove")) continue; // Skip validation for this as it's from the user's wallet if (txType === EphemeralAccountType.EVM) validateEvmTransaction(tx, ephemerals.EVM); if (txType === EphemeralAccountType.Substrate) await validateSubstrateTransaction(tx, ephemerals.Substrate, ephemerals.EVM); diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index 62d4c0947..3865512da 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -94,6 +94,9 @@ export const signTransactionsActor = async ({ let squidRouterApproveHash: string | undefined = undefined; let squidRouterSwapHash: string | undefined = undefined; + let squidRouterNoPermitTransferHash: string | undefined = undefined; + let squidRouterNoPermitApproveHash: string | undefined = undefined; + let squidRouterNoPermitSwapHash: string | undefined = undefined; let assethubToPendulumHash: string | undefined = undefined; let moneriumOfframpSignature: string | undefined = undefined; let moneriumOnrampPermit: PermitSignature | undefined = undefined; @@ -142,6 +145,17 @@ export const signTransactionsActor = async ({ } else if (tx.phase === "squidRouterSwap") { squidRouterSwapHash = await signAndSubmitEvmTransaction(tx); input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); + } else if (tx.phase === "squidRouterNoPermitTransfer") { + input.parent.send({ phase: "started", type: "SIGNING_UPDATE" }); + squidRouterNoPermitTransferHash = await signAndSubmitEvmTransaction(tx); + input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); + } else if (tx.phase === "squidRouterNoPermitApprove") { + input.parent.send({ phase: "started", type: "SIGNING_UPDATE" }); + squidRouterNoPermitApproveHash = await signAndSubmitEvmTransaction(tx); + input.parent.send({ phase: "signed", type: "SIGNING_UPDATE" }); + } else if (tx.phase === "squidRouterNoPermitSwap") { + squidRouterNoPermitSwapHash = await signAndSubmitEvmTransaction(tx); + input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); } else if (tx.phase === "assethubToPendulum") { if (!substrateWalletAccount) { throw new Error("Missing substrateWalletAccount, user needs to be connected to a wallet account. "); @@ -182,6 +196,9 @@ export const signTransactionsActor = async ({ moneriumOfframpSignature, moneriumOnrampPermit, squidRouterApproveHash, + squidRouterNoPermitApproveHash, + squidRouterNoPermitSwapHash, + squidRouterNoPermitTransferHash, squidRouterSwapHash }; diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 2d2b90019..fd0133bb3 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -45,6 +45,9 @@ const PHASE_DURATIONS: Record = { pendulumToMoonbeamXcm: 40, spacewalkRedeem: 130, squidRouterApprove: 10, + squidRouterNoPermitApprove: 10, + squidRouterNoPermitSwap: 60, + squidRouterNoPermitTransfer: 30, squidRouterPay: 60, squidRouterPermitExecute: 30, squidRouterSwap: 10, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 0a2e0998c..5449426fe 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -100,6 +100,9 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr assetSymbol: outputAssetSymbol }), squidRouterApprove: getSquidRouterSwapMessage(), + squidRouterNoPermitApprove: getSquidRouterPermitMessage(), + squidRouterNoPermitSwap: getSquidRouterPermitMessage(), + squidRouterNoPermitTransfer: getSquidRouterPermitMessage(), squidRouterPay: getSquidRouterSwapMessage(), squidRouterPermitExecute: getSquidRouterPermitMessage(), squidRouterSwap: getSquidRouterSwapMessage(), diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index ed7d9aeac..f3d91b947 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -16,6 +16,9 @@ export type RampPhase = | "moneriumOnrampSelfTransfer" | "moneriumOnrampMint" | "squidRouterPermitExecute" + | "squidRouterNoPermitTransfer" + | "squidRouterNoPermitApprove" + | "squidRouterNoPermitSwap" | "stellarCreateAccount" | "squidRouterApprove" | "squidRouterSwap" @@ -191,6 +194,9 @@ export interface UpdateRampRequest { additionalData?: { squidRouterApproveHash?: string; squidRouterSwapHash?: string; + squidRouterNoPermitTransferHash?: string; + squidRouterNoPermitApproveHash?: string; + squidRouterNoPermitSwapHash?: string; assethubToPendulumHash?: string; moneriumOfframpSignature?: string; // Required to trigger Monerium offramp moneriumOnrampPermit?: PermitSignature; From 15f917a03f7feb9a5eb146feb004617aa5f68403 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 18:25:36 +0200 Subject: [PATCH 2/5] Increase Alfredpay poll interval and change logging level to debug for API requests --- .../phases/handlers/alfredpay-offramp-transfer-handler.ts | 2 +- packages/shared/src/services/alfredpay/alfredpayApiService.ts | 2 +- packages/shared/src/services/brla/brlaApiService.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts index dd2066f23..d8830da97 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts @@ -15,7 +15,7 @@ import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; -const ALFREDPAY_POLL_INTERVAL_MS = 5000; +const ALFREDPAY_POLL_INTERVAL_MS = 30000; const ALFREDPAY_OFFRAMP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index a5670f263..63097d978 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -93,7 +93,7 @@ export class AlfredpayApiService { options.body = JSON.stringify(payload); } const fullUrl = `${ALFREDPAY_BASE_URL}${url}`; - logger.current.info(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); + logger.current.debug(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); const response = await fetch(fullUrl, options); diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 798313254..72a4333a6 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -143,7 +143,7 @@ export class BrlaApiService { options.body = body; } const fullUrl = `${BRLA_BASE_URL}${requestUri}`; - logger.current.info(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); + logger.current.debug(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); const response = await fetch(fullUrl, options); From a0b11b23cbb03d9c2636bfbff3bc23c86961a710 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 17:34:41 +0000 Subject: [PATCH 3/5] Initial plan From b53e604bb9fa39e61fd208110667583cc3a8dcce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 17:40:08 +0000 Subject: [PATCH 4/5] Address PR review: improve nonces() error handling, validate tx sender, fix phase messages --- .../squidrouter-permit-execution-handler.ts | 17 ++++++++++++---- .../offramp/routes/evm-to-alfredpay.ts | 20 ++++++++++++++++--- .../src/pages/progress/phaseMessages.ts | 14 ++++++++++--- apps/frontend/src/translations/en.json | 3 +++ apps/frontend/src/translations/pt.json | 3 +++ bun.lock | 1 + 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts index a2b38bdfc..19e6dcc16 100644 --- a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts @@ -118,7 +118,8 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { state: RampState, hash: `0x${string}` | undefined, fromNetwork: EvmNetworks, - label: string + label: string, + expectedFrom?: `0x${string}` ): Promise { if (!hash) { throw this.createRecoverableError(`${label} hash not yet reported by frontend`); @@ -128,29 +129,37 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { if (!receipt || receipt.status !== "success") { throw this.createRecoverableError(`${label} tx failed: ${hash}`); } + if (expectedFrom && receipt.from.toLowerCase() !== expectedFrom.toLowerCase()) { + throw this.createUnrecoverableError(`${label} tx ${hash} was sent by ${receipt.from}, expected ${expectedFrom}`); + } logger.info(`${label} tx confirmed: ${hash}`); } private async executeNoPermitFallback(state: RampState, fromNetwork: EvmNetworks): Promise { + const expectedFrom = state.state.walletAddress as `0x${string}` | undefined; + if (state.state.isDirectTransfer) { await this.waitForUserHash( state, state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined, fromNetwork, - "No-permit direct transfer" + "No-permit direct transfer", + expectedFrom ); } else { await this.waitForUserHash( state, state.state.squidRouterNoPermitApproveHash as `0x${string}` | undefined, fromNetwork, - "No-permit approve" + "No-permit approve", + expectedFrom ); await this.waitForUserHash( state, state.state.squidRouterNoPermitSwapHash as `0x${string}` | undefined, fromNetwork, - "No-permit swap" + "No-permit swap", + expectedFrom ); } diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index b70080eef..0947eef17 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -22,7 +22,16 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; -import { encodeAbiParameters, encodeFunctionData, keccak256, PublicClient, pad, parseAbiParameters, toHex } from "viem"; +import { + ContractFunctionExecutionError, + encodeAbiParameters, + encodeFunctionData, + keccak256, + PublicClient, + pad, + parseAbiParameters, + toHex +} from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { MOONBEAM_EXECUTOR_PRIVATE_KEY } from "../../../../../constants/constants"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; @@ -232,6 +241,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ const chainId = getNetworkId(fromNetwork)!; // Probe EIP-2612 support: tokens that don't implement nonces() (e.g. USDT on Base) revert here. + // Only treat contract-call failures as "no permit"; rethrow network/transport errors. let userNonce: bigint | null = null; try { userNonce = (await publicClient.readContract({ @@ -240,8 +250,12 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ args: [userAddress], functionName: "nonces" })) as bigint; - } catch { - userNonce = null; + } catch (error) { + if (error instanceof ContractFunctionExecutionError) { + userNonce = null; + } else { + throw error; + } } const supportsPermit = userNonce !== null; diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 5449426fe..9351652e4 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -100,9 +100,17 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr assetSymbol: outputAssetSymbol }), squidRouterApprove: getSquidRouterSwapMessage(), - squidRouterNoPermitApprove: getSquidRouterPermitMessage(), - squidRouterNoPermitSwap: getSquidRouterPermitMessage(), - squidRouterNoPermitTransfer: getSquidRouterPermitMessage(), + squidRouterNoPermitApprove: t("pages.progress.squidRouterNoPermitApprove", { + assetSymbol: inputAssetSymbol + }), + squidRouterNoPermitSwap: t("pages.progress.squidRouterNoPermitSwap", { + assetSymbol: inputAssetSymbol, + fromNetwork: quote.from, + toNetwork: quote.to + }), + squidRouterNoPermitTransfer: t("pages.progress.squidRouterNoPermitTransfer", { + assetSymbol: inputAssetSymbol + }), squidRouterPay: getSquidRouterSwapMessage(), squidRouterPermitExecute: getSquidRouterPermitMessage(), squidRouterSwap: getSquidRouterSwapMessage(), diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 4b4117961..809048ca7 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1185,6 +1185,9 @@ "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferring {{assetSymbol}} from Pendulum --> Moonbeam", + "squidRouterNoPermitApprove": "Approving {{assetSymbol}} for cross-chain transfer", + "squidRouterNoPermitSwap": "Transferring {{assetSymbol}} from {{fromNetwork}} to {{toNetwork}}", + "squidRouterNoPermitTransfer": "Transferring {{assetSymbol}} to Vortex", "squidRouterPermitExecute": "Initializing the transfer of {{assetSymbol}} from {{fromNetwork}}", "squidRouterSwap": "Transferring {{assetSymbol}} from {{fromNetwork}} to {{toNetwork}}", "stellarPayment": "Transferring {{assetSymbol}} from Stellar --> local partner", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 19ed043d5..f1f8f10a7 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1189,6 +1189,9 @@ "pendulumToAssethubXcm": "Transferindo {{assetSymbol}} de Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferindo {{assetSymbol}} de Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferindo {{assetSymbol}} de Pendulum --> Moonbeam", + "squidRouterNoPermitApprove": "Aprovando {{assetSymbol}} para transferência cross-chain", + "squidRouterNoPermitSwap": "Transferindo {{assetSymbol}} de {{fromNetwork}} para {{toNetwork}}", + "squidRouterNoPermitTransfer": "Transferindo {{assetSymbol}} para Vortex", "squidRouterPermitExecute": "Autorizando transferência de {{assetSymbol}} de {{fromNetwork}}", "squidRouterSwap": "Transferindo {{assetSymbol}} de {{fromNetwork}} para {{toNetwork}}", "stellarPayment": "Transferindo {{assetSymbol}} de Stellar --> parceiro local", diff --git a/bun.lock b/bun.lock index 2e2f56008..48638ddf9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "vortex-monorepo", From 288c601fd0842c9e3c19d5330770773e1b1c6210 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 4 May 2026 20:30:45 +0200 Subject: [PATCH 5/5] Enhance error handling for pixKey and receiverTaxId validation with detailed logging --- .../api/src/api/services/ramp/ramp.service.ts | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 67d39ca83..04fd6cc69 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -769,16 +769,44 @@ export class RampService extends BaseRampService { } // To make it harder to extract information, both the pixKey and the receiverTaxId are required to be correct. + // The user-facing error stays generic, but server-side logs differentiate failure modes for diagnosis. + let pixKeyData; try { - const pixKeyData = await brlaApiService.validatePixKey(pixKey); - //validate the recipient's taxId with partial information - if (!validateMaskedNumber(normalizeTaxId(pixKeyData.taxId), normalizeTaxId(receiverTaxId))) { - throw new APIError({ - message: "Invalid pixKey or receiverTaxId.", - status: httpStatus.BAD_REQUEST - }); - } - } catch (_error) { + pixKeyData = await brlaApiService.validatePixKey(pixKey); + } catch (error) { + logger.warn( + `validateBrlaOfframpRequest: pix-info lookup failed for pixKey=${pixKey}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + throw new APIError({ + message: "Invalid pixKey or receiverTaxId.", + status: httpStatus.BAD_REQUEST + }); + } + + let masksMatch: boolean; + try { + // Do NOT pass the masked taxId through normalizeTaxId: that helper strips all + // non-digits, which would also strip the `*` mask characters and break the + // length-aligned comparison done by validateMaskedNumber. + masksMatch = validateMaskedNumber(pixKeyData.taxId, normalizeTaxId(receiverTaxId)); + } catch (error) { + logger.warn( + `validateBrlaOfframpRequest: pix key owner taxId is not comparable to receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId( + receiverTaxId + )}: ${error instanceof Error ? error.message : String(error)}` + ); + throw new APIError({ + message: "Invalid pixKey or receiverTaxId.", + status: httpStatus.BAD_REQUEST + }); + } + + if (!masksMatch) { + logger.warn( + `validateBrlaOfframpRequest: pix key owner taxId does not match receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId(receiverTaxId)}` + ); throw new APIError({ message: "Invalid pixKey or receiverTaxId.", status: httpStatus.BAD_REQUEST