Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c6a2cc6
refactor flow routing logic
gianfra-t Jun 8, 2026
cc3d30d
add morpho deposit flow variant
gianfra-t Jun 8, 2026
6eab26b
check for allowance to await for state sync
gianfra-t Jun 9, 2026
d3e17f9
Add vault "token" to UI selector
gianfra-t Jun 9, 2026
8a3a5fa
deposit into destination address, bypass small subsidy cap issue
gianfra-t Jun 9, 2026
264bd01
use morphoDeposit as single phase handler
gianfra-t Jun 9, 2026
7623656
adjust dev command
gianfra-t Jun 11, 2026
9eec84e
add bridge step to morpho demo flow
gianfra-t Jun 11, 2026
295315f
adjust quote service logic
gianfra-t Jun 11, 2026
92ff6d1
test flow
gianfra-t Jun 11, 2026
bb2fd7d
add morpho offramp changes
gianfra-t Jun 12, 2026
d69f35c
adjust morpho offramp to start from any evm
gianfra-t Jun 15, 2026
525707e
better permit execution
gianfra-t Jun 15, 2026
2e7e432
fix bug in nabla minimum output calculation
gianfra-t Jun 15, 2026
5515a7a
adjust morpho flow from non-base networks
gianfra-t Jun 16, 2026
60d0813
adjust squidrouter pay for Morhpo flow
gianfra-t Jun 16, 2026
39ee8d2
adjust final settlement phase for morpho onramp outside Base
gianfra-t Jun 16, 2026
3ec7c68
refactor fund ephemeral handler
gianfra-t Jun 16, 2026
5f9fc34
disable intent mocking
gianfra-t Jun 17, 2026
3795378
use real ip for intent calling
gianfra-t Jun 17, 2026
b32923e
first proposal and refactor
gianfra-t Jun 17, 2026
049c5a1
refactor proposal
gianfra-t Jun 18, 2026
6673c75
update simulation state when subsidizing
gianfra-t Jun 18, 2026
1d3cbac
remove morpho changes
gianfra-t Jun 18, 2026
9dcb75a
remove opencode config
gianfra-t Jun 18, 2026
374bff3
types improvements, re-implement example flow for refactor, invarianc…
gianfra-t Jul 6, 2026
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,7 +82,7 @@ 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);
Expand Down Expand Up @@ -147,7 +141,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 +181,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 +358,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
69 changes: 19 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 @@ -124,7 +123,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 +190,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 +215,20 @@ 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}.`);
const evmNetworksToFund = new Set<EvmNetworks>();
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 +242,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.0002",
[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
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class MykoboOnrampDepositHandler extends BasePhaseHandler {
logger.info(
`MykoboOnrampDepositHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${expectedAmountRaw} EURC (threshold: ${recoveryThresholdRaw}). Skipping deposit wait.`
);
return this.transitionToNextPhase(state, "fundEphemeral");
return state;
}

logger.info(
Expand Down Expand Up @@ -106,7 +106,7 @@ export class MykoboOnrampDepositHandler extends BasePhaseHandler {
`MykoboOnrampDepositHandler: EURC deposit received on Base ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.`
);

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

private async ephemeralAlreadyFunded(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class MykoboPayoutOnBasePhaseHandler extends BasePhaseHandler {
await this.sendMykoboPayoutTransaction(state, mykoboPayoutTxHash);
await this.pollMykoboUntilCompleted(mykoboTransactionId);

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

private async sendMykoboPayoutTransaction(state: RampState, mykoboPayoutTxHash?: `0x${string}`): Promise<void> {
Expand Down
Loading