fix(lite-backend): never hang or panic a sync on malformed server data
The lightwalletd server controls the compact ciphertext length, the block, prev-block and tx hash lengths, and (through the chain) the full txs' epks. Each of these could wedge or poison a sync: - try_sapling_compact_note_decryption asserts a 52-byte ciphertext. A shorter (or full 580-byte) one panicked a pool job in scan_output_internal while the original sender stayed alive, so the collector waited forever with sync_lock, txs.write() and a wallet read guard held. - scan_taddress_txids kept its own sender alive too: one panicking t-address job (scan_full_tx) left crx.iter().take(n) blocked forever. - CompactBlock::hash()/prev_hash() assert 32 bytes when no header is sent, and every vtx hash is copied into a [u8; 32], one of them under txs.write(): a 31-byte tx hash poisoned the wallet for good, and a bad hash in the block's txid list panicked after the witnesses had moved. - scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() on the epk. Consensus rejects only a small-order epk, so a mixed-order one can be mined: an own tx then failed every sync, and the mempool receiver thread died silently. Changes: - append_leaf(node: Node, ..) commits a leaf to the existing, block and new witnesses and the tree; the epk-error path, the new short-ciphertext path and the normal path all use it (a behaviour-neutral extraction). - A compact ciphertext under 52 bytes is skipped with its cmu still committed; a longer one is truncated to 52 bytes, which is exact (the compact plaintext is those bytes and the cmu re-check validates it). - Each decryption job catches a panic and sends exactly one message; drop(tx) after the dispatch, and the collector logs a vanished or panicked job and treats the output as not ours. - scan_taddress_txids runs through the private seam scan_taddress_txids_with(job): catch_unwind per job, drop(ctx), and a count check, so a dead job is an error, never a hang or a silent partial success. - scan_block_with_pool checks the block, prev-block and vtx hash lengths right after the parse and returns Err(-1), as for an unparsable block, before any change (Err(height) would refetch the same block forever under sync_lock). - scan_full_tx and scan_full_mempool_tx skip an output whose epk is not in the prime-order subgroup (one check per output, used by every decryption and the outgoing recovery). - The mempool receiver thread catches a panic of scan_full_mempool_tx. Flagged behaviour changes: - a compact ciphertext under 52 bytes is skipped (its cmu is still committed); one over 52 bytes is truncated; - pool-job panics are reported (logged; the output is treated as not ours, which a rescan repairs) instead of hanging the sync; - bad hash lengths return Err(-1) before any change; - outputs with a non-prime-order epk are skipped; - the mempool thread survives a bad tx. Wallet format v9 is unchanged; no ABI or symbol change. Honest data takes the same path as before (an old-vs-new run over generated honest chains is byte-identical). Tests (each on its own thread under a 15 s watchdog): 0/51/52/53/580-byte ciphertexts in one block on the inline and deferred witness paths (the 51-byte notes are skipped, the 53- and 580-byte notes are found, the tree and every witness match the reference); a 31-byte tx hash, an empty block hash, a 31-byte prev hash and a 33-byte foreign tx hash each return Err(-1) with the txs lock usable and last_scanned_height, trees and witnesses unchanged; a mixed-order epk in scan_full_tx and scan_full_mempool_tx does not panic and decrypts nothing while the real epk still decrypts; a t-address job panicking for 1 of 3 addresses is an Err. The oracle generator gains short (0..=51-byte) and full (580-byte) ciphertext outputs without an extra random draw, so each seed keeps its sequence of blocks, values and keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+80
-21
@@ -1171,7 +1171,13 @@ pub fn start_mempool_monitor(lc: Arc<LightClient>) -> Result<(), String> {
|
|||||||
&rtx.data[..])
|
&rtx.data[..])
|
||||||
{
|
{
|
||||||
let light_wallet_clone = lc.wallet.clone();
|
let light_wallet_clone = lc.wallet.clone();
|
||||||
light_wallet_clone.read().unwrap().scan_full_mempool_tx(&tx, rtx.height as i32, 0, true);
|
// One bad mempool tx must not end this thread: the mempool monitor would stop silently.
|
||||||
|
let scanned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
light_wallet_clone.read().unwrap().scan_full_mempool_tx(&tx, rtx.height as i32, 0, true);
|
||||||
|
}));
|
||||||
|
if scanned.is_err() {
|
||||||
|
error!("Scanning mempool tx {} panicked; skipped it", tx.txid());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1631,26 +1637,14 @@ pub fn start_mempool_monitor(lc: Arc<LightClient>) -> Result<(), String> {
|
|||||||
end_height: u64,
|
end_height: u64,
|
||||||
no_cert: bool
|
no_cert: bool
|
||||||
) -> Result<Vec<()>, String> {
|
) -> Result<Vec<()>, String> {
|
||||||
let addresses = self.wallet.read()
|
// The per-address job: fetch the address's txids and full-scan each tx (scan_full_tx).
|
||||||
.map_err(|e| format!("Failed to read wallet: {:?}", e))?
|
self.scan_taddress_txids_with(pool, {
|
||||||
.get_all_taddresses().iter()
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
|
|
||||||
let (ctx, crx) = channel();
|
|
||||||
let num_addresses = addresses.len();
|
|
||||||
|
|
||||||
for address in addresses {
|
|
||||||
let address_clone = address.clone();
|
|
||||||
let wallet = self.wallet.clone();
|
let wallet = self.wallet.clone();
|
||||||
let pool = pool.clone();
|
|
||||||
let server_uri = self.get_server_uri();
|
let server_uri = self.get_server_uri();
|
||||||
let ctx = ctx.clone();
|
move |address: String| -> Result<(), String> {
|
||||||
let block_times = block_times.clone();
|
let wallet = wallet.clone();
|
||||||
|
let block_times = block_times.clone();
|
||||||
pool.execute(move || {
|
fetch_transparent_txids(
|
||||||
|
|
||||||
let r = fetch_transparent_txids(
|
|
||||||
&server_uri,
|
&server_uri,
|
||||||
address,
|
address,
|
||||||
start_height,
|
start_height,
|
||||||
@@ -1687,16 +1681,49 @@ pub fn start_mempool_monitor(lc: Arc<LightClient>) -> Result<(), String> {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The t-address collector; `job` is the per-address work (a private seam, so a test can make a job panic). Every
|
||||||
|
// job sends exactly one result, a panicking one included, and only the jobs hold senders: the collector can
|
||||||
|
// neither wait forever on a job that died (with sync_lock held) nor report a partial scan as success.
|
||||||
|
fn scan_taddress_txids_with<F>(&self, pool: &ThreadPool, job: F) -> Result<Vec<()>, String>
|
||||||
|
where F: Fn(String) -> Result<(), String> + Send + Sync + 'static
|
||||||
|
{
|
||||||
|
let addresses = self.wallet.read()
|
||||||
|
.map_err(|e| format!("Failed to read wallet: {:?}", e))?
|
||||||
|
.get_all_taddresses().iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
let job = Arc::new(job);
|
||||||
|
let (ctx, crx) = channel();
|
||||||
|
let num_addresses = addresses.len();
|
||||||
|
|
||||||
|
for address in addresses {
|
||||||
|
let address_clone = address.clone();
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let job = job.clone();
|
||||||
|
|
||||||
|
pool.execute(move || {
|
||||||
|
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (*job)(address)))
|
||||||
|
.unwrap_or_else(|_| Err("t-address scan job panicked".to_string()));
|
||||||
match ctx.send(r) {
|
match ctx.send(r) {
|
||||||
Ok(_) => info!("Successfully sent data for address: {}", address_clone),
|
Ok(_) => info!("Successfully sent data for address: {}", address_clone),
|
||||||
Err(e) => error!("Failed to send data for address: {}: {:?}", address_clone, e),
|
Err(e) => error!("Failed to send data for address: {}: {:?}", address_clone, e),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
drop(ctx);
|
||||||
|
|
||||||
crx.iter().take(num_addresses).collect()
|
// Ends once every job has sent its result and dropped its sender.
|
||||||
|
let results: Vec<Result<(), String>> = crx.iter().collect();
|
||||||
|
if results.len() != num_addresses {
|
||||||
|
return Err(format!("t-address scan: {} of {} jobs reported", results.len(), num_addresses));
|
||||||
|
}
|
||||||
|
results.into_iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2088,4 +2115,36 @@ pub fn test_bad_import() {
|
|||||||
assert_eq!(lc.do_seed_phrase().unwrap()["seed"], TEST_SEED.to_string());
|
assert_eq!(lc.do_seed_phrase().unwrap()["seed"], TEST_SEED.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C245 Fix 2 (s2-D6a seam). A t-address job that panicked left the collector waiting forever, with sync_lock
|
||||||
|
// held, for a result that never came: the original sender stayed alive. Each job now reports exactly once (a
|
||||||
|
// panic is an Err) and the collector counts the reports. Runs under the 15 s watchdog.
|
||||||
|
#[test]
|
||||||
|
pub fn test_taddress_scan_job_panic_is_error() {
|
||||||
|
let (n, all_ok, one_err, one_panic) = crate::lightwallet::tests::within_watchdog("t-address scan", || {
|
||||||
|
let tmp = TempDir::new("lctest").unwrap();
|
||||||
|
let lc = LightClient::new(&main_config(&tmp), 0).unwrap();
|
||||||
|
lc.do_new_address("R").unwrap();
|
||||||
|
lc.do_new_address("R").unwrap();
|
||||||
|
let addrs = lc.wallet.read().unwrap().get_all_taddresses();
|
||||||
|
let pool = threadpool::ThreadPool::new(2);
|
||||||
|
let all_ok = lc.scan_taddress_txids_with(&pool, |_a: String| -> Result<(), String> { Ok(()) });
|
||||||
|
let a1 = addrs[1].clone();
|
||||||
|
let one_err = lc.scan_taddress_txids_with(&pool, move |a: String| -> Result<(), String> {
|
||||||
|
if a == a1 { Err("injected error".to_string()) } else { Ok(()) }
|
||||||
|
});
|
||||||
|
let a1 = addrs[1].clone();
|
||||||
|
let one_panic = lc.scan_taddress_txids_with(&pool, move |a: String| -> Result<(), String> {
|
||||||
|
if a == a1 {
|
||||||
|
panic!("injected t-address job panic");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
(addrs.len(), all_ok, one_err, one_panic)
|
||||||
|
});
|
||||||
|
assert_eq!(n, 3);
|
||||||
|
assert_eq!(all_ok, Ok(vec![(); 3]));
|
||||||
|
assert_eq!(one_err, Err("injected error".to_string()));
|
||||||
|
assert_eq!(one_panic, Err("t-address scan job panicked".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-48
@@ -1544,6 +1544,15 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) {
|
|||||||
}
|
}
|
||||||
// Scan shielded sapling outputs to see if anyone of them is us, and if it is, extract the memo
|
// Scan shielded sapling outputs to see if anyone of them is us, and if it is, extract the memo
|
||||||
for output in tx.shielded_outputs.iter() {
|
for output in tx.shielded_outputs.iter() {
|
||||||
|
// The epk is chain data. Sapling consensus rejects only a small-order epk, so one outside the prime-order
|
||||||
|
// subgroup can be mined; it is never ours (the compact scan skips it too). Skip the output, never panic.
|
||||||
|
let epk_prime = match output.ephemeral_key.as_prime_order(&JUBJUB) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
warn!("Output in tx {} has a non-prime-order epk; skipping it", tx.txid());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let ivks: Vec<_> = self.zkeys.read().unwrap().iter()
|
let ivks: Vec<_> = self.zkeys.read().unwrap().iter()
|
||||||
.map(|zk| zk.extfvk.fvk.vk.ivk()
|
.map(|zk| zk.extfvk.fvk.vk.ivk()
|
||||||
).collect();
|
).collect();
|
||||||
@@ -1551,7 +1560,6 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) {
|
|||||||
let ct = output.enc_ciphertext;
|
let ct = output.enc_ciphertext;
|
||||||
// Search all of our keys
|
// Search all of our keys
|
||||||
for ivk in ivks {
|
for ivk in ivks {
|
||||||
let epk_prime = output.ephemeral_key.as_prime_order(&JUBJUB).unwrap();
|
|
||||||
let (note, _to, memo) = match try_sapling_note_decryption(&ivk, &epk_prime, &cmu, &ct) {
|
let (note, _to, memo) = match try_sapling_note_decryption(&ivk, &epk_prime, &cmu, &ct) {
|
||||||
Some(ret) => ret,
|
Some(ret) => ret,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -1593,7 +1601,7 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) {
|
|||||||
&ovk,
|
&ovk,
|
||||||
&output.cv,
|
&output.cv,
|
||||||
&output.cmu,
|
&output.cmu,
|
||||||
&output.ephemeral_key.as_prime_order(&JUBJUB).unwrap(),
|
&epk_prime,
|
||||||
&output.enc_ciphertext,
|
&output.enc_ciphertext,
|
||||||
&output.out_ciphertext) {
|
&output.out_ciphertext) {
|
||||||
Some((note, payment_address, memo)) => {
|
Some((note, payment_address, memo)) => {
|
||||||
@@ -1603,7 +1611,6 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) {
|
|||||||
// Check if this output belongs to our wallet using IVK decryption.
|
// Check if this output belongs to our wallet using IVK decryption.
|
||||||
// This correctly detects change sent to diversified addresses,
|
// This correctly detects change sent to diversified addresses,
|
||||||
// not just the wallet's default z-address.
|
// not just the wallet's default z-address.
|
||||||
let epk_prime = output.ephemeral_key.as_prime_order(&JUBJUB).unwrap();
|
|
||||||
let is_to_self = ivks_for_change.iter().any(|ivk| {
|
let is_to_self = ivks_for_change.iter().any(|ivk| {
|
||||||
try_sapling_note_decryption(ivk, &epk_prime, &output.cmu, &output.enc_ciphertext).is_some()
|
try_sapling_note_decryption(ivk, &epk_prime, &output.cmu, &output.enc_ciphertext).is_some()
|
||||||
});
|
});
|
||||||
@@ -1678,6 +1685,14 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for output in tx.shielded_outputs.iter() {
|
for output in tx.shielded_outputs.iter() {
|
||||||
|
// As in scan_full_tx: an epk outside the prime-order subgroup is never ours. Skip the output, never panic.
|
||||||
|
let epk_prime = match output.ephemeral_key.as_prime_order(&JUBJUB) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
warn!("Mempool tx {} has an output with a non-prime-order epk; skipping it", tx.txid());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let ivks: Vec<_> = self.zkeys.read().unwrap().iter()
|
let ivks: Vec<_> = self.zkeys.read().unwrap().iter()
|
||||||
.map(|zk| zk.extfvk.fvk.vk.ivk())
|
.map(|zk| zk.extfvk.fvk.vk.ivk())
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1687,7 +1702,6 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
|
|
||||||
// Search all of our keys
|
// Search all of our keys
|
||||||
for ivk in ivks {
|
for ivk in ivks {
|
||||||
let epk_prime = output.ephemeral_key.as_prime_order(&JUBJUB).unwrap();
|
|
||||||
let (note, _to, memo) = match try_sapling_note_decryption(&ivk, &epk_prime, &cmu, &ct) {
|
let (note, _to, memo) = match try_sapling_note_decryption(&ivk, &epk_prime, &cmu, &ct) {
|
||||||
Some(ret) => ret,
|
Some(ret) => ret,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -1997,6 +2011,29 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Commit one leaf (an output's cmu): to the existing notes' witnesses (or the deferred list), to the witnesses of
|
||||||
|
/// this block's and this tx's earlier outputs, and to the tree. Every output whose cmu parses is a leaf, whether
|
||||||
|
/// or not it can be trial-decrypted; a skipped leaf makes every later anchor diverge from consensus.
|
||||||
|
fn append_leaf(node: Node, tree: &mut CommitmentTree<Node>, defer_existing: bool, deferred_nodes: &mut Vec<Node>,
|
||||||
|
existing_witnesses: &mut [&mut IncrementalWitness<Node>],
|
||||||
|
block_witnesses: &mut [&mut IncrementalWitness<Node>],
|
||||||
|
new_witnesses: &mut [&mut IncrementalWitness<Node>]) {
|
||||||
|
if defer_existing {
|
||||||
|
deferred_nodes.push(node);
|
||||||
|
} else {
|
||||||
|
for witness in existing_witnesses.iter_mut() {
|
||||||
|
witness.append(node).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for witness in block_witnesses.iter_mut() {
|
||||||
|
witness.append(node).unwrap();
|
||||||
|
}
|
||||||
|
for witness in new_witnesses.iter_mut() {
|
||||||
|
witness.append(node).unwrap();
|
||||||
|
}
|
||||||
|
tree.append(node).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Scans a [`CompactOutput`] with a set of [`ExtendedFullViewingKey`]s.
|
/// Scans a [`CompactOutput`] with a set of [`ExtendedFullViewingKey`]s.
|
||||||
///
|
///
|
||||||
/// Returns a [`WalletShieldedOutput`] and corresponding [`IncrementalWitness`] if this
|
/// Returns a [`WalletShieldedOutput`] and corresponding [`IncrementalWitness`] if this
|
||||||
@@ -2037,25 +2074,23 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
Ok(epk) => epk,
|
Ok(epk) => epk,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
error!("Failed to parse epk for output index {} — committing cmu to tree, skipping decryption", index);
|
error!("Failed to parse epk for output index {} — committing cmu to tree, skipping decryption", index);
|
||||||
let node = Node::new(cmu.into());
|
LightWallet::append_leaf(Node::new(cmu.into()), tree, defer_existing, deferred_nodes,
|
||||||
if defer_existing {
|
existing_witnesses, block_witnesses, new_witnesses);
|
||||||
deferred_nodes.push(node);
|
|
||||||
} else {
|
|
||||||
for witness in existing_witnesses {
|
|
||||||
witness.append(node).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for witness in block_witnesses {
|
|
||||||
witness.append(node).unwrap();
|
|
||||||
}
|
|
||||||
for witness in new_witnesses {
|
|
||||||
witness.append(node).unwrap();
|
|
||||||
}
|
|
||||||
tree.append(node).unwrap();
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The server controls the ciphertext length, and try_sapling_compact_note_decryption asserts 52 bytes
|
||||||
|
// (COMPACT_NOTE_SIZE). Longer: the compact plaintext is exactly the first 52 bytes (the cmu re-check validates
|
||||||
|
// it), so truncate. Shorter: undecryptable; commit the cmu like any other leaf and skip decryption.
|
||||||
let ct = output.ciphertext;
|
let ct = output.ciphertext;
|
||||||
|
if ct.len() < 52 {
|
||||||
|
error!("Output index {} has a {}-byte compact ciphertext (expected 52) — committing cmu to tree, skipping decryption",
|
||||||
|
index, ct.len());
|
||||||
|
LightWallet::append_leaf(Node::new(cmu.into()), tree, defer_existing, deferred_nodes,
|
||||||
|
existing_witnesses, block_witnesses, new_witnesses);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ct = if ct.len() > 52 { ct[..52].to_vec() } else { ct };
|
||||||
|
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
ivks.iter().enumerate().for_each(|(account, ivk)| {
|
ivks.iter().enumerate().for_each(|(account, ivk)| {
|
||||||
@@ -2066,48 +2101,45 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
|
|
||||||
pool.execute(move || {
|
pool.execute(move || {
|
||||||
let m = try_sapling_compact_note_decryption(&ivk, &epk, &cmu, &ct);
|
// Exactly one message per job: a panic is caught and sent as None, so the collector below never
|
||||||
let r = match m {
|
// waits for a result that cannot come.
|
||||||
Some((note, to)) => {
|
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
tx.send(Some(Some((note, to, account))))
|
try_sapling_compact_note_decryption(&ivk, &epk, &cmu, &ct)
|
||||||
},
|
}));
|
||||||
None => {
|
let msg = match r {
|
||||||
tx.send(Some(None))
|
Ok(Some((note, to))) => Some(Some((note, to, account))),
|
||||||
}
|
Ok(None) => Some(None),
|
||||||
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
|
if let Err(e) = tx.send(msg) {
|
||||||
match r {
|
error!("Compact decryption result lost: {:?}", e);
|
||||||
Ok(_) => {},
|
|
||||||
Err(e) => println!("Send error {:?}", e)
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Only the jobs hold senders now: a job that ends without a message makes recv() below fail, not block.
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
// Increment tree and witnesses
|
// Increment tree and witnesses
|
||||||
let node = Node::new(cmu.into());
|
LightWallet::append_leaf(Node::new(cmu.into()), tree, defer_existing, deferred_nodes,
|
||||||
if defer_existing {
|
existing_witnesses, block_witnesses, new_witnesses);
|
||||||
deferred_nodes.push(node);
|
|
||||||
} else {
|
|
||||||
for witness in existing_witnesses {
|
|
||||||
witness.append(node).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for witness in block_witnesses {
|
|
||||||
witness.append(node).unwrap();
|
|
||||||
}
|
|
||||||
for witness in new_witnesses {
|
|
||||||
witness.append(node).unwrap();
|
|
||||||
}
|
|
||||||
tree.append(node).unwrap();
|
|
||||||
|
|
||||||
// Collect all the RXs and fine if there was a valid result somewhere
|
// Collect all the RXs and fine if there was a valid result somewhere
|
||||||
let mut wsos = vec![];
|
let mut wsos = vec![];
|
||||||
for _i in 0..ivks.len() {
|
for _i in 0..ivks.len() {
|
||||||
let n = rx.recv().unwrap();
|
let n = match rx.recv() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => {
|
||||||
|
error!("A compact decryption job for output index {} ended without a result", index);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
let epk = epk.clone();
|
let epk = epk.clone();
|
||||||
|
|
||||||
let wso = match n {
|
let wso = match n {
|
||||||
None => panic!("Got a none!"),
|
None => {
|
||||||
|
error!("Compact decryption failed (panic) for output index {}; treating it as not ours", index);
|
||||||
|
None
|
||||||
|
},
|
||||||
Some(None) => None,
|
Some(None) => None,
|
||||||
Some(Some((note, to, account))) => {
|
Some(Some((note, to, account))) => {
|
||||||
// A note is marked as "change" if the account that received it
|
// A note is marked as "change" if the account that received it
|
||||||
@@ -2287,6 +2319,15 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CompactBlock::hash()/prev_hash() assert 32 bytes when no header is sent, and every vtx hash is copied into a
|
||||||
|
// [u8; 32] (one of them under txs.write()). Malformed server data is an error before any change, never a panic.
|
||||||
|
// Err(-1) as for an unparsable block: Err(height) would refetch the same block forever under sync_lock.
|
||||||
|
if (block.header().is_none() && (block.hash.len() != 32 || block.prevHash.len() != 32))
|
||||||
|
|| block.vtx.iter().any(|t| t.hash.len() != 32) {
|
||||||
|
error!("Malformed CompactBlock at height {} (bad hash length)", block.height);
|
||||||
|
return Err(-1);
|
||||||
|
}
|
||||||
|
|
||||||
// Scanned blocks MUST be height-sequential.
|
// Scanned blocks MUST be height-sequential.
|
||||||
let height = block.get_height() as i32;
|
let height = block.get_height() as i32;
|
||||||
if height == self.last_scanned_height() {
|
if height == self.last_scanned_height() {
|
||||||
|
|||||||
+57
-10
@@ -6,7 +6,8 @@
|
|||||||
//! A seeded generator builds random fake compact chains and feeds them to a `LightWallet`
|
//! A seeded generator builds random fake compact chains and feeds them to a `LightWallet`
|
||||||
//! through its public entry points (`scan_block_with_pool`, `invalidate_block`, `read`/`write`,
|
//! through its public entry points (`scan_block_with_pool`, `invalidate_block`, `read`/`write`,
|
||||||
//! `send_to_address`, `scan_full_tx`). It covers receives to HD, diversified, imported-sk,
|
//! `send_to_address`, `scan_full_tx`). It covers receives to HD, diversified, imported-sk,
|
||||||
//! view-only and foreign keys, zero-value and dust notes, outputs with an unparseable epk,
|
//! view-only and foreign keys, zero-value and dust notes, outputs with an unparseable epk or a
|
||||||
|
//! malformed compact ciphertext (0..=51 bytes: skipped; the full 580 bytes: truncated to 52),
|
||||||
//! compact spends with change, real `send_to_address` sends that are mined later (or never),
|
//! compact spends with change, real `send_to_address` sends that are mined later (or never),
|
||||||
//! reorgs of 1..=100 blocks (in one call or block by block, with spends re-mined on the new
|
//! reorgs of 1..=100 blocks (in one call or block by block, with spends re-mined on the new
|
||||||
//! branch), and save/reload.
|
//! branch), and save/reload.
|
||||||
@@ -358,6 +359,18 @@ enum SendKind {
|
|||||||
Real,
|
Real,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How `make_output` encodes an output's compact form. Every kind has a parseable cmu, i.e. is a leaf.
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum OutKind {
|
||||||
|
Good,
|
||||||
|
/// A 31-byte (unparseable) epk: never decrypted.
|
||||||
|
BadEpk,
|
||||||
|
/// A 0..=51-byte ciphertext (C245): never decrypted.
|
||||||
|
ShortCt,
|
||||||
|
/// The full 580-byte ciphertext (C245): the compact plaintext is its first 52 bytes, so the note is found.
|
||||||
|
FullCt,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Stats {
|
struct Stats {
|
||||||
blocks: usize,
|
blocks: usize,
|
||||||
@@ -374,6 +387,9 @@ struct Stats {
|
|||||||
requeued: usize,
|
requeued: usize,
|
||||||
remined: usize,
|
remined: usize,
|
||||||
full_checks: usize,
|
full_checks: usize,
|
||||||
|
/// Generated outputs with a short (0..=51-byte) / the full (580-byte) compact ciphertext.
|
||||||
|
short_ct: usize,
|
||||||
|
full_ct: usize,
|
||||||
/// Reorgs while a note is pending, by `at`: above the sent tip, across it (anchor kept),
|
/// Reorgs while a note is pending, by `at`: above the sent tip, across it (anchor kept),
|
||||||
/// popping the anchor block.
|
/// popping the anchor block.
|
||||||
pending_reorgs: [usize; 3],
|
pending_reorgs: [usize; 3],
|
||||||
@@ -566,7 +582,9 @@ impl Sim {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_output(&mut self, ovk: OutgoingViewingKey, to: &PaymentAddress<Bls12>, value: u64, bad_epk: bool)
|
/// BadEpk and ShortCt draw the same 31 random bytes (the epk, or what sets the ciphertext length), so the
|
||||||
|
/// generator's stream does not depend on the kind: every seed generates the same chain as before ShortCt existed.
|
||||||
|
fn make_output(&mut self, ovk: OutgoingViewingKey, to: &PaymentAddress<Bls12>, value: u64, kind: OutKind)
|
||||||
-> (CompactOutput, Note<Bls12>) {
|
-> (CompactOutput, Note<Bls12>) {
|
||||||
let note = Note {
|
let note = Note {
|
||||||
g_d: to.diversifier.g_d::<Bls12>(&JUBJUB).unwrap(),
|
g_d: to.diversifier.g_d::<Bls12>(&JUBJUB).unwrap(),
|
||||||
@@ -577,19 +595,36 @@ impl Sim {
|
|||||||
let enc = SaplingNoteEncryption::new(ovk, note.clone(), to.clone(), Memo::default(), &mut self.rng);
|
let enc = SaplingNoteEncryption::new(ovk, note.clone(), to.clone(), Memo::default(), &mut self.rng);
|
||||||
let mut cmu = vec![];
|
let mut cmu = vec![];
|
||||||
note.cm(&JUBJUB).into_repr().write_le(&mut cmu).unwrap();
|
note.cm(&JUBJUB).into_repr().write_le(&mut cmu).unwrap();
|
||||||
|
let mut junk = vec![];
|
||||||
|
if kind == OutKind::BadEpk || kind == OutKind::ShortCt {
|
||||||
|
junk.resize(31, 0);
|
||||||
|
self.rng.fill_bytes(&mut junk);
|
||||||
|
}
|
||||||
let mut epk = vec![];
|
let mut epk = vec![];
|
||||||
if bad_epk {
|
if kind == OutKind::BadEpk {
|
||||||
// Unparseable epk: the wallet must still append the cmu to the tree.
|
// Unparseable epk: the wallet must still append the cmu to the tree.
|
||||||
epk.resize(31, 0);
|
epk = junk.clone();
|
||||||
self.rng.fill_bytes(&mut epk);
|
|
||||||
} else {
|
} else {
|
||||||
enc.epk().write(&mut epk).unwrap();
|
enc.epk().write(&mut epk).unwrap();
|
||||||
}
|
}
|
||||||
let ct = enc.encrypt_note_plaintext();
|
let ct = enc.encrypt_note_plaintext();
|
||||||
|
let ct = match kind {
|
||||||
|
// Undecryptable: the wallet must skip it (no hang, no panic) and still append the cmu to the tree.
|
||||||
|
OutKind::ShortCt => {
|
||||||
|
self.stats.short_ct += 1;
|
||||||
|
ct[..junk[0] as usize % 52].to_vec()
|
||||||
|
}
|
||||||
|
// The wallet must truncate it to 52 bytes and find a note of ours.
|
||||||
|
OutKind::FullCt => {
|
||||||
|
self.stats.full_ct += 1;
|
||||||
|
ct.to_vec()
|
||||||
|
}
|
||||||
|
_ => ct[..52].to_vec(),
|
||||||
|
};
|
||||||
let mut co = CompactOutput::new();
|
let mut co = CompactOutput::new();
|
||||||
co.set_cmu(cmu);
|
co.set_cmu(cmu);
|
||||||
co.set_epk(epk);
|
co.set_epk(epk);
|
||||||
co.set_ciphertext(ct[..52].to_vec());
|
co.set_ciphertext(ct);
|
||||||
(co, note)
|
(co, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +639,9 @@ impl Sim {
|
|||||||
} else {
|
} else {
|
||||||
fvk.default_address().unwrap().1
|
fvk.default_address().unwrap().1
|
||||||
};
|
};
|
||||||
let (co, note) = self.make_output(fvk.fvk.ovk, &to, value, false);
|
// Value 7 mod 16 (no extra draw): the full 580-byte ciphertext.
|
||||||
|
let kind = if value % 16 == 7 { OutKind::FullCt } else { OutKind::Good };
|
||||||
|
let (co, note) = self.make_output(fvk.fvk.ovk, &to, value, kind);
|
||||||
(co, Owned { account, note, to })
|
(co, Owned { account, note, to })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +732,15 @@ impl Sim {
|
|||||||
let (ovk, to) = self.foreign[self.rng.gen_range(0, self.foreign.len())].clone();
|
let (ovk, to) = self.foreign[self.rng.gen_range(0, self.foreign.len())].clone();
|
||||||
let v = self.gen_value();
|
let v = self.gen_value();
|
||||||
let bad = self.rng.gen_bool(0.04);
|
let bad = self.rng.gen_bool(0.04);
|
||||||
let (co, _) = self.make_output(ovk, &to, v, bad);
|
// A bad output has an unparseable epk (even value) or a short ciphertext (odd value); a good one with
|
||||||
|
// value 7 mod 16 carries the full ciphertext. No extra draw: the generator's stream is unchanged.
|
||||||
|
let kind = match (bad, v % 2, v % 16) {
|
||||||
|
(true, 0, _) => OutKind::BadEpk,
|
||||||
|
(true, _, _) => OutKind::ShortCt,
|
||||||
|
(false, _, 7) => OutKind::FullCt,
|
||||||
|
_ => OutKind::Good,
|
||||||
|
};
|
||||||
|
let (co, _) = self.make_output(ovk, &to, v, kind);
|
||||||
ctx.outputs.push(co);
|
ctx.outputs.push(co);
|
||||||
ours.push(None);
|
ours.push(None);
|
||||||
}
|
}
|
||||||
@@ -1581,11 +1626,13 @@ impl Sim {
|
|||||||
|
|
||||||
fn summary(&self) -> String {
|
fn summary(&self) -> String {
|
||||||
let s = &self.stats;
|
let s = &self.stats;
|
||||||
format!("seed {:#x}: {} blocks, {} outputs ({} ours, {} notes), {} compact spends, {} reorgs (max {}), \
|
format!("seed {:#x}: {} blocks, {} outputs ({} ours, {} notes; {} short-ct, {} full-ct), {} compact spends, \
|
||||||
|
{} reorgs (max {}), \
|
||||||
{} reloads, {} probes, {} sends ({} mined), {} insufficient, {} requeued/{} re-mined, \
|
{} reloads, {} probes, {} sends ({} mined), {} insufficient, {} requeued/{} re-mined, \
|
||||||
{} full checks, max {} cleared lists, {} keys; time gen {:.1}s scan {:.1}s ref {:.1}s \
|
{} full checks, max {} cleared lists, {} keys; time gen {:.1}s scan {:.1}s ref {:.1}s \
|
||||||
check {:.1}s send {:.1}s reorg {:.1}s reload {:.1}s periodic {:.1}s; {}",
|
check {:.1}s send {:.1}s reorg {:.1}s reload {:.1}s periodic {:.1}s; {}",
|
||||||
self.seed, s.blocks, s.outputs, s.ours, self.notes.len(), s.spends, s.reorgs, s.max_depth,
|
self.seed, s.blocks, s.outputs, s.ours, self.notes.len(), s.short_ct, s.full_ct, s.spends, s.reorgs,
|
||||||
|
s.max_depth,
|
||||||
s.reloads, s.probes, s.sends, s.mined_sends, s.insufficient, s.requeued, s.remined,
|
s.reloads, s.probes, s.sends, s.mined_sends, s.insufficient, s.requeued, s.remined,
|
||||||
s.full_checks, self.cleared_seen.get(), self.w.zkeys.read().unwrap().len(),
|
s.full_checks, self.cleared_seen.get(), self.w.zkeys.read().unwrap().len(),
|
||||||
s.t_gen, s.t_scan, s.t_ref, s.t_check, s.t_send, s.t_reorg, s.t_reload, s.t_full,
|
s.t_gen, s.t_scan, s.t_ref, s.t_check, s.t_send, s.t_reorg, s.t_reload, s.t_full,
|
||||||
|
|||||||
+97
@@ -3696,3 +3696,100 @@ fn test_seed_phrase_empty_when_encrypted_seed_is_zero() {
|
|||||||
wallet.seed = [0u8; 32];
|
wallet.seed = [0u8; 32];
|
||||||
assert_eq!(wallet.get_seed_phrase(), "");
|
assert_eq!(wallet.get_seed_phrase(), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs `f` on its own thread and returns its result, failing when there is none within 15 s: a scan that hangs (the
|
||||||
|
/// C245 failure mode) fails its test instead of wedging the suite. A panic in `f` is re-raised here.
|
||||||
|
pub(crate) fn within_watchdog<T: Send + 'static, F: FnOnce() -> T + Send + 'static>(what: &str, f: F) -> T {
|
||||||
|
use std::sync::mpsc::{channel, RecvTimeoutError};
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
let t = std::thread::spawn(move || {
|
||||||
|
let _ = tx.send(f());
|
||||||
|
});
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_secs(15)) {
|
||||||
|
Ok(v) => {
|
||||||
|
let _ = t.join();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
Err(RecvTimeoutError::Timeout) => panic!("{}: no result within the 15 s watchdog (hang)", what),
|
||||||
|
Err(RecvTimeoutError::Disconnected) => match t.join() {
|
||||||
|
Err(p) => std::panic::resume_unwind(p),
|
||||||
|
Ok(()) => panic!("{}: the test thread ended without a result", what),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scan_full_tx_mixed_order_epk_no_panic() {
|
||||||
|
// C245 Fix 4. A full tx's epk is chain data: Sapling consensus rejects only a small-order epk, so an epk plus a
|
||||||
|
// point of order 2 can be mined. scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() and panicked
|
||||||
|
// (an own tx failed every sync; the mempool thread died). The output is skipped now; with its real epk the same
|
||||||
|
// output still decrypts.
|
||||||
|
within_watchdog("mixed-order epk", || {
|
||||||
|
use pairing::bls12_381::Fr;
|
||||||
|
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||||
|
use zcash_primitives::jubjub::{edwards, Unknown};
|
||||||
|
use zcash_primitives::transaction::components::{OutputDescription, GROTH_PROOF_SIZE};
|
||||||
|
|
||||||
|
let mut rng = OsRng;
|
||||||
|
let wallet = LightWallet::new(None, &get_test_config(), 0, 0).unwrap();
|
||||||
|
let extfvk = wallet.zkeys.read().unwrap()[0].extfvk.clone();
|
||||||
|
let to = extfvk.default_address().unwrap().1;
|
||||||
|
let note = Note {
|
||||||
|
g_d: to.diversifier.g_d::<Bls12>(&JUBJUB).unwrap(),
|
||||||
|
pk_d: to.pk_d.clone(),
|
||||||
|
value: 50_000,
|
||||||
|
r: Fs::random(&mut rng),
|
||||||
|
};
|
||||||
|
let memo = Memo::from_bytes(b"C245 mixed-order epk").unwrap();
|
||||||
|
let enc = SaplingNoteEncryption::new(extfvk.fvk.ovk, note.clone(), to.clone(), memo.clone(), &mut rng);
|
||||||
|
let cmu = note.cm(&JUBJUB);
|
||||||
|
let cv: edwards::Point<Bls12, Unknown> =
|
||||||
|
edwards::Point::<Bls12, Unknown>::rand(&mut rng, &JUBJUB).mul_by_cofactor(&JUBJUB).into();
|
||||||
|
let epk: edwards::Point<Bls12, Unknown> = enc.epk().clone().into();
|
||||||
|
|
||||||
|
// (0, -1) has order 2: epk + (0, -1) is on the curve but outside the prime-order subgroup.
|
||||||
|
let mut minus_one = Fr::one();
|
||||||
|
minus_one.negate();
|
||||||
|
let t2 = edwards::Point::<Bls12, Unknown>::get_for_y(minus_one, false, &JUBJUB).unwrap();
|
||||||
|
assert!(t2 != edwards::Point::zero() && t2.double(&JUBJUB) == edwards::Point::zero());
|
||||||
|
let mixed = epk.add(&t2, &JUBJUB);
|
||||||
|
assert!(epk.as_prime_order(&JUBJUB).is_some() && mixed.as_prime_order(&JUBJUB).is_none());
|
||||||
|
|
||||||
|
let mut txid = [0u8; 32];
|
||||||
|
rng.fill_bytes(&mut txid);
|
||||||
|
let tx_with_epk = |e: &edwards::Point<Bls12, Unknown>| {
|
||||||
|
let mut data = TransactionData::new();
|
||||||
|
data.shielded_outputs.push(OutputDescription {
|
||||||
|
cv: cv.clone(),
|
||||||
|
cmu,
|
||||||
|
ephemeral_key: e.clone(),
|
||||||
|
enc_ciphertext: enc.encrypt_note_plaintext(),
|
||||||
|
out_ciphertext: enc.encrypt_outgoing_plaintext(&cv, &cmu),
|
||||||
|
zkproof: [0u8; GROTH_PROOF_SIZE],
|
||||||
|
});
|
||||||
|
Transaction { txid: TxId(txid), data }
|
||||||
|
};
|
||||||
|
let honest = tx_with_epk(&epk);
|
||||||
|
let bad = tx_with_epk(&mixed);
|
||||||
|
|
||||||
|
// The wallet holds the note (compact scan of the honest tx), without its memo yet.
|
||||||
|
let mut cb = FakeCompactBlock::new(0, BlockHash([0; 32]));
|
||||||
|
cb.add_tx(&honest);
|
||||||
|
wallet.scan_block(&cb.as_bytes()).unwrap();
|
||||||
|
let memo_of = |w: &LightWallet| w.txs.read().unwrap()[&TxId(txid)].notes[0].memo.clone();
|
||||||
|
assert!(memo_of(&wallet).is_none());
|
||||||
|
|
||||||
|
let r = catch_unwind(AssertUnwindSafe(|| wallet.scan_full_tx(&bad, 0, 0)));
|
||||||
|
assert!(r.is_ok(), "scan_full_tx panicked on a mixed-order epk");
|
||||||
|
assert!(memo_of(&wallet).is_none(), "an output with a mixed-order epk was decrypted");
|
||||||
|
let r = catch_unwind(AssertUnwindSafe(|| wallet.scan_full_mempool_tx(&bad, 1, 0, true)));
|
||||||
|
assert!(r.is_ok(), "scan_full_mempool_tx panicked on a mixed-order epk");
|
||||||
|
assert!(wallet.incoming_mempool_txs.read().unwrap().is_empty());
|
||||||
|
|
||||||
|
// Control: the same output with its real epk is ours, in both scans.
|
||||||
|
wallet.scan_full_tx(&honest, 0, 0);
|
||||||
|
assert!(memo_of(&wallet) == Some(memo.clone()), "the honest output did not decrypt");
|
||||||
|
wallet.scan_full_mempool_tx(&honest, 1, 0, true);
|
||||||
|
assert!(wallet.incoming_mempool_txs.read().unwrap().contains_key(&TxId(txid)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+125
-1
@@ -37,6 +37,7 @@ use zcash_primitives::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::data::SaplingNoteData;
|
use super::data::SaplingNoteData;
|
||||||
|
use super::tests::within_watchdog;
|
||||||
use super::{LightWallet, MAX_REORG};
|
use super::{LightWallet, MAX_REORG};
|
||||||
use crate::lightclient::LightClientConfig;
|
use crate::lightclient::LightClientConfig;
|
||||||
|
|
||||||
@@ -76,6 +77,11 @@ fn foreign() -> ExtendedFullViewingKey {
|
|||||||
|
|
||||||
/// An output paying `value` to the default address of `fvk` (the body of tests.rs add_tx_paying_to).
|
/// An output paying `value` to the default address of `fvk` (the body of tests.rs add_tx_paying_to).
|
||||||
fn out_to(fvk: &ExtendedFullViewingKey, value: u64) -> CompactOutput {
|
fn out_to(fvk: &ExtendedFullViewingKey, value: u64) -> CompactOutput {
|
||||||
|
out_to_ct(fvk, value, 52)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// out_to whose compact ciphertext is the first `ct_len` bytes of the full 580-byte note ciphertext (out_to: 52).
|
||||||
|
fn out_to_ct(fvk: &ExtendedFullViewingKey, value: u64, ct_len: usize) -> CompactOutput {
|
||||||
let mut rng = OsRng;
|
let mut rng = OsRng;
|
||||||
let to = fvk.default_address().unwrap().1;
|
let to = fvk.default_address().unwrap().1;
|
||||||
let note = Note {
|
let note = Note {
|
||||||
@@ -94,7 +100,7 @@ fn out_to(fvk: &ExtendedFullViewingKey, value: u64) -> CompactOutput {
|
|||||||
let mut cout = CompactOutput::new();
|
let mut cout = CompactOutput::new();
|
||||||
cout.set_cmu(cmu);
|
cout.set_cmu(cmu);
|
||||||
cout.set_epk(epk);
|
cout.set_epk(epk);
|
||||||
cout.set_ciphertext(enc_ciphertext[..52].to_vec());
|
cout.set_ciphertext(enc_ciphertext[..ct_len].to_vec());
|
||||||
cout
|
cout
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,3 +800,121 @@ fn deferred_append_path_with_bad_outputs() {
|
|||||||
deferred_case(17);
|
deferred_case(17);
|
||||||
deferred_case(3);
|
deferred_case(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C245: malformed server data. Each test runs on its own thread under a 15 s watchdog (within_watchdog), so the old
|
||||||
|
// failure mode, a scan waiting forever for a pool job that died, fails the test instead of wedging the suite.
|
||||||
|
|
||||||
|
/// A block at the chain's next height on its tip, carrying `txs`. Not applied to the model.
|
||||||
|
fn raw_block(c: &Chain, txs: Vec<CompactTx>) -> CompactBlock {
|
||||||
|
let mut cb = CompactBlock::new();
|
||||||
|
cb.set_height(c.next as u64);
|
||||||
|
cb.hash = random_hash().to_vec();
|
||||||
|
cb.prevHash = c.prev.0.to_vec();
|
||||||
|
for (i, mut t) in txs.into_iter().enumerate() {
|
||||||
|
t.set_index(i as u64);
|
||||||
|
cb.vtx.push(t);
|
||||||
|
}
|
||||||
|
cb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// K notes, then one block whose outputs carry compact ciphertexts of 0, 51, 52, 53 and 580 bytes. The server controls
|
||||||
|
/// that length, and try_sapling_compact_note_decryption asserts 52. Shorter: the output cannot be trial-decrypted and
|
||||||
|
/// is skipped, even when it pays us, but its cmu is a leaf like any other. Longer (53, or the full 580 bytes from a
|
||||||
|
/// server that sends full ciphertexts): the compact plaintext is the first 52 bytes, so the note is found. The model is
|
||||||
|
/// the reference: it appends every parseable cmu whatever the ciphertext, i.e. exactly what a wallet fed 52-byte
|
||||||
|
/// ciphertexts in those slots commits. K = 3 takes the inline witness path, K = 17 the deferred one.
|
||||||
|
fn bad_ciphertext_case(k: usize) {
|
||||||
|
let mut c = Chain::new();
|
||||||
|
for _ in 0..k {
|
||||||
|
let g = c.pay_us(10_000);
|
||||||
|
c.mine(vec![g]);
|
||||||
|
}
|
||||||
|
let ctx = format!("bad ciphertext K={}", k);
|
||||||
|
let f = foreign();
|
||||||
|
let ours = c.ours();
|
||||||
|
// tx1: our note; our note with a 51-byte ciphertext (skipped); a foreign note
|
||||||
|
let (t1, id1) = tx_with(vec![out_to(&ours, 1_111), out_to_ct(&ours, 9_999, 51), out_to(&f, 1)], vec![]);
|
||||||
|
// tx2: a foreign 51-byte ciphertext; our note with the full 580-byte ciphertext (found)
|
||||||
|
let (t2, id2) = tx_with(vec![out_to_ct(&f, 2, 51), out_to_ct(&ours, 2_222, 580)], vec![]);
|
||||||
|
// tx3: our note with a 53-byte ciphertext (found); a foreign empty ciphertext
|
||||||
|
let (t3, id3) = tx_with(vec![out_to_ct(&ours, 3_333, 53), out_to_ct(&f, 3, 0)], vec![]);
|
||||||
|
let size_before = c.model.tree.size();
|
||||||
|
c.mine(vec![
|
||||||
|
GenTx { ctx: t1, txid: id1, ours: Some(0), spends: vec![] },
|
||||||
|
GenTx { ctx: t2, txid: id2, ours: Some(1), spends: vec![] },
|
||||||
|
GenTx { ctx: t3, txid: id3, ours: Some(0), spends: vec![] },
|
||||||
|
]);
|
||||||
|
assert_eq!(c.model.tree.size() - size_before, 7, "{}: every output is a leaf", ctx);
|
||||||
|
c.compare(true, &ctx);
|
||||||
|
{
|
||||||
|
let txs = c.wallet.txs.read().unwrap();
|
||||||
|
let values = |id: &TxId| txs[id].notes.iter().map(|nd| nd.note.value).collect::<Vec<u64>>();
|
||||||
|
assert_eq!(values(&id1), vec![1_111], "{}: the 51-byte note must be skipped", ctx);
|
||||||
|
assert_eq!(values(&id2), vec![2_222], "{}: the 580-byte note must be found", ctx);
|
||||||
|
assert_eq!(values(&id3), vec![3_333], "{}: the 53-byte note must be found", ctx);
|
||||||
|
}
|
||||||
|
let root = c.wallet.blocks.read().unwrap().last().unwrap().tree.root();
|
||||||
|
assert!(root == c.model.tree.root(), "{}: tip root differs from the reference", ctx);
|
||||||
|
assert!(check_anchor_alignment(&c.wallet, &ctx) >= k + 3, "{}: every live note is checked", ctx);
|
||||||
|
|
||||||
|
// One more block: every note, including the three from the bad-ciphertext block, advances.
|
||||||
|
let g = c.pay_foreign(2);
|
||||||
|
c.mine(vec![g]);
|
||||||
|
c.compare(true, &ctx);
|
||||||
|
check_anchor_alignment(&c.wallet, &ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_compact_ciphertext_is_skipped_or_truncated() {
|
||||||
|
within_watchdog("bad compact ciphertext", || {
|
||||||
|
bad_ciphertext_case(3);
|
||||||
|
bad_ciphertext_case(17);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash lengths are server data too. CompactBlock::hash()/prev_hash() assert 32 bytes when no header is sent, and every
|
||||||
|
/// tx hash was copied into a [u8; 32]: for a tx of ours under txs.write() (the panic poisoned the wallet for good), and
|
||||||
|
/// for the block's txid list after the witnesses had moved. Each is Err(-1) now, before any change: the lock is usable,
|
||||||
|
/// last_scanned_height and every tree and witness are unchanged, and the next good block scans.
|
||||||
|
#[test]
|
||||||
|
fn malformed_hashes_are_an_error_before_any_change() {
|
||||||
|
within_watchdog("malformed hashes", || {
|
||||||
|
let mut c = Chain::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
let g = c.pay_us(10_000);
|
||||||
|
c.mine(vec![g]);
|
||||||
|
}
|
||||||
|
let ours = c.ours();
|
||||||
|
let mut cases: Vec<(&str, CompactBlock)> = vec![];
|
||||||
|
let (mut t, _) = tx_with(vec![out_to(&ours, 5_000)], vec![]);
|
||||||
|
t.hash.truncate(31);
|
||||||
|
cases.push(("our tx with a 31-byte hash", raw_block(&c, vec![t])));
|
||||||
|
let (t, _) = tx_with(vec![out_to(&ours, 5_000)], vec![]);
|
||||||
|
let mut b = raw_block(&c, vec![t]);
|
||||||
|
b.hash = vec![];
|
||||||
|
cases.push(("an empty block hash and no header", b));
|
||||||
|
let mut b = raw_block(&c, vec![]);
|
||||||
|
b.prevHash.truncate(31);
|
||||||
|
cases.push(("a 31-byte prev hash and no header", b));
|
||||||
|
let (t1, _) = tx_with(vec![out_to(&ours, 5_000)], vec![]);
|
||||||
|
let (mut t2, _) = tx_with(vec![out_to(&foreign(), 1)], vec![]);
|
||||||
|
t2.hash.push(0);
|
||||||
|
cases.push(("our tx and a foreign tx with a 33-byte hash", raw_block(&c, vec![t1, t2])));
|
||||||
|
|
||||||
|
let h = c.wallet.last_scanned_height();
|
||||||
|
for (name, b) in cases {
|
||||||
|
let bytes = b.write_to_bytes().unwrap();
|
||||||
|
let w = &c.wallet;
|
||||||
|
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| w.scan_block(&bytes)));
|
||||||
|
assert!(r.is_ok(), "{}: scan_block panicked", name);
|
||||||
|
assert_eq!(r.unwrap(), Err(-1), "{}", name);
|
||||||
|
assert!(c.wallet.txs.read().is_ok(), "{}: the txs lock is poisoned", name);
|
||||||
|
assert_eq!(c.wallet.last_scanned_height(), h, "{}: last_scanned_height moved", name);
|
||||||
|
c.compare(true, name);
|
||||||
|
}
|
||||||
|
let g = c.pay_us(7_000);
|
||||||
|
c.mine(vec![g]);
|
||||||
|
c.compare(true, "after the malformed blocks");
|
||||||
|
check_anchor_alignment(&c.wallet, "after the malformed blocks");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user