Ledger-verified wallet history on the Internet Computer
*How Klenba reads ICP and ICRC ledgers so a wallet's transaction history can be checked rather than
trusted — and the traps that cost us real time.*
The problem with "history"
Most wallets show a history assembled from what the app happened to observe. The app was running, it
saw a transfer, it wrote a row. If it was offline, if it misread the memo, if it double-counted, the
user has no way to tell. The list looks authoritative, which is the dangerous part.
A wallet that shows a wrong history is worse than one that shows none, because you will act on it.
So the rule we built to: **every entry must be verifiable against the ledger it happened on, and the
proof must be stored with the entry.** No client-asserted rows. Ever.
What "verified" means concretely
An entry is stored only if it came from the ledger, and it carries:
- the ledger canister it came from
- the block index of the transaction
- a block hash where the ledger provides one
- the direction (send or receive) derived from matching the account on both sides
We deliberately store the proof rather than a boolean "verified" flag: a flag can be set by a bug, but
a block index can be re-checked by anyone.
Receives matter as much as sends. Sends are easy to know about — you initiated them. Receives are
where wallets quietly lie, because the only honest source is the chain. Both directions are read from
the ledger.
The traps
These are the ones that cost us time. They are all still true.
1. The ICP ledger has no get_transactions.
That is an ICRC-1/ICRC-3 interface. The ICP ledger exposes query_blocks({start, length}) instead,
where transfers live inside operation : opt variant { Transfer | Mint | Burn | Approve }. If you
write the ICRC path first and assume it generalises, you will get nothing.
2. query_blocks returns an error shape, not an empty result.
For an out-of-range start you get IC0536, not zero blocks. Treating "error" as "no more data" makes
a sync report success while collecting nothing.
**3. ICRC get_transactions returns bare transactions.**
There is no {id; transaction} wrapper. Each entry is the transaction itself, and the starting index
comes back separately as first_index. Off-by-one here silently shifts every block index you store —
which destroys the only proof you have.
4. Account identifiers are hex, and case matters.
The ICP ledger's account identifier is hex-encoded. We encoded lowercase; the ledger's canonical form
in the responses was uppercase. String comparison therefore matched nothing, and ICP history came
back empty while every other ledger worked. The fix was to normalise to uppercase and compare with a
case-insensitive sameAccount(). Silent, and it looked like "the ledger has no history for you".
5. Both ledgers archive, and you must follow it.
Neither keeps all blocks in the main canister forever. query_blocks hands back archived_blocks
callbacks; ICRC has an equivalent. A sync that reads only the main canister appears to work and then
quietly stops covering new history once the ledger archives. Following the archive is not optional.
6. Fees, memos and timestamps live in different places.
On ICP, created_at_time is non-optional and the timestamp you want is the block's timestamp. On
ICRC, memo, created_at_time and fee sit inside the transfer field. Read them from the wrong
level and you get plausible-looking zeros.
7. Amounts are nat, fees are per-ledger.
Use the ledger's own fee() rather than a constant. Fees change, and a hard-coded fee corrupts
statements.
Storing it: append-only, sharded, and upgrade-safe
History grows without bound, so it cannot live in one canister forever.
- Shards hold entries, with hard caps on accounts and entries per account, and a per-account
entry cap enforced at write time.
- The router holds only metadata — which shard owns which account — and has no ability to move
funds. It cannot be a single point of theft.
- A queue for shard creation/activation, controlled by an admin path, so scale-up is deliberate
rather than automatic under load.
- Non-destructive upgrades: append-only stable fields, versioned seeds, variant freeze. A shard
created by an older version must still be readable by a newer one.
Shard initialisation takes its parameters (router principal, allowlist, caps) as **constructor
arguments** rather than an open init method. An open initShard is a hijack surface: anyone could
call it and point a shard at their own router.
Statements from verified entries only
A monthly statement is generated from verified entries alone. Legacy rows written before the
verification rule existed are never deleted — they are labelled unverified and excluded from
totals. Deleting them would hide history; including them would launder it.
What it looks like running
A real 0.01 ICP transfer was matched, stored with its block index, and read back through the app —
which is the only test that counts. Current sync coverage: ICP, ckBTC, ckETH and one ICRC token.
Limitations, stated plainly
- No historical backfill. Coverage begins when a user's history is first synced; earlier activity
is not reconstructed. New users are fully covered.
- Per-ledger sync failures are not yet reported individually — a failing ledger can hide inside a
general "ok" result. Fixing that is on the list.
- Verification is only as good as the ledger's own guarantees. Where a ledger provides no block
hash, we store the block index alone and say so.
*Klenba is a non-custodial ICP and ICRC vault. wallet.klenba.com. The backend is metadata-only by
design: it cannot move funds. If you are building on ICP and hitting any of the traps above, the
short version is: read the ledger, store the proof, and normalise your hex.*