← klenba.com

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:

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.

entry cap enforced at write time.

funds. It cannot be a single point of theft.

rather than automatic under load.

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

is not reconstructed. New users are fully covered.

general "ok" result. Fixing that is on the list.

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.*

Open the vault →