Skip to content
Open
11 changes: 5 additions & 6 deletions apps/api/src/api/services/monerium/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};

Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/api/services/quote/core/quote-fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/api/services/quote/engines/fee/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/api/services/ramp/ramp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
});
}
Expand Down
69 changes: 67 additions & 2 deletions apps/api/src/api/services/transactions/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,65 @@ 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(
setTx =>
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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}.`,
Expand Down
6 changes: 5 additions & 1 deletion apps/frontend/src/machines/actors/sign.actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/endpoints/ramp.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading