Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler {
);
}

return this.transitionToNextPhase(state, "complete");
return state;
}

private async recreateAlfredpayOfframp(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler {
`AlfredpayOnrampMintHandler: Balance reached on Polygon ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.`
);

return this.transitionToNextPhase(state, "fundEphemeral");
return state;
}

private async pollAlfredpayOnrampStatus(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,13 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
// transferred to the ephemeral. We accept a balance of at least 95% of the
// pre-computed expected amount to account for fee differences between quote
// creation time and execution time.
const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw)
.times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR)
.toFixed(0, 0);
const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0);

if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) {
logger.info(
`BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${preComputedExpectedAmountRaw} BRLA (threshold: ${recoveryThresholdRaw}). Skipping mint flow.`
);
return this.transitionToNextPhase(state, "fundEphemeral");
return state;
}

const brlaApiService = BrlaApiService.getInstance();
Expand Down Expand Up @@ -149,10 +147,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
// Derive the expected on-chain amount from the live quote's outputAmount rather than
// the stale pre-computed metadata value. The live quote accounts for the actual fees
// applied at execution time, so this is the amount that will truly arrive on Base.
const expectedAmountReceived = multiplyByPowerOfTen(
new Big(aveniaQuote.outputAmount),
tokenDetails.decimals
).toFixed(0, 0);
const expectedAmountReceived = multiplyByPowerOfTen(new Big(aveniaQuote.outputAmount), tokenDetails.decimals).toFixed(0, 0);

logger.info(
`BrlaOnrampMintHandler: Live Avenia quote output is ${aveniaQuote.outputAmount} BRLA (raw: ${expectedAmountReceived}). Pre-computed metadata value was ${preComputedExpectedAmountRaw}.`
Expand Down Expand Up @@ -198,7 +193,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
: new Error(`Error checking Base balance: ${error}`);
}

return this.transitionToNextPhase(state, "fundEphemeral");
return state;
}

private async ephemeralAlreadyFunded(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler {
// We need to check for existing ticket, recovery scenario
if (payOutTicketId) {
await this.checkTicketStatusPaid({ subAccountId: taxIdRecord.subAccountId, ticketId: payOutTicketId });
return this.transitionToNextPhase(state, "complete");
return state;
}

// send the "final destination"
Expand Down Expand Up @@ -135,7 +135,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler {
});

await this.checkTicketStatusPaid({ subAccountId: taxIdRecord.subAccountId, ticketId: payOutTicketId });
return this.transitionToNextPhase(state, "complete");
return state;
} catch (e) {
logger.error("Error in brlaPayoutOnBase", e);
throw this.createUnrecoverableError("BrlaPayoutOnBasePhaseHandler: Failed to trigger BRLA offramp.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export class DestinationTransferHandler extends BasePhaseHandler {
const receipt = await client.getTransactionReceipt({ hash: destinationTransferTxHash as `0x${string}` });

if (receipt.status === "success") {
return this.transitionToNextPhase(state, "complete");
return state;
} else {
throw new Error(`Transaction ${destinationTransferTxHash} failed on chain.`);
}
Expand Down Expand Up @@ -167,7 +167,7 @@ export class DestinationTransferHandler extends BasePhaseHandler {
});
// (optional) wait for balance to be updated on user - destination

return this.transitionToNextPhase(state, "complete");
return state;
} catch (error) {
throw this.createRecoverableError(
`DestinationTransferHandler: Error during phase execution - ${(error as Error).message}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,6 @@ export class DistributeFeesHandler extends BasePhaseHandler {
throw this.createUnrecoverableError(`Quote ticket not found for ID: ${state.quoteId}`);
}

// Determine next phase
const nextPhase = state.type === RampDirection.BUY ? "subsidizePostSwap" : "subsidizePreSwap";

// Check if we already have a hash stored
const existingHash = state.state.distributeFeeHash || null;

Expand All @@ -92,7 +89,7 @@ export class DistributeFeesHandler extends BasePhaseHandler {

if (status === ExtrinsicStatus.Success) {
logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`);
return this.transitionToNextPhase(state, nextPhase);
return state;
} else {
logger.info(`Existing distribute fee EVM transaction was not successful (status: ${status}), will retry`);
}
Expand All @@ -103,7 +100,7 @@ export class DistributeFeesHandler extends BasePhaseHandler {

if (status === ExtrinsicStatus.Success) {
logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`);
return this.transitionToNextPhase(state, nextPhase);
return state;
} else {
logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`);
}
Expand All @@ -115,7 +112,7 @@ export class DistributeFeesHandler extends BasePhaseHandler {
const distributeFeeTransaction = this.getPresignedTransaction(state, "distributeFees");
if (distributeFeeTransaction === undefined) {
logger.info("No fee distribution transaction data found. Skipping fee distribution.");
return this.transitionToNextPhase(state, nextPhase);
return state;
}

// The funding token (USDC) may not yet be on the ephemeral when we reach this phase
Expand Down Expand Up @@ -157,7 +154,7 @@ export class DistributeFeesHandler extends BasePhaseHandler {
}

logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`);
return this.transitionToNextPhase(updatedState, nextPhase);
return state;
} catch (e: unknown) {
logger.error(`Error distributing fees for ramp ${state.id}:`, e);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,6 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
return "finalSettlementSubsidy";
}

private getNextPhase(state: RampState, quote: QuoteTicket): RampPhase {
return state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)
? "alfredpayOfframpTransfer"
: "destinationTransfer";
}

protected async executePhase(state: RampState): Promise<RampState> {
logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`);

Expand All @@ -76,7 +70,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
!(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken))
) {
logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for direct-transfer ramp ${state.id}`);
return this.transitionToNextPhase(state, this.getNextPhase(state, quote));
return state;
}

const evmClientManager = EvmClientManager.getInstance();
Expand All @@ -88,14 +82,16 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {

if (isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) {
logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for Base direct-transfer route (ramp ${state.id})`);
return this.transitionToNextPhase(state, this.getNextPhase(state, quote));
return state;
}

const isAlfredpaySell = state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken);

const outTokenDetails =
state.type === RampDirection.BUY
? (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails)
? quote.outputCurrency === EvmToken.MORPHO_VAULT
? (getOnChainTokenDetails(quote.network as Networks, EvmToken.USDC) as EvmTokenDetails)
: (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails)
: isAlfredpaySell
? getOnChainTokenDetails(Networks.Polygon, ALFREDPAY_EVM_TOKEN)
: getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC);
Expand All @@ -110,6 +106,17 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
let expectedAmountRaw: Big | undefined;
switch (state.type) {
case RampDirection.BUY:
if (quote.outputCurrency === EvmToken.MORPHO_VAULT) {
// MORPHO_VAULT onramp: SquidRouter delivers the deposit asset (USDC) to the ephemeral, not vault shares.
// Vault shares are minted by the later morphoDeposit phase. Use the raw deposit amount from state metadata.
if (!state.state.morphoDepositAmountRaw) {
throw new Error(
"FinalSettlementSubsidyHandler: Missing morphoDepositAmountRaw in state metadata for MORPHO_VAULT onramp"
);
}
expectedAmountRaw = Big(state.state.morphoDepositAmountRaw);
break;
}
expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals);
break;
case RampDirection.SELL:
Expand Down Expand Up @@ -147,7 +154,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
logger.info(
`FinalSettlementSubsidyHandler: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.`
);
return this.transitionToNextPhase(state, this.getNextPhase(state, quote));
return state;
}
}

Expand Down Expand Up @@ -187,7 +194,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
logger.info(
`FinalSettlementSubsidyHandler: Delivered amount (${delivered.toString()}) meets expected amount with actualBalance=${actualBalance.toString()} and preSettlementBalance=${preBalance.toString()}. No subsidy needed.`
);
return this.transitionToNextPhase(state, this.getNextPhase(state, quote));
return state;
}

logger.info(
Expand Down Expand Up @@ -364,7 +371,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
}
});

return this.transitionToNextPhase(state, this.getNextPhase(state, quote));
return state;
} catch (error) {
throw this.createRecoverableError(
`FinalSettlementSubsidyHandler: Error during phase execution - ${(error as Error).message}`
Expand Down
84 changes: 34 additions & 50 deletions apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import RampState from "../../../../models/rampState.model";
import { UnrecoverablePhaseError } from "../../../errors/phase-error";
import { multiplyByPowerOfTen } from "../../pendulum/helpers";
import { fundEphemeralAccount } from "../../pendulum/pendulum.service";
import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils";
import { BasePhaseHandler } from "../base-phase-handler";
import { getEvmFundingAccount } from "../evm-funding";
import { verifyUserSubmittedTxByHash } from "../helpers/user-tx-verifier";
Expand Down Expand Up @@ -86,6 +85,13 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler {
return false;
}

protected getRequiresMorphoNetworkFunding(state: RampState): boolean {
const meta = state.state as StateMetadata;
if (!meta.morphoNetwork) return false;
if (!isNetworkEVM(meta.morphoNetwork as EvmNetworks)) return false;
return true;
}

protected getRequiresDestinationEvmFunding(state: RampState): boolean {
// Required for onramps where the destination is an EVM network (not AssetHub)
if (isOnramp(state) && state.to !== Networks.AssetHub) {
Expand Down Expand Up @@ -124,7 +130,10 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler {
return;
}

const hasSquidApproveBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterApprove");
const hasSquidApproveBlueprint = state.unsignedTxs.some(
tx =>
tx.phase === "squidRouterApprove" && tx.signer.toLowerCase() !== (state.state.evmEphemeralAddress ?? "").toLowerCase()
);
if (!hasSquidApproveBlueprint) return;

await verifyUserSubmittedTxByHash({
Expand Down Expand Up @@ -188,12 +197,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler {

const isPolygonFunded = requiresPolygonEphemeralAddress ? await isPolygonEphemeralFunded(evmEphemeralAddress) : true;

const destinationNetwork = getNetworkFromDestination(state.to);
const isDestinationEvmFunded =
requiresDestinationEvmFunding && destinationNetwork && isNetworkEVM(destinationNetwork) // for type safety
? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork)
: true;

if (!isPendulumFunded) {
logger.info(`Funding PEN ephemeral account ${substrateEphemeralAddress}`);
if (isOnramp(state) && state.to !== Networks.AssetHub) {
Expand All @@ -219,11 +222,28 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler {
logger.info("Polygon ephemeral address already funded.");
}

if (isOnramp(state) && !isDestinationEvmFunded && destinationNetwork && isNetworkEVM(destinationNetwork)) {
logger.info(`Funding destination EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`);
await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork);
} else if (requiresDestinationEvmFunding) {
logger.info(`Destination EVM ephemeral address already funded on ${destinationNetwork}.`);
// Deduplicate morpho + destination EVM networks: a single network can appear
// in both sets (e.g. onramp Morpho on Arbitrum, where the destination ==
// morpho network). Funding twice for the same address on the same chain
// would over-fund the ephemeral.
const evmNetworksToFund = new Set<EvmNetworks>();
const morphoNetwork = (state.state as StateMetadata).morphoNetwork as EvmNetworks | undefined;
if (morphoNetwork && this.getRequiresMorphoNetworkFunding(state)) {
evmNetworksToFund.add(morphoNetwork);
}
const destinationNetwork = getNetworkFromDestination(state.to);
if (isOnramp(state) && destinationNetwork && isNetworkEVM(destinationNetwork) && requiresDestinationEvmFunding) {
evmNetworksToFund.add(destinationNetwork);
}

for (const network of evmNetworksToFund) {
const isFunded = await isDestinationEvmEphemeralFunded(evmEphemeralAddress, network);
if (!isFunded) {
logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${network}`);
await this.fundDestinationEvmEphemeralAccount(state, network);
} else {
logger.info(`EVM ephemeral account already funded on ${network}.`);
}
}
} catch (e) {
logger.error("Error in FundEphemeralPhaseHandler:", e);
Expand All @@ -237,43 +257,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler {
throw recoverableError;
}

return this.transitionToNextPhase(state, this.nextPhaseSelector(state, quote));
}

protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase {
if (
(state.state.isDirectTransfer === true &&
!(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken))) ||
(isOnramp(state) && isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network))
) {
return "destinationTransfer";
}

// brla onramp case
if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) {
return "subsidizePreSwap";
}
// mykobo (EURC) onramp case
if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) {
return "subsidizePreSwap";
}
// alfredpay onramp case
if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) {
return "subsidizePreSwap";
}

// off ramp cases
if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) {
return "distributeFees";
} else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) {
return "finalSettlementSubsidy";
} else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) {
return "distributeFees";
} else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.EURC) {
return "distributeFees";
} else {
return "moonbeamToPendulum"; // Via contract.subsidizePreSwap
}
return state;
}

protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise<void> {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/api/services/phases/handlers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string):
// will spuriously succeed (or fail) and downstream phases may run with a
// short-funded ephemeral.
export const DESTINATION_EVM_FUNDING_AMOUNTS: Record<EvmNetworks, string> = {
[VortexNetworks.Ethereum]: "0.00016",
[VortexNetworks.Arbitrum]: "0.000045",
[VortexNetworks.Ethereum]: "0.005",
[VortexNetworks.Arbitrum]: "0.0001",
[VortexNetworks.Base]: "0.000034",
[VortexNetworks.Polygon]: "0.6",
[VortexNetworks.BSC]: "0.000115",
Expand Down
18 changes: 5 additions & 13 deletions apps/api/src/api/services/phases/handlers/initial-phase-handler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { FiatToken, isAlfredpayToken, RampDirection, RampPhase } from "@vortexfi/shared";
import { RampPhase } from "@vortexfi/shared";
import logger from "../../../../config/logger";
import { config } from "../../../../config/vars";
import QuoteTicket from "../../../../models/quoteTicket.model";
import RampState from "../../../../models/rampState.model";
import { BasePhaseHandler } from "../base-phase-handler";

/**
* Handler for the initial phase
* Handler for the initial phase.
* Routing is handled by the PhaseProcessor via phaseFlow.
* This handler only performs setup and sandbox short-circuiting.
*/
export class InitialPhaseHandler extends BasePhaseHandler {
/**
Expand Down Expand Up @@ -34,17 +36,7 @@ export class InitialPhaseHandler extends BasePhaseHandler {
return this.transitionToNextPhase(state, "complete");
}

if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.BRL) {
return this.transitionToNextPhase(state, "brlaOnrampMint");
} else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) {
return this.transitionToNextPhase(state, "mykoboOnrampDeposit");
} else if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) {
return this.transitionToNextPhase(state, "alfredpayOnrampMint");
} else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) {
return this.transitionToNextPhase(state, "squidRouterPermitExecute");
}

return this.transitionToNextPhase(state, "fundEphemeral");
return state;
}
}

Expand Down
Loading