Skip to content

Commit

Permalink
Merge pull request #349 from pendulum-chain/account-for-min-balance-a…
Browse files Browse the repository at this point in the history
…ssethub

Account for minimum balance when off-ramping from AssetHub.
  • Loading branch information
gianfra-t authored Jan 17, 2025
2 parents 062a8df + 129d362 commit ec8c1cb
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 5 deletions.
7 changes: 6 additions & 1 deletion src/hooks/useInputTokenBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
106 changes: 106 additions & 0 deletions src/services/phases/polkadot/__tests__/eventsListener.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
15 changes: 13 additions & 2 deletions src/services/phases/polkadot/assethub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -33,15 +34,17 @@ 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');
}
if (!assetHubNode) {
throw new Error('AssetHub node not available');
}

setOfframpSigningPhase?.('started');

const didInputTokenArrivedOnPendulum = async () => {
const inputBalanceRaw = await getRawInputBalance(state, context);
return inputBalanceRaw.gt(Big(0));
Expand All @@ -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() };
}

Expand Down
49 changes: 47 additions & 2 deletions src/services/phases/polkadot/eventListener.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { ApiPromise } from '@polkadot/api';

import { parseEventRedeemExecution } from './eventParsers';
import { parseEventRedeemExecution, parseEventXcmSent } from './eventParsers';

interface IPendingEvent {
filter: any;
Expand All @@ -13,8 +13,11 @@ interface IPendingEvent {
export class EventListener {
static eventListeners = new Map<ApiPromise, EventListener>();

private unsubscribeHandle: (() => void) | null = null;

pendingIssueEvents: IPendingEvent[] = [];
pendingRedeemEvents: IPendingEvent[] = [];
pendingXcmSentEvents: IPendingEvent[] = [];

api: ApiPromise | undefined = undefined;

Expand All @@ -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);
});
});
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
}
9 changes: 9 additions & 0 deletions src/services/phases/polkadot/eventParsers.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit ec8c1cb

Please sign in to comment.