Skip to content
Open
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
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