693 lines
24 KiB
Rust

use common::test_utils::produce_dummy_block;
use lee::{Account, AccountId};
use tempfile::tempdir;
use super::*;
fn marker_id() -> AccountId {
AccountId::new([1; 32])
}
/// A state distinguishable by the marker account's balance, so tests can tell
/// which snapshot a write persisted.
///
/// TODO: is this a bit too much of a hot-fix for test snapshot?
fn state_with_balance(balance: u128) -> V03State {
V03State::new().with_public_accounts([(
marker_id(),
Account {
balance,
..Account::default()
},
)])
}
fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) {
let genesis = produce_dummy_block(1, None, vec![]);
let dbio = RocksDBIO::create(path, &genesis, &state_with_balance(100)).unwrap();
(dbio, genesis)
}
fn deposit_record(seed: u8) -> PendingDepositEventRecord {
PendingDepositEventRecord {
deposit_op_id: HashType([seed; 32]),
source_tx_hash: HashType([seed; 32]),
amount: u64::from(seed),
metadata: vec![seed],
}
}
fn dispatch_record(seed: u8) -> PendingCrossZoneDispatchRecord {
PendingCrossZoneDispatchRecord::recorded([seed; 32], vec![seed; 4])
}
/// A distinct message key per index, for filling the pending list.
fn key_from_index(index: usize) -> [u8; 32] {
let mut key = [0_u8; 32];
key[..8].copy_from_slice(&u64::try_from(index).expect("test index fits").to_le_bytes());
key
}
fn stored_balance(dbio: &RocksDBIO) -> u128 {
dbio.get_lee_state()
.unwrap()
.get_account_by_id(marker_id())
.balance
}
#[test]
fn store_followed_block_persists_new_block_and_state() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored.header.hash, block2.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
assert_eq!(
dbio.latest_block_meta().unwrap().expect("meta is set").id,
2
);
assert_eq!(stored_balance(&dbio), 200);
}
#[test]
fn store_followed_block_finalized_marks_block() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), true)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[test]
fn store_followed_block_redelivery_is_a_noop_and_keeps_finalized() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), true)
.unwrap();
dbio.store_followed_block(&block2, &state_with_balance(300), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(
matches!(stored.bedrock_status, BedrockStatus::Finalized),
"re-delivery must not demote a finalized block"
);
assert_eq!(
stored_balance(&dbio),
200,
"re-delivery must not overwrite the persisted state"
);
}
#[test]
fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
// Block 2 is already stored (own production); one update then finalizes it
// and adopts block 3.
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
let head_tip = BlockMeta {
id: 3,
hash: block3.header.hash,
};
dbio.store_update(&StoreUpdate {
blocks: &[(&block2, true), (&block3, false)],
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(300))
})
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized));
let stored3 = dbio.get_block(3).unwrap().expect("block 3 is stored");
assert!(matches!(stored3.bedrock_status, BedrockStatus::Pending));
// Meta and state land together on the last block of the batch.
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 3);
assert_eq!(meta.hash, block3.header.hash);
assert_eq!(stored_balance(&dbio), 300);
}
#[test]
fn final_snapshot_round_trips_and_is_absent_on_fresh_store() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
// Fresh store: no finalization observed yet.
assert!(dbio.get_final_snapshot().unwrap().is_none());
// A follow update that finalizes block 2 lands the snapshot in the same batch.
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
let final_meta = BlockMeta {
id: 2,
hash: block2.header.hash,
};
dbio.store_update(&StoreUpdate {
blocks: &[(&block2, true)],
head_tip: Some(&final_meta),
final_snapshot: Some((&state_with_balance(200), &final_meta)),
..StoreUpdate::new(&state_with_balance(300))
})
.unwrap();
let (final_state, meta) = dbio
.get_final_snapshot()
.unwrap()
.expect("final snapshot is stored");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2.header.hash);
assert_eq!(final_state.get_account_by_id(marker_id()).balance, 200);
// The head state is stored independently of the final snapshot.
assert_eq!(stored_balance(&dbio), 300);
}
#[test]
fn store_followed_block_overwrites_competing_block_at_same_id() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
.unwrap();
// A reorg replaces block 2: the competing block wins the slot.
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
dbio.store_followed_block(&block2b, &state_with_balance(300), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored.header.hash, block2b.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
assert_eq!(stored_balance(&dbio), 300);
// The tip meta must follow the reorg winner, or a restart seeds the chain
// from the orphaned block's hash.
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
}
#[test]
fn net_shortening_reorg_drops_stale_blocks() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// A shorter competing chain wins: block 2 is replaced, block 3 gets no
// replacement.
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
let head_tip = BlockMeta {
id: 2,
hash: block2b.header.hash,
};
dbio.store_update(&StoreUpdate {
blocks: &[(&block2b, false)],
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(400))
})
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored2.header.hash, block2b.header.hash);
assert!(
dbio.get_block(3).unwrap().is_none(),
"stale block above the new head must be deleted, or restart replay panics on its broken link"
);
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
assert_eq!(stored_balance(&dbio), 400);
}
#[test]
fn shrink_only_reorg_rewinds_tip_meta() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// Orphan-only update: block 3 falls off the branch with no replacement.
let head_tip = BlockMeta {
id: 2,
hash: block2.header.hash,
};
dbio.store_update(&StoreUpdate {
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(200))
})
.unwrap();
assert!(
dbio.get_block(3).unwrap().is_none(),
"the orphaned block must not survive the tip rewind"
);
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2.header.hash);
assert_eq!(stored_balance(&dbio), 200);
}
#[test]
fn checkpoint_lands_with_an_orphan_only_update() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// Orphan-only update: no payload to write, but the checkpoint covering it
// must still land, or a restart resumes past the orphan.
let head_tip = BlockMeta {
id: 2,
hash: block2.header.hash,
};
dbio.store_update(&StoreUpdate {
checkpoint: Some(b"cp-orphan"),
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(200))
})
.unwrap();
assert_eq!(
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
Some(b"cp-orphan".as_slice())
);
assert!(dbio.get_block(3).unwrap().is_none());
}
#[test]
fn checkpoint_only_update_does_not_rewrite_the_head_state() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
// An event carrying nothing but a checkpoint (the common case) must not
// drag a full state serialization along with it — the caller's state is
// ignored while the chain stands still.
let head_tip = BlockMeta::from(&block2);
dbio.store_update(&StoreUpdate {
checkpoint: Some(b"cp-idle"),
head_tip: Some(&head_tip),
..StoreUpdate::new(&state_with_balance(999))
})
.unwrap();
assert_eq!(
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
Some(b"cp-idle".as_slice())
);
assert_eq!(stored_balance(&dbio), 200);
}
#[test]
fn several_deposits_in_one_update_are_all_recorded() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
// The records live in one whole-vector cell: staged per event against a
// fresh disk read, the second would clobber the first.
let first = deposit_record(1);
let second = deposit_record(2);
let already_known = dbio.get_pending_deposit_events().unwrap();
assert!(already_known.is_empty());
let outcome = dbio
.store_update(&StoreUpdate {
new_deposit_events: &[first.clone(), second.clone()],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
assert_eq!(outcome.accepted_deposits, 2);
let stored = dbio.get_pending_deposit_events().unwrap();
assert_eq!(stored.len(), 2);
assert!(stored.contains(&first));
assert!(stored.contains(&second));
}
#[test]
fn redelivered_deposit_is_not_accepted_twice() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let record = deposit_record(1);
dbio.store_update(&StoreUpdate {
new_deposit_events: std::slice::from_ref(&record),
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
let outcome = dbio
.store_update(&StoreUpdate {
new_deposit_events: &[record],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
assert_eq!(
outcome.accepted_deposits, 0,
"a re-delivered deposit is already owed, not newly accepted"
);
assert_eq!(dbio.get_pending_deposit_events().unwrap().len(), 1);
}
#[test]
fn finalized_deposit_records_are_removed_by_op_id() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let first = deposit_record(1);
let second = deposit_record(2);
dbio.store_update(&StoreUpdate {
new_deposit_events: &[first.clone(), second.clone()],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
// Only the finalized op id is dropped; the other record stays.
dbio.store_update(&StoreUpdate {
remove_deposit_records: &[first.deposit_op_id],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
let stored = dbio.get_pending_deposit_events().unwrap();
assert_eq!(stored, vec![second]);
}
#[test]
fn dispatch_records_round_trip_and_dedupe_by_message_key() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let record = dispatch_record(1);
assert_eq!(
dbio.add_pending_cross_zone_dispatches(vec![record.clone()])
.unwrap(),
1
);
// The watcher re-reads a slot it stalled on, so the same delivery arrives
// again; recording it twice would double-count its failed attempts.
assert_eq!(
dbio.add_pending_cross_zone_dispatches(vec![record.clone(), dispatch_record(2)])
.unwrap(),
1,
"only the delivery not already held is newly recorded"
);
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![record, dispatch_record(2)]
);
}
#[test]
fn recording_past_the_cap_writes_nothing() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
// What fills this list is chosen by peer zones, so the bound is what stops a
// peer deciding how large our store gets. Refusing the whole write leaves
// the watcher's floor where it is, so the slot is read again later and
// nothing is lost.
let full: Vec<_> = (0..MAX_PENDING_CROSS_ZONE_DISPATCHES)
.map(|seed| PendingCrossZoneDispatchRecord::recorded(key_from_index(seed), vec![0_u8; 4]))
.collect();
assert_eq!(
dbio.add_pending_cross_zone_dispatches(full).unwrap(),
MAX_PENDING_CROSS_ZONE_DISPATCHES
);
let over = PendingCrossZoneDispatchRecord::recorded(
key_from_index(MAX_PENDING_CROSS_ZONE_DISPATCHES),
vec![0_u8; 4],
);
assert!(
dbio.add_pending_cross_zone_dispatches(vec![over]).is_err(),
"recording past the cap must fail so the caller holds its floor"
);
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap().len(),
MAX_PENDING_CROSS_ZONE_DISPATCHES,
"a refused write must leave the list untouched"
);
// Re-offering only what is already held is not growth, so it still succeeds.
assert_eq!(
dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded(
key_from_index(0),
vec![0_u8; 4]
)])
.unwrap(),
0
);
}
#[test]
fn settled_dispatch_records_are_dropped_outside_an_update() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
// The watcher re-reads a slot it already consumed and re-records a delivery
// that settled long ago. Its key will never appear in a future block, so the
// store-update path cannot reach it and this is the only thing that does.
let first = dispatch_record(1);
let second = dispatch_record(2);
dbio.add_pending_cross_zone_dispatches(vec![first.clone(), second.clone()])
.unwrap();
assert_eq!(
dbio.drop_settled_cross_zone_dispatches(&[first.message_key])
.unwrap(),
1
);
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![second]
);
// Dropping one that is already gone is a no-op, not an error.
assert_eq!(
dbio.drop_settled_cross_zone_dispatches(&[first.message_key])
.unwrap(),
0
);
}
#[test]
fn finalized_dispatch_records_are_removed_by_message_key() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let first = dispatch_record(1);
let second = dispatch_record(2);
dbio.add_pending_cross_zone_dispatches(vec![first.clone(), second.clone()])
.unwrap();
// Only the finalized delivery's key is dropped. Two deliveries can sit in
// the same block, so a record must go by its own identity rather than by
// anything about the height its delivery landed at.
dbio.store_update(&StoreUpdate {
remove_dispatch_records: &[first.message_key],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![second]
);
}
#[test]
fn record_dispatch_failure_drops_the_record_at_the_limit() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let record = dispatch_record(1);
let key = record.message_key;
let survivor = dispatch_record(2);
dbio.add_pending_cross_zone_dispatches(vec![record, survivor.clone()])
.unwrap();
assert!(!dbio.record_dispatch_failure(key, 3).unwrap());
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap()[0].failed_attempts,
1,
"a failure short of the limit is counted, not given up on"
);
assert!(!dbio.record_dispatch_failure(key, 3).unwrap());
assert!(
dbio.record_dispatch_failure(key, 3).unwrap(),
"the third failure is the one it is given up on"
);
// Dropped rather than flagged: a delivery the drain will never feed into a
// block again is one nothing would ever remove, so flagging it would let a
// peer that can make deliveries fail grow the list without bound.
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![survivor],
"giving up on a delivery drops its record and leaves the others alone"
);
// A key with no record reads as given up on: there is nothing left to count
// against, and nothing will feed it into a block.
assert!(
dbio.record_dispatch_failure(key, 3).unwrap(),
"a failure against a dropped delivery must not re-create its record"
);
assert_eq!(dbio.get_pending_cross_zone_dispatches().unwrap().len(), 1);
}
#[test]
fn repeated_withdrawal_key_in_one_update_folds_once_per_occurrence() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let key = WithdrawalReconciliationKey {
released_note_id: [3; 32],
};
// Two local intents for the same key in one update. A per-occurrence disk
// read would miss the staged increment and record the pair as one.
dbio.store_update(&StoreUpdate {
new_withdraw_intents: &[key, key],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
let recorded = dbio
.get_opt::<UnseenWithdrawCountCell>(key)
.unwrap()
.map(|cell| cell.0);
assert_eq!(recorded, Some(2));
// Both L1 events arrive in one update; a per-occurrence disk read would
// miss the staged decrement and consume only one.
let outcome = dbio
.store_update(&StoreUpdate {
consumed_withdrawals: &[key, key],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
assert!(outcome.unmatched_withdrawals.is_empty());
// Both decrements landed; a per-occurrence disk read would leave `Some(1)`.
// (The absolute value trails the intent count by one — `consume` still
// treats a stored 0 as consumable — but that predates the batching and is
// replicated as-is.)
let remaining = dbio
.get_opt::<UnseenWithdrawCountCell>(key)
.unwrap()
.map(|cell| cell.0);
assert_eq!(remaining, Some(0));
}
#[test]
fn unmatched_withdrawal_is_reported_and_writes_nothing() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let key = WithdrawalReconciliationKey {
released_note_id: [4; 32],
};
let outcome = dbio
.store_update(&StoreUpdate {
consumed_withdrawals: &[key],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
assert_eq!(outcome.unmatched_withdrawals.len(), 1);
assert!(
dbio.get_opt::<UnseenWithdrawCountCell>(key)
.unwrap()
.is_none(),
"an unmatched withdraw must not leave a counter behind"
);
}
#[test]
fn produced_block_persists_its_publish_checkpoint() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.atomic_update(&block2, &[], &state_with_balance(200), Some(b"cp-produced"))
.unwrap();
// Storing the block without the checkpoint would let a restart restore a
// pending set that no longer holds the inscription we just published.
assert_eq!(
dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(),
Some(b"cp-produced".as_slice())
);
assert_eq!(
dbio.get_block(2).unwrap().unwrap().header.hash,
block2.header.hash
);
}
#[test]
fn produced_block_below_disk_head_pins_meta_and_prunes() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// Producing at height 2 while the disk head is still 3: the produce path
// pins the tip meta to the produced block and drops the stale suffix in
// the same write, mirroring the follow path.
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.atomic_update(&block2b, &[], &state_with_balance(400), None)
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored2.header.hash, block2b.header.hash);
assert!(dbio.get_block(3).unwrap().is_none());
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
assert_eq!(stored_balance(&dbio), 400);
}