fin-proof: history_first is the attestation's start, never restamped at a proof-chain root (chain_len marks the root)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 07:58:32 +00:00
parent d4041f5f8c
commit 2b7464a488
3 changed files with 5 additions and 8 deletions

View file

@ -13,7 +13,7 @@ The aggregator guest's public values (`BlockOutput`, 340 bytes, mirrored in cons
| `fin_version` | 2 | 1. Zero means "no finality claim" (the old layout never carries the extension) |
| `table_root` | 32 | Commitment to the weight state after the segment's last chain block (section 2) |
| `history_root` | 32 | Merkle mountain range over every chain block the proof chain attests: leaf `n` = `sha256(number ‖ block_hash ‖ daa_n ‖ table_root_n ‖ keys_hash_n ‖ total_n)` |
| `history_first` | 8 | The chain block this proof chain's own attestation started at (section 4.5); earlier blocks in the history come from the root |
| `history_first` | 8 | The chain block the attestation counts from (the activation block on the node's tracker); the proof chain's own root is `number - chain_len + 1` (section 4.5) |
| `lock_index` | 8 | Highest checkpoint index the proof chain has verified a certificate for; 0 before the first |
| `lock_hash` | 32 | That checkpoint's block hash |
| `lock_number` | 8 | Its chain-block number |
@ -87,7 +87,7 @@ The departure: in the guest the frozen table of Q5 never expires. On the node `f
### 4.5 Roots, restarts and the bridging client
A proof chain has a root: the first block whose fold the chain of proofs verified. When the previous proof carries no extension (the activation block, or a fresh chain after an unproven segment, spec 7.8 item 7) the guest takes the witness state as given, stamps `history_first` at that block, and the attestation of that proof chain starts there. On the chain this is safe: the node's tracker holds the true state at every block and the native compare refuses a record whose root state differs (section 5.1). For a light client it is a trust break only if it accepts the root blind. The client rule: a root is accepted from the release it shipped with, or by bridging: the client holds the extension of its last verified proof (ending at block `E`), fetches the few unattested blocks' witnesses (`igneum_getFinalityWitness`, the same bytes a prover gets) and folds them itself with the same code, from the table it also fetches and checks against its held `table_root`; if the fold lands on the new root's extension, the new chain continues what the client verified, at the trust level of section 5.2 for those few blocks. A prover outage therefore costs light clients a bridge of a few blocks, never a 30-day blackout. The mandatory-proof rule (7.8 item 10), once on, makes restarts rare.
A proof chain has a root: the first block whose fold the chain of proofs verified, `number - chain_len + 1`. When the previous proof carries no extension (the activation block, or a fresh chain after an unproven segment, spec 7.8 item 7) the guest takes the witness state as given; `history_first` is not restamped, it stays the attestation's own start (the node's tracker counts from the activation block and never re-roots, so a restamped field would fail the native compare on every restart). On the chain this is safe: the node's tracker holds the true state at every block and the native compare refuses a record whose root state differs (section 5.1). For a light client it is a trust break only if it accepts the root blind. The client rule: a root is accepted from the release it shipped with, or by bridging: the client holds the extension of its last verified proof (ending at block `E`), fetches the few unattested blocks' witnesses (`igneum_getFinalityWitness`, the same bytes a prover gets) and folds them itself with the same code, from the table it also fetches and checks against its held `table_root`; if the fold lands on the new root's extension, the new chain continues what the client verified, at the trust level of section 5.2 for those few blocks. A prover outage therefore costs light clients a bridge of a few blocks, never a 30-day blackout. The mandatory-proof rule (7.8 item 10), once on, makes restarts rare.
The node's tracker keeps the table after each of the last 640 chain blocks (the record window plus a margin) and the table at the latest lock whatever its age (the frozen table), the witnesses of the same blocks, and the state at the tip; it answers `igneum_getFinalityWitness(first, last)` only for a range starting at the block after its last fold, which is where an aggregator proves. Memory at 10,000 keys: 640 x 1.1 MB, 700 MB, which is the reason the Merkle key table of section 2 is the scale step and the kept window a parameter.

View file

@ -207,9 +207,9 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
Some(ext) => assert_eq!(&state.extension(), ext, "the finality state is not the one the previous proof committed"),
None => {
// the root: a state the proof did not verify (the node's native compare does, a light client bridges
// to it from the proof it holds); the attestation of this proof chain starts at this block
// to it from the proof it holds). `history_first` stays the attestation's own start (the tracker's,
// from the activation block); where THIS proof chain's verification starts is `number - chain_len + 1`
assert_eq!(state.end_number + 1, first.number, "the root state ends at the parent of this block");
state.history_first = first.number;
}
}
assert_eq!(f.block.number, first.number, "the finality witness is of this chain block");

View file

@ -45,10 +45,7 @@ pub fn prepare(file: &FinWitnessFile, first_number: u64, rooted: bool) -> Result
if state.end_number + 1 != first_number {
bail!("the finality root state ends at chain block {} and the first block to aggregate is {first_number}", state.end_number);
}
if rooted {
// no previous proof: the guest stamps the attestation's start at this block (agg.rs, the root branch)
state.history_first = first_number;
}
let _ = rooted; // the guest keeps the witness's history_first at a root (agg.rs, the root branch)
let mut out = Vec::with_capacity(file.blocks.len());
for (i, b) in file.blocks.iter().enumerate() {
if b.block.number != first_number + i as u64 {