From 9c21436b8c7f51b396842ad32e009e191c5378ee Mon Sep 17 00:00:00 2001 From: Tobias Date: Wed, 24 Jun 2026 11:24:07 -0400 Subject: [PATCH] Show token issue actions in account history --- src/api/hyperion.ts | 140 +++++++++++++++++++++++ src/api/index.ts | 2 + src/components/TransactionsTable.vue | 2 +- src/types/Api.ts | 1 + test/jest/__tests__/api/hyperion.spec.ts | 136 +++++++++++++++++++++- 5 files changed, 277 insertions(+), 4 deletions(-) diff --git a/src/api/hyperion.ts b/src/api/hyperion.ts index ab1e4aa8..b5beaba8 100644 --- a/src/api/hyperion.ts +++ b/src/api/hyperion.ts @@ -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; /** @@ -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(); + 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 => { + try { + const response = await getTransactions(filter); + return response.data.actions || []; + } catch (e) { + console.error(e); + return []; + } +}; + +const getHeldTokenContracts = async (account: string, tokenContract?: string): Promise => { + 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 { + 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[] = []; + + 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 { diff --git a/src/api/index.ts b/src/api/index.ts index 990bffd0..71033097 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -9,6 +9,7 @@ import { getCreator, getTokens, getTransactions, + getAccountTransactions, getTransaction, getTransactionV1, getChildren, @@ -42,6 +43,7 @@ export const api = { getCreator, getTokens, getTransactions, + getAccountTransactions, getTransaction, getChildren, getTableRows, diff --git a/src/components/TransactionsTable.vue b/src/components/TransactionsTable.vue index d83e682c..3fdb2d01 100644 --- a/src/components/TransactionsTable.vue +++ b/src/components/TransactionsTable.vue @@ -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, diff --git a/src/types/Api.ts b/src/types/Api.ts index 50858ac8..67afa3d1 100644 --- a/src/types/Api.ts +++ b/src/types/Api.ts @@ -103,6 +103,7 @@ export type ApiClient = { getCreator: (address: string) => Promise; getTokens: (address: string) => Promise; getTransactions: (filter: HyperionTransactionFilter) => Promise; + getAccountTransactions: (filter: HyperionTransactionFilter) => Promise; getTransaction: (address: string) => Promise; getChildren: (address: string) => Promise; getTableRows: (tableInput: GetTableRowsParams) => Promise; diff --git a/test/jest/__tests__/api/hyperion.spec.ts b/test/jest/__tests__/api/hyperion.spec.ts index d9552ecd..2077150d 100644 --- a/test/jest/__tests__/api/hyperion.spec.ts +++ b/test/jest/__tests__/api/hyperion.spec.ts @@ -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 @@ -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 | undefined; + return params?.[key] || ''; +}; + const mockHyperion: AxiosInstance = { interceptors: { request: { @@ -124,10 +202,44 @@ const mockHyperion: AxiosInstance = { use: jest.fn(), } as unknown as AxiosInterceptorManager>, }, - 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; @@ -135,8 +247,9 @@ const mockHyperion: AxiosInstance = { import { getTokens, DEFAULT_ICON, + getAccountTransactions, } from 'src/api/hyperion'; -import { Token } from 'src/types'; +import { Action, Token } from 'src/types'; describe('Hyperion API', () => { @@ -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'); });