Skip to content

feat(ledger): add multichain chain access - #64

Draft
Wondertan wants to merge 7 commits into
feat/ledger-catalogfrom
feat/ledger-client
Draft

Wondertan wants to merge 7 commits into
feat/ledger-catalogfrom
feat/ledger-client

Conversation

@Wondertan

@Wondertan Wondertan commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Stacked on #62. Adds chain access to @libid/ledger that serves many ledgers from one client. Every use case starts multichain, so there is no single-chain client. The client runs reads and writes that the layer above describes as one implementation per ledger family, so the ledger holds no identity or application getters. (Folds in the former #66.)

const client = connect({
  ledgers: [
    { ledger: Ledgers.EdenTestnet },                // read through the connected wallet
    { ledger: anvil, rpc: local, indexer: false },   // this ledger reads only its chain
    { ledger: other, rpc, indexer: otherIndexer },    // this ledger uses another indexer
    { ledger: custom, client: customClient },         // this ledger goes through another client
  ],
  indexer: indexer({ origin, deployment: 'identityRegistry' }), // default for every ledger
})
await client.read(Ledgers.EdenTestnet, identitiesOf, [account])
const session = await client.connect(wallet)          // one wallet for every EVM ledger
await session.send(ledger, tx)                        // switches the wallet's chain if needed
client.ledger(reportedChain)                          // runtime lookup, e.g. what a wallet reports

Package

  • @libid/ledger gains the ./client and ./evm entry points, both built into the published package, and a runtime dependency on viem. The root entry point stays dependency-free apart from @noble/hashes.

Catalog

  • Ledgers names the pinned ledgers (Ledgers.EdenTestnet); its members are the ledgers themselves, so no catalog id is a string.
  • Ledgers.EdenTestnet and Ledgers.Sepolia pin the libid.IdentityRegistry deployment (0x0531…1366, key identityRegistry). This is the same canonical address on both chains, from libid-contracts 0.15.0 and chain-configurations. Both carry the testnet notary and a public explorer. On 2026-10-01 I checked on-chain that each chain's verifier reports the chain hash its catalog entry derives (the test vectors), that the registry answers, and that the testnet notary's signer is trusted. The names indexer covers the registry on both chains.
  • libID pins no RPCs. Explorers are pinned where public.

Multichain client

  • Every method takes the Ledger it acts on.
  • Reads go through the connected wallet while it is on the ledger's chain, then an optional RPC. With neither, a read fails with unreachable. Without an RPC, a wallet that does not know the chain cannot be offered it. Configured endpoints are validated.
  • Catalog ledgers are pinned: a catalog chain is served only with its catalog definition, so its deployments and notary cannot be changed.
  • A ledger can instead be reached through another LedgerClient, such as a custom or differently configured one.
  • walletRequirements lists chains, methods and events per CAIP-2 namespace, ready for WalletConnect's namespaces.
  • Duplicate chains and unserved ledgers are rejected.
  • Ledgers carry family (derived by defineLedger), so family-dependent types resolve by plain property lookup. Code generic over families type-checks without conditional types.
  • Each family implements a Driver: one ledger's operations. The client routes to them.

Family modules

  • @libid/ledger/evm holds the EVM Reader, Tx and Provider types for query authors, the EVM driver, and the chain-hash and address checks defineLedger uses.
  • The EVM driver carries handles.link's wallet-first transport over as-is: WalletConnect CAIP-25 approvals, failure classification, cooldowns, and discarding reads that cross a chain change.
  • connect(wallet) connects the wallet the user picked for every served ledger of its family, replacing any connected before. The Session follows the wallet: account tracks account switches, subscribe reports them, and chooseAccount() opens the wallet's account picker (wallet_requestPermissions), or fails as unsupported.
  • session.send(ledger, tx) switches the wallet to the ledger's chain if needed, rechecks the account and simulates before asking the wallet to send. A LedgerError from send (wallet-changed, not-sent, rejected) means nothing was sent; any other error leaves the outcome unknown.
  • @libid/ledger/client stays family-agnostic and refers to family types only through one Families entry per family. The root entry gains only the type-only Account brand.

Indexer

  • A Query keeps its required chain implementation per family and may add an indexer implementation for the same result.
  • One indexer({ origin, deployment }) serves every chain its /v1/status reports.
  • On a ledger with an indexer, queries with an indexer implementation are answered by the indexer alone, while it is current:
    • the named deployment is reported on that chain;
    • it is within maxLag blocks (default 20) of the head, as reported and by head gap;
    • there is no window error and the report is valid;
    • the index does not move backwards during the read.
  • Otherwise the read fails with LedgerError('indexer-unavailable'); there is no fallback to the chain. A caller's abort stays an abort, and a query's own errors pass through. indexer: false gives a ledger none, so its queries read the chain.
  • Status checks and request settings are ported from handles.link's indexer.ts. Application endpoints stay in consuming queries.

Adding a family
Adding a second family was simulated. The compiler flagged only three things:

  • the conformance harness registry;
  • the driver dispatch;
  • queries and commands without the new family.

The router, the EVM driver and the conformance suite needed no change.

Verification

  • pnpm --filter @libid/ledger build, typecheck, test: 104 tests pass.
    • 49 conformance tests. These cover every LedgerClient and Session member, including several ledgers with independent state, runtime lookup, unserved and duplicate ledgers, per-namespace wallet requirements, and delegation. They run twice: plain, and with an unavailable default indexer.
    • 16 indexer tests, including one indexer serving two chains and the composed scenario above.
    • 11 EVM tests, including the six wallet-first transport tests ported from handles.link's rpc.test.ts, and 8 catalog tests.
  • Mutation checks, each caught:
    • routing by chain;
    • per-ledger indexer override;
    • duplicate chain guard;
    • chain fallback;
    • session indexer wiring;
    • wallet-requirement chains;
    • indexer lag bound;
    • backwards-index check.
  • One redundant branch that no test could catch was removed.
  • The root index.d.ts imports nothing and client.d.ts names no viem types.
  • Workspace-wide pnpm build, typecheck, test (ledger 104, popup 154, ceremony 996), lint (biome and oxlint's complexity limit), fmt:check and test:packages pass on main at cbe7f35.

Not run: against the live testnet indexer or a real wallet; @libid/identity will be the first consumer.

@Wondertan Wondertan changed the title feat(ledger): add namespace-keyed chain access feat(ledger): add multichain chain access Sep 30, 2026
@Wondertan
Wondertan added this pull request to stack #68 September 30, 2026 20:04
@Wondertan
Wondertan removed this pull request from stack #68 October 2, 2026 20:13
@Wondertan
Wondertan added this pull request to stack #107 October 2, 2026 20:13
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://feat-ledger-client.previews.lib.id, https://feat-ledger-client-libid.grounded-systems.workers.dev (commit b967faa)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://4bb274e2.previews.lib.id, https://4bb274e2-libid.grounded-systems.workers.dev b967faa 2026-10-02T20:15:22.699Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://726ac0fe.previews.lib.id, https://726ac0fe-libid.grounded-systems.workers.dev 0bf8963 2026-10-02T20:13:52.711Z Visit the dashboard ↗

`@libid/ledger/client` serves many ledgers from one client. It runs reads and
writes the layer above describes as one implementation per ledger family, so
the ledger holds no identity or application getters.

- `connect({ ledgers, indexer })` reaches each ledger through its RPC or
  through another client. Every method takes the ledger it acts on, and
  `client.ledger(chain)` looks one up at runtime.
- `read` pins a query's actions to one ledger state. `tx` and `estimate` build
  a command's transaction and its network fee.
- `connect(ledger, wallet)` returns a session whose reads prefer the wallet's
  own RPC, ported from handles.link. `send` rechecks the wallet and simulates
  first; a `LedgerError` from it means nothing was sent.
- A query may add an `indexer` implementation. One libID indexer serves every
  chain it reports and is used while current, otherwise the chain answers. The
  default indexer can be replaced or turned off per ledger.
- EVM specifics live in `@libid/ledger/evm`, and ledgers carry their family.

A conformance suite covers every client and session member against a harness
each family must provide, including with an unavailable indexer.

Assisted-by: Claude Opus 5.5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
`readFailure` exceeded the workspace's oxlint complexity limit. Its checks move
into named predicates for refusals, transient failures, unsupported methods and
transport errors, with no change in behavior.

Assisted-by: Claude Opus 5.5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
A client now reads each ledger through the wallet connected on it, then the
ledger's RPC. Catalog ledgers carry a public RPC and explorer where one exists,
used by default, offered to wallets that must add the chain, and overridable per
client entry; a ledger without a public RPC needs one configured. A catalog
chain is served only with its catalog definition, so its deployments cannot be
changed. `Session.read` is gone: client reads use the connected wallet.

Assisted-by: Claude Opus 5.5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
A client entry's `rpc` and `explorer` must be endpoints, like a ledger's
public ones, so a misconfigured URL fails when the client is created.

Assisted-by: Claude Opus 5.5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
…p indexer fallback

- The catalog is `Ledgers`, whose members are the ledgers themselves
  (`Ledgers.EdenTestnet`), so no catalog id is a string.
- Eden's `identityNames` moves to the live `libid.IdentityNames.3` deployment
  (0x5b86…7796) named by chain-configurations and indexed by the names indexer;
  the previous address has no `accountsOf` getters. Its public RPC stays on
  Gateway.fm, which allows browser origins; the chain-configurations endpoint
  sends no CORS headers.
- On a ledger with an indexer, queries with an indexer implementation are
  answered by the indexer alone. A stale or unavailable indexer fails the read
  with `indexer-unavailable` instead of falling back to the chain; a caller's
  abort stays an abort, and a query's own errors pass through.

BREAKING CHANGE: `ledgers['eden-testnet']` is now `Ledgers.EdenTestnet`, and
indexer failures no longer fall back to the chain.

Assisted-by: Claude Opus 5.5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
… pin no RPC

- `client.connect(wallet)` connects the wallet the user picked for every served
  ledger of its family, replacing any connected before. `session.send(ledger,
  tx)` switches the wallet to that ledger's chain first.
- The session follows the wallet: `account` tracks account switches,
  `subscribe` reports them, and `chooseAccount()` opens the wallet's account
  picker (`wallet_requestPermissions`), or fails as `unsupported`.
- libID pins no RPC; Eden's is removed. An RPC is optional: without one, a
  ledger is read only through a connected wallet on its chain, reads with
  neither fail with `unreachable`, and a wallet that does not know the chain
  cannot be offered it.
- Ledger errors thrown inside viem transports are unwrapped, so callers see
  `unreachable` rather than a generic RPC error.

BREAKING CHANGE: `connect(ledger, wallet)` is now `connect(wallet)`, and
`send(tx)` is now `send(ledger, tx)`.

Assisted-by: Claude Opus 5.5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
libid-contracts 0.15.0 renamed the identity contract to IdentityRegistry and
deployed it fresh as `libid.IdentityRegistry` (0x0531…1366), the same on every
EVM chain; chain-configurations now lists it on Eden and Sepolia. The catalog's
deployment key is `identityRegistry`, Eden moves to the new registry, and
Sepolia joins with the testnet notary and its public explorer. Both verifiers'
chain hashes, the registry and the notary's trust were checked on-chain, and
the names indexer already covers the new registry on both chains.

Assisted-by: Claude Opus 5.5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant