Skip to content
Open
5 changes: 3 additions & 2 deletions apps/api/src/api/controllers/brla.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,11 +568,12 @@ export const newKyc = async (
* @throws 500 - For any server-side errors during processing
*/
export const initiateKybLevel1 = async (
req: Request<unknown, unknown, unknown, { subAccountId?: string }>,
req: Request<unknown, { redirectUrl: string }, unknown, { subAccountId?: string }>,
res: Response<KybLevel1Response | BrlaErrorResponse>
): Promise<void> => {
try {
const { subAccountId } = req.query;
const { redirectUrl } = req.body as { redirectUrl: string };

if (!subAccountId) {
res.status(httpStatus.BAD_REQUEST).json({ error: "Missing subAccountId" });
Expand All @@ -593,7 +594,7 @@ export const initiateKybLevel1 = async (
}

const brlaApiService = BrlaApiService.getInstance();
const response = await brlaApiService.initiateKybLevel1(subAccountId);
const response = await brlaApiService.initiateKybLevel1(subAccountId, redirectUrl);

res.status(httpStatus.OK).json(response);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
BrlaApiService,
BrlaCurrency,
checkEvmBalancePeriodically,
FiatToken,
getAnyFiatTokenDetailsMoonbeam,
EvmToken,
evmTokenConfig,
Networks,
RampPhase,
waitUntilTrueWithTimeout
Expand All @@ -28,7 +28,7 @@ import { StateMetadata } from "../meta-state-types";
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

// Phase description: wait for the tokens to arrive at the Moonbeam ephemeral address.
// Phase description: wait for the tokens to arrive at the Base ephemeral address.
// If the timeout is reached, we assume the user has NOT made the payment and we cancel the ramp.
export class BrlaOnrampMintHandler extends BasePhaseHandler {
public getPhaseName(): RampPhase {
Expand Down Expand Up @@ -106,7 +106,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
inputPaymentMethod: AveniaPaymentMethod.INTERNAL,
inputThirdParty: false,
outputCurrency: BrlaCurrency.BRLA,
outputPaymentMethod: AveniaPaymentMethod.MOONBEAM,
outputPaymentMethod: AveniaPaymentMethod.BASE,
outputThirdParty: false,
subAccountId: taxIdRecord.subAccountId
});
Expand All @@ -118,7 +118,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
quoteToken: aveniaQuote.quoteToken,
ticketBlockchainOutput: {
walletAddress: state.state.evmEphemeralAddress,
walletChain: AveniaPaymentMethod.MOONBEAM
walletChain: AveniaPaymentMethod.BASE
}
},
taxIdRecord.subAccountId
Expand All @@ -127,20 +127,24 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
const expectedAmountReceived = quote.metadata.aveniaTransfer?.outputAmountRaw;

logger.info(
`BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${quote.metadata.aveniaTransfer.outputAmountDecimal} BRLA to Moonbeam address ${state.state.evmEphemeralAddress}`
`BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${quote.metadata.aveniaTransfer.outputAmountDecimal} BRLA to Base address ${state.state.evmEphemeralAddress}`
);

try {
const pollingTimeMs = 1000;
const tokenDetails = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL);
const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA];

if (!tokenDetails) {
throw new Error("BRLA token details not found for Base network");
}

await checkEvmBalancePeriodically(
tokenDetails.moonbeamErc20Address,
tokenDetails.erc20AddressSourceChain,
evmEphemeralAddress,
expectedAmountReceived,
pollingTimeMs,
EVM_BALANCE_CHECK_TIMEOUT_MS,
Networks.Moonbeam
Networks.Base
);
} catch (error) {
if (!(error instanceof BalanceCheckError)) throw error;
Expand All @@ -153,7 +157,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {

throw isCheckTimeout
? this.createRecoverableError(`BrlaOnrampMintHandler: phase timeout reached with error: ${error}`)
: new Error(`Error checking Moonbeam balance: ${error}`);
: new Error(`Error checking Base balance: ${error}`);
}

return this.transitionToNextPhase(state, "fundEphemeral");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { PhaseError } from "../../../errors/phase-error";
import { BasePhaseHandler } from "../base-phase-handler";
import { StateMetadata } from "../meta-state-types";

export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler {
export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler {
public getPhaseName(): RampPhase {
return "brlaPayoutOnMoonbeam";
return "brlaPayoutOnBase";
}

protected async executePhase(state: RampState): Promise<RampState> {
Expand Down Expand Up @@ -179,4 +179,4 @@ export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler {
}
}

export default new BrlaPayoutOnMoonbeamPhaseHandler();
export default new BrlaPayoutOnBasePhaseHandler();
141 changes: 125 additions & 16 deletions apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import { ISubmittableResult } from "@polkadot/types/types";
import {
ApiManager,
decodeSubmittableExtrinsic,
EvmClientManager,
EvmNetworks,
isEvmTransactionData,
Networks,
RampDirection,
RampPhase,
TransactionTemporarilyBannedError
TransactionTemporarilyBannedError,
waitUntilTrueWithTimeout
} from "@vortexfi/shared";
import { privateKeyToAccount } from "viem/accounts";
import logger from "../../../../config/logger";
import { SUBSCAN_API_KEY } from "../../../../constants/constants";
import { MOONBEAM_FUNDING_PRIVATE_KEY, SUBSCAN_API_KEY } from "../../../../constants/constants";
import QuoteTicket from "../../../../models/quoteTicket.model";
import RampState from "../../../../models/rampState.model";
import { PhaseError } from "../../../errors/phase-error";
Expand All @@ -26,7 +32,7 @@ enum ExtrinsicStatus {
}

/**
* Handler for distributing Network, Vortex, and Partner fees using a stablecoin on Pendulum
* Handler for distributing Network, Vortex, and Partner fees using a stablecoin on Pendulum or EVM chains
*/
export class DistributeFeesHandler extends BasePhaseHandler {
private apiManager: ApiManager;
Expand Down Expand Up @@ -60,18 +66,34 @@ export class DistributeFeesHandler extends BasePhaseHandler {
// Check if we already have a hash stored
const existingHash = state.state.distributeFeeHash || null;

// For BRL onramp flows, distributio happens on EVM (Base).
const isEvmTransaction = quote.inputCurrency === "BRL" || quote.outputCurrency === "BRL";

if (existingHash) {
logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`);

const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => {
throw this.createRecoverableError("Failed to check extrinsic status");
});
if (isEvmTransaction) {
const status = await this.checkEvmTransactionStatus(existingHash).catch((_: unknown) => {
throw this.createRecoverableError("Failed to check EVM transaction status");
});

if (status === ExtrinsicStatus.Success) {
logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`);
return this.transitionToNextPhase(state, nextPhase);
if (status === ExtrinsicStatus.Success) {
logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`);
return this.transitionToNextPhase(state, nextPhase);
} else {
logger.info(`Existing distribute fee EVM transaction was not successful (status: ${status}), will retry`);
}
} else {
logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`);
const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => {
throw this.createRecoverableError("Failed to check extrinsic status");
});

if (status === ExtrinsicStatus.Success) {
logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`);
return this.transitionToNextPhase(state, nextPhase);
} else {
logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`);
}
}
}

Expand All @@ -83,12 +105,21 @@ export class DistributeFeesHandler extends BasePhaseHandler {
return this.transitionToNextPhase(state, nextPhase);
}

const { api } = await this.apiManager.getApi("pendulum");
let actualTxHash: string;

const decodedTx = decodeSubmittableExtrinsic(distributeFeeTransaction.txData as string, api);
if (isEvmTransaction) {
logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`);
actualTxHash = await this.submitEvmTransaction(
distributeFeeTransaction.txData,
distributeFeeTransaction.network as EvmNetworks
);
} else {
const { api } = await this.apiManager.getApi("pendulum");
const decodedTx = decodeSubmittableExtrinsic(distributeFeeTransaction.txData as string, api);

logger.info(`Submitting fee distribution transaction for ramp ${state.id}...`);
const actualTxHash = await this.submitTransaction(decodedTx, api);
logger.info(`Submitting substrate fee distribution transaction for ramp ${state.id}...`);
actualTxHash = await this.submitTransaction(decodedTx, api);
}

logger.info(`Transaction broadcast with hash ${actualTxHash}. Persisting hash...`);

Expand All @@ -100,8 +131,12 @@ export class DistributeFeesHandler extends BasePhaseHandler {
}
});

// Wait for extrinsic success using Subscan API
await this.waitForExtrinsicSuccess(actualTxHash);
// Wait for transaction success
if (isEvmTransaction) {
await this.waitForEvmTransactionSuccess(actualTxHash, distributeFeeTransaction.network as EvmNetworks);
} else {
await this.waitForExtrinsicSuccess(actualTxHash);
}

logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`);
return this.transitionToNextPhase(updatedState, nextPhase);
Expand Down Expand Up @@ -279,6 +314,80 @@ export class DistributeFeesHandler extends BasePhaseHandler {
throw error;
}
}

/**
* Submit an EVM transaction
* @param txData The EVM transaction data
* @param network The EVM network
* @returns The transaction hash
*/
private async submitEvmTransaction(txData: any, network: EvmNetworks): Promise<string> {
logger.debug(`Submitting EVM transaction to ${network} for ${this.getPhaseName()} phase`);

const evmClientManager = EvmClientManager.getInstance();
const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`);

return await evmClientManager.sendTransactionWithBlindRetry(network, fundingAccount, {
data: txData.data as `0x${string}`,
gas: BigInt(txData.gas || "100000"),
maxFeePerGas: txData.maxFeePerGas ? BigInt(txData.maxFeePerGas) : undefined,
maxPriorityFeePerGas: txData.maxPriorityFeePerGas ? BigInt(txData.maxPriorityFeePerGas) : undefined,
to: txData.to as `0x${string}`,
value: BigInt(txData.value || "0")
});
}

/**
* Wait for EVM transaction success
* @param txHash The transaction hash
* @param network The EVM network
*/
private async waitForEvmTransactionSuccess(txHash: string, network: EvmNetworks): Promise<void> {
const evmClientManager = EvmClientManager.getInstance();
const publicClient = evmClientManager.getClient(network);

await waitUntilTrueWithTimeout(
async () => {
try {
const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` });
return receipt?.status === "success";
} catch (error) {
logger.debug(`Error checking EVM transaction receipt: ${error}`);
return false;
}
},
2000, // check every 2 seconds
180000 // timeout after 3 minutes
);
}

/**
* Check EVM transaction status
* @param txHash The transaction hash
* @returns ExtrinsicStatus: Success, Fail, or Undefined
*/
private async checkEvmTransactionStatus(txHash: string): Promise<ExtrinsicStatus> {
try {
const evmClientManager = EvmClientManager.getInstance();
// Always on Base for EVM.
const publicClient = evmClientManager.getClient(Networks.Base);

const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` });

if (receipt) {
if (receipt.status === "success") {
return ExtrinsicStatus.Success;
} else {
return ExtrinsicStatus.Fail;
}
}

return ExtrinsicStatus.Undefined;
} catch (error: unknown) {
logger.error(`Error checking EVM transaction status: ${error}`);
return ExtrinsicStatus.Undefined;
}
}
}

export default new DistributeFeesHandler();
Loading
Loading