diff --git a/apps/api/src/api/services/monerium/index.ts b/apps/api/src/api/services/monerium/index.ts index a01e25965..e237e5cb0 100644 --- a/apps/api/src/api/services/monerium/index.ts +++ b/apps/api/src/api/services/monerium/index.ts @@ -68,7 +68,7 @@ export const checkAddressExists = async (address: string, network: Networks): Pr return null; } catch (error) { logger.error("Failed to fetch address:", error); - return null; + throw error; } }; @@ -168,12 +168,11 @@ export const getMoneriumUserIban = async ({ authToken, profileId }: FetchIbansPa throw new Error(`API request failed with status ${response.status}: ${response.statusText}`); } const data: IbanDataResponse = await response.json(); - // Look for the IBAN data specifically for the Polygon chain. - // We choose Polygon as the default chain for Monerium EUR minting, - // so user registered with us should always have a Polygon-linked address. - const ibanData = data.ibans.find(item => item.chain === "polygon"); + // Look for the IBAN data specifically for the configured Monerium mint chain. + // This aligns with sandbox/prod chain selection. + const ibanData = data.ibans.find(item => item.chain === MONERIUM_MINT_CHAIN); if (!ibanData) { - throw new Error("No IBAN found for the specified chain (polygon)"); + throw new Error(`No IBAN found for the specified chain (${MONERIUM_MINT_CHAIN})`); } return ibanData; diff --git a/apps/api/src/api/services/quote/core/quote-fees.ts b/apps/api/src/api/services/quote/core/quote-fees.ts index ef413bdf7..9196d707b 100644 --- a/apps/api/src/api/services/quote/core/quote-fees.ts +++ b/apps/api/src/api/services/quote/core/quote-fees.ts @@ -78,6 +78,7 @@ async function calculatePartnerAndVortexFees( ): Promise<{ partnerMarkupFee: Big; vortexFee: Big }> { let totalPartnerMarkupInFeeCurrency = new Big(0); let totalVortexFeeInFeeCurrency = new Big(0); + let shouldApplyDefaultVortexFees = !partnerId; // 1. Fetch and process partner-specific configurations if partnerName is provided if (partnerId) { @@ -142,15 +143,17 @@ async function calculatePartnerAndVortexFees( // Log warning if partner found but no applicable custom fees if (!hasApplicableFees) { logger.warn(`Partner with name '${partnerId}' found, but no active markup defined. Proceeding with default fees.`); + shouldApplyDefaultVortexFees = true; } } else { // No specific partner records found, will use default Vortex fee below logger.warn(`No fee configuration found for partner with name '${partnerId}'. Proceeding with default fees.`); + shouldApplyDefaultVortexFees = true; } } // 2. If no partner was provided initially, use default Vortex fees - if (!partnerId) { + if (shouldApplyDefaultVortexFees) { // Query all vortex records for this ramp type const vortexFoundationPartners = await Partner.findAll({ where: { diff --git a/apps/api/src/api/services/quote/engines/fee/index.ts b/apps/api/src/api/services/quote/engines/fee/index.ts index 37ba1c250..772b8e27a 100644 --- a/apps/api/src/api/services/quote/engines/fee/index.ts +++ b/apps/api/src/api/services/quote/engines/fee/index.ts @@ -43,6 +43,13 @@ export abstract class BaseFeeEngine implements Stage { this.validate(ctx); + if (request.rampType === RampDirection.SELL && !ctx.nablaSwap) { + throw new APIError({ + message: "Missing nabla swap output amount for off-ramp fee calculation", + status: httpStatus.BAD_REQUEST + }); + } + const { anchorFee, feeCurrency, partnerMarkupFee, vortexFee } = await calculateFeeComponents({ from: request.from, inputAmount: request.inputAmount, diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 08bd1939e..172c2aad4 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -38,7 +38,7 @@ import RampState 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, getMoneriumUserProfile, MONERIUM_MINT_CHAIN } from "../monerium"; import { StateMetadata } from "../phases/meta-state-types"; import phaseProcessor from "../phases/phase-processor"; import { prepareOfframpTransactions } from "../transactions/offramp"; @@ -238,7 +238,8 @@ export class RampService extends BaseRampService { presignedTxs.forEach((newTx: UnsignedTx) => { const existingIndex = updatedTxs.findIndex( - tx => tx.phase === newTx.phase && tx.network === newTx.network && tx.signer === newTx.signer + tx => + tx.phase === newTx.phase && tx.network === newTx.network && tx.signer === newTx.signer && tx.nonce === newTx.nonce ); if (existingIndex >= 0) { updatedTxs[existingIndex] = newTx; @@ -888,7 +889,7 @@ export class RampService extends BaseRampService { const ibanData = await getIbanForAddress( additionalData.moneriumWalletAddress, additionalData.moneriumAuthToken, - quote.to as EvmNetworks // Fixme: assethub network type issue. + MONERIUM_MINT_CHAIN ); const userProfile = SANDBOX_ENABLED diff --git a/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts b/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts index eb6f4dea8..939954058 100644 --- a/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts +++ b/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts @@ -114,7 +114,7 @@ export async function buildPaymentAndMergeTx({ currentCreateAccountTransaction.sign(fundingAccountKeypair); createAccountTransactions.push({ - sequence: fundingAccount.sequenceNumber(), // TODO do we require this? + sequence: currentFundingAccount.sequenceNumber(), // TODO do we require this? tx: currentCreateAccountTransaction.toEnvelope().toXDR().toString("base64") }); } diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index f2ef3fd54..c93f5baf5 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -21,6 +21,56 @@ import QuoteTicket from "../../../models/quoteTicket.model"; import { APIError } from "../../errors/api-error"; /// Checks if all the transactions in 'subset' are contained in 'set' based on phase, network, nonce, and signer. +function isEvmTransactionLike( + data: unknown +): data is { + to: string; + data: string; + value: string; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; +} { + return typeof data === "object" && data !== null && "to" in data && "data" in data; +} + +function txDataMatches(setTxData: PresignedTx["txData"], subsetTxData: PresignedTx["txData"]): boolean { + if (setTxData === subsetTxData) { + return true; + } + + if (typeof setTxData === "string" && typeof subsetTxData === "string") { + return setTxData === subsetTxData; + } + + if (isEvmTransactionLike(setTxData) && isEvmTransactionLike(subsetTxData)) { + return JSON.stringify(setTxData) === JSON.stringify(subsetTxData); + } + + const signed = typeof setTxData === "string" ? setTxData : typeof subsetTxData === "string" ? subsetTxData : null; + const unsigned = isEvmTransactionLike(setTxData) ? setTxData : isEvmTransactionLike(subsetTxData) ? subsetTxData : null; + + if (signed && unsigned) { + try { + const parsed = EvmTransaction.from(signed); + return ( + parsed.to?.toLowerCase() === unsigned.to.toLowerCase() && + parsed.data === unsigned.data && + parsed.value?.toString() === unsigned.value && + parsed.gasLimit?.toString() === unsigned.gas && + parsed.maxFeePerGas?.toString() === unsigned.maxFeePerGas && + parsed.maxPriorityFeePerGas?.toString() === unsigned.maxPriorityFeePerGas && + parsed.nonce === unsigned.nonce + ); + } catch { + return false; + } + } + + return false; +} + export function areAllTxsIncluded(subset: PresignedTx[], set: PresignedTx[]): boolean { for (const subsetTx of subset) { const match = set.find( @@ -28,7 +78,8 @@ export function areAllTxsIncluded(subset: PresignedTx[], set: PresignedTx[]): bo setTx.phase === subsetTx.phase && setTx.network === subsetTx.network && setTx.nonce === subsetTx.nonce && - setTx.signer === subsetTx.signer + setTx.signer === subsetTx.signer && + txDataMatches(setTx.txData, subsetTx.txData) ); if (!match) { @@ -122,7 +173,15 @@ function validateEvmTransaction(tx: PresignedTx, expectedSigner: string) { }); } - const transactionMeta = EvmTransaction.from(txData); + let transactionMeta: EvmTransaction; + try { + transactionMeta = EvmTransaction.from(txData); + } catch (error) { + throw new APIError({ + message: `Invalid EVM transaction data: ${(error as Error).message}`, + status: httpStatus.BAD_REQUEST + }); + } if (!transactionMeta.from) { throw new APIError({ message: "EVM transaction data must be signed and include a 'from' address", @@ -157,6 +216,12 @@ async function validateSubstrateTransaction(tx: PresignedTx, expectedSignerSubst if (tx.phase === "moonbeamToPendulumXcm" || tx.phase === "moonbeamCleanup") { // Moonbeam uses EVM addresses but the transactions are Substrate-based + if (!expectedSignerEvm) { + throw new APIError({ + message: `Expected EVM signer for Substrate transaction is not provided for phase ${tx.phase}`, + status: httpStatus.BAD_REQUEST + }); + } if (signer.toLowerCase() !== expectedSignerEvm.toLowerCase()) { throw new APIError({ message: `Substrate transaction signer ${signer} does not match the expected signer ${expectedSignerEvm} for phase ${tx.phase}.`, diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index 769e751d0..1eb013587 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -95,7 +95,11 @@ export const signTransactionsActor = async ({ // Monerium onramp if (rampDirection === RampDirection.SELL && quote?.from === "sepa") { if (!getMessageSignature) throw new Error("getMessageSignature not available"); - const offrampMessage = await MoneriumService.createRampMessage(rampState.quote.outputAmount, "THIS WILL BE THE IBAN"); + const iban = rampState.ramp?.ibanPaymentData?.iban; + if (!iban) { + throw new Error("Missing IBAN for Monerium offramp signature"); + } + const offrampMessage = await MoneriumService.createRampMessage(rampState.quote.outputAmount, iban); moneriumOfframpSignature = await getMessageSignature(offrampMessage); } diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 6ce16a4b1..29943dbe0 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -52,7 +52,8 @@ export interface AccountMeta { export interface EvmTransactionData { to: EvmAddress; - data: EvmAddress; + // Calldata hex string (0x...) + data: string; value: string; gas: string; maxFeePerGas?: string;