From fe81d3fc8d00b80772a1175054fbc4ea9a0c1b81 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Nov 2024 23:56:08 +0200 Subject: [PATCH 1/2] TW-1587: [research] Activity History on OKLink. With 'fill' --- .../templates/activity/EvmActivityList.tsx | 27 +- src/app/templates/activity/loading-logic.ts | 1 + src/lib/activity/evm/fetch.ts | 14 + src/lib/activity/evm/parse/oklink.ts | 198 +++++++++++++ src/lib/activity/types.ts | 1 + src/lib/apis/oklink/index.ts | 262 ++++++++++++++++++ 6 files changed, 483 insertions(+), 20 deletions(-) create mode 100644 src/lib/activity/evm/parse/oklink.ts create mode 100644 src/lib/apis/oklink/index.ts diff --git a/src/app/templates/activity/EvmActivityList.tsx b/src/app/templates/activity/EvmActivityList.tsx index 74f3b1c5f..bd9ba72e4 100644 --- a/src/app/templates/activity/EvmActivityList.tsx +++ b/src/app/templates/activity/EvmActivityList.tsx @@ -1,12 +1,9 @@ import React, { FC, useMemo } from 'react'; -import { AxiosError } from 'axios'; - import { DeadEndBoundaryError } from 'app/ErrorBoundary'; import { useLoadPartnersPromo } from 'app/hooks/use-load-partners-promo'; import { EvmActivity } from 'lib/activity'; -import { getEvmAssetTransactions } from 'lib/activity/evm'; -import { useSafeState } from 'lib/ui/hooks'; +import { getEvmActivities } from 'lib/activity/evm/fetch'; import { useAccountAddressForEvm } from 'temple/front'; import { useEvmChainByChainId } from 'temple/front/chains'; @@ -30,43 +27,33 @@ export const EvmActivityList: FC = ({ chainId, assetSlug, filterKind }) = useLoadPartnersPromo(); - const [nextPage, setNextPage] = useSafeState(undefined); - const { activities, isLoading, reachedTheEnd, setActivities, setIsLoading, setReachedTheEnd, loadNext } = useActivitiesLoadingLogic( async (initial, signal) => { - const page = initial ? undefined : nextPage; - if (page === null) return; + if (reachedTheEnd) return; setIsLoading(true); const currActivities = initial ? [] : activities; try { - const { activities: newActivities, nextPage: newNextPage } = await getEvmAssetTransactions( - accountAddress, - chainId, - assetSlug, - page, - signal - ); + const olderThanBlockHeight = activities.at(activities.length - 1)?.blockHeight; + + const newActivities = await getEvmActivities(accountAddress, chainId, olderThanBlockHeight); if (signal.aborted) return; setActivities(currActivities.concat(newActivities)); - setNextPage(newNextPage); - if (newNextPage === null || newActivities.length === 0) setReachedTheEnd(true); + if (newActivities.length === 0) setReachedTheEnd(true); } catch (error) { if (signal.aborted) return; console.error(error); - if (error instanceof AxiosError && error.status === 501) setReachedTheEnd(true); } setIsLoading(false); }, - [chainId, accountAddress, assetSlug], - () => setNextPage(undefined) + [chainId, accountAddress, assetSlug] ); const displayActivities = useMemo( diff --git a/src/app/templates/activity/loading-logic.ts b/src/app/templates/activity/loading-logic.ts index f73291051..51007f342 100644 --- a/src/app/templates/activity/loading-logic.ts +++ b/src/app/templates/activity/loading-logic.ts @@ -4,6 +4,7 @@ import { useWillUnmount } from 'lib/ui/hooks/useWillUnmount'; export function useActivitiesLoadingLogic( loadActivities: (initial: boolean, signal: AbortSignal) => Promise, resetDeps: unknown[], + /** @deprecated - Not used ? */ onReset?: EmptyFn, initialIsLoading = true ) { diff --git a/src/lib/activity/evm/fetch.ts b/src/lib/activity/evm/fetch.ts index b8c0afae3..cabcf243e 100644 --- a/src/lib/activity/evm/fetch.ts +++ b/src/lib/activity/evm/fetch.ts @@ -1,3 +1,4 @@ +import { fetchOklinkTransactions } from 'lib/apis/oklink'; import { getEvmERC20Transfers, getEvmTransactions } from 'lib/apis/temple/endpoints/evm'; import { fromAssetSlug } from 'lib/assets'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; @@ -5,6 +6,7 @@ import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; import { EvmActivity } from '../types'; import { parseGoldRushTransaction, parseGoldRushERC20Transfer } from './parse'; +import { parseOklinkTransaction } from './parse/oklink'; export async function getEvmAssetTransactions( walletAddress: string, @@ -57,3 +59,15 @@ export async function getEvmAssetTransactions( nextPage }; } + +export async function getEvmActivities( + walletAddress: string, + chainId: number, + // assetSlug?: string, + olderThanBlockHeight?: `${number}`, + signal?: AbortSignal +) { + const items = await fetchOklinkTransactions(walletAddress, chainId, olderThanBlockHeight, signal); + + return items.map(item => parseOklinkTransaction(item, chainId, walletAddress)); +} diff --git a/src/lib/activity/evm/parse/oklink.ts b/src/lib/activity/evm/parse/oklink.ts new file mode 100644 index 000000000..3f75418ae --- /dev/null +++ b/src/lib/activity/evm/parse/oklink.ts @@ -0,0 +1,198 @@ +import { + ActivityOperKindEnum, + ActivityOperTransferType, + ActivityStatus, + EvmActivity, + EvmActivityAsset, + EvmOperation +} from 'lib/activity/types'; +import { TransactionFillsResponseDataItem } from 'lib/apis/oklink'; +import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { TempleChainKind } from 'temple/types'; + +export function parseOklinkTransaction( + item: TransactionFillsResponseDataItem, + chainId: number, + accountAddress: string +): EvmActivity { + const accountAddressLowerCased = accountAddress.toLowerCase(); + + const status = + item.state === 'success' + ? ActivityStatus.applied + : item.state === 'pending' + ? ActivityStatus.pending + : ActivityStatus.failed; + + const operations = item.tokenTransferDetails.map(transfer => + parseTransferDetails(transfer, accountAddressLowerCased, item) + ); + + const gasOperation = parseForGasOperation(item, accountAddressLowerCased); + + const rootOperation = parseForRootOperation(item, accountAddressLowerCased); + + if (rootOperation) operations.unshift(rootOperation); + if (gasOperation) operations.unshift(gasOperation); + + const operationsCount = + item.tokenTransferDetails.length + + // + item.contractDetails.length // Not length, subset of it + (gasOperation ? 1 : 0) + + (rootOperation ? 1 : 0); + + return { + chain: TempleChainKind.EVM, + chainId, + hash: item.txid, + operations, + operationsCount, + status, + addedAt: new Date(Number(item.transactionTime)).toISOString(), + blockHeight: item.height + }; +} + +function parseTransferDetails( + item: TransactionFillsResponseDataItem['tokenTransferDetails'][number], + accountAddress: string, + fillsRes: TransactionFillsResponseDataItem +): EvmOperation { + const fromAddress = item.from; + const toAddress = item.to; + + const type = (() => { + if (fromAddress === accountAddress) + return item.isToContract ? ActivityOperTransferType.send : ActivityOperTransferType.sendToAccount; + if (toAddress === accountAddress) + return item.isFromContract ? ActivityOperTransferType.receive : ActivityOperTransferType.receiveFromAccount; + + return null; + })(); + + if (type == null) + return { + kind: ActivityOperKindEnum.interaction, + withAddress: fillsRes.outputDetails[0].outputHash + // withAddress: item.tokenContractAddress + }; + + const { amount, tokenId, symbol } = item; + + const amountSigned = + type === ActivityOperTransferType.send || type === ActivityOperTransferType.sendToAccount ? `-${amount}` : amount; + + const asset: EvmActivityAsset = { + contract: item.tokenContractAddress, + tokenId, + amountSigned, + // decimals, + symbol + // nft + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset + }; +} + +function parseForRootOperation(item: TransactionFillsResponseDataItem, accountAddress: string): EvmOperation | null { + if (item.tokenTransferDetails.length || item.contractDetails.length) return null; + + if (item.methodId === KnownMethodsEnum.ApprovalForAll) { + if (item.inputDetails[0].inputHash !== accountAddress) return null; + + const kind = ActivityOperKindEnum.approve; + + const contractAddress = item.outputDetails[0].outputHash; + + const spenderAddress = item.inputData.match(ApprovalForAllMethodRegEx)?.[1]; + if (!spenderAddress) return null; + + const asset: EvmActivityAsset = { + contract: contractAddress, + amountSigned: null, + // decimals: NaN, // We are not supposed to use these in this case (of 'Unlimited' amount) + // symbol, + nft: true + // iconURL + }; + + return { kind, spenderAddress: `0x${spenderAddress}`, asset }; + } + + /* + if (item.methodId === KnownMethodsEnum.Approval) { + if (item.inputDetails[0].inputHash !== accountAddress) return null; + + const kind = ActivityOperKindEnum.approve; + + const contractAddress = item.outputDetails[0].outputHash; + + const spenderAddress = item.inputData.match(ApprovalMethodRegEx)?.[1]; + if (!spenderAddress) return null; + + const asset: EvmActivityAsset = { + contract: contractAddress, + tokenId, + amountSigned, + // decimals, + // symbol, + nft, + // iconURL + }; + + return { kind, spenderAddress: `0x${spenderAddress}`, asset }; + } + */ + + return null; +} + +enum KnownMethodsEnum { + Approval = '0x095ea7b3', + ApprovalForAll = '0xa22cb465' +} + +const ApprovalMethodRegEx = new RegExp(`${KnownMethodsEnum.Approval}${'0'.repeat(24)}(.{40})`); +const ApprovalForAllMethodRegEx = new RegExp(`${KnownMethodsEnum.ApprovalForAll}${'0'.repeat(24)}(.{40})`); + +function parseForGasOperation(item: TransactionFillsResponseDataItem, accountAddress: string): EvmOperation | null { + const amount = item.amount; + if (amount === '0') return null; + + const inputDetails = item.inputDetails[0]; + const outputDetails = item.outputDetails[0]; + + const fromAddress = inputDetails.inputHash; + const toAddress = outputDetails.outputHash; + + const type = (() => { + if (fromAddress === accountAddress) + return outputDetails.isContract ? ActivityOperTransferType.send : ActivityOperTransferType.sendToAccount; + if (toAddress === accountAddress) + return inputDetails.isContract ? ActivityOperTransferType.receive : ActivityOperTransferType.receiveFromAccount; + + return null; + })(); + + if (type == null) return null; + + const kind = ActivityOperKindEnum.transfer; + + const amountSigned = + type === ActivityOperTransferType.send || type === ActivityOperTransferType.sendToAccount ? `-${amount}` : amount; + + const asset: EvmActivityAsset = { + contract: EVM_TOKEN_SLUG, + amountSigned, + // decimals, + symbol: item.transactionSymbol + }; + + return { kind, type, fromAddress, toAddress, asset }; +} diff --git a/src/lib/activity/types.ts b/src/lib/activity/types.ts index 7e9200239..2390dad9e 100644 --- a/src/lib/activity/types.ts +++ b/src/lib/activity/types.ts @@ -73,6 +73,7 @@ export interface EvmActivity extends ChainActivityBase { chainId: number; blockExplorerUrl?: string; operations: EvmOperation[]; + blockHeight: `${number}`; } interface EvmOperationBase extends OperationBase { diff --git a/src/lib/apis/oklink/index.ts b/src/lib/apis/oklink/index.ts new file mode 100644 index 000000000..e76d35c5c --- /dev/null +++ b/src/lib/apis/oklink/index.ts @@ -0,0 +1,262 @@ +import axios from 'axios'; + +import { filterUnique } from 'lib/utils'; + +/** Maximum allowed for the endpoint */ +const MAX_HASHES_FOR_FILLS = 20; + +export async function fetchOklinkTransactions( + address: string, + chainId: number, + olderThanBlockHeight?: `${number}`, + // newerThanBlockHeight?: string, + signal?: AbortSignal +) { + const chainShortName = CHAINS[chainId]; + if (!chainShortName) return []; + + const endBlockHeight = olderThanBlockHeight ? String(Number(olderThanBlockHeight) - 1) : undefined; + + const [data1, data2, data3, data4, data5] = await Promise.all([ + makeTransactionsRequest(address, chainShortName, 'transaction', endBlockHeight, signal), + makeTransactionsRequest(address, chainShortName, 'token_20', endBlockHeight, signal), + makeTransactionsRequest(address, chainShortName, 'token_721', endBlockHeight, signal), + makeTransactionsRequest(address, chainShortName, 'token_1155', endBlockHeight, signal), + makeTransactionsRequest(address, chainShortName, 'token_10', endBlockHeight, signal) + ]); + + const allItems = [ + ...data1.transactionLists, + ...data2.transactionLists, + ...data3.transactionLists, + ...data4.transactionLists, + ...data5.transactionLists + ].sort((a, b) => (a.transactionTime > b.transactionTime ? -1 : 1)); + + const uniqHashes = filterUnique(allItems.map(item => item.txId)).slice(0, MAX_HASHES_FOR_FILLS); + + await new Promise(r => void setTimeout(r, 1_000)); + + const fillsRes = await makeTransactionsFillsRequest(address, chainShortName, uniqHashes); + + return fillsRes.sort((a, b) => (a.transactionTime > b.transactionTime ? -1 : 1)); +} + +/** See: https://www.oklink.com/docs/en/#fundamental-blockchain-data-address-data-get-address-transaction-list */ +async function makeTransactionsRequest( + address: string, + chainShortName: string, + /** Defaults to 'transaction' */ + protocolType?: ProtocolType, + // startBlockHeight?: string, + endBlockHeight?: string, + signal?: AbortSignal +) { + const data = await axios + .get>('explorer/address/transaction-list', { + baseURL: 'https://www.oklink.com/api/v5', + headers: { + 'Ok-Access-Key': '' + }, + params: { + chainShortName, + address, + limit: 50, // Maximum allowed + // page: '1', + protocolType, + // startBlockHeight, + endBlockHeight + }, + signal + }) + .then(response => { + if (response.data.code !== '0') throw new Error(response.data.msg); + + return response.data.data[0]; + }); + + return data; +} + +interface OklinkResponse { + code: string; + msg: string; + data: T; +} + +type ProtocolType = 'transaction' | 'token_20' | 'token_721' | 'token_1155' | 'token_10'; + +interface TransactionListResponseData { + /** number */ + page: string; + /** number */ + limit: string; + /** number */ + totalPage: string; + chainFullName: string; + chainShortName: string; + transactionLists: OklinkTransaction[]; +} + +export interface OklinkTransaction { + txId: HexString; + /** + * 0xa22cb465 = 'ApprovalForAll' + */ + methodId: '' | HexString; + blockHash: HexString; + /** number */ + height: string; + /** number - UNIX time */ + transactionTime: string; + from: HexString; + to: HexString; + isFromContract: boolean; + isToContract: boolean; + /** Decimal number */ + amount: string; + transactionSymbol: string; + /** Decimal number */ + txFee: string; + state: 'success' | 'fail' | 'pending'; + tokenContractAddress: '' | HexString; + tokenId: ''; + challengeStatus: ''; + l1OriginHash: ''; +} + +/** See: https://www.oklink.com/docs/en/#fundamental-blockchain-data-address-data-get-address-transaction-list */ +async function makeTransactionsFillsRequest( + address: string, + chainShortName: string, + hashes: HexString[], + signal?: AbortSignal +) { + const data = await axios + .get>('explorer/transaction/transaction-fills', { + baseURL: 'https://www.oklink.com/api/v5', + headers: { + 'Ok-Access-Key': '' + }, + params: { + chainShortName, + address, + txid: String(hashes) + }, + signal + }) + .then(response => { + if (response.data.code !== '0') throw new Error(response.data.msg); + + return response.data.data; + }); + + return data; +} + +export interface TransactionFillsResponseDataItem { + chainFullName: string; + chainShortName: string; + txid: HexString; + /** integer */ + height: `${number}`; + /** UNIX time */ + transactionTime: string; + /** float */ + amount: string; + transactionSymbol: string; + /** float */ + txfee: string; + /** integer */ + index: string; + /** integer */ + confirm: string; + inputDetails: [ + { + inputHash: HexString; + isContract: boolean; + amount: ''; + } + ]; + outputDetails: [ + { + outputHash: HexString; + isContract: boolean; + amount: ''; + } + ]; + state: 'success'; + /** integer */ + gasLimit: string; + /** integer */ + gasUsed: string; + /** float */ + gasPrice: string; + totalTransactionSize: ''; + virtualSize: '0'; + weight: ''; + nonce: string; + /** + * 0:original transaction + * 1: EIP2930 + * 2: EIP1559 + */ + transactionType: '0' | '1' | '2'; + methodId: '' | HexString; + errorLog: ''; + inputData: HexString; + isAaTransaction: boolean; + tokenTransferDetails: { + /** integer */ + index: `${number}`; + /** Full name */ + token: string; + tokenContractAddress: '' | HexString; + symbol: string; + from: HexString; + to: HexString; + /** integer */ + tokenId: '' | `${number}`; + /** float */ + amount: `${number}`; + isFromContract: boolean; + isToContract: boolean; + }[]; + contractDetails: { + index: `${number}` | `${number}_${string}`; + from: HexString; + to: HexString; + /** float */ + amount: `${number}`; + gasLimit: `${number}`; + isFromContract: boolean; + isToContract: boolean; + }[]; +} + +const CHAINS: Record = { + 1: 'ETH', + 10: 'OP', + 56: 'BSC', + 61: 'ETC', + 66: 'OKTC', + 100: 'GNOSIS', + 137: 'POLYGON', + 250: 'FTM', + 324: 'ZKSYNC', + 459: 'KAVA', + 8453: 'BASE', + 1101: 'POLYGON_ZKEVM', + 2020: 'RONIN', + 8217: 'KAIA', + 42161: 'ARBITRUM', + 43114: 'AVAXC', + 59144: 'LINEA', + 10001: 'ETHW', + 80002: 'AMOY_TESTNET', + 80001: 'MUMBAI_TESTNET', + 81457: 'BLAST', + 513100: 'DIS (ETHF)', + 534352: 'SCROLL', // + 11155111: 'SEPOLIA_TESTNET' +}; From b25f19e2e46937871e66eebb4268b0eef4bfbfad Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Nov 2024 20:36:28 +0200 Subject: [PATCH 2/2] TW-1587: [research] Activity History on OKLink. Render for filtered EVM chains --- .../ActivityOperationBase/index.tsx | 29 ++++- .../activity/ActivityItem/EvmActivity.tsx | 9 +- .../ActivityItem/EvmActivityOperation.tsx | 5 +- .../templates/activity/EvmActivityList.tsx | 2 + src/lib/activity/evm/fetch.ts | 12 +- src/lib/activity/evm/parse/oklink.ts | 109 +++++++++++++++++- src/lib/activity/types.ts | 2 +- src/lib/apis/oklink/index.ts | 58 +++++----- 8 files changed, 183 insertions(+), 43 deletions(-) diff --git a/src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx b/src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx index fbc152e8d..10ea4fa1e 100644 --- a/src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx +++ b/src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx @@ -25,6 +25,7 @@ interface Props { transferType?: ActivityOperTransferType; hash: string; asset?: ActivityItemBaseAssetProp; + atomic: boolean; blockExplorerUrl?: string; status?: ActivityStatus; withoutAssetIcon?: boolean; @@ -44,7 +45,19 @@ export interface ActivityItemBaseAssetProp { } export const ActivityOperationBaseComponent = memo( - ({ kind, transferType, hash, chain, asset, blockExplorerUrl, status, withoutAssetIcon, onClick, addressChip }) => { + ({ + kind, + transferType, + hash, + chain, + asset, + atomic, + blockExplorerUrl, + status, + withoutAssetIcon, + onClick, + addressChip + }) => { const isForEvm = chain.kind === TempleChainKind.EVM; const assetSlug = asset @@ -69,14 +82,14 @@ export const ActivityOperationBaseComponent = memo( > {kind === ActivityOperKindEnum.approve ? null : asset.amountSigned ? ( - {atomsToTokens(asset.amountSigned, asset.decimals)} + {atomic ? atomsToTokens(asset.amountSigned, asset.decimals) : asset.amountSigned} ) : null} {symbolStr ? {symbolStr} : null} ); - }, [asset, kind, onClick]); + }, [asset, kind, onClick, atomic]); const fiatJsx = useMemo(() => { if (!asset) return null; @@ -84,13 +97,19 @@ export const ActivityOperationBaseComponent = memo( if (!asset.amountSigned) return asset.amountSigned === null ? 'Unlimited' : null; if (kind === ActivityOperKindEnum.approve) - return {atomsToTokens(asset.amountSigned, asset.decimals)}; + return ( + + {atomic ? atomsToTokens(asset.amountSigned, asset.decimals) : asset.amountSigned} + + ); if (!assetSlug) return null; const amountForFiat = kind === 'bundle' || isTransferActivityOperKind(kind) - ? atomsToTokens(asset.amountSigned, asset.decimals) + ? atomic + ? atomsToTokens(asset.amountSigned, asset.decimals) + : asset.amountSigned : null; if (!amountForFiat) return null; diff --git a/src/app/templates/activity/ActivityItem/EvmActivity.tsx b/src/app/templates/activity/ActivityItem/EvmActivity.tsx index 75821b526..8573e0869 100644 --- a/src/app/templates/activity/ActivityItem/EvmActivity.tsx +++ b/src/app/templates/activity/ActivityItem/EvmActivity.tsx @@ -70,7 +70,9 @@ const EvmActivityBatchComponent = memo(({ activity, chain, assetSlug const decimals = getMetadata(slug)?.decimals ?? asset.decimals; - if (decimals != null) return slug; + // if (decimals != null) return slug; + + return slug; } } @@ -98,7 +100,7 @@ const EvmActivityBatchComponent = memo(({ activity, chain, assetSlug const decimals = assetMetadata?.decimals ?? faceAssetBase?.decimals; - if (decimals == null) return; + // if (decimals == null) return; const symbol = assetMetadata?.symbol || faceAssetBase?.symbol; @@ -109,7 +111,7 @@ const EvmActivityBatchComponent = memo(({ activity, chain, assetSlug contract, tokenId, amountSigned: faceAmount.toFixed(), - decimals, + decimals: 0, symbol }; }, [getMetadata, operations, faceSlug]); @@ -121,6 +123,7 @@ const EvmActivityBatchComponent = memo(({ activity, chain, assetSlug hash={hash} chain={chain} asset={batchAsset} + atomic={false} blockExplorerUrl={blockExplorerUrl} status={status} withoutAssetIcon={Boolean(assetSlug)} diff --git a/src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx b/src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx index 3cf9a9154..f5d368507 100644 --- a/src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx +++ b/src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx @@ -32,13 +32,13 @@ export const EvmActivityOperationComponent = memo( const decimals = assetBase.amountSigned === null ? NaN : assetMetadata?.decimals ?? assetBase.decimals; - if (decimals == null) return; + // if (decimals == null) return; const symbol = assetMetadata?.symbol || assetBase.symbol; const asset: ActivityItemBaseAssetProp = { ...assetBase, - decimals, + decimals: -1, symbol }; @@ -57,6 +57,7 @@ export const EvmActivityOperationComponent = memo( hash={hash} chain={chain} asset={asset} + atomic={false} blockExplorerUrl={blockExplorerUrl} status={status} withoutAssetIcon={withoutAssetIcon} diff --git a/src/app/templates/activity/EvmActivityList.tsx b/src/app/templates/activity/EvmActivityList.tsx index bd9ba72e4..6741eb738 100644 --- a/src/app/templates/activity/EvmActivityList.tsx +++ b/src/app/templates/activity/EvmActivityList.tsx @@ -63,6 +63,8 @@ export const EvmActivityList: FC = ({ chainId, assetSlug, filterKind }) = const groupedActivities = useGroupingByDate(displayActivities); + console.log('AAA:', activities); + const contentJsx = useMemo( () => groupedActivities.map(([dateStr, activities]) => ( diff --git a/src/lib/activity/evm/fetch.ts b/src/lib/activity/evm/fetch.ts index cabcf243e..0121b49b7 100644 --- a/src/lib/activity/evm/fetch.ts +++ b/src/lib/activity/evm/fetch.ts @@ -1,3 +1,5 @@ +import { groupBy } from 'lodash'; + import { fetchOklinkTransactions } from 'lib/apis/oklink'; import { getEvmERC20Transfers, getEvmTransactions } from 'lib/apis/temple/endpoints/evm'; import { fromAssetSlug } from 'lib/assets'; @@ -6,7 +8,7 @@ import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; import { EvmActivity } from '../types'; import { parseGoldRushTransaction, parseGoldRushERC20Transfer } from './parse'; -import { parseOklinkTransaction } from './parse/oklink'; +import { parseOklinkTransactionsGroup } from './parse/oklink'; export async function getEvmAssetTransactions( walletAddress: string, @@ -67,7 +69,11 @@ export async function getEvmActivities( olderThanBlockHeight?: `${number}`, signal?: AbortSignal ) { - const items = await fetchOklinkTransactions(walletAddress, chainId, olderThanBlockHeight, signal); + const allItems = await fetchOklinkTransactions(walletAddress, chainId, olderThanBlockHeight, signal); + + const groups = Object.entries(groupBy(allItems, 'txId')); + + const walletAddressLowerCased = walletAddress.toLowerCase(); - return items.map(item => parseOklinkTransaction(item, chainId, walletAddress)); + return groups.map(([hash, items]) => parseOklinkTransactionsGroup(items, chainId, walletAddressLowerCased, hash)); } diff --git a/src/lib/activity/evm/parse/oklink.ts b/src/lib/activity/evm/parse/oklink.ts index 3f75418ae..939fe4098 100644 --- a/src/lib/activity/evm/parse/oklink.ts +++ b/src/lib/activity/evm/parse/oklink.ts @@ -6,11 +6,116 @@ import { EvmActivityAsset, EvmOperation } from 'lib/activity/types'; -import { TransactionFillsResponseDataItem } from 'lib/apis/oklink'; +import { OklinkTransaction, TransactionFillsResponseDataItem } from 'lib/apis/oklink'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; import { TempleChainKind } from 'temple/types'; -export function parseOklinkTransaction( +export function parseOklinkTransactionsGroup( + items: OklinkTransaction[], + chainId: number, + accountAddress: string, + hash: string +): EvmActivity { + const firstItem = items.at(0)!; + + const status = + firstItem.state === 'success' + ? ActivityStatus.applied + : firstItem.state === 'pending' + ? ActivityStatus.pending + : ActivityStatus.failed; + + const operations = items.map(item => parseOklinkTransaction(item, accountAddress)); + + return { + chain: TempleChainKind.EVM, + chainId, + hash, + operations, + operationsCount: items.length, + status, + addedAt: new Date(Number(firstItem.transactionTime)).toISOString(), + blockHeight: firstItem.height + }; +} + +function parseOklinkTransaction(item: OklinkTransaction, accountAddress: string): EvmOperation { + const fromAddress = item.from; + const toAddress = item.to; + + if (!item.methodId) { + const type = (() => { + if (fromAddress === accountAddress) + return item.isToContract ? ActivityOperTransferType.send : ActivityOperTransferType.sendToAccount; + if (toAddress === accountAddress) + return item.isFromContract ? ActivityOperTransferType.receive : ActivityOperTransferType.receiveFromAccount; + + return null; + })(); + + if (type == null) + return { + kind: ActivityOperKindEnum.interaction, + withAddress: item.tokenContractAddress || undefined + }; + + const { amount, tokenId, transactionSymbol: symbol } = item; + + const amountSigned = + type === ActivityOperTransferType.send || type === ActivityOperTransferType.sendToAccount ? `-${amount}` : amount; + + const asset: EvmActivityAsset = { + contract: item.tokenContractAddress, + tokenId, + amountSigned, + // decimals, + symbol + // nft + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset + }; + } + + if (item.methodId === KnownMethodsEnum.ApprovalForAll) { + const kind = ActivityOperKindEnum.approve; + + const asset: EvmActivityAsset = { + contract: item.to, + amountSigned: null, + // decimals: NaN, // We are not supposed to use these in this case (of 'Unlimited' amount) + // symbol, + nft: true + // iconURL + }; + + return { kind, asset }; + } + + if (item.methodId === KnownMethodsEnum.Approval) { + const kind = ActivityOperKindEnum.approve; + + const asset: EvmActivityAsset = { + contract: item.to, + amountSigned: '0' // ?! + // decimals: NaN, // We are not supposed to use these in this case (of 'Unlimited' amount) + // symbol, + // nft: true // ?! + // iconURL + }; + + return { kind, asset }; + } + + return { kind: ActivityOperKindEnum.interaction, withAddress: item.to }; +} + +export function parseOklinkFillResponseData( item: TransactionFillsResponseDataItem, chainId: number, accountAddress: string diff --git a/src/lib/activity/types.ts b/src/lib/activity/types.ts index 2390dad9e..bd9663a9c 100644 --- a/src/lib/activity/types.ts +++ b/src/lib/activity/types.ts @@ -82,7 +82,7 @@ interface EvmOperationBase extends OperationBase { interface EvmApproveOperation extends EvmOperationBase { kind: ActivityOperKindEnum.approve; - spenderAddress: string; + spenderAddress?: string; } interface EvmTransferOperation extends EvmOperationBase { diff --git a/src/lib/apis/oklink/index.ts b/src/lib/apis/oklink/index.ts index e76d35c5c..6317beec3 100644 --- a/src/lib/apis/oklink/index.ts +++ b/src/lib/apis/oklink/index.ts @@ -2,16 +2,13 @@ import axios from 'axios'; import { filterUnique } from 'lib/utils'; -/** Maximum allowed for the endpoint */ -const MAX_HASHES_FOR_FILLS = 20; - export async function fetchOklinkTransactions( address: string, chainId: number, olderThanBlockHeight?: `${number}`, // newerThanBlockHeight?: string, signal?: AbortSignal -) { +): Promise { const chainShortName = CHAINS[chainId]; if (!chainShortName) return []; @@ -25,21 +22,9 @@ export async function fetchOklinkTransactions( makeTransactionsRequest(address, chainShortName, 'token_10', endBlockHeight, signal) ]); - const allItems = [ - ...data1.transactionLists, - ...data2.transactionLists, - ...data3.transactionLists, - ...data4.transactionLists, - ...data5.transactionLists - ].sort((a, b) => (a.transactionTime > b.transactionTime ? -1 : 1)); - - const uniqHashes = filterUnique(allItems.map(item => item.txId)).slice(0, MAX_HASHES_FOR_FILLS); - - await new Promise(r => void setTimeout(r, 1_000)); - - const fillsRes = await makeTransactionsFillsRequest(address, chainShortName, uniqHashes); - - return fillsRes.sort((a, b) => (a.transactionTime > b.transactionTime ? -1 : 1)); + return [...data1, ...data2, ...data3, ...data4, ...data5].sort((a, b) => + a.transactionTime > b.transactionTime ? -1 : 1 + ); } /** See: https://www.oklink.com/docs/en/#fundamental-blockchain-data-address-data-get-address-transaction-list */ @@ -50,13 +35,14 @@ async function makeTransactionsRequest( protocolType?: ProtocolType, // startBlockHeight?: string, endBlockHeight?: string, - signal?: AbortSignal -) { - const data = await axios + signal?: AbortSignal, + prevItems: OklinkTransaction[] = [] +): Promise { + const items = await axios .get>('explorer/address/transaction-list', { baseURL: 'https://www.oklink.com/api/v5', headers: { - 'Ok-Access-Key': '' + 'Ok-Access-Key': process.env._OKLINK_API_KEY }, params: { chainShortName, @@ -72,10 +58,28 @@ async function makeTransactionsRequest( .then(response => { if (response.data.code !== '0') throw new Error(response.data.msg); - return response.data.data[0]; + return response.data.data[0].transactionLists; }); - return data; + // Cutting out trailing number of same-hash items, since there might be more on the 'next page' + + const lastHash = items.at(items.length - 1)?.txId; + + // if (!lastHash) return prevItems; + + // const countSameLastHashes = items.length - items.findIndex(d => d.txId === lastHash); + + // if (countSameLastHashes < items.length) return [...prevItems, ...items.toSpliced(-countSameLastHashes)]; + + // return [...prevItems, ...items, ...(await makeTransactionsRequest(address, chainShortName, protocolType, endBlockHeight, signal))]; + + if (!lastHash) return []; + + const countSameLastHashes = items.length - items.findIndex(d => d.txId === lastHash); + + if (countSameLastHashes < items.length) return items.toSpliced(-countSameLastHashes); + + return items; // TODO: Do sth for this `else` } interface OklinkResponse { @@ -106,7 +110,7 @@ export interface OklinkTransaction { methodId: '' | HexString; blockHash: HexString; /** number */ - height: string; + height: `${number}`; /** number - UNIX time */ transactionTime: string; from: HexString; @@ -136,7 +140,7 @@ async function makeTransactionsFillsRequest( .get>('explorer/transaction/transaction-fills', { baseURL: 'https://www.oklink.com/api/v5', headers: { - 'Ok-Access-Key': '' + 'Ok-Access-Key': process.env._OKLINK_API_KEY }, params: { chainShortName,