> ## Documentation Index
> Fetch the complete documentation index at: https://celoref.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Edge cases

> Every known edge case, its status, how it is handled and the test that shows it.

Every edge case we know of: the ones in the original design and the ones found while building and
testing. Each has a status and the test that shows it.

* **Solved**: the code handles it and a test proves it.
* **Reduced**: the code narrows it; something is left, and we say what.
* **Inherent**: no code fixes it; documented so builders and users can decide.

Tests, run from the [repository](https://github.com/soumyacodes007/Arkognito): `unit` = `npm test`,
`p1` = the Phase 1 spike scripts, `p2`–`p5` = `npm run test:phaseN`, `edge` = `npm run test:edge`,
`issuer` = `npm run test:issuer`, `browser` = `apps/demo/test-worker-vault.html`,
`friction` = a reproduction in `arkiv/friction.md`.

## Network: who sees your IP

| # | Case | Status | How | Test |
| - | - | - | - | - |
| R7 | Relayer logs IP + request, linking a network identity to `S` | **Solved** with OHTTP; **Reduced** without | `ohttpRelay` option: every relayer call is HPKE-encrypted to the relayer's gateway key and sent through an independent OHTTP relay. The relay sees the IP, not the request; the relayer sees the request, not the IP. Gateway keys come from heartbeat entities on Arkiv, so a relay cannot swap in its own. Without OHTTP: relayers keep no logs, but you have to trust that. | unit (round trip, relay sees only ciphertext, wrong key fails), edge (client never contacts relayer; relay refuses unknown targets) |
| T3 | Same IP at issuer and relayer links issuance to redemption | **Solved** with OHTTP | Redemption goes through the relay, so the relayer never sees the IP that the issuer saw. | edge |
| N1 | OHTTP relay used as an open proxy | **Solved** | The relay forwards only to relayers announced in live heartbeats (or an explicit allowlist). | edge |
| N24 | Heartbeat squatting: anyone can publish a heartbeat claiming a real relayer's URL with their own key, to block it or capture traffic | **Solved** | The client tries every key claimed for a URL; only the real gateway can decrypt, and its answer must name that heartbeat's creator. A squatter's key just fails. | edge (fake heartbeat with a random key: client still reaches the real relayer) |
| N25 | SSRF through the relay: a fake heartbeat naming an internal address (e.g. 169.254.169.254) | **Solved** | The relay forwards only to `https` targets on public hosts that answer `/v1/info` with the heartbeat creator's address and the same gateway key; no redirects. `OHTTP_ALLOW_LOCAL=on` is for local development only. | edge (internal target refused; loopback refused in production mode) |
| N2 | Relay and relayer run by the same party | **Inherent** | OHTTP only helps if they do not collude. Run the relay under a different operator. | — |
| N3 | The Arkiv RPC provider sees your IP and what you read | **Reduced** | See "Reads" below: no read names your records. Your IP still reaches the RPC; pass your own `publicClient` through a VPN, Tor or your own RPC to hide it. | edge |

## Reads: what the RPC provider learns

| # | Case | Status | How | Test |
| - | - | - | - | - |
| D1 | `myEntities()` asking for "owner in \[S1, S2…]" reveals your address set | **Solved** | The client downloads the app's (key, owner, tag) list for everyone, opens its own tags locally, and fetches only whole batches, plus 2 random cover batches. | edge (a recording RPC proxy: no read names a key or owner of yours) |
| N4 | Reading your record back right after writing it (`getEntity(key)`) told the RPC which record is yours | **Solved** | The post-write check fetches the whole batch by block and creator, never one key. | edge |
| N5 | Paging breaks when a block lands mid page-through (cursor tied to its block) | **Solved** | Every page-through is pinned with `atBlock(head)`. | p2 (200+ records) |
| D2 | Large apps make the download slow | **Reduced** | The list carries no payloads; payloads come per batch. Still linear in the app's record count. | — |

## Keys and finding your records

| # | Case | Status | How | Test |
| - | - | - | - | - |
| N6 | After your early records expire, a counter restarts and reuses an old `S`, linking new and old records in history | **Solved** | No counters. Each record's key comes from a fresh random 15-byte id. The id travels in the record's `vtag` attribute, AES-GCM-encrypted under a key only your root can derive; you find your records by trial-decrypting tags. | unit (200 keys, all distinct) |
| K7 | Two devices pick the same record address | **Solved** | Same: random ids, no shared counter. | unit, edge (phone + laptop, 3 distinct owners) |
| K8 | Gaps in counters hide records beyond a gap limit | **Solved** | Same: there is no gap limit to fall off. | edge (second device finds everything) |
| N7 | Tags themselves as a fingerprint | **Solved** | Every tag is 44 bytes (59 chars) for records and scopes alike, with a fresh IV each time. | unit, edge |
| K1 | Wallet signs non-deterministically | **Solved** | Signs twice, refuses on mismatch, offers random-root mode with a recovery code. | unit |
| K2 | Smart-contract wallets | **Reduced** | Detected and refused with a clear error (account has code). Not supported in v1. | — |
| K3 | Phishing site asks for the same signature | **Reduced** | EIP-712 with a fixed purpose, app and chain id; the wallet shows the requesting site. A user who signs it anyway on a phishing site loses unlinkability for that app. | — |
| K4 | The derivation signature leaks | **Inherent** | Past records become linkable. Rotate to a new purpose for future records. | — |
| K5 | Lost wallet | **Inherent** | Records stay on Arkiv but cannot be found or proven. Random-root mode has a recovery code. | unit |
| K6 | Same keys on another network | **Solved** | Chain id is in the derivation message. | unit |

## Browser compromise

| # | Case | Status | How | Test |
| - | - | - | - | - |
| D3 | The page (or a script it loads) reads derived keys | **Solved** for reading; **Reduced** overall | Keys live in a Web Worker; the page gets addresses, tags, ciphertext and signatures only. | browser (page-side object holds nothing but `app`) |
| N8 | A compromised page makes a one-time key sign anything (e.g. a statement linking it to your wallet) | **Solved** | Signers expose only `signEnvelope(fields)` and `signProof(fields)`; the worker builds each digest itself. There is no "sign these bytes". | unit, browser |
| N9 | A compromised page asks for ownership proofs of every record | **Inherent** | A hostile frontend can still use the worker as designed. Only load Arkognito on pages you trust; no third-party scripts. | — |
| N10 | Tokens in localStorage read by another script on the origin | **Reduced** | Tokens are unlinkable to records, so a stolen token reveals nothing; it can be spent by the thief (one write). | — |
| N11 | Two tabs submit at once and land in the same batch (B4 across tabs) | **Solved** where Web Locks exist | Writes take a `navigator.locks` lock per user per app. | — (browser API) |
| N23 | A server writing for many users in one process gets all of them serialised behind one lock (and a record waiting for `minAnonymity` blocks everyone) | **Solved** | The lock is per user: its name derives from the user's own vault and never leaves the machine. Found because Node 24 implements Web Locks. | edge (3 users in one process land in one batch) |

## Timing

| # | Case | Status | How | Test |
| - | - | - | - | - |
| B1 | "Request arrives, 120 ms later the record appears" | **Solved** | Batching every ≥ 5 blocks with shuffle, plus a 0–20 s client delay. | p3 |
| T2 | Token fetched right before use links issuance and redemption in time | **Solved** | Tokens are fetched at session start, kept across sessions (`browserTokenStore`), spent oldest first, and never before they are 60 s old. | unit (oldest first, min age) |
| B5 | Predictable flush times | Accepted | Same for everyone. | — |
| N12 | Polling the relayer's status reveals interest | Accepted | The relayer already knows the request; through OHTTP it does not know who asks. | — |

## Batches and anonymity sets

| # | Case | Status | How | Test |
| - | - | - | - | - |
| B2 | Quiet hours: a batch of 1 or 2 | **Solved** when asked for | `minAnonymity: k`: the relayer holds the record until a batch of ≥ k forms, up to `maxHoldMs`, and fails it rather than write it in a smaller batch. Default 1 (write anyway, report the size). | edge (alone → refused; 3 together → batch of 3) |
| B4 | One user's records in the same batch | **Solved** | One write in flight per client and per app across tabs. | p3 |
| N13 | Spent-token markers ordered like the records they paid for | **Solved** | Markers are shuffled separately from records. | — (code) |
| N14 | One bad record reverts everyone's batch | **Solved** | Strict validation before queueing; a failed batch is split in halves and retried. | — (code; validation in edge) |
| B3 | Decoys to pad small batches | Out of scope | Decoys would pollute public app data. `minAnonymity` is the honest alternative. | — |

## Content fingerprinting

| # | Case | Status | How | Test |
| - | - | - | - | - |
| F1 | Unusual expiry | **Solved** | Rounded up to 1 h / 1 d / 7 d / 30 d / 90 d. | unit |
| F2 | Payload size | **Solved** | Padded to 512 B / 1 KB / 4 KB / 16 KB. | unit, p3 |
| N15 | Encrypted records larger than plain ones | **Solved** | Padding always reserves room for encryption, so both land in the same bucket. | unit |
| F3 | Timestamps, precise locations, postal codes, free text in attributes | **Reduced** | `check()` warns; `coarse.day/hour/geo/bucket` helpers; `strict: true` refuses. | unit |
| N16 | A rare combination of ordinary values identifies the writer | **Reduced** | Rarity check (k-anonymity): warns when fewer than k (default 3) existing records share the values. | unit, edge |
| A6 | Expiry hides records from queries but the payload stays in transaction history | **Solved** for private data | `encrypt: true`: AES-256-GCM under a per-record key only the owner derives. Public data stays public by design. | unit, edge, friction |
| F4 | App-specific attribute names reveal the app | Accepted | The app is public anyway. | — |
| F5 | Content identifies a person by itself | **Inherent** | No cryptography fixes this. Documented. | — |

## Relayer behaviour

| # | Case | Status | How | Test |
| - | - | - | - | - |
| R1 | Relayer edits payload, attributes or expiry | **Solved** | Signed envelope; reader checks signature against `$owner`. | p2 |
| R2 | Relayer transfers to the wrong owner | **Solved** | Client verifies the batch, marks the relayer bad, retries elsewhere. | p2 |
| R3 | Relayer drops or delays (censorship) | **Solved** | Timeout when the queue stops moving; failover. | p3 |
| R4 | Relayer writes, then fails to say so; the retry writes a duplicate | **Solved** | Before retrying elsewhere the client looks for its own tag in a fresh listing and recovers the record. | edge (ghost relayer: one record, `recovered: true`) |
| R5 | Stale key predictions | **Solved** | One writer per wallet; re-predict on failure. | p1 |
| R6 | Relayer out of gas | **Reduced** | Balance checked every minute, alert below a threshold. | — |
| R8 | One relayer alive | **Solved** | Two relayers, discovery via heartbeats; dead ones expire out. | p3 |
| N17 | Stale heartbeat settings after a restart (old gateway key) | **Solved** | On start the relayer patches its existing heartbeat with current settings. | — (code) |

## Tokens and abuse

| # | Case | Status | How | Test |
| - | - | - | - | - |
| T1 | Issuer gives one user a unique key | **Solved** | Keys only from Arkiv; two for one day = refused. | p4 |
| T4 | Same token at two relayers at once | **Solved** + monitored | Shared spent list, checked at redemption and right before writing; any id recorded twice is reported. | p4, issuer |
| T5 | One person, many wallets | **Solved** when the issuer requires it | Optional attestation codes (e.g. handed out in person): quota per code, one day per code. Without codes: per wallet, stated plainly. | edge |
| T6 | Tokens shared or sold | **Reduced** | Daily expiry keeps the damage small. | — |
| T7 | Per-place limits would reveal where you went | Decision | Daily totals only. | — |
| T8 | Key reuse across protocols | **Solved** | Dedicated RSA key per app per day. | p4 |
| T9 | Day boundary | **Solved** | Previous day's tokens accepted for one hour. | unit |
| N18 | Issuer restart resets everyone's quota | **Solved** | Optional quota file, keyed by HMAC so it names no wallet. | issuer |
| N19 | Concurrent issuance requests overrun the quota | **Solved** | Quota reserved before any await. | issuer |
| X1 | Spam with tokens off | **Reduced** | Per-address rate limit kept in memory only, never logged. | edge |
| X3 | Large junk payloads | **Solved** | 16 KB per record. | unit |
| N20 | Memory exhaustion through a huge queue | **Solved** | Queue cap (503). | edge |
| X2 | Banning a bad actor | Decision | At issuance (wallet or code), never at record level. | — |

## Arkiv-specific

| # | Case | Status | How | Test |
| - | - | - | - | - |
| A1 | `$creator` permanent | **Solved** | The relayer creates every record. | p1 |
| A2 | `OwnershipTransferred` shows relayer → `S` | Accepted | That is the anonymity set. | — |
| A3 | `S` has no gas | Decision | Records are create-only; updates are new records. | — |
| A4 | `permissionlessExtension` | Decision | Off. | — |
| A7 | 200 per page, no ordering, no count | **Solved** | Pinned page-through, sort in JS. | p2 |
| A8 | 128-byte string limit | **Solved** | Signature in the payload; tag is 59 chars. | unit |
| N21 | Uppercase attribute names rejected by the node | **Solved** | Lowercase enforced in client and relayer. | friction |
| N22 | SDK refuses `$createdAt` filters the node supports | **Worked around** | Raw query for batch fetches. | edge |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.