diff --git a/src/hooks/useInputTokenBalance.ts b/src/hooks/useInputTokenBalance.ts index 959d244f..9797e3db 100644 --- a/src/hooks/useInputTokenBalance.ts +++ b/src/hooks/useInputTokenBalance.ts @@ -46,10 +46,15 @@ const useAssetHubBalance = (assetId?: number): string | undefined => { const getBalance = async () => { try { const { api } = assetHubNode; + const assetInfo = await api.query.assets.asset(assetId); + const { minBalance: rawMinBalance } = assetInfo.toJSON() as { minBalance: number }; + const accountInfo = await api.query.assets.account(assetId, walletAccount.address); const rawBalance = (accountInfo.toJSON() as { balance?: number })?.balance ?? 0; - const formattedBalance = nativeToDecimal(rawBalance, USDC_DECIMALS).toFixed(2, 0).toString(); + + const offrampableBalance = rawBalance > 0 ? rawBalance - rawMinBalance : 0; + const formattedBalance = nativeToDecimal(offrampableBalance, USDC_DECIMALS).toFixed(2, 0).toString(); setBalance(formattedBalance); } catch (error) { console.error('Failed to fetch AssetHub balance:', error); diff --git a/src/services/phases/polkadot/__tests__/eventsListener.test.tsx b/src/services/phases/polkadot/__tests__/eventsListener.test.tsx new file mode 100644 index 00000000..8836d986 --- /dev/null +++ b/src/services/phases/polkadot/__tests__/eventsListener.test.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { ApiPromise, WsProvider } from '@polkadot/api'; +import { EventListener } from '../eventListener'; +import { ASSETHUB_WSS, PENDULUM_WSS } from '../../../../constants/constants'; +import { hexToString, stellarHexToPublic } from '../convert'; + +export class TestableEventListener extends EventListener { + constructor(api: ApiPromise) { + super(api); + // We DO NOT WANT to actually subscribe, for testing. + this.unsubscribe(); + } + + // Analogous to what we would do in the callback of the subscription + processEventsForTest(events: any[]) { + events.forEach((event) => { + this.processEvents(event, this.pendingIssueEvents); + this.processEvents(event, this.pendingRedeemEvents); + this.processEvents(event, this.pendingXcmSentEvents); + }); + } +} + +async function getEventsFromBlock(api: ApiPromise, blockHash: string) { + const at = await api.at(blockHash); + const events = await at.query.system.events(); + return events; +} +// Tests for EventListener's filters and parseEvent functions, specifically: Redeem.ExecuteRedeem and PolkadotXcm.Sent events. +// Request redeem event parser is tested in spacewalk.test.tsx. +describe('EventListener Tests', () => { + it('should detect successful polkadotXcm.Sent event', async () => { + const XCM_SENT_EVENT_BLOCK_HASH = '0xbac62e758e09f7e51fae2c74a8766c7e5e57a224d4a9ca8828e782ed9754340e'; + const ORIGIN_XCM_ACCOUNT = '5DqTNJsGp6UayR5iHAZvH4zquY6ni6j35ZXLtJA6bXwsfixg'; + + const provider = new WsProvider(ASSETHUB_WSS); + const api = await ApiPromise.create({ provider }); + + const events = await getEventsFromBlock(api, XCM_SENT_EVENT_BLOCK_HASH); + + const listener = new TestableEventListener(api); + + const promise = listener.waitForXcmSentEvent(ORIGIN_XCM_ACCOUNT, 50000000); // We're not testing for timeout, so we set a high value. + + // Bypass subscription and directly process the events + listener.processEventsForTest(events); + + await expect(promise).resolves.toMatchObject({ + originAddress: ORIGIN_XCM_ACCOUNT, + }); + }); + + it('should detect successful ExecuteRedeem Event', async () => { + const EXECUTE_REDEEM_EVENT_BLOCK_HASH = '0x8c8dc97201be2fdc3aa050218a866e809aa0f2770a5e6dc413e41966c37d493a'; + const REDEEM_ID = '0xa6c042f8816aaddd148fb2d24176312ca9a65bb331617fdfd33f8573a20e921e'; + const REDEEMER = '6g7GLX4eBUCswt8ZaU3qkwntcu1NxkALZbyB4t1oU2WeKDFk'; + const VAULT_ID = { + accountId: '6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs', + currencies: { + collateral: { + XCM: 10, + }, + wrapped: { + Stellar: { + AlphaNum4: { + code: hexToString('0x41525300'), + issuer: stellarHexToPublic('0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1'), + }, + }, + }, + }, + }; + const AMOUNT = 538780000000000; + const ASSET = { + Stellar: { + AlphaNum4: { + code: hexToString('0x41525300'), + issuer: stellarHexToPublic('0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1'), + }, + }, + }; + const FEE = 0; + const TRANSFER_FEE = 0; + + const provider = new WsProvider(PENDULUM_WSS); + const api = await ApiPromise.create({ provider }); + + const events = await getEventsFromBlock(api, EXECUTE_REDEEM_EVENT_BLOCK_HASH); + + const listener = new TestableEventListener(api); + + const promise = listener.waitForRedeemExecuteEvent(REDEEM_ID, 50000000); + + listener.processEventsForTest(events); + await expect(promise).resolves.toMatchObject({ + redeemId: REDEEM_ID, + redeemer: REDEEMER, + vaultId: VAULT_ID, + amount: AMOUNT, + asset: ASSET, + fee: FEE, + transferFee: TRANSFER_FEE, + }); + }); +}); diff --git a/src/services/phases/polkadot/assethub.ts b/src/services/phases/polkadot/assethub.ts index 8232160c..7b9a78b1 100644 --- a/src/services/phases/polkadot/assethub.ts +++ b/src/services/phases/polkadot/assethub.ts @@ -7,6 +7,7 @@ import Big from 'big.js'; import { ExecutionContext, OfframpingState } from '../../offrampingFlow'; import { waitUntilTrue } from '../../../helpers/function'; import { getRawInputBalance } from './ephemeral'; +import { EventListener } from './eventListener'; export function createAssethubAssetTransfer(assethubApi: ApiPromise, receiverAddress: string, rawAmount: string) { const receiverId = u8aToHex(decodeAddress(receiverAddress)); @@ -33,6 +34,10 @@ export async function executeAssetHubXCM(state: OfframpingState, context: Execut const { assetHubNode, walletAccount, setOfframpSigningPhase } = context; const { pendulumEphemeralAddress } = state; + // We wait for up to 1 minute. XCM event should appear on the same block. + const maxWaitingTimeMinutes = 1; + const maxWaitingTimeMs = maxWaitingTimeMinutes * 60 * 1000; + if (!walletAccount) { throw new Error('Wallet account not available'); } @@ -40,8 +45,6 @@ export async function executeAssetHubXCM(state: OfframpingState, context: Execut throw new Error('AssetHub node not available'); } - setOfframpSigningPhase?.('started'); - const didInputTokenArrivedOnPendulum = async () => { const inputBalanceRaw = await getRawInputBalance(state, context); return inputBalanceRaw.gt(Big(0)); @@ -53,8 +56,16 @@ export async function executeAssetHubXCM(state: OfframpingState, context: Execut if (assetHubXcmTransactionHash === undefined) { const tx = createAssethubAssetTransfer(assetHubNode.api, pendulumEphemeralAddress, inputAmount.raw); context.setOfframpSigningPhase('started'); + + const eventListener = EventListener.getEventListener(assetHubNode.api); + const xcmSentEventPromise = eventListener.waitForXcmSentEvent(walletAccount.address, maxWaitingTimeMs); + const { hash } = await tx.signAndSend(walletAccount.address, { signer: walletAccount.signer as Signer }); setOfframpSigningPhase?.('finished'); + + await xcmSentEventPromise; + eventListener.unsubscribe(); + return { ...state, assetHubXcmTransactionHash: hash.toString() }; } diff --git a/src/services/phases/polkadot/eventListener.tsx b/src/services/phases/polkadot/eventListener.tsx index 5c7066ee..e876f547 100644 --- a/src/services/phases/polkadot/eventListener.tsx +++ b/src/services/phases/polkadot/eventListener.tsx @@ -3,7 +3,7 @@ import { ApiPromise } from '@polkadot/api'; -import { parseEventRedeemExecution } from './eventParsers'; +import { parseEventRedeemExecution, parseEventXcmSent } from './eventParsers'; interface IPendingEvent { filter: any; @@ -13,8 +13,11 @@ interface IPendingEvent { export class EventListener { static eventListeners = new Map(); + private unsubscribeHandle: (() => void) | null = null; + pendingIssueEvents: IPendingEvent[] = []; pendingRedeemEvents: IPendingEvent[] = []; + pendingXcmSentEvents: IPendingEvent[] = []; api: ApiPromise | undefined = undefined; @@ -33,10 +36,11 @@ export class EventListener { } async initEventSubscriber() { - this.api!.query.system.events((events) => { + this.unsubscribeHandle = await this.api!.query.system.events((events) => { events.forEach((event) => { this.processEvents(event, this.pendingIssueEvents); this.processEvents(event, this.pendingRedeemEvents); + this.processEvents(event, this.pendingXcmSentEvents); }); }); } @@ -67,6 +71,32 @@ export class EventListener { }); } + waitForXcmSentEvent(originAddress: string, maxWaitingTimeMs: number) { + const filter = (event: any) => { + if (event.event.section === 'polkadotXcm' && event.event.method === 'Sent') { + const eventParsed = parseEventXcmSent(event); + if (eventParsed.originAddress == originAddress) { + return eventParsed; + } + } + return null; + }; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Max waiting time exceeded for XCM Sent event from origin: ${originAddress}`)); + }, maxWaitingTimeMs); + + this.pendingXcmSentEvents.push({ + filter, + resolve: (event) => { + clearTimeout(timeout); + resolve(event); + }, + }); + }); + } + processEvents(event: any, pendingEvents: IPendingEvent[]) { pendingEvents.forEach((pendingEvent, index) => { const matchedEvent = pendingEvent.filter(event); @@ -77,4 +107,19 @@ export class EventListener { } }); } + + unsubscribe() { + if (this.unsubscribeHandle) { + this.unsubscribeHandle(); + this.unsubscribeHandle = null; + } + + this.pendingIssueEvents = []; + this.pendingRedeemEvents = []; + this.pendingXcmSentEvents = []; + + EventListener.eventListeners.delete(this.api!); + + this.api = undefined; + } } diff --git a/src/services/phases/polkadot/eventParsers.tsx b/src/services/phases/polkadot/eventParsers.tsx index 861ed22d..f69b9e49 100644 --- a/src/services/phases/polkadot/eventParsers.tsx +++ b/src/services/phases/polkadot/eventParsers.tsx @@ -1,6 +1,7 @@ // @todo: remove no-explicit-any /* eslint-disable @typescript-eslint/no-explicit-any */ import Big from 'big.js'; +import { encodeAddress } from '@polkadot/util-crypto'; import { stellarHexToPublic, hexToString } from './convert'; @@ -54,6 +55,14 @@ export function parseEventRedeemExecution(event: any) { return mappedData; } +export function parseEventXcmSent(event: any) { + const rawEventData = JSON.parse(event.event.data.toString()); + const mappedData = { + originAddress: encodeAddress(rawEventData[0].interior.x1[0].accountId32.id.toString()), + }; + return mappedData; +} + function extractStellarAssetInfo(data: any) { if ('stellarNative' in data.stellar) { return {