Yesterday we wrote up Phase A. The chain
runs end-to-end: SDK upgraded, Celestia DA wired, Risc0 zkVM landed, real
$LGT transfers through sov-bank, devnet config checked in.
A working engine isn't the same as a working surface, though. Phase A gave us a chain. The next pass made it readable, queryable, upgrade-aware, and public. This post is what landed in the days after.
What shipped
1. The chain repo is public.
ligate-chain flipped from private to public on
github.com/ligate-io/ligate-chain.
Dual-licensed Apache-2.0 / MIT (LICENSE-APACHE + LICENSE-MIT
checked in). README polished with the brand lockup, CI badge, license
badge, docs link, and a quick-start that runs cargo build and
cargo test end-to-end. Public-repo hygiene landed alongside:
CONTRIBUTING.md, CODE_OF_CONDUCT.md, issue + PR templates,
Dependabot config (with sensible ignores for SDK-locked dependencies
that we can't bump independently). SECURITY.md documents responsible
disclosure via GitHub Private Advisories as the primary channel.
The flip was deliberate. We held the repo private through Phase A because a half-finished SDK migration is a worse first impression than no repo. With Phase A merged and the licensing + community files in, there's nothing embarrassing to read.
2. Query RPC is live.
Three REST endpoints expose the read side of the attestation module:
GET /modules/attestation/state/schemas/items/{schema_id}
GET /modules/attestation/state/attestor-sets/items/{attestor_set_id}
GET /modules/attestation/state/attestations/items/{attestation_id}
Single-key lookups for the three protocol primitives. Wired through
the SDK's ModuleRestApi derive on crates/modules/attestation and
covered by five integration tests against the
TestRunner::setup_rest_api_server harness. A verifier tool can now
hit a Ligate node over HTTP and answer "does an attestation with
this id exist?" without parsing any raw chain bytes.
Range queries (list_by_schema, time-bucketed, by-submitter,
aggregations) deliberately do not live on the node. Those go in
the indexer service tracked under
#91. The node
gives you single-key truth; the indexer answers everything else. Same
pattern as Cosmos / Substrate / EVM — the chain is for consensus,
the indexer is for product queries.
3. Wire-format snapshot tests.
crates/modules/attestation/tests/borsh_snapshot.rs now pins the
Borsh layout of every public protocol type (Schema,
AttestorSet, Attestation, SignedAttestationPayload, the
CallMessage enum). Any change to a field, a field order, or an
enum-variant order fires the test red.
This sounds boring. It is exactly the boring discipline a permanent
ledger needs. The wire format is the contract clients depend on. If
we silently re-order a CallMessage variant, every signed-but-not-
yet-included transaction silently re-routes to the wrong handler.
The snapshot test makes that an immediate red CI rather than a
mysterious post-deploy bug.
Paired with this: every public protocol enum (AttestationError,
ClientError, GenesisError) is now #[non_exhaustive]. Adding a
new error variant downstream is now a non-breaking change for
consumers who already wrote _ => arms — which they're forced to,
because the attribute requires it.
4. Upgrade story written down.
docs/protocol/upgrades.md
is now the policy document for how the chain handles change. Two
concepts:
- Soft fork — anything that doesn't break state continuity or
signature validity. Adding a new
CallMessagevariant at the end of the enum, adding a new module to the runtime composition, adding a new RPC endpoint, adding a new error variant. Ships in normal releases. Old and new binaries co-exist on the network. - Hard fork — anything that changes how an existing on-chain
value is encoded, addressed, or interpreted. Reordering an enum
variant. Bumping a
MAX_*constant. Changing theSchemaIdderivation rule. These bump the chain id, regenerate genesis, and drop in-flight transactions on the old chain.
The chain id itself is now a documented ladder, locked across the
spec, runbook, README, and genesis example: ligate-localnet (local
only, never bumped) → ligate-devnet-1 → ligate-devnet-2 (on a
devnet hard fork) → ligate-testnet-1 → ligate-1 (mainnet) →
ligate-2 (post-mainnet hard fork). No ambiguity about what a chain
id "means" or how it ticks.
What that gets you, in one paragraph
A reader can now clone the chain repo, build it, run its tests, hit its REST endpoints, audit its wire-format guarantees, and read the policy document for how upgrades will be coordinated through devnet, testnet, and mainnet. None of that was true a week ago.
What's still ahead
Phase B is partner integration. The protocol works end-to-end as of A.4, the surface is now legible as of this pass. The next things on deck:
- The Themisra Proof-of-Prompt schema crate, rebased on the new SDK.
- The Iris MCP server + relayer, in a separate repo.
- The list / range query layer in the indexer service, not on the node.
- A public devnet faucet, explorer, and hosted RPC endpoint (Q2 2026).
- v1's
stakinganddisputesmodules.
If you're a regulator, an audit firm, an AI provider, or a standards body and you want to register a schema or run an attestor set against this protocol, hello@ligate.io.
Architecture: the three things in detail
The remainder of this post is for readers who want to understand how the query RPC, the wire-format guarantees, and the upgrade machinery actually work. If you only wanted the headline, you have it.
Query RPC: where the endpoints come from
The three endpoints aren't custom HTTP handlers. They come from the
SDK's ModuleRestApi derive macro, applied to the attestation
module's call type. The derive walks the module's StateMap and
StateValue declarations, and for each one with a (K, V) pair it
emits a GET /modules/{module_name}/state/{name}/items/{key} route.
The handler resolves the key, reads the latest committed state from
the SDK's storage layer, and returns the value as JSON.
Three StateMaps on the attestation module → three endpoints. The
test suite under
crates/modules/attestation/tests/integration.rs
covers the round-trip: register a schema, hit get_schema, expect
the right shape back; submit an attestation, hit get_attestation,
expect the right hash. Five tests, all under
TestRunner::setup_rest_api_server, no manual route table to keep
in sync.
The deliberate non-feature: no list, no filter, no aggregation. Those queries belong in an indexer process that subscribes to chain events, materializes them into a Postgres-style relational view, and answers application-level questions ("how many attestations did OpenAI submit in March?", "what schemas does this account own?"). A node should not be doing analytics. The indexer service is tracked under #91 and ships independently of the chain release cycle.
Wire-format snapshots: the boring discipline
Borsh is positional. Every field's encoding offset is determined by
its declaration order. Every enum variant gets a tag equal to its
declaration index. So enum Foo { A, B, C } and
enum Foo { A, C, B } produce different bytes for the same logical
value. A test that doesn't compare bytes won't catch the swap.
borsh_snapshot.rs builds a deterministic instance of each public
protocol type, encodes it with Borsh, and asserts the resulting
hex against a known-good hex string. Any reordering fires the test.
Adding a field or a variant at the end shifts only the trailing
bytes and the snapshot is updated in the same PR — that's an explicit
acknowledgment of "I know I'm changing the wire format, I've thought
about whether existing data still decodes, here's the new snapshot."
Combined with the #[non_exhaustive] enum convention, the rule
becomes: only ever append. Reorder, rename, or remove a public
type's fields, and CI breaks.
CHAIN_HASH guard: hard forks can't sneak through
The CHAINHASH constant lives in crates/stf/build.rs and is derived
deterministically from the runtime's universal-wallet schema at
build time. It's a hash of the _shape of the runtime: which
modules are composed, in what order, with which state-item layouts.
The SDK's transaction authenticator includes CHAIN_HASH in every
signature's domain-separation tag. So when a node operator runs a
binary built against runtime composition X, that node only accepts
transactions signed against runtime composition X. If you swap a
module in or change a module's Config shape, the hash changes,
and every old transaction stops verifying.
Practical effect: there's no possibility of a node "accidentally"
running with a subtly different runtime than its peers. They either
agree on the hash or they don't transact. Chain operators can't
silently roll a runtime change forward through cargo update and
hope nothing notices. A runtime change is a hard fork by
construction.
The hash is reproducible across machines because the schema is
derived from canonical Rust types via a deterministic hasher, with
the spec parameters pinned to MockDaSpec + MockZkvm + MultiAddressEvm for hash purposes regardless of the binary's
actual DA / ZkVM / address spec at runtime. Two operators on
ligate-devnet-1 get byte-identical CHAIN_HASH outputs, even if
one runs against MockDa locally and the other against Celestia
mocha.
Chain-id ladder: the rule for going up
ligate-localnet (local-only, never advances)
ligate-devnet-1
ligate-devnet-2 (devnet hard-fork: rename to -3, -4, ...)
ligate-testnet-1
ligate-testnet-2 (testnet hard-fork)
ligate-1 (mainnet)
ligate-2 (mainnet hard-fork)
Locked in constants.toml,
the protocol spec, the devnet runbook, the README, and the example
genesis file — five places that will all fail to build / fail to load
genesis if they disagree. There's no "ligate-devnet-2.0.1" or
"ligate-1-rc". Hard forks are integers; soft forks are normal
releases. The ladder removes the entire class of "what does this
chain id mean" questions.
If you want to read the code: the chain repo flipped public this week. Start at the README, then the attestation protocol spec, then the new upgrades doc. Issues and discussions are open at github.com/ligate-io/ligate-chain.