Skip to content

Vector anchor - #841

Merged
0xSasaPrsic merged 9 commits into
mainfrom
vector-fulfill-anchor-binding
Aug 17, 2026
Merged

Vector anchor#841
0xSasaPrsic merged 9 commits into
mainfrom
vector-fulfill-anchor-binding

Conversation

@0xSasaPrsic

Copy link
Copy Markdown
Member

No description provided.

0xSasaPrsic and others added 8 commits August 5, 2026 13:30
newHead is a uint256 in ProofOutputs but was narrowed with
U256::to::<u64>(), which is uint_try_to().expect(..) and panics on
overflow. The narrowing runs before Groth16Verifier::verify, so the
value is still attacker-supplied at that point -- a dummy 4-byte proof
reaches it. mock_fulfill narrows the same field with no proof
verification at all.

Only the Updater can call either, so this is not permissionless, but a
runtime panic reachable from extrinsic input is a robustness bug: the
extrinsic traps instead of returning an error, wasting the author's
slot and making any block carrying it unimportable.

Replace both conversions with try_into() and a SlotOutOfRange error.
The SP1 circuit derives prevHeader/prevHead from a store it reads
straight from prover CBOR, so the proof alone does not tie an update to
this pallet's state. Without an on-chain binding, Headers is a set of
independent roots rather than a chain, and execute() authorizes bridge
messages against those roots.

Add the upstream SP1Helios.sol check to fulfill: Headers[prevHead] must
equal the proof's prevHeader. This is anchor membership, not tip
continuity -- a proof may anchor at any header the pallet has already
accepted, which is what real prover output does. Verified against
mainnet: the fixture's anchor matches Headers[14823232] exactly.

That invariant has to be reachable from a standing start, and nothing
outside fulfill wrote the state it needs. Genesis now seeds head, its
header, and the head period's sync committee hash together, asserting
they are consistent, so a fresh chain is either unconfigured or fully
able to sync. Updater and the SP1 key stay root-settable: anchor state
is history a chain cannot reconstruct, those two are rotatable policy.

The fulfill benchmark sets Head without Headers, so it needs the same
anchor seed to reach the code it measures.
enable_mock was root-gated but had no chain restriction, so mock could
be armed on a chain bridging Ethereum mainnet, after which the Updater
could write arbitrary headers and execution state roots with no proof
at all. Refuse to arm it when SourceChainId is 1, and re-check in
mock_fulfill so a flag set before this change, or surviving a source
chain id change, still cannot be used.

That guard is also what lets mock_fulfill keep working without the
anchor binding fulfill has. The extrinsic already accepts an arbitrary
header and state root unproven, so requiring a known anchor would buy
no security there -- it would only stop mock from bootstrapping a test
chain, which is its purpose. Recorded as a comment so the asymmetry
with fulfill does not read as an oversight.
Three tests covering how a chain gets from empty to syncing, from both
directions.

The operator sequence: genesis supplies the anchor state no extrinsic
can write, root extrinsics supply the rest, and a real SP1 proof is
then accepted. No direct storage writes, so this exercises the actual
public API rather than a shortcut.

fulfill cannot self-start. Its values are proof-bound, so it cannot
present the zero startSyncCommitteeHash an unseeded chain would match,
and it fails at the committee check before the anchor binding is even
reached -- which is why seeding head and header alone was not enough.

mock_fulfill can start from literally nothing, because its values are
unproven and can carry that zero hash. That asymmetry is the intended
split: mock is the test-chain escape hatch, fulfill must never be able
to conjure a bridge from thin air.
Genesis takes the anchor triple -- `head`, that head's `header`, and the sync
committee hash for the head's period -- and writes them together, so a chain
starting mid-history can accept its first `fulfill`.

Require all three or none. `head` gates the branch, so a spec supplying
`header` and `sync_committee_hash` while omitting `head` would silently drop
both and boot a chain that looks configured while rejecting every update.
Chain specs are JSON patches merged onto the default config, so an omitted
field defaults to zero rather than failing to deserialize; nothing else
catches this.

Tests cover each missing leg of the triple, the inverse case, and the all-zero
case that keeps the older testnet specs booting.
The variant now covers both the disabled flag and the mainnet source chain
guard, so the old name reported `enable_mock` failures as "mock is not
enabled" to a caller trying to enable it. The variant keeps its position, so
on-chain error indices are unchanged.

Also drops the append-here comment above the newer variants.
9f50561 added `head`, `header` and `syncCommitteeHash` to the vector
genesis patch in mainnet.chain.spec.json. That edit could never take
effect: the node embeds mainnet.chain.spec.raw.json, whose `genesis.raw`
key/values are loaded directly, so `BuildGenesisConfig::build` never runs
for mainnet. Regenerating the raw spec to pick the fields up is not an
option either -- it would change the genesis hash and fork the live
network.

So the file only made the plain and raw specs disagree while implying
mainnet gets seeded with an SP1 anchor when it does not. Restored to its
previous contents byte for byte.

Seeding the anchor on an already-running chain needs a migration or a
root call, not genesis.
@ToufeeqP
ToufeeqP marked this pull request as ready for review August 17, 2026 05:47
@0xSasaPrsic
0xSasaPrsic merged commit 82a9b31 into main Aug 17, 2026
12 checks passed
@0xSasaPrsic
0xSasaPrsic deleted the vector-fulfill-anchor-binding branch August 17, 2026 09:03
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.

2 participants