Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,14 +1,10 @@
import {
checkEvmBalancePeriodically,
checkEvmBalanceForToken,
EvmClientManager,
EvmNetworks,
EvmTokenDetails,
FiatToken,
getAnyFiatTokenDetailsMoonbeam,
getOnChainTokenDetails,
isEvmToken,
multiplyByPowerOfTen,
Networks,
RampDirection,
RampPhase
} from "@vortexfi/shared";
Expand Down Expand Up @@ -38,11 +34,14 @@ export class DestinationTransferHandler extends BasePhaseHandler {
throw new Error("Quote not found for the given state");
}

if (!isEvmToken(quote.outputCurrency)) {
throw new Error("DestinationTransferHandler: Output currency is not an EVM token");
const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails;
if (!outTokenDetails) {
throw new Error(
`DestinationTransferHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}`
);
}

const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer");
const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails;
const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString();
const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before
const { destinationTransferTxHash } = state.state;
Expand All @@ -66,14 +65,14 @@ export class DestinationTransferHandler extends BasePhaseHandler {

// main phase execution loop:
try {
await checkEvmBalancePeriodically(
outTokenDetails.erc20AddressSourceChain,
state.state.evmEphemeralAddress,
expectedAmountRaw,
BALANCE_POLLING_TIME_MS,
EVM_BALANCE_CHECK_TIMEOUT_MS,
destinationNetwork
);
await checkEvmBalanceForToken({
amountDesiredRaw: expectedAmountRaw,
chain: destinationNetwork,
intervalMs: BALANCE_POLLING_TIME_MS,
ownerAddress: state.state.evmEphemeralAddress,
timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS,
tokenDetails: outTokenDetails
});

// send the transaction, log hash in the state for recovery.
const txHash = await evmClientManager.sendRawTransactionWithRetry(
Expand Down
110 changes: 64 additions & 46 deletions apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {
checkEvmBalancePeriodically,
checkEvmBalanceForToken,
EvmClientManager,
EvmNetworks,
EvmTokenDetails,
getEvmBalance,
getNetworkId,
getOnChainTokenDetails,
getRoute,
isEvmToken,
isNativeEvmToken,
multiplyByPowerOfTen,
NATIVE_TOKEN_ADDRESS,
Networks,
RampCurrency,
RampDirection,
Expand All @@ -25,7 +27,6 @@ import { BasePhaseHandler } from "../base-phase-handler";

const BALANCE_POLLING_TIME_MS = 5000;
const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";

const NATIVE_TOKENS: Record<EvmNetworks, { symbol: string; decimals: number }> = {
[Networks.Ethereum]: { decimals: 18, symbol: "ETH" },
Expand Down Expand Up @@ -57,14 +58,18 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {

const quote = await QuoteTicket.findByPk(state.quoteId);
if (!quote) {
throw new Error("Quote not found for the given state");
throw new Error("FinalSettlementSubsidyHandler: Quote not found for the given state");
}

if (!isEvmToken(quote.outputCurrency)) {
throw new Error("FinalSettlementSubsidyHandler: Output currency is not an EVM token");
const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails;
if (!outTokenDetails) {
throw new Error(
`FinalSettlementSubsidyHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}`
);
}

const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails;
const isNative = isNativeEvmToken(outTokenDetails);

const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals);
const destinationNetwork = quote.network as EvmNetworks;
const publicClient = evmClientManager.getClient(destinationNetwork);
Expand All @@ -86,20 +91,21 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
}
}

const actualBalance = await checkEvmBalancePeriodically(
outTokenDetails.erc20AddressSourceChain,
ephemeralAddress,
"1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less.
BALANCE_POLLING_TIME_MS,
EVM_BALANCE_CHECK_TIMEOUT_MS,
destinationNetwork
);
// 2. Check ephemeral address balance (handles both native and ERC-20 automatically)
const actualBalance = await checkEvmBalanceForToken({
amountDesiredRaw: "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less.
chain: destinationNetwork,
intervalMs: BALANCE_POLLING_TIME_MS,
ownerAddress: ephemeralAddress,
timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS,
tokenDetails: outTokenDetails
});

const actualBalanceFundingAccount = await publicClient.readContract({
abi: erc20Abi,
address: outTokenDetails.erc20AddressSourceChain as `0x${string}`,
args: [fundingAccount.address],
functionName: "balanceOf"
// 3. Check funding account balance (handles both native and ERC-20 automatically)
const actualBalanceFundingAccount = await getEvmBalance({
chain: destinationNetwork,
ownerAddress: fundingAccount.address as `0x${string}`,
tokenDetails: outTokenDetails
});

const subsidyAmountRaw = expectedAmountRaw.minus(actualBalance);
Expand All @@ -111,10 +117,12 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
return this.transitionToNextPhase(state, "destinationTransfer");
}

logger.info(`FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units to ${ephemeralAddress}`);
logger.info(
`FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}`
);

// Check if funding account has enough balance
if (new Big(actualBalanceFundingAccount.toString()).lt(subsidyAmountRaw)) {
// 4. Top up funding account if insufficient balance (ERC-20 only; native tokens are transferred directly)
if (!isNative && actualBalanceFundingAccount.lt(subsidyAmountRaw)) {
logger.info(
`FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}`
);
Expand Down Expand Up @@ -203,40 +211,50 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
logger.info("FinalSettlementSubsidyHandler: Swap successful. Waiting for balance update...");

// Wait for balance checks to pass
await checkEvmBalancePeriodically(
outTokenDetails.erc20AddressSourceChain,
fundingAccount.address,
subsidyAmountRaw.toString(),
BALANCE_POLLING_TIME_MS,
EVM_BALANCE_CHECK_TIMEOUT_MS,
destinationNetwork
);
await checkEvmBalanceForToken({
amountDesiredRaw: subsidyAmountRaw.toString(),
chain: destinationNetwork,
intervalMs: BALANCE_POLLING_TIME_MS,
ownerAddress: fundingAccount.address,
timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS,
tokenDetails: outTokenDetails
});
}

// Execution Loop
// 5. Execute the subsidy transfer (native value transfer vs ERC-20 transfer)
let txHash: `0x${string}` | undefined = state.state.finalSettlementSubsidyTxHash as `0x${string}` | undefined;

try {
const data = encodeFunctionData({
abi: erc20Abi,
args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))],
functionName: "transfer"
});

const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas();

let receipt: TransactionReceipt | undefined = undefined;
let attempt = 0;

while (attempt < 5 && (!receipt || receipt.status !== "success")) {
// Blind retry for transaction submission
txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, {
data,
maxFeePerGas,
maxPriorityFeePerGas,
to: outTokenDetails.erc20AddressSourceChain as `0x${string}`,
value: 0n
});
if (isNative) {
// Native token: simple value transfer, no contract interaction
txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, {
maxFeePerGas,
maxPriorityFeePerGas,
to: ephemeralAddress,
value: BigInt(subsidyAmountRaw.toFixed(0))
});
} else {
// ERC-20: encode transfer call
const data = encodeFunctionData({
abi: erc20Abi,
args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))],
functionName: "transfer"
});

txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, {
data,
maxFeePerGas,
maxPriorityFeePerGas,
to: outTokenDetails.erc20AddressSourceChain as `0x${string}`,
value: 0n
});
}

receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
AxelarScanStatusFees,
BalanceCheckError,
BalanceCheckErrorType,
checkEvmBalancePeriodically,
checkEvmBalanceForToken,
EvmClientManager,
EvmNetworks,
EvmTokenDetails,
Expand All @@ -28,7 +28,6 @@ import { axelarGasServiceAbi } from "../../../../contracts/AxelarGasService";
import QuoteTicket from "../../../../models/quoteTicket.model";
import RampState from "../../../../models/rampState.model";
import { SubsidyToken } from "../../../../models/subsidy.model";
import { PhaseError } from "../../../errors/phase-error";
import { BasePhaseHandler } from "../base-phase-handler";

const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds
Expand Down Expand Up @@ -133,14 +132,14 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler {
const ephemeralAddress = state.state.evmEphemeralAddress;

if (outTokenDetails && ephemeralAddress) {
balanceCheckPromise = checkEvmBalancePeriodically(
outTokenDetails.erc20AddressSourceChain,
ephemeralAddress,
"1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less.
BALANCE_POLLING_TIME_MS,
EVM_BALANCE_CHECK_TIMEOUT_MS,
toChain
);
balanceCheckPromise = checkEvmBalanceForToken({
amountDesiredRaw: "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less.
chain: toChain,
intervalMs: BALANCE_POLLING_TIME_MS,
ownerAddress: ephemeralAddress,
timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS,
tokenDetails: outTokenDetails
});
} else {
logger.warn(
"SquidRouterPayPhaseHandler: Cannot perform balance check optimization (missing expected token details or address)."
Expand Down Expand Up @@ -252,7 +251,9 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler {
logger.info("SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check.");
}
} catch (error) {
logger.error(`SquidRouterPayPhaseHandler: Error in bridge status loop for ${swapHash}:`, error);
throw this.createRecoverableError(
`SquidRouterPayPhaseHandler: Failed to check bridge status for ${swapHash}, error: ${error instanceof Error ? error.message : String(error)}`
);
}

await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS));
Expand Down Expand Up @@ -387,11 +388,39 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler {

const squidRouterStatus = await getStatus(swapHash, fromChainId, toChainId, state.state.squidRouterQuoteId);
return squidRouterStatus;
} catch (error) {
logger.error(`SquidRouterPayPhaseHandler: Error fetching Squidrouter status for swap hash ${swapHash}:`, error);
throw this.createRecoverableError(
`SquidRouterPayPhaseHandler: Failed to fetch Squidrouter status for swap hash ${swapHash}`
} catch (squidRouterError) {
logger.warn(
`SquidRouterPayPhaseHandler: SquidRouter status check failed for swap hash ${swapHash}, attempting Axelar fallback: ${squidRouterError instanceof Error ? squidRouterError.message : String(squidRouterError)}`
);

try {
const axelarScanStatus = await getStatusAxelarScan(swapHash);

if (!axelarScanStatus) {
throw new Error(
`SquidRouterPayPhaseHandler: Axelar scan status not found for swap hash ${swapHash} during fallback attempt.`
);
}

// Map Axelar status to SquidRouter format, assuming GMP transaction.
const mappedStatus =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting this status directly as axelarScanStatus for any other case than success may not be ideal, but is the best we can do in this scenario.

axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed"
? "success"
: axelarScanStatus.status;

return {
id: "",
isGMPTransaction: true,
routeStatus: [],
squidTransactionStatus: "",
status: mappedStatus
} as SquidRouterPayResponse;
} catch (axelarError) {
logger.error(
`SquidRouterPayPhaseHandler: Both SquidRouter and Axelar fallback failed for swap hash ${swapHash}. Axelar fallback error: ${axelarError instanceof Error ? axelarError.message : String(axelarError)}`
);
throw new Error(`SquidRouterPayPhaseHandler: Failed to fetch Squidrouter status for swap hash ${swapHash}`);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export class OnRampSquidRouterBrlToEvmEngine extends BaseSquidRouterEngine {
);
}

const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain;
const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, toNetwork);
const toTokenAddress = toToken.erc20AddressSourceChain;
// biome-ignore lint/style/noNonNullAssertion: Context is validated in validate
const pendulumToMoonbeamXcm = ctx.pendulumToMoonbeamXcm!;

Expand All @@ -43,9 +44,9 @@ export class OnRampSquidRouterBrlToEvmEngine extends BaseSquidRouterEngine {
fromToken: AXL_USDC_MOONBEAM,
inputAmountDecimal: pendulumToMoonbeamXcm.outputAmountDecimal,
inputAmountRaw: pendulumToMoonbeamXcm.outputAmountRaw,
outputDecimals: getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, toNetwork).decimals,
outputDecimals: toToken.decimals,
toNetwork,
toToken
toToken: toTokenAddress
},
type: "moonbeam-to-evm"
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import {
ERC20_EURE_POLYGON_DECIMALS,
ERC20_EURE_POLYGON_V1,
getNetworkFromDestination,
Networks,
OnChainToken,
RampDirection
} from "@vortexfi/shared";
import { ERC20_EURE_POLYGON_V1, getNetworkFromDestination, Networks, OnChainToken, RampDirection } from "@vortexfi/shared";
import httpStatus from "http-status";
import { APIError } from "../../../../errors/api-error";
import { getTokenDetailsForEvmDestination } from "../../core/squidrouter";
Expand Down Expand Up @@ -42,7 +35,8 @@ export class OnRampSquidRouterEurToEvmEngine extends BaseSquidRouterEngine {
});
}

const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain;
const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to);
const toTokenAddress = toToken.erc20AddressSourceChain;
// biome-ignore lint/style/noNonNullAssertion: Context is validated in validate
const moneriumMint = ctx.moneriumMint!;

Expand All @@ -53,9 +47,9 @@ export class OnRampSquidRouterEurToEvmEngine extends BaseSquidRouterEngine {
fromToken: ERC20_EURE_POLYGON_V1,
inputAmountDecimal: moneriumMint.outputAmountDecimal,
inputAmountRaw: moneriumMint.outputAmountRaw,
outputDecimals: ERC20_EURE_POLYGON_DECIMALS,
outputDecimals: toToken.decimals,
toNetwork,
toToken
toToken: toTokenAddress
},
type: "evm-to-evm"
};
Expand Down
Loading