Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions crates/z-engine/src/birthday.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ pub const BLOCK_SECONDS: u32 = 75;
/// Extra blocks subtracted when birthday is an approximate date, not an exact height.
pub const DATE_SAFETY_BLOCKS: u32 = 200;

/// Published birthday bin. 10,000 blocks is about nine days at the 75-second
/// target. Compact-block requests, birthday tree state, and transparent history
/// use this floor so the exact creation height is not sent. Rounding down scans
/// the extra blocks. Regtest keeps the fixture height.
pub const BIRTHDAY_ESTIMATE_BLOCKS: u32 = 10_000;

pub fn coarse_birthday_height(network: Network, birthday: u32) -> u32 {
if network == Network::Regtest || birthday <= 1 {
return birthday;
}
(birthday / BIRTHDAY_ESTIMATE_BLOCKS * BIRTHDAY_ESTIMATE_BLOCKS).max(1)
}

/// Session catch-up this small is “almost at tip”, not a full-chain sync.
pub const NEAR_TIP_BLOCKS: u32 = 512;

Expand Down Expand Up @@ -556,6 +569,31 @@ mod tests {
}
}

#[test]
fn coarse_birthday_is_a_ten_thousand_block_estimate() {
assert_eq!(
coarse_birthday_height(Network::Mainnet, 1_687_104),
1_680_000
);
assert_eq!(
coarse_birthday_height(Network::Mainnet, 1_680_000),
1_680_000
);
assert_eq!(coarse_birthday_height(Network::Testnet, 9_999), 1);
assert_eq!(coarse_birthday_height(Network::Mainnet, 1), 1);
assert_eq!(
coarse_birthday_height(Network::Regtest, 1_687_104),
1_687_104
);
assert_eq!(
coarse_birthday_height(
Network::Mainnet,
coarse_birthday_height(Network::Mainnet, 1_687_104)
),
1_680_000
);
}

#[test]
fn parse_digits_or_date() {
assert_eq!(parse_birthday_input("1687104", 3_000_000).unwrap(), 1687104);
Expand Down
26 changes: 22 additions & 4 deletions crates/z-engine/src/transparent_pir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ impl ProjectionState {
let mut decoded = Vec::new();
let mut identities = BTreeMap::new();
let mut received_totals = BTreeMap::<[u8; 32], u64>::new();
let mut below_floor_outputs = BTreeSet::<([u8; 32], u32)>::new();
let mut in_scope_outputs = BTreeSet::<([u8; 32], u32)>::new();
for record in &checkpoint.records {
if !hash(&record.revision) {
return Err(refused("transparent_pir_revision_invalid"));
Expand All @@ -218,13 +220,22 @@ impl ProjectionState {
.map_err(|_| refused("transparent_pir_record_invalid"))?;
let event = TransparentEvent::from_bytes(&bytes)
.map_err(|_| refused("transparent_pir_record_invalid"))?;
if event.to_bytes() != bytes
|| u64::from(event_height(event)) < entry.required_from
|| u64::from(event_height(event)) > scope.anchor.height
{
let height = u64::from(event_height(event));
if event.to_bytes() != bytes || height > scope.anchor.height {
return Err(refused("transparent_pir_record_invalid"));
}
// The first publication shard overlaps the birthday estimate. Keep
// that older event out of the wallet scope instead of refusing the
// checkpoint. A spend of it is outside the scope too, unless this
// same checkpoint also has the receive inside the scope.
if height < entry.required_from {
if let TransparentEvent::Receive(receive) = event {
below_floor_outputs.insert((receive.txid.0, receive.output_index));
}
continue;
}
if let TransparentEvent::Receive(receive) = event {
in_scope_outputs.insert((receive.txid.0, receive.output_index));
let total = received_totals.entry(receive.txid.0).or_default();
*total = total
.checked_add(receive.value)
Expand All @@ -241,6 +252,13 @@ impl ProjectionState {
}
decoded.push((script, event));
}
decoded.retain(|(_, event)| {
let TransparentEvent::Spend(spend) = event else {
return true;
};
let outpoint = (spend.spent_txid.0, spend.spent_output_index);
!(below_floor_outputs.contains(&outpoint) && !in_scope_outputs.contains(&outpoint))
});
decoded.sort_by_key(|(_, e)| match e {
TransparentEvent::Receive(e) => (e.height, e.transaction_index, 0, e.output_index),
TransparentEvent::Spend(e) => (e.height, e.transaction_index, 1, e.input_index),
Expand Down
236 changes: 234 additions & 2 deletions crates/z-engine/src/web/transparent_pir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,50 @@ use crate::transparent_pir::{
};
use transparent_events::TransparentEvent;

/// Mainnet and testnet history starts at the coarse birthday. Regtest stays at
/// genesis so fixture checkpoints keep their existing floors. A saved event
/// below the estimate keeps the genesis obligation: the journal is not allowed
/// to forget a range it already required.
fn transparent_history_floor(wallet: &WebWallet, anchor: u64) -> u64 {
if wallet.network == crate::Network::Regtest {
return 0;
}
let estimate = u64::from(crate::birthday::coarse_birthday_height(
wallet.network,
wallet.birthday,
));
if saved_transparent_event_below(wallet, estimate) {
return 0;
}
estimate.min(anchor)
}

fn saved_transparent_event_below(wallet: &WebWallet, floor: u64) -> bool {
if floor == 0 {
return false;
}
if wallet
.transparent_pir
.records
.values()
.any(|(height, _)| u64::from(*height) < floor)
{
return true;
}
let Some(checkpoint) = wallet.transparent_pir.continuation.as_ref() else {
return false;
};
checkpoint.records.iter().any(|record| {
let Ok(bytes) = super::super::from_hex(&record.event) else {
return true;
};
let Ok(event) = TransparentEvent::from_bytes(&bytes) else {
return true;
};
u64::from(crate::transparent_pir::event_height(event)) < floor
})
}

impl WebWallet {
pub(crate) fn reconcile_transparent_pir_history(&mut self) {
let owned: std::collections::BTreeSet<_> = self
Expand Down Expand Up @@ -68,12 +112,13 @@ impl WebWallet {
height: u64::from(height),
hash: super::super::to_hex(&hash),
};
let required_from = transparent_history_floor(self, u64::from(height));
let scripts = self
.public_transparent_scripts()?
.into_iter()
.map(|(script, _)| TransparentPirScript {
script: super::super::to_hex(&script),
required_from: 0,
required_from,
Comment thread
tzarebczan marked this conversation as resolved.
})
.collect();
let mut scope = self.transparent_pir.begin(source_id, anchor, scripts)?;
Expand Down Expand Up @@ -527,7 +572,9 @@ impl WebWallet {
mod tests {
use super::*;
use crate::keys::{account_from_mnemonic, REGTEST_FAUCET_MNEMONIC};
use crate::transparent_pir::{TransparentPirRecord, TransparentPirScope};
use crate::transparent_pir::{
TransparentPirCheckpoint, TransparentPirRecord, TransparentPirScope,
};
use transparent_events::{ReceiveEvent, SpendEvent, Txid};
use z_transparent_pir_v2::Coverage;

Expand Down Expand Up @@ -605,6 +652,191 @@ mod tests {
spent_output_index: 0,
})
}

fn mainnet_wallet(birthday: u32, scanned: u32) -> WebWallet {
let mut w = WebWallet::from_account(
account_from_mnemonic(REGTEST_FAUCET_MNEMONIC, crate::Network::Mainnet, 0).unwrap(),
birthday,
)
.unwrap();
w.scanned_height = scanned;
w.block_hashes.insert(scanned, "11".repeat(32));
w
}

#[test]
fn regtest_transparent_history_stays_at_genesis() {
let mut w = wallet();
let scope: TransparentPirScope =
serde_json::from_str(&w.begin_transparent_pir(&"ab".repeat(32)).unwrap()).unwrap();
assert!(scope.scripts.iter().all(|script| script.required_from == 0));
}

#[test]
fn mainnet_transparent_history_uses_the_birthday_estimate() {
let mut w = mainnet_wallet(1_687_104, 1_700_000);
let scope: TransparentPirScope =
serde_json::from_str(&w.begin_transparent_pir(&"ab".repeat(32)).unwrap()).unwrap();
assert!(!scope.scripts.is_empty());
assert!(scope
.scripts
.iter()
.all(|script| script.required_from == 1_680_000));
let mut early = mainnet_wallet(1, 10);
let scope: TransparentPirScope =
serde_json::from_str(&early.begin_transparent_pir(&"ab".repeat(32)).unwrap()).unwrap();
assert!(scope.scripts.iter().all(|script| script.required_from == 1));
}

#[test]
fn saved_event_below_the_estimate_keeps_the_genesis_floor() {
let mut w = mainnet_wallet(1_687_104, 1_700_000);
// A current journal, not a legacy one that begin refuses to open.
w.transparent_pir.unverified_outputs = Some(std::collections::BTreeSet::new());
w.transparent_pir
.records
.insert("r:old:0".into(), (100, "aa".repeat(32)));
let scope: TransparentPirScope =
serde_json::from_str(&w.begin_transparent_pir(&"ab".repeat(32)).unwrap()).unwrap();
assert!(scope.scripts.iter().all(|script| script.required_from == 0));

let mut resumed = mainnet_wallet(1_687_104, 1_700_000);
resumed.transparent_pir.continuation = Some(TransparentPirCheckpoint {
source_id: "ab".repeat(32),
generation: 1,
scope_id: "cd".repeat(32),
anchor: scope_anchor(1_700_000),
checkpoint_id: "ef".repeat(32),
previous_checkpoint_id: None,
covered_through: 0,
complete: false,
pending_pages: 0,
unresolved_spends: 0,
records: vec![TransparentPirRecord {
script: "51".into(),
event: super::super::super::to_hex(&receive().to_bytes()),
shard_id: 0,
revision: "ab".repeat(32),
}],
coverage: Vec::new(),
rewind_to: None,
continuation: String::new(),
});
let scope: TransparentPirScope =
serde_json::from_str(&resumed.begin_transparent_pir(&"ab".repeat(32)).unwrap())
.unwrap();
assert!(scope.scripts.iter().all(|script| script.required_from == 0));
}

#[test]
fn shard_event_below_the_birthday_estimate_does_not_reject_the_checkpoint() {
fn history(w: &mut WebWallet, events: Vec<TransparentEvent>) -> TransparentPirCheckpoint {
let scope: TransparentPirScope =
serde_json::from_str(&w.begin_transparent_pir(&"ab".repeat(32)).unwrap()).unwrap();
let script = super::super::super::to_hex(&w.public_transparent_scripts().unwrap()[0].0);
let through = scope.anchor.height;
TransparentPirCheckpoint {
source_id: scope.source_id,
generation: scope.generation,
scope_id: scope.scope_id,
anchor: scope.anchor,
checkpoint_id: "cd".repeat(32),
previous_checkpoint_id: None,
covered_through: through,
complete: true,
pending_pages: 0,
unresolved_spends: 0,
records: events
.into_iter()
.map(|event| TransparentPirRecord {
script: script.clone(),
event: super::super::super::to_hex(&event.to_bytes()),
shard_id: 0,
revision: "ef".repeat(32),
})
.collect(),
coverage: scope
.scripts
.into_iter()
.map(|script| Coverage {
script: script.script,
required_from: script.required_from,
through,
revision: "ef".repeat(32),
})
.collect(),
rewind_to: None,
continuation: String::new(),
}
}
let overlap = TransparentEvent::Receive(ReceiveEvent {
metadata: None,
height: 1_679_000,
txid: Txid([3; 32]),
transaction_index: 1,
output_index: 0,
value: 150_000,
coinbase: false,
});
let spent_overlap = TransparentEvent::Spend(SpendEvent {
metadata: None,
height: 1_690_000,
spending_txid: Txid([4; 32]),
transaction_index: 1,
input_index: 0,
spent_txid: Txid([3; 32]),
spent_output_index: 0,
});
let mut w = mainnet_wallet(1_687_104, 1_700_000);
let overlap_checkpoint = history(&mut w, vec![overlap, spent_overlap]);
w.apply_transparent_pir_checkpoint(&serde_json::to_string(&overlap_checkpoint).unwrap())
.unwrap();
assert!(w.utxos.is_empty());

let mut missing = mainnet_wallet(1_687_104, 1_700_000);
let in_scope_spend = TransparentEvent::Spend(SpendEvent {
metadata: None,
height: 1_690_000,
spending_txid: Txid([5; 32]),
transaction_index: 1,
input_index: 0,
spent_txid: Txid([6; 32]),
spent_output_index: 1,
});
let missing_checkpoint = history(&mut missing, vec![in_scope_spend]);
assert!(missing
.apply_transparent_pir_checkpoint(&serde_json::to_string(&missing_checkpoint).unwrap())
.unwrap_err()
.to_string()
.contains("transparent_pir_unresolved_spend"));
assert!(missing.utxos.is_empty());

let mut funded = mainnet_wallet(1_687_104, 1_700_000);
let in_scope = TransparentEvent::Receive(ReceiveEvent {
metadata: None,
height: 1_681_000,
txid: Txid([7; 32]),
transaction_index: 1,
output_index: 0,
value: 150_000,
coinbase: false,
});
let funded_checkpoint = history(&mut funded, vec![in_scope]);
funded
.apply_transparent_pir_checkpoint(&serde_json::to_string(&funded_checkpoint).unwrap())
.unwrap();
assert_eq!(funded.utxos.len(), 1);
assert_eq!(funded.utxos[0].value_zat, 150_000);
assert_eq!(funded.utxos[0].height, 1_681_000);
}

fn scope_anchor(height: u64) -> crate::transparent_pir::TransparentPirAnchor {
crate::transparent_pir::TransparentPirAnchor {
height,
hash: "11".repeat(32),
}
}

fn public_range(
w: &mut WebWallet,
with_spend: bool,
Expand Down
6 changes: 6 additions & 0 deletions crates/z-transparent-pir-v2/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,9 @@ only the typed local cap preserves that known content failure and owed query
count. The failure update attaches to the unchanged durable pending cursor.
Real Walker regressions cover stamped anchors and both event/byte refusals with
publisher-built page fragments, followed by terminal checkpoint and cold resume.

A restored web journal stored `required_from: 0` as its genesis placeholder.
`MemoryStore::raise_genesis_placeholder` replaces that zero with the current
scope floor for the same script. A nonzero earlier floor stays unchanged.
`Pass` applies the raise only when that script has no stored event older than
the floor, so a saved earlier event keeps the genesis walk.
12 changes: 12 additions & 0 deletions crates/z-transparent-pir-v2/src/memory_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ impl MemoryStore {
self.event_limit = Some(limit);
}

/// Replaces the web genesis placeholder. An explicit earlier floor stays.
pub(crate) fn raise_genesis_placeholder(&mut self, script: &[u8], floor: u64) {
if floor == 0 {
return;
}
if let Some(existing) = self.state.scripts.get_mut(script) {
if existing.required_from == 0 {
existing.required_from = floor;
}
}
}

pub(crate) fn retain_scope(&mut self, scripts: &std::collections::BTreeSet<Vec<u8>>) {
self.state.scripts.retain(|s, _| scripts.contains(s));
self.state.coverage.retain(|s, _| scripts.contains(s));
Expand Down
Loading
Loading