From f232fd8ba20bb3d05f59b27eada1e0087c3ba5d1 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Mon, 10 Aug 2026 14:33:54 +0200 Subject: [PATCH] fix: preserve a root cert's ZK receipt on a receipt-less re-send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root cert re-sent for the same commitment often omits its ZK receipt (the sender assumes the relay still holds it — a valid client optimization). is_better_than accepts the fresher-anchor cert, so the receipt-less cert overwrote the receipt-bearing one and the relay could no longer prove the commitment to a fresh client: every sub-handle then failed with "receipt required". When storing an update whose incoming root cert has no receipt and whose commitment root is unchanged, keep the existing cert_data. The bulk INSERT reads the existing cert_data in-place via a subquery (no extra fetch), and since the value is unchanged the storage- byte trigger nets out. The zone still updates to the fresher anchor. Adds test_receiptless_root_update_preserves_receipt using two_commits_both_finalized (a second commit carries a real receipt). --- relay/src/store.rs | 57 +++++++++++++++++++++++++++--- relay/tests/integration_tests.rs | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/relay/src/store.rs b/relay/src/store.rs index 4ccbdd9..d8c698e 100644 --- a/relay/src/store.rs +++ b/relay/src/store.rs @@ -5,8 +5,9 @@ use std::path::Path; use std::sync::Mutex; use anyhow::anyhow; +use libveritas::ProvableOption; use libveritas::Zone; -use libveritas::cert::Certificate; +use libveritas::cert::{Certificate, Witness}; use resolver::ReverseRecord; use rusqlite::{Connection, OptionalExtension, params}; use spaces_protocol::slabel::SLabel; @@ -173,6 +174,18 @@ fn sovereignty_rank(s: libveritas::SovereigntyState) -> u8 { } } +/// Whether two zones carry the same on-chain commitment (Exists, equal state +/// root) — so a receipt verified for one still proves the other. +fn same_commitment_root(a: &Zone, b: &Zone) -> bool { + matches!( + (&a.commitment, &b.commitment), + ( + ProvableOption::Exists { value: x }, + ProvableOption::Exists { value: y }, + ) if x.onchain.state_root == y.onchain.state_root + ) +} + /// SQLite-backed store for handles. pub struct SqliteStore { conn: Mutex, @@ -316,6 +329,9 @@ impl SqliteStore { epoch_height: u32, offchain_seq: u64, delegate_offchain_seq: u64, + /// Keep the existing stored cert_data instead of this update's cert + /// (its receipt was omitted; see the preservation note below). + preserve_cert: bool, } let mut entries = Vec::with_capacity(updates.len()); @@ -339,6 +355,7 @@ impl SqliteStore { epoch_height: update.epoch_height, offchain_seq: update.offchain_seq, delegate_offchain_seq: update.delegate_offchain_seq, + preserve_cert: false, }); } @@ -427,6 +444,21 @@ impl SqliteStore { } } + // Preserve the ZK receipt: a root cert re-sent for the same + // commitment often omits it (the sender assumes we still hold + // it), which would leave us serving a receipt-less cert that no + // sub-handle can verify against the tip. Keep our existing + // cert_data (the zone still updates to the fresher anchor above; + // only the cert, and its receipt, is retained). Same commitment + // root is the safe condition: a receipt-less cert only reaches + // here if we already verified that commitment. The insert reads + // the existing cert_data in-place, so no extra fetch is needed. + if matches!(&update.cert.witness, Witness::Root { receipt: None }) + && same_commitment_root(&update.zone, existing) + { + e.preserve_cert = true; + } + Some(e) }) .collect(); @@ -453,10 +485,21 @@ impl SqliteStore { params![seq_base + to_store.len() as i64], )?; - // Bulk INSERT + // Bulk INSERT. For preserve_cert rows the cert_data column reads the + // existing stored value in-place (keeping its ZK receipt) rather than + // taking this update's receipt-less cert; the zone still updates. The + // subquery runs before REPLACE deletes the old row, and since the value + // is unchanged the storage-byte trigger nets out on cert_data. let placeholders: Vec = to_store .iter() - .map(|_| "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".to_string()) + .map(|e| { + if e.preserve_cert { + "(?, ?, (SELECT cert_data FROM handles WHERE handle = ?), ?, ?, ?, ?, ?, ?, ?)" + .to_string() + } else { + "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".to_string() + } + }) .collect(); let query = format!( "INSERT OR REPLACE INTO handles (handle, space, cert_data, zone_data, epoch_height, offchain_seq, delegate_offchain_seq, sync_seq, zone_hash, updated_at) VALUES {}", @@ -467,7 +510,13 @@ impl SqliteStore { for (i, e) in to_store.iter().enumerate() { params.push(Box::new(e.handle.clone())); params.push(Box::new(e.space.clone())); - params.push(Box::new(e.cert_data.clone())); + // cert_data: bind the handle for the SELECT subquery when preserving, + // otherwise this update's cert bytes. + if e.preserve_cert { + params.push(Box::new(e.handle.clone())); + } else { + params.push(Box::new(e.cert_data.clone())); + } params.push(Box::new(e.zone_data.clone())); params.push(Box::new(e.epoch_height)); params.push(Box::new(e.offchain_seq as i64)); diff --git a/relay/tests/integration_tests.rs b/relay/tests/integration_tests.rs index 07b4738..579c65a 100644 --- a/relay/tests/integration_tests.rs +++ b/relay/tests/integration_tests.rs @@ -418,6 +418,65 @@ fn test_finalize_without_commitment_upgrade_replaces_temp() { ); } +/// A root cert re-sent for the same commitment often omits its ZK receipt (the +/// sender assumes the relay still holds it). `is_better_than` accepts the +/// fresher-anchor cert, and without preservation the receipt-less cert would +/// overwrite the receipt-bearing one — leaving the relay unable to prove the +/// commitment to any fresh client, so sub-handles fail with "receipt required". +/// The store must keep the existing receipt. Regression for the `@test10000` case. +#[test] +fn test_receiptless_root_update_preserves_receipt() { + use libveritas::cert::Witness; + use relay::store::HandleRecord; + + // A second commitment proves a transition from the first, so its root cert + // carries a ZK receipt (a first commit has nothing to prove and omits it). + let mut state = ChainState::new(); + let mut runner = FixtureRunner::new(&mut state, two_commits_both_finalized()); + runner.run(&mut state); + let handler = setup_handler(&state); + let bundle = runner.build_bundle(); + let msg = state.message(vec![bundle]); + handler.handle_message(msg).unwrap(); + + let root = handler.store.get_handle("@two-finalized").unwrap().unwrap(); + assert!( + matches!(root.cert.witness, Witness::Root { receipt: Some(_) }), + "precondition: the stored root cert carries a receipt" + ); + + // Re-send the root proven against a fresher anchor but with the receipt + // omitted, same commitment and key. + let mut cert = root.cert.clone(); + if let Witness::Root { receipt } = &mut cert.witness { + *receipt = None; + } + let mut zone = root.zone.clone(); + zone.anchor += 1; + let update = HandleRecord { + cert, + zone, + epoch_height: root.epoch_height, + offchain_seq: root.offchain_seq, + delegate_offchain_seq: root.delegate_offchain_seq, + }; + // The fresher-anchor cert IS accepted by is_better_than — that's the trap. + assert!(update.zone.is_better_than(&root.zone).unwrap()); + + handler.store.update_handles(&[update]).unwrap(); + + let after = handler.store.get_handle("@two-finalized").unwrap().unwrap(); + assert!( + matches!(after.cert.witness, Witness::Root { receipt: Some(_) }), + "the receipt must survive a receipt-less re-send for the same commitment" + ); + assert_eq!( + after.zone.anchor, + root.zone.anchor + 1, + "the zone still updates to the fresher anchor" + ); +} + #[test] fn test_all_fixtures() { let fixtures: Vec<(&str, Fixture, Vec<&str>)> = vec![