Skip to content
Draft
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
140 changes: 140 additions & 0 deletions src/api/hyperion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const url =

const MAX_REQUESTS_COUNT = 5;
const INTERVAL_MS = 10;
const ACCOUNT_TOKEN_ISSUE_LIMIT = 100;
const MAX_ACCOUNT_TOKEN_ISSUE_CONTRACTS = 25;
let PENDING_REQUESTS = 0;

/**
Expand Down Expand Up @@ -160,6 +162,144 @@ export const getTransactions = async function (
});
};

const getActionIdentity = (action: Action): string => [
action.trx_id,
action.global_sequence,
action.action_ordinal,
].join(':');

const getActionTimestamp = (action: Action): number => (
new Date(action['@timestamp'] || action.timestamp || '').getTime()
);

const mergeActions = (actions: Action[], extras: Action[], sort: 'desc' | 'asc'): Action[] => {
const merged = new Map<string, Action>();
actions.concat(extras).forEach((action) => {
merged.set(getActionIdentity(action), action);
});

return Array.from(merged.values()).sort((a, b) => {
const diff = getActionTimestamp(a) - getActionTimestamp(b);
return sort === 'asc' ? diff : -diff;
});
};

const actionFilterAllows = (actionFilter: string, actionName: string): boolean => {
if (!actionFilter) {
return true;
}

const names = actionFilter.split(',').map(name => name.trim()).filter(Boolean);
const positiveNames = names.filter(name => !name.startsWith('!'));

if (positiveNames.length > 0) {
return positiveNames.includes(actionName);
}

return !names.includes('!' + actionName);
};

const getAccountActionExtras = (
filter: HyperionTransactionFilter,
extras: { [key: string]: string },
): HyperionTransactionFilter => ({
page: 1,
limit: Math.max(filter.limit || 10, ACCOUNT_TOKEN_ISSUE_LIMIT),
sort: filter.sort,
after: filter.after,
before: filter.before,
extras: {
...filter.extras,
...extras,
},
});

const getActionsSafely = async (filter: HyperionTransactionFilter): Promise<Action[]> => {
try {
const response = await getTransactions(filter);
return response.data.actions || [];
} catch (e) {
console.error(e);
return [];
}
};

const getHeldTokenContracts = async (account: string, tokenContract?: string): Promise<string[]> => {
if (tokenContract) {
return [tokenContract];
}

const tokens = await getTokens(account) || [];
return Array.from(new Set(tokens.map((token: Token) => token.contract)))
.filter(Boolean)
.slice(0, MAX_ACCOUNT_TOKEN_ISSUE_CONTRACTS);
};

const isIssueToAccount = (action: Action, account: string): boolean => {
const issueData = action.act?.data as { to?: string };
return action.act?.name === 'issue' && issueData?.to === account;
};

export const getAccountTransactions = async function (
filter: HyperionTransactionFilter,
): Promise<GetActionsResponse> {
const response = await getTransactions(filter);
const account = filter.account;

if (!account) {
return response;
}

const actionFilter = filter.extras?.['act.name'] || '';
const tokenContract = filter.extras?.['act.account'];
const supplementalPromises: Promise<Action[]>[] = [];

if (actionFilterAllows(actionFilter, 'transfer')) {
supplementalPromises.push(getActionsSafely(getAccountActionExtras(filter, {
'act.name': 'transfer',
'transfer.to': account,
})));
}

if (actionFilterAllows(actionFilter, 'issue')) {
const contracts = await getHeldTokenContracts(account, tokenContract);
supplementalPromises.push(...contracts.map(contract => (
getActionsSafely(getAccountActionExtras(filter, {
'act.account': contract,
'act.name': 'issue',
})).then(actions => actions.filter(action => isIssueToAccount(action, account)))
)));
}

if (supplementalPromises.length === 0) {
return response;
}

const supplementalActions = (await Promise.all(supplementalPromises)).reduce(
(allActions, actions) => allActions.concat(actions),
[] as Action[],
);
const mergedActions = mergeActions(
response.data.actions || [],
supplementalActions,
filter.sort || 'desc',
);
const limit = filter.limit || 10;
const uniqueExtrasCount = Math.max(0, mergedActions.length - (response.data.actions || []).length);

return {
...response,
data: {
...response.data,
actions: mergedActions.slice(0, limit),
total: {
...response.data.total,
value: response.data.total.value + uniqueExtrasCount,
},
},
};
};

export const getTransaction = async function (
address?: string,
): Promise<ActionData> {
Expand Down
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getCreator,
getTokens,
getTransactions,
getAccountTransactions,
getTransaction,
getTransactionV1,
getChildren,
Expand Down Expand Up @@ -42,6 +43,7 @@ export const api = {
getCreator,
getTokens,
getTransactions,
getAccountTransactions,
getTransaction,
getChildren,
getTableRows,
Expand Down
2 changes: 1 addition & 1 deletion src/components/TransactionsTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export default defineComponent({
if (tokenModel.value) {
limit = 100;
}
const response = await api.getTransactions({
const response = await api.getAccountTransactions({
page,
limit,
account: account.value,
Expand Down
1 change: 1 addition & 0 deletions src/types/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type ApiClient = {
getCreator: (address: string) => Promise<any>;
getTokens: (address: string) => Promise<Token[]>;
getTransactions: (filter: HyperionTransactionFilter) => Promise<GetActionsResponse>;
getAccountTransactions: (filter: HyperionTransactionFilter) => Promise<GetActionsResponse>;
getTransaction: (address: string) => Promise<ActionData>;
getChildren: (address: string) => Promise<Action[]>;
getTableRows: (tableInput: GetTableRowsParams) => Promise<unknown>;
Expand Down
136 changes: 133 additions & 3 deletions test/jest/__tests__/api/hyperion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,76 @@ const userTokens = {
],
};

const makeAction = (
trxId: string,
account: string,
name: string,
data: unknown,
timestamp: string,
globalSequence: number,
): Action => ({
'@timestamp': timestamp,
account_ram_deltas: [],
act: {
account,
authorization: [],
data,
name,
},
action_ordinal: 1,
block_num: 1,
cpu_usage_us: 0,
creator_action_ordinal: 0,
global_sequence: globalSequence,
net_usage_words: 0,
notified: [],
producer: '',
signatures: [],
timestamp,
trx_id: trxId,
receipts: [],
account,
authorization: [],
data,
name,
});

const accountAction = makeAction(
'base-trx',
'eosio.token',
'transfer',
{ from: 'someaccount', to: 'otheraccount', quantity: '1.0000 TLOS' },
'2026-06-24T15:00:00.000',
1,
);

const incomingTransferAction = makeAction(
'incoming-transfer-trx',
'eosio.token',
'transfer',
{ from: 'otheraccount', to: 'someaccount', quantity: '2.0000 TLOS' },
'2026-06-24T14:50:00.000',
2,
);

const issueToAccountAction = makeAction(
'issue-trx',
'vapaeetokens',
'issue',
{ to: 'someaccount', quantity: '3.0000 CNT', memo: 'bridge mint' },
'2026-06-24T15:10:00.000',
3,
);

const issueToOtherAccountAction = makeAction(
'issue-other-trx',
'vapaeetokens',
'issue',
{ to: 'otheraccount', quantity: '3.0000 CNT', memo: 'bridge mint' },
'2026-06-24T15:20:00.000',
4,
);

installQuasarPlugin();

// mocking internal chain implementation
Expand Down Expand Up @@ -112,9 +182,17 @@ global.AbortController = jest.fn(() => ({

jest.mock('axios', () => ({
create: () => mockHyperion,
get: jest.fn().mockImplementation(url => Promise.resolve({ data: tokenList })),
get: jest.fn().mockImplementation(() => Promise.resolve({ data: tokenList })),
}));

const getParam = (
config: AxiosRequestConfig | undefined,
key: string,
): string => {
const params = config?.params as Record<string, string> | undefined;
return params?.[key] || '';
};

const mockHyperion: AxiosInstance = {
interceptors: {
request: {
Expand All @@ -124,19 +202,54 @@ const mockHyperion: AxiosInstance = {
use: jest.fn(),
} as unknown as AxiosInterceptorManager<AxiosResponse<unknown>>,
},
get: jest.fn().mockImplementation((url) => {
get: jest.fn().mockImplementation((...args: unknown[]) => {
const url = args[0] as string;
const config = args[1] as AxiosRequestConfig | undefined;
if(url === 'v2/state/get_tokens') {
return Promise.resolve({ data: userTokens });
}
if(url === 'v2/history/get_actions') {
if(getParam(config, 'account') === 'someaccount') {
return Promise.resolve({
data: {
actions: [accountAction],
total: { value: 1 },
},
});
}
if(getParam(config, 'transfer.to') === 'someaccount') {
return Promise.resolve({
data: {
actions: [incomingTransferAction],
total: { value: 1 },
},
});
}
if(getParam(config, 'act.account') === 'vapaeetokens' && getParam(config, 'act.name') === 'issue') {
return Promise.resolve({
data: {
actions: [issueToAccountAction, issueToOtherAccountAction],
total: { value: 2 },
},
});
}
return Promise.resolve({
data: {
actions: [],
total: { value: 0 },
},
});
}
}),
} as unknown as AxiosInstance;


import {
getTokens,
DEFAULT_ICON,
getAccountTransactions,
} from 'src/api/hyperion';
import { Token } from 'src/types';
import { Action, Token } from 'src/types';


describe('Hyperion API', () => {
Expand All @@ -163,6 +276,23 @@ describe('Hyperion API', () => {
});
});

describe('getAccountTransactions()', () => {
it('includes incoming transfers and token issues where the account is only the action data recipient', async () => {
const response = await getAccountTransactions({
account: 'someaccount',
limit: 10,
sort: 'desc',
});

expect(response.data.actions.map(action => action.trx_id)).toEqual([
'issue-trx',
'base-trx',
'incoming-transfer-trx',
]);
expect(response.data.total.value).toBe(3);
});
});

afterAll(() => {
jest.unmock('axios');
});
Expand Down
Loading