Tracing Wallets: Follow Funds and Contract Calls
Tracing Wallets: Follow Funds and Contract Calls
Tracing a wallet is about more than spotting a couple of ERC-20 transfers. To understand whether funds are safe or where they went, you need a full timeline of external transactions, internal value moves, token transfers, and the contracts touched along the way. This walkthrough shows how I build that view with inexpensive tools that fit a sub-$1k wallet.
What a complete wallet trace must capture
Block explorers surface pieces of the puzzle in separate tabs. A reliable trace needs all of them stitched together:
- External transactions where the wallet signed calls to contracts or EOAs.
- Internal transactions (value transfers triggered inside contracts) that never appear as top-level calls.
- Token transfers emitted as events, including ERC-20 and ERC-721 movements.
- Contract method calls decoded from input data to explain why funds moved.
Missing any of these hides facts like router fees siphoned inside a swap or NFTs moved by an auction settlement.
Tools that cover the whole trail without overspending
- Explorer APIs (Etherscan-style):
txlist,tokentx, andtxlistinternalendpoints are the only realistic way to filter by address on mainnet without running your own indexer. Free tiers are usually enough for a handful of wallets; aggressive pagination keeps you under rate limits. - RPC endpoints: Needed to pull receipts, decode logs, and fetch ABI data. Free tiers from Alchemy/Infura work for analysis as long as you cache results instead of hammering new blocks.
- ABI sources: Verified contracts on explorers expose ABIs you can pull as JSON. Keep a local cache so decoding does not rely on repeated HTTP calls.
Build a timeline with explorer APIs and Web3.py
Start with the explorer to get the raw list of transactions, then hydrate each hash with on-chain receipts for accuracy. The snippet below keeps calls low while producing a single ordered timeline.
import requests
from web3 import Web3
RPC_URL = 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'
ETHERSCAN_KEY = 'YOUR_ETHERSCAN_KEY'
ADDRESS = '0xYourWallet'
w3 = Web3(Web3.HTTPProvider(RPC_URL))
def fetch(endpoint, startblock=0, endblock=99999999):
url = f'https://api.etherscan.io/api?module=account&action={endpoint}'
params = {
'address': ADDRESS,
'startblock': startblock,
'endblock': endblock,
'page': 1,
'offset': 1000,
'sort': 'asc',
'apikey': ETHERSCAN_KEY,
}
return requests.get(url, params=params, timeout=10).json()['result']
# External calls and token transfers
txs = fetch('txlist')
token_events = fetch('tokentx')
internal_txs = fetch('txlistinternal')
timeline = {}
for tx in txs:
hash_ = tx['hash']
receipt = w3.eth.get_transaction_receipt(hash_)
timeline[hash_] = {
'block': int(tx['blockNumber']),
'from': tx['from'],
'to': tx['to'],
'value_eth': Web3.from_wei(int(tx['value']), 'ether'),
'status': receipt.status,
'logs': receipt.logs,
}
for movement in token_events:
entry = timeline.setdefault(movement['hash'], {'block': int(movement['blockNumber'])})
entry.setdefault('token_transfers', []).append({
'token': movement['contractAddress'],
'from': movement['from'],
'to': movement['to'],
'value': int(movement['value']),
'symbol': movement['tokenSymbol'],
})
for internal in internal_txs:
entry = timeline.setdefault(internal['hash'], {'block': int(internal['blockNumber'])})
entry.setdefault('internal', []).append({
'from': internal['from'],
'to': internal['to'],
'value_eth': Web3.from_wei(int(internal['value']), 'ether'),
'type': internal['type'],
})
# Ordered view you can export to CSV or a notebook
ordered = [timeline[h] | {'hash': h} for h in sorted(timeline, key=lambda k: timeline[k]['block'])]
This combines explorer-level indexing (cheap) with receipts (authoritative). For a sub-$1k wallet you rarely exceed free API tiers; if you do, throttle requests to one per second and persist timeline to disk between runs.
Decode token movements directly from receipts
Explorer token tabs can miss edge cases like non-standard events. Parsing receipts keeps you honest about what moved.
from eth_abi import decode
TRANSFER_SIG = w3.keccak(text='Transfer(address,address,uint256)').hex()
def decode_transfer(log):
if log['topics'][0].hex() != TRANSFER_SIG:
return None
sender = '0x' + log['topics'][1].hex()[-40:]
receiver = '0x' + log['topics'][2].hex()[-40:]
amount, = decode(['uint256'], bytes.fromhex(log['data'][2:]))
return {
'token': log['address'],
'from': sender,
'to': receiver,
'amount': amount,
}
decoded_transfers = []
for entry in ordered:
for log in entry.get('logs', []):
transfer = decode_transfer(log)
if transfer:
decoded_transfers.append({
'tx': entry['hash'],
**transfer,
})
Cross-check decoded_transfers against explorer results. Differences usually come from proxy contracts emitting custom events or wrapped assets that re-emit transfers—valuable signals when hunting hidden fees.
Explain contract calls with ABI decoding
Logs show the effect; decoding input data tells you the intent. Pull verified ABIs once and keep them locally to avoid repeated HTTP calls.
import json
def load_abi(address):
# Replace with a small local cache; shown inline for clarity
abi_url = 'https://api.etherscan.io/api'
params = {
'module': 'contract',
'action': 'getabi',
'address': address,
'apikey': ETHERSCAN_KEY,
}
abi_json = requests.get(abi_url, params=params, timeout=10).json()['result']
return json.loads(abi_json)
def decode_call(tx_hash):
tx = w3.eth.get_transaction(tx_hash)
abi = load_abi(tx['to'])
contract = w3.eth.contract(address=tx['to'], abi=abi)
function, args = contract.decode_function_input(tx['input'])
return {
'function': function.fn_name,
'args': args,
'to': tx['to'],
'from': tx['from'],
}
decoded_calls = [decode_call(entry['hash']) for entry in ordered if 'logs' in entry]
With decoded calls you can answer questions like:
- Was the outflow a swap (
exactInputSingle) or a bridge (swapAndBridge)? - Did the wallet approve a router implicitly inside a helper contract?
- Which NFT token IDs moved in a single settlement?
Risk analysis: where traces go wrong
- Missing proxies or meta-transactions: Calls to
EIP-1967proxies or Permit2-style helpers mask the real target. Always decode the implementation address and track delegated calls. - Centralized API dependence: Explorer APIs can be rate-limited or censored. Cache responses and keep a secondary provider to avoid blind spots when an address is under investigation.
- Chain-specific quirks: Some L2s omit internal transactions or expose them via different endpoints. Test the workflow on the specific chain before trusting conclusions.
- Privacy expectations: Tracing others’ wallets is public data but may have legal or ethical constraints in your jurisdiction; be clear about purpose and consent, especially for client work.
Practical takeaways for daily tracing
- Start with explorer pagination to build the skeleton, then trust receipts for the truth.
- Normalize everything to a single timeline before drawing conclusions; interleaved internal transfers often explain “missing” ETH.
- Cache ABIs and receipts locally so reruns are fast and do not burn through free API tiers.
- Keep the budget low by batching requests and focusing on the few contracts that actually moved value, not every noisy log.
This workflow has kept me honest when validating swaps, investigating suspicious approvals, or reconciling balances across chains—without needing heavyweight indexing infrastructure.