Every crypto tax tool I have audited on Base makes the same two mistakes, and both inflate a number the user is about to hand to an accountant. One is a swap priced from two different sources that cannot agree. The other is a disposal with no cost basis scored as a 100% loss. Neither is exotic. Both are arithmetic that looks correct until you check that a swap should net to zero.

This post is the working notes from building a browser-only scanner and tax exporter for Base, with the real numbers from three live wallets. Everything below was measured on Base mainnet (chain 8453), not estimated.

Scanner, free, no account: https://9xj89gzrtw-hue.github.io/base-tax-audit/

1. A swap exchanges equal value. Your calculator says it does not.

If you swap 332.67 USDC for some token, the two legs of that trade are worth the same thing by construction. Any tool that reports otherwise is not reporting a market move, it is reporting the difference between two price sources it mixed together.

Here is the mechanism. A ledger row for a swap has a sent leg and a received leg, and each leg gets a price from whatever source was available:

Source Meaning Trust
stable a dollar stablecoin, $1 by definition highest
historic the ETH/USD rate recorded on that very transaction high
spot today's rate applied to a trade from months ago low
unknown no rate at all none

A naive exporter treats those as interchangeable. They are not, and the error has a direction: for a token that ran up since the trade, spot overstates what you received, which overstates the gain on the disposal, which overstates the tax.

What it looked like before the fix

A wallet with 25 real USDC ↔ XDP trades exported these rows:

-1.61, +2.23, -0.66, +1.18, -0.94, ... 

Those are swaps between a stablecoin and a token, so every single one nets to exactly zero in reality. Twenty-five rows of phantom gain and loss, each one a difference between $1.00 and a spot quote.

The bug was in the back-fill condition. The original code only repriced a leg when the other side was entirely unpriced. When both legs had a price — USDC at exactly $1, XDP at today's spot — nothing was corrected, and the drift became taxable income.

The rule set that fixed it

Repricing runs in a fixed priority order, and every repriced leg is marked implied and has its source overwritten. A row never claims a price it did not observe.

  1. One side anchored, the other soft → every soft leg on the other side is repriced to what the anchored side paid. stable beats spot, so 332.67 USDC out means the XDP received cost $332.67.
  2. Both sides anchored → only a one-sided soft leg can absorb the remainder. With soft legs on both sides there is no basis for choosing which to shrink, so the row is left alone.
  3. Neither side anchored, but one side wholly unpriced → that leg is worth exactly what the priced leg paid, because there is no competing estimate to disagree with.
  4. Neither side anchored, both priced → nothing is inferred. Two spot prices that disagree are a disagreement, not an answer, and forcing them to balance would fabricate a rate.

All 25 rows now export at net $0.00, and the UI says "25 swap rows priced from the other leg" so a preparer knows exactly which rows were derived rather than observed.

Rule 3 also fixed a row that netted to −$23.36 instead of zero: 0.01 ETH sent for 1,171 ADP, where ADP has no market rate. The unpriced leg was worth $0, so the row looked like a $23 loss.

2. FIFO reported −$386 of losses that never happened

An asset disposed of at no known price was recorded as a disposal with zero proceeds — a 100% loss. There were two independent causes:

  • a disposal with no price available for the asset, and
  • a disposal with no matching purchase inside the scanned window, so there is no basis to subtract.

Neither is a loss. Neither is even a fact about the trade. The fix is to count them separately, surface both, and exclude them from the realised figure:

const unpriced = rows.filter(r => r.disposed && !r.priceKnown).length; const unmatched = rows.filter(r => r.disposed && !r.basisFound).length; // realised excludes both, and the export says why 

The honest output for a wallet whose history starts before your scan window is "basis outside the window, gain not computable here" — not a confident negative number. If your tool happily prints a realised loss for a wallet it only partially scanned, that is the tell.

3. Unlimited Permit2 approvals, checked from the client

The other half of the product is a pre-filing safety check: what can move this wallet's tokens right now.

The obvious implementation is an eth_getLogs sweep over Approval events from genesis. On Base that is not possible from a browser, and the reason is measurable:

Public endpoint eth_getLogs block range cap
base-rpc.publicnode.com 50,000
base.drpc.org 10,000
gateway.tenderly.co/public/base 1,000
1rpc.io/base 50
base-mainnet.public.blastapi.io 10

Base produces roughly 43,200 blocks per day. A wallet active since 2024 needs on the order of a million blocks swept per token. At the most generous 50,000-block cap that is 20+ requests per token, and that endpoint rate-limits long before the sweep finishes.

So ask the chain a question that has no history requirement:

allowance(owner, spender) -> uint256 

That is authoritative and current. A revoked approval reads 0 and is dropped. An approval that was granted and later superseded reads its latest value. No event decoding, no indexer dependency, no archive node.

The matrix is tokens the wallet holds × spenders that can move them. Tokens: 11 blue chips always, even at zero balance, because an approval outlives the balance — a drained wallet with a live approval can be re-drained the moment it is funded again. Spenders: 23 contracts verified with eth_getCode, plus contracts discovered from the wallet's own history.

Two candidate spenders were dropped during the build because eth_getCode returned empty bytecode: the Across SpokePool and EURC. Shipping a dead address would have produced silent false negatives — a scam scanner that reassures you.

The measured result

On 0xB2Da8A3a86ee0b3C5334B9bC02E5A76FF0419020, 14 tokens × 23 spenders = 322 allowance reads, two live unlimited approvals, both to Uniswap Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3):

Token Allowance Exposed
XDP unlimited $793.03
USDC unlimited $479.75

$1,272.78 exposed through one master allowance contract. Both confirmed with a raw eth_call before the UI was trusted.

The one case this method escapes: an approval granted to a contract the wallet never transacted with directly. It is rare — approvals are almost always granted by transacting with the spender — but it is not zero, so the panel states the limitation rather than claiming completeness.

4. The latency that mattered was not the chain

The first implementation chained everything and took 16–28 s. Two thirds of that belonged to a third party: Blockscout's token-balances measured between 2.4 s and 14.4 s under load.

The fix that actually moved the number was parallelising the JSON-RPC batches. batchCall originally sent 25-call chunks sequentially, so 322 allowance reads meant 13 round trips in a queue. Spreading the same calls over 8 concurrent HTTP requests:

253 reads in 750 ms, versus ~3 s sequential.

The second fix was architectural: the emergency answer — is my money exposed right now — must not wait for an indexer at all. Blue-chip balances and the allowance matrix both fire at t=0 against RPC only, so the risk panel paints at 300–1200 ms while the transaction history is still loading. Progressive paint, measured in-browser:

Milestone Time
Balance + unlimited approvals 300 – 1200 ms
Full token coverage (indexer) 2.7 – 12 s
Tax ledger (indexer) 8 – 18 s
Cached re-scan 432 ms

5. Paying without a server, and what that obliges you to state

There is no backend, so there is no session to mint and no licence database. A licence is:

  1. a Base transaction transferring at least 9 USDC to the project address,
  2. verified in the browser via eth_getTransactionReceipt, checking status === 0x1 and decoding the ERC-20 Transfer log for that token and recipient, and
  3. stored as { txHash, paid, block }, then re-verified on every load — a copied localStorage value is not a licence, because the chain decides.

Two things this design forces you to get right. First, a wallet that cannot afford the purchase must be told before it signs:

const { usdc, eth } = await preflight(from); // balanceOf + getBalance, live if (usdc < PRO_PRICE_UNITS) { // "This wallet holds 3.20 USDC on Base. The licence costs 9.00 — top up, then pay again." return; } 

Second, a rejected signature must grant nothing and say so. Both are covered by an 18-assertion Chromium test that drives the real page with a mock provider and asserts the calldata, the receipt decode, the stored licence, the rejected-signature path and the no-wallet fallback. Mocking window.ethereum is the only way to test a payment flow honestly; mocking the product would not prove anything.

The machine-readable surfaces are published as static files, because there is no API behind them:

  • /.well-known/x402 — price, asset, payTo, and, stated plainly, that delivery is the receipt rather than a server-side session.
  • /.well-known/mcp.json + a working stdio MCP server (scan_approvals, check_allowance, pricing) that imports the same engine the page uses.

The x402 record is worth reading if you are wiring agent payments: it says noServerDispatch, because claiming a paid endpoint you do not run is exactly the kind of thing that makes an agent pay for nothing.

What to take away

  • A swap that does not net to zero is a pricing bug, not a market move. Rank your price sources and mark every derived leg.
  • A realised loss on an asset with no basis is a missing data bug. Say "not computable", not "−$386".
  • allowance() answers "what is exposed right now" in one request per pair. A log sweep answers "what was ever approved" and costs an archive node. Know which question the user asked.
  • 322 chain reads in under a second is a batching problem, not an infrastructure problem.
  • If you sell a licence as a transaction, verify the receipt on every load and tell an underfunded wallet before it signs.

Scan a wallet for free, no account, no upload: https://9xj89gzrtw-hue.github.io/base-tax-audit/

Code and the full engineering notes: https://github.com/9xj89gzrtw-hue/base-tax-audit