Skip to content
Merged
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@ storybook-static


CLAUDE.local.md
.claude
.claude
/contracts/relayer/artifacts/*
/contracts/relayer/cache/*
/.roo/*
9 changes: 4 additions & 5 deletions apps/api/src/api/services/phases/base-phase-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ export abstract class BasePhaseHandler implements PhaseHandler {
* @param state The current ramp state
* @param nextPhase The next phase
* @param metadata Additional metadata for the transition
* @returns The updated ramp state
* @returns The updated ramp state. Returns a new in-memory instance.
*/
protected async transitionToNextPhase(state: RampState, nextPhase: RampPhase, metadata?: unknown): Promise<RampState> {
protected transitionToNextPhase(state: RampState, nextPhase: RampPhase, metadata?: unknown): RampState {
const phaseHistory = [
...state.phaseHistory,
{
Expand All @@ -105,12 +105,11 @@ export abstract class BasePhaseHandler implements PhaseHandler {
}
];

await state.update({
return RampState.build({
...state.get(),
currentPhase: nextPhase,
phaseHistory
});

return state.reload();
}

/**
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/api/services/phases/phase-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,18 @@ export class PhaseProcessor {
}, maxExecuteTime);
});

const updatedState = await Promise.race([handler.execute(state), timeoutPromise]).finally(() => {
const pendingState = await Promise.race([handler.execute(state), timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});

// Single source of authority for phase transitions.
// Persist only the phase-related fields on the original persisted instance
// to avoid inserting new records or clobbering unrelated columns.
const updatedState = await state.update(
{ currentPhase: pendingState.currentPhase, phaseHistory: pendingState.phaseHistory },
{ fields: ["currentPhase", "phaseHistory"] }
);

// If the phase has changed, process the next phase
// except for complete or fail phases which are terminal.
if (
Expand Down
40 changes: 30 additions & 10 deletions apps/api/src/api/services/quote/engines/discount/offramp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared";
import Big from "big.js";
import logger from "../../../../../config/logger";
import { QuoteContext } from "../../core/types";
import { BaseDiscountEngine, DiscountComputation } from ".";
import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers";
Expand Down Expand Up @@ -37,41 +38,60 @@ export class OffRampDiscountEngine extends BaseDiscountEngine {
const targetDiscount = partner?.targetDiscount ?? 0;
const maxSubsidy = partner?.maxSubsidy ?? 0;

// Calculate expected output amount based on oracle price + target discount
// Calculate the oracle-based expected output in BRL.
const {
expectedOutput: expectedOutputAmountDecimal,
expectedOutput: oracleExpectedOutputDecimal,
adjustedDifference,
adjustedTargetDiscount
} = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner);
const expectedOutputAmountRaw = multiplyByPowerOfTen(expectedOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

// Account for the anchor fee deducted in the Finalize stage, which reduces the user's received amount.
// We need to add it back to the expected output to calculate the subsidy correctly.
const anchorFeeInBrl = ctx.fees?.displayFiat?.anchor ? new Big(ctx.fees.displayFiat.anchor) : new Big(0);
const adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.plus(anchorFeeInBrl);

if (anchorFeeInBrl.gt(0)) {
logger.info(
`OffRampDiscountEngine: Adjusted expected BRL from ${oracleExpectedOutputDecimal.toFixed(6)} ` +
`to ${adjustedExpectedOutputDecimal.toFixed(6)} (anchor fee: ${anchorFeeInBrl.toFixed(6)} BRL)`
);
ctx.addNote?.(
`OffRampDiscountEngine: Adjusted expected BRL output from ${oracleExpectedOutputDecimal.toFixed(4)} ` +
`to ${adjustedExpectedOutputDecimal.toFixed(4)} BRL to account for anchor fee of ${anchorFeeInBrl.toFixed(4)} BRL`
);
}

const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal;
const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

// Calculate ideal subsidy (uncapped - the full shortfall needed to reach expected output)
const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal)
// Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output)
const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal)
? new Big(0)
: expectedOutputAmountDecimal.minus(actualOutputAmountDecimal);
: adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal);
const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

// Calculate actual subsidy (capped by maxSubsidy)
const actualSubsidyAmountDecimal =
targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputAmountDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0);
targetDiscount > 0
? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy)
: Big(0);
const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal);
const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0);

const subsidyRate = expectedOutputAmountDecimal.gt(0)
? actualSubsidyAmountDecimal.div(expectedOutputAmountDecimal)
const subsidyRate = adjustedExpectedOutputDecimal.gt(0)
? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal)
: new Big(0);

return {
actualOutputAmountDecimal,
actualOutputAmountRaw,
adjustedDifference,
adjustedTargetDiscount,
expectedOutputAmountDecimal,
expectedOutputAmountDecimal: adjustedExpectedOutputDecimal,
expectedOutputAmountRaw,
idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal,
idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw,
Expand Down
106 changes: 94 additions & 12 deletions apps/api/src/api/services/quote/engines/discount/onramp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared";
import {
EvmToken,
getNetworkFromDestination,
multiplyByPowerOfTen,
Networks,
OnChainToken,
RampDirection
} from "@vortexfi/shared";
import Big from "big.js";
import logger from "../../../../../config/logger";
import { getEvmBridgeQuote } from "../../core/squidrouter";
import { QuoteContext } from "../../core/types";
import { BaseDiscountEngine, DiscountComputation } from ".";
import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers";
Expand Down Expand Up @@ -29,6 +38,59 @@ export class OnRampDiscountEngine extends BaseDiscountEngine {
}
}

/**
* Queries squidrouter to determine the actual conversion rate from axlUSDC on Moonbeam
* to the final destination token on the target EVM chain.
*
* The oracle price is based on the Binance USDT-BRL rate, but the Nabla swap on Pendulum
* outputs axlUSDC (not USDT). Since axlUSDC may trade at a discount to USDT via
* squidrouter, using the oracle USDT rate as the axlUSDC subsidy target means the user
* would receive slightly less than the oracle-promised amount after the squidrouter step.
*
* This method fetches the actual axlUSDC → destination token rate so the discount engine
* can back-calculate the precise axlUSDC amount required on Pendulum.
*
* @param ctx - The quote context (must have request.outputCurrency and request.to set)
* @param expectedAxlUSDCDecimal - The oracle-based expected axlUSDC amount used as probe input
* @returns The conversion rate (destination token units per axlUSDC) or null on failure
*/
private async getSquidRouterAxlUSDCConversionRate(ctx: QuoteContext, expectedAxlUSDCDecimal: Big): Promise<Big | null> {
const req = ctx.request;
const toNetwork = getNetworkFromDestination(req.to);

if (!toNetwork) {
return null;
}

try {
const bridgeQuote = await getEvmBridgeQuote({
amountDecimal: expectedAxlUSDCDecimal.toString(),
fromNetwork: Networks.Moonbeam,
inputCurrency: EvmToken.AXLUSDC as unknown as OnChainToken,
outputCurrency: req.outputCurrency as OnChainToken,
rampType: req.rampType,
toNetwork
});

if (expectedAxlUSDCDecimal.lte(0) || bridgeQuote.outputAmountDecimal.lte(0)) {
return null;
}

const conversionRate = bridgeQuote.outputAmountDecimal.div(expectedAxlUSDCDecimal);
logger.info(
`OnRampDiscountEngine: SquidRouter axlUSDC→${req.outputCurrency} rate: ${conversionRate.toFixed(6)} ` +
`(input: ${expectedAxlUSDCDecimal.toFixed(6)} axlUSDC, output: ${bridgeQuote.outputAmountDecimal.toFixed(6)} ${req.outputCurrency})`
);
return conversionRate;
} catch (error) {
logger.warn(
`OnRampDiscountEngine: Could not fetch SquidRouter axlUSDC→${req.outputCurrency} conversion rate, ` +
`falling back to 1:1 assumption. Error: ${error}`
);
return null;
}
}

protected async compute(ctx: QuoteContext): Promise<DiscountComputation> {
// biome-ignore lint/style/noNonNullAssertion: Context is validated in validate
const nablaSwap = ctx.nablaSwap!;
Expand All @@ -43,43 +105,63 @@ export class OnRampDiscountEngine extends BaseDiscountEngine {
const targetDiscount = partner?.targetDiscount ?? 0;
const maxSubsidy = partner?.maxSubsidy ?? 0;

// Calculate expected output amount based on oracle price + target discount
// Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms.
const {
expectedOutput: expectedOutputAmountDecimal,
expectedOutput: oracleExpectedOutputDecimal,
adjustedDifference,
adjustedTargetDiscount
} = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner);
const expectedOutputAmountRaw = multiplyByPowerOfTen(expectedOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

// For onramps, we have to deduct the fees from the output amount of the nabla swap
// For onramps to EVM chains (not AssetHub), the Nabla output token (axlUSDC on
// Pendulum) is subsequently bridged via squidrouter (Moonbeam → EVM destination). The
// oracle gives a USDT-BRL rate, but axlUSDC may not trade 1:1 with USDT on squidrouter.
// So we use the actual squidrouter route to determine the required axlUSDC amount
let adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal;
if (ctx.request.to !== "assethub") {
const squidRouterRate = await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal);

if (squidRouterRate !== null && squidRouterRate.gt(0)) {
adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.div(squidRouterRate);
ctx.addNote?.(
`OnRampDiscountEngine: Adjusted expected axlUSDC from ${oracleExpectedOutputDecimal.toFixed(6)} ` +
`to ${adjustedExpectedOutputDecimal.toFixed(6)} (squidRouter rate: ${squidRouterRate.toFixed(6)})`
);
}
}

const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

// For onramps, fees are deducted from the nabla output (not before the swap)
const deductedFeesAfterSwap = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup);
const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal.minus(deductedFeesAfterSwap);
const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

// Calculate ideal subsidy (uncapped - the full shortfall needed to reach expected output)
const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal)
// Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output)
const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal)
? new Big(0)
: expectedOutputAmountDecimal.minus(actualOutputAmountDecimal);
: adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal);
const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

// Calculate actual subsidy (capped by maxSubsidy)
const actualSubsidyAmountDecimal =
targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputAmountDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0);
targetDiscount > 0
? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy)
: Big(0);
const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0);

const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal);
const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0);

const subsidyRate = expectedOutputAmountDecimal.gt(0)
? actualSubsidyAmountDecimal.div(expectedOutputAmountDecimal)
const subsidyRate = adjustedExpectedOutputDecimal.gt(0)
? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal)
: new Big(0);

return {
actualOutputAmountDecimal,
actualOutputAmountRaw,
adjustedDifference,
adjustedTargetDiscount,
expectedOutputAmountDecimal,
expectedOutputAmountDecimal: adjustedExpectedOutputDecimal,
expectedOutputAmountRaw,
idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal,
idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw,
Expand Down
Loading