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
10 changes: 10 additions & 0 deletions docs/ai/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ Requests are still handled **one at a time**: while a request from one dApp is u

## Ethereum

### Transaction history

A token's transaction list is built from two sources: the indexed history of the chain, and the copy OISY keeps for the user in their own backend canister. The stored copy is what makes history survive — it is read first, the chain is then asked only for what happened after the newest stored entry, and finalized entries are written back as they are seen. Scrolling to the end of a list asks for the page before it: first from stored history, and once that is exhausted, from the chain for anything older still, which is then stored too. So history reaches further back the more the wallet is used, and is not limited to what one indexer request will return.

This applies to a chain's own coin — ETH, and the native coin of each supported EVM chain — and to ERC20 tokens, each keyed separately so one token's history never mixes with another's. Collectible (ERC721 and ERC1155) transfers are not stored this way: their lists are re-read from the chain each time and show only what one request returns.

Stored history is bounded per token, and the oldest entries are dropped once that bound is reached; anything trimmed is still reachable from the chain while the indexer will serve it. Entries are held as the chain reported them. Where that would read oddly — a vault's share mints and burns are recorded against the zero address rather than the vault — the list presents the sensible counterparty without altering what was stored.

Token transfers that merely look like the user's own activity are filtered out before display or storage. An attacker can emit a zero-value transfer that names the user as sender, so a transfer is checked against who actually signed and paid for the transaction, not against the transfer event alone.

### Transaction fees

When an Ethereum send or approval flow is open, OISY fetches the current network gas fee and keeps it current for as long as the flow stays open. If the wallet is backgrounded — common on mobile, where switching apps, locking the screen, or bouncing between a dApp and OISY during a WalletConnect approval suspends the tab — the fee fetch can be interrupted. OISY recovers on its own: it re-fetches the fee when the wallet returns to the foreground, and retries transient fetch failures automatically, so a send is not left permanently unable to proceed.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
<script lang="ts">
import { isNullish } from '@dfinity/utils';
import { isNullish, nonNullish } from '@dfinity/utils';
import type { Snippet } from 'svelte';
import type { TokenId as BackendTokenId } from '$declarations/backend/backend.did';
import { sortedEthTransactions } from '$eth/derived/eth-transactions.derived';
import {
getEthBackendPaginationCursor,
loadNextEthUserTransactions
} from '$eth/services/eth-user-transactions.services';
import { isTokenEthereumNative } from '$eth/utils/native-token.utils';
import { loadNextEthUserTransactions } from '$eth/services/eth-user-transactions.services';
import { toBackendTokenId } from '$eth/utils/user-transactions.utils';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import { ethAddress } from '$lib/derived/address.derived';
import { authIdentity } from '$lib/derived/auth.derived';
import type { Token } from '$lib/types/token';
import { last } from '$lib/utils/array.utils';
import { isNetworkEthereum } from '$lib/utils/network.utils';

interface Props {
token: Token;
Expand All @@ -24,16 +19,12 @@

let disableInfiniteScroll = $state(false);

// Older history is fetched with `txlist`, which only answers for the chain's native asset. An ERC
// token's earlier transfers are not reachable this way, so its list stays where the loader left it.
let transactionTokenId: BackendTokenId | undefined = $derived(
isNetworkEthereum(token.network) && isTokenEthereumNative(token)
? { EvmNative: token.network.chainId }
: undefined
);
// Only a token whose history this path can store has older pages to ask for - non-fungible
// transfers come from endpoints it does not read, so their lists stay where the loader left them.
let pageable = $derived(nonNullish(toBackendTokenId(token)));

const onIntersect = async () => {
if (isNullish(transactionTokenId)) {
if (!pageable) {
disableInfiniteScroll = true;

return;
Expand All @@ -50,10 +41,7 @@
const { hasMore } = await loadNextEthUserTransactions({
identity: $authIdentity,
address: $ethAddress,
transactionTokenId,
tokenId: token.id,
networkId: token.network.id,
cursor: getEthBackendPaginationCursor(token.id),
token,
oldestLoadedBlockNumber
});

Expand Down
53 changes: 53 additions & 0 deletions src/frontend/src/eth/services/erc-transfers.services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { alchemyProviders } from '$eth/providers/alchemy.providers';
import { etherscanProviders } from '$eth/providers/etherscan.providers';
import type { EthAddress } from '$eth/types/address';
import type { Erc20Token } from '$eth/types/erc20';
import type { Erc4626Token } from '$eth/types/erc4626';
import { filterSpamErc20Transfers } from '$eth/utils/eth-transactions-spam.utils';
import { retryWithDelay } from '$lib/services/rest.services';
import type { Address } from '$lib/types/address';
import type { NetworkId } from '$lib/types/network';
import type { Transaction } from '$lib/types/transaction';

/**
* Fetches a window of a token's ERC20 `Transfer` events, without the ones that only look like the
* user's activity.
*
* Shared by the two paths that pull transfers from Etherscan - the initial load and the fetch for
* history older than the list - so that both filter spam the same way and neither has to know how.
*/
export const fetchErc20Transfers = async ({
networkId,
token,
address,
startBlock,
endBlock
}: {
networkId: NetworkId;
token: Erc20Token | Erc4626Token;
address: Address;
startBlock?: number;
endBlock?: number;
}): Promise<Transaction[]> => {
const { erc20Transactions } = etherscanProviders(networkId);

const transactions = await retryWithDelay({
request: async () =>
await erc20Transactions({ contract: token, address, startBlock: startBlock ?? 0, endBlock })
});

const { getTransaction } = alchemyProviders(networkId);

return filterSpamErc20Transfers({
transactions,
userAddress: address,
// The `transaction.from` is the `Transfer` event's _from (who tokens move from), not
// the EOA that signed the tx. In address-poisoning scams the attacker emits
// `Transfer(victim, attacker, 0)`, so `transaction.from == victim`. We need the
// outer tx sender via RPC to tell whether the user actually initiated it.
getTransactionSender: async (hash: string): Promise<EthAddress | undefined> => {
const tx = await getTransaction(hash);
return tx?.from;
}
});
};
133 changes: 71 additions & 62 deletions src/frontend/src/eth/services/eth-transactions.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,24 @@ import { enabledErc1155Tokens } from '$eth/derived/erc1155.derived';
import { enabledErc20Tokens } from '$eth/derived/erc20.derived';
import { erc4626Tokens } from '$eth/derived/erc4626.derived';
import { enabledErc721Tokens } from '$eth/derived/erc721.derived';
import { alchemyProviders } from '$eth/providers/alchemy.providers';
import { etherscanProviders } from '$eth/providers/etherscan.providers';
import { infuraProviders } from '$eth/providers/infura.providers';
import { fetchErc20Transfers } from '$eth/services/erc-transfers.services';
import {
loadEthUserTransactions,
saveEthFinalizedTransactions,
setEthBackendPaginationCursor
} from '$eth/services/eth-user-transactions.services';
import { ethTransactionsStore } from '$eth/stores/eth-transactions.store';
import type { EthAddress } from '$eth/types/address';
import type { Erc1155CustomToken } from '$eth/types/erc1155-custom-token';
import type { Erc20CustomToken } from '$eth/types/erc20-custom-token';
import type { Erc4626CustomToken } from '$eth/types/erc4626-custom-token';
import type { Erc721CustomToken } from '$eth/types/erc721-custom-token';
import type { EthereumChainId } from '$eth/types/network';
import { isTokenErc1155 } from '$eth/utils/erc1155.utils';
import { isTokenErc20 } from '$eth/utils/erc20.utils';
import { isTokenErc4626, normalizeErc4626MintBurnTransfers } from '$eth/utils/erc4626.utils';
import { isTokenErc721 } from '$eth/utils/erc721.utils';
import { filterSpamErc20Transfers } from '$eth/utils/eth-transactions-spam.utils';
import { isSupportedEthTokenId } from '$eth/utils/eth.utils';
import { toBackendTokenId } from '$eth/utils/user-transactions.utils';
import { isSupportedEvmNativeTokenId } from '$evm/utils/native-token.utils';
import { TRACK_COUNT_ETH_LOADING_TRANSACTIONS_ERROR } from '$lib/constants/analytics.constants';
import { ethAddress as addressStore } from '$lib/derived/address.derived';
Expand Down Expand Up @@ -64,7 +61,7 @@ export const loadEthereumTransactions = ({
return loadEthTransactions({ identity, networkId, tokenId, chainId, updateOnly, silent });
}

return loadErcTransactions({ networkId, tokenId, standard, updateOnly });
return loadErcTransactions({ identity, networkId, tokenId, standard, updateOnly });
};

// If we use the update method instead of the set method, we can keep the existing transactions and just update their data.
Expand All @@ -81,7 +78,7 @@ export const reloadEthereumTransactions = (params: {
const hasStoredEthTransactions = (tokenId: TokenId): boolean =>
(get(ethTransactionsStore)?.[tokenId] ?? []).length > 0;

const maxEthNativeBlockNumberInStore = (tokenId: TokenId): number | undefined => {
const maxBlockNumberInStore = (tokenId: TokenId): number | undefined => {
const rows = get(ethTransactionsStore)?.[tokenId];

if (isNullish(rows) || rows.length === 0) {
Expand Down Expand Up @@ -196,7 +193,7 @@ const loadEthTransactions = async ({
newestStoredBlockIndex: stored?.newestBlockIndex,
maxBlockFromTransactionsStore: nonNullish(stored?.newestBlockIndex)
? undefined
: maxEthNativeBlockNumberInStore(tokenId)
: maxBlockNumberInStore(tokenId)
});

const newTransactions = await loadNewEthNativeTransactionsAfterStartBlock({
Expand Down Expand Up @@ -272,11 +269,13 @@ const loadEthTransactions = async ({
};

const loadErcTransactions = async ({
identity,
networkId,
tokenId,
standard,
updateOnly = false
}: {
identity: NullishIdentity;
networkId: NetworkId;
tokenId: TokenId;
standard: TokenStandard;
Expand All @@ -303,18 +302,54 @@ const loadErcTransactions = async ({
return { success: false };
}

// Non-fungible transfers come from endpoints this path does not store, so they keep fetching their
// whole history every time - and must not pick up an incremental start block from the store.
const transactionTokenId = toBackendTokenId(token);
const cached = USER_TRANSACTIONS_LOAD_FROM_BACKEND_ENABLED && nonNullish(transactionTokenId);

try {
const transactions = isTokenErc4626(token)
? await loadErc4626Transactions({ networkId, token, address })
: isTokenErc20(token)
? await loadErc20Transactions({ networkId, token, address })
const stored = cached
? await loadEthUserTransactions({ identity, tokenId: transactionTokenId })
: undefined;

// Only while the list is being built from scratch. The periodic refresh comes through here too,
// so resetting the cursor unconditionally would send the next scroll back over pages the user
// already has.
if (cached && !hasStoredEthTransactions(tokenId)) {
setEthBackendPaginationCursor({ tokenId, nextStart: stored?.nextStart });
}

const startBlock = cached
? resolveEthIncrementalStartBlock({
newestStoredBlockIndex: stored?.newestBlockIndex,
maxBlockFromTransactionsStore: nonNullish(stored?.newestBlockIndex)
? undefined
: maxBlockNumberInStore(tokenId)
})
: 0;

const fetched =
isTokenErc4626(token) || isTokenErc20(token)
? await fetchErc20Transfers({ networkId, token, address, startBlock })
: isTokenErc721(token)
? await loadErc721Transactions({ networkId, token, address })
: isTokenErc1155(token)
? await loadErc1155Transactions({ networkId, token, address })
: [];

const certifiedTransactions = transactions.map((transaction) => ({
// Combine newest-first: new transactions (desc) then stored (desc from backend)
const allTransactions = [...fetched, ...(stored?.transactions ?? [])];

// Applied here rather than on the fetched rows alone, because stored rows are held as the chain
// reported them - see `normalizeErc4626MintBurnTransfers`.
const displayedTransactions = isTokenErc4626(token)
? normalizeErc4626MintBurnTransfers({
transactions: allTransactions,
vaultAddress: token.address
})
: allTransactions;

const certifiedTransactions = displayedTransactions.map((transaction) => ({
data: transaction,
// We set the certified property to false because we don't have a way to certify ERC transactions for now.
certified: false
Expand All @@ -324,9 +359,32 @@ const loadErcTransactions = async ({
certifiedTransactions.forEach((transaction) =>
ethTransactionsStore.update({ tokenId, transaction })
);
} else if (cached) {
// Prepended rather than set, because once a token is cached this batch is not the whole
// history: it is the newest stored page plus whatever is newer than it. Replacing the slot
// would throw away every older page the user scrolled in - and the periodic refresh runs
// through here every 30 seconds.
ethTransactionsStore.prepend({ tokenId, transactions: certifiedTransactions });
} else {
// Collectibles still fetch their whole history every time, so replacing the slot is a real
// refresh and drops what the chain no longer reports.
ethTransactionsStore.set({ tokenId, transactions: certifiedTransactions });
}

// Saved as fetched, before the vault normalisation, so the backend holds what the chain reported.
if (cached && fetched.length > 0) {
const blockNumbers = fetched.map(({ blockNumber }) => blockNumber).filter(nonNullish);
const maxBlockNumber = blockNumbers.length > 0 ? Math.max(...blockNumbers) : 0;

if (maxBlockNumber > 0) {
saveEthFinalizedTransactions({
identity,
tokenId: transactionTokenId,
transactions: fetched,
currentBlockNumber: maxBlockNumber
}).catch((err) => consoleError('Background save of finalized transactions failed:', err));
}
}
} catch (err: unknown) {
ethTransactionsStore.nullify(tokenId);

Expand Down Expand Up @@ -354,55 +412,6 @@ const loadErcTransactions = async ({
return { success: true };
};

const loadErc20Transactions = async ({
networkId,
token,
address
}: {
networkId: NetworkId;
token: Erc20CustomToken | Erc4626CustomToken;
address: Address;
}): Promise<Transaction[]> => {
const { erc20Transactions } = etherscanProviders(networkId);

const transactions = await retryWithDelay({
request: async () => await erc20Transactions({ contract: token, address })
});

const { getTransaction } = alchemyProviders(networkId);

return filterSpamErc20Transfers({
transactions,
userAddress: address,
// The `transaction.from` is the `Transfer` event's _from (who tokens move from), not
// the EOA that signed the tx. In address-poisoning scams the attacker emits
// `Transfer(victim, attacker, 0)`, so `transaction.from == victim`. We need the
// outer tx sender via RPC to tell whether the user actually initiated it.
getTransactionSender: async (hash: string): Promise<EthAddress | undefined> => {
const tx = await getTransaction(hash);
return tx?.from;
}
});
};

/**
* Loads ERC4626 vault token transactions, presenting share mints and burns as transfers with the
* vault - see `normalizeErc4626MintBurnTransfers` for why.
*/
const loadErc4626Transactions = async ({
networkId,
token,
address
}: {
networkId: NetworkId;
token: Erc4626CustomToken;
address: Address;
}): Promise<Transaction[]> => {
const transactions = await loadErc20Transactions({ networkId, token, address });

return normalizeErc4626MintBurnTransfers({ transactions, vaultAddress: token.address });
};

const loadErc721Transactions = async ({
networkId,
token,
Expand Down
Loading
Loading