mirror of
https://github.com/lux-rs/chains.git
synced 2026-10-30 20:53:50 +00:00
These were in luxfi/compute, a private repo whose licence is the same BSD-3-Clause as luxfi/node's. Nothing here needed protecting, and keeping it private broke the public Rust chain builds that reference it. Sibling paths lose a level: the chains sat three deep in compute and sit one deep here. The conformance generator loses a replace naming an absolute path on one machine, and takes luxfi/chains from the proxy. Co-authored-by: Hanzo Dev <[email protected]>
742 lines
29 KiB
Rust
742 lines
29 KiB
Rust
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
|
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
|
|
//! The three gates a transaction passes: structure, the payer's signature, and
|
|
//! authorization — and what each of them refuses.
|
|
|
|
mod common;
|
|
|
|
use common::*;
|
|
|
|
use lux_fhevm::error::Error;
|
|
use lux_fhevm::fhe;
|
|
use lux_fhevm::state::{
|
|
self, committee_digest, derive_handle, derive_permit_id, derive_request_id,
|
|
};
|
|
use lux_fhevm::transaction::{
|
|
validate_committee, AdvancePayload, FulfillPayload, GrantPayload, RegisterPayload, RequestPayload, RevokePayload,
|
|
Transaction, MAX_CIPHERTEXT_SIZE, TX_ADVANCE_EPOCH, TX_FULFILL_DECRYPT, TX_GRANT_PERMIT,
|
|
TX_REGISTER_CIPHERTEXT, TX_REQUEST_DECRYPT, TX_REVOKE_PERMIT,
|
|
};
|
|
|
|
#[test]
|
|
fn a_well_formed_transaction_is_the_baseline_every_refusal_is_measured_against() {
|
|
let k = TestKey::new();
|
|
register_tx(&k, TEST_SCHEME, digest_of("ok"), 1)
|
|
.syntactic_verify()
|
|
.expect("a well-formed registration");
|
|
}
|
|
|
|
/// Each structural rule actually refuses. Every case is a well-formed transaction
|
|
/// with exactly one thing wrong, so a rule that stopped being enforced fails here
|
|
/// instead of shipping.
|
|
#[test]
|
|
fn each_structural_rule_refuses_what_it_names() {
|
|
let k = TestKey::new();
|
|
let digest = digest_of("subject");
|
|
let handle = derive_handle(&digest, TEST_SCHEME.as_bytes());
|
|
let scheme = TEST_SCHEME.as_bytes().to_vec();
|
|
|
|
// One case: a name, the transaction with exactly one thing wrong, and the
|
|
// refusal it must draw.
|
|
type Case = (&'static str, Transaction, fn(&Error) -> bool);
|
|
let cases: Vec<Case> = vec![
|
|
(
|
|
"unknown transaction type",
|
|
Transaction { tx_type: 99, nonce: 1, ..Default::default() },
|
|
|e| matches!(e, Error::InvalidTxType),
|
|
),
|
|
(
|
|
"unknown scheme",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: b"paillier".to_vec(),
|
|
nonce: 1,
|
|
payload: RegisterPayload { digest, size: 1, ..Default::default() }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::UnknownScheme(_)),
|
|
),
|
|
(
|
|
"nonce zero",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 0,
|
|
subject: handle,
|
|
payload: RegisterPayload { digest, size: 1, ..Default::default() }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::BadNonce),
|
|
),
|
|
(
|
|
"undecodable payload",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: b"not json".to_vec(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"register with no digest",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: derive_handle(&[0u8; 32], TEST_SCHEME.as_bytes()),
|
|
payload: RegisterPayload { size: 1, ..Default::default() }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"register with zero size",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: RegisterPayload { digest, ..Default::default() }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"register with a size beyond anything servable",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: RegisterPayload {
|
|
digest,
|
|
kind: 4,
|
|
level: 3,
|
|
size: MAX_CIPHERTEXT_SIZE + 1,
|
|
}
|
|
.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"register with a negative level",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: RegisterPayload { digest, size: 1, level: -1, ..Default::default() }
|
|
.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"register whose subject is not the derived handle",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: {
|
|
let mut s = [0u8; 32];
|
|
s[0] = 0xde;
|
|
s[1] = 0xad;
|
|
s
|
|
},
|
|
payload: RegisterPayload { digest, size: 1, ..Default::default() }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::HandleMismatch(_)),
|
|
),
|
|
(
|
|
"register under a different scheme than the handle names",
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: b"bfv-n13".to_vec(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: RegisterPayload { digest, size: 1, ..Default::default() }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::HandleMismatch(_)),
|
|
),
|
|
(
|
|
"grant conferring nothing",
|
|
Transaction {
|
|
tx_type: TX_GRANT_PERMIT,
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: GrantPayload { grantee: k.addr, operations: 0, expiry: 0 }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"grant with unknown operation bits",
|
|
Transaction {
|
|
tx_type: TX_GRANT_PERMIT,
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: GrantPayload { grantee: k.addr, operations: 1 << 20, expiry: 0 }.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"grant with a negative expiry",
|
|
Transaction {
|
|
tx_type: TX_GRANT_PERMIT,
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: GrantPayload {
|
|
grantee: k.addr,
|
|
operations: fhe::PERMIT_OP_DECRYPT,
|
|
expiry: -1,
|
|
}
|
|
.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"request naming no permit",
|
|
Transaction {
|
|
tx_type: TX_REQUEST_DECRYPT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: RequestPayload::default().to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"request with a negative expiry",
|
|
Transaction {
|
|
tx_type: TX_REQUEST_DECRYPT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: RequestPayload {
|
|
permit_id: [1u8; 32],
|
|
expiry: -1,
|
|
..Default::default()
|
|
}
|
|
.to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"fulfil carrying no result",
|
|
Transaction {
|
|
tx_type: TX_FULFILL_DECRYPT,
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: FulfillPayload::default().to_json(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"revoke whose payload is not an object",
|
|
Transaction {
|
|
tx_type: TX_REVOKE_PERMIT,
|
|
nonce: 1,
|
|
payload: b"[]".to_vec(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
(
|
|
"advance whose payload does not decode",
|
|
Transaction {
|
|
tx_type: TX_ADVANCE_EPOCH,
|
|
nonce: 1,
|
|
payload: b"{".to_vec(),
|
|
..Default::default()
|
|
},
|
|
|e| matches!(e, Error::InvalidPayload(_)),
|
|
),
|
|
];
|
|
|
|
for (name, tx, want) in cases {
|
|
let got = tx.syntactic_verify().expect_err(name);
|
|
assert!(want(&got), "{name}: refused with {got:?}");
|
|
}
|
|
|
|
// The control: the well-formed shapes all pass.
|
|
Transaction {
|
|
tx_type: TX_REGISTER_CIPHERTEXT,
|
|
scheme: scheme.clone(),
|
|
nonce: 1,
|
|
subject: handle,
|
|
payload: RegisterPayload { digest, kind: 4, level: 3, size: 4096 }.to_json(),
|
|
..Default::default()
|
|
}
|
|
.syntactic_verify()
|
|
.expect("a sound registration");
|
|
Transaction {
|
|
tx_type: TX_REVOKE_PERMIT,
|
|
nonce: 1,
|
|
payload: RevokePayload::default().to_json(),
|
|
..Default::default()
|
|
}
|
|
.syntactic_verify()
|
|
.expect("a sound revocation");
|
|
}
|
|
|
|
/// An epoch proposal carries the whole committee check with it, so a committee
|
|
/// that could never sign cannot be voted in.
|
|
#[test]
|
|
fn an_epoch_proposal_carries_the_whole_committee_check() {
|
|
let (good, _) = new_committee(3);
|
|
let pk = b"next-network-key".to_vec();
|
|
|
|
let advance = |c: &[fhe::CommitteeMember], threshold: i64, key: &[u8]| Transaction {
|
|
tx_type: TX_ADVANCE_EPOCH,
|
|
nonce: 1,
|
|
subject: committee_digest(1, threshold, key, c),
|
|
payload: AdvancePayload {
|
|
epoch: 1,
|
|
committee: if c.is_empty() { None } else { Some(c.to_vec()) },
|
|
threshold,
|
|
public_key: key.to_vec(),
|
|
}
|
|
.to_json(),
|
|
..Default::default()
|
|
};
|
|
|
|
advance(&good, 2, &pk).syntactic_verify().expect("a sound proposal");
|
|
|
|
assert!(matches!(
|
|
advance(&[], 1, &pk).syntactic_verify().unwrap_err(),
|
|
Error::InvalidCommittee(_)
|
|
));
|
|
assert!(matches!(
|
|
advance(&good, 0, &pk).syntactic_verify().unwrap_err(),
|
|
Error::InvalidThreshold(_)
|
|
));
|
|
assert!(matches!(
|
|
advance(&good, 4, &pk).syntactic_verify().unwrap_err(),
|
|
Error::InvalidThreshold(_)
|
|
));
|
|
assert!(matches!(
|
|
advance(&good, 2, b"").syntactic_verify().unwrap_err(),
|
|
Error::InvalidCommittee(_)
|
|
));
|
|
|
|
// Out of canonical order: two members proposing the same set would hash it
|
|
// differently and never agree, so the order is part of the value.
|
|
let mut shuffled = good.clone();
|
|
shuffled.swap(0, 2);
|
|
assert!(matches!(
|
|
advance(&shuffled, 2, &pk).syntactic_verify().unwrap_err(),
|
|
Error::InvalidCommittee(_)
|
|
));
|
|
|
|
// A member whose key cannot be parsed could never attest — installing it would
|
|
// wedge the chain, because a committee that cannot speak also cannot be
|
|
// replaced.
|
|
let mut unusable = good.clone();
|
|
unusable[1].public_key = b"not-an-mldsa-65-key".to_vec();
|
|
assert!(matches!(
|
|
advance(&unusable, 2, &pk).syntactic_verify().unwrap_err(),
|
|
Error::InvalidCommittee(_)
|
|
));
|
|
|
|
// A proposal whose subject does not hash its own contents is refused, so the
|
|
// signature always covers the committee actually being voted for.
|
|
let mut mismatched = advance(&good, 2, &pk);
|
|
mismatched.subject = [0xff; 32];
|
|
assert!(matches!(mismatched.syntactic_verify().unwrap_err(), Error::HandleMismatch(_)));
|
|
}
|
|
|
|
/// A committee's members must arrive in canonical order and without repeats.
|
|
/// Members hash the SET to vote on it, so two orderings of the same members would
|
|
/// be two different proposals and the committee could never agree with itself.
|
|
#[test]
|
|
fn a_committee_is_ordered_and_free_of_repeats() {
|
|
let (c, _) = new_committee(3);
|
|
|
|
let shuffled = vec![c[2].clone(), c[0].clone(), c[1].clone()];
|
|
assert!(matches!(
|
|
validate_committee(&shuffled, 2, b"pk").unwrap_err(),
|
|
Error::InvalidCommittee(_)
|
|
));
|
|
|
|
let repeated = vec![c[0].clone(), c[0].clone(), c[1].clone()];
|
|
assert!(
|
|
matches!(
|
|
validate_committee(&repeated, 2, b"pk").unwrap_err(),
|
|
Error::InvalidCommittee(_)
|
|
),
|
|
"one seat twice is not two seats"
|
|
);
|
|
|
|
validate_committee(&c, 2, b"pk").expect("the control");
|
|
}
|
|
|
|
/// The payer authentication path: an unsigned, a tampered and an impersonating
|
|
/// transaction are each refused, and only a genuine signature by the claimed
|
|
/// identity passes.
|
|
#[test]
|
|
fn only_a_genuine_signature_by_the_claimed_identity_authenticates() {
|
|
let k = TestKey::new();
|
|
let other = TestKey::new();
|
|
let chain = test_chain_id();
|
|
|
|
register_tx(&k, TEST_SCHEME, digest_of("auth"), 1)
|
|
.authenticate(&chain)
|
|
.expect("a genuine signature");
|
|
|
|
let mut unsigned = register_tx(&k, TEST_SCHEME, digest_of("auth"), 1);
|
|
unsigned.auth.clear();
|
|
unsigned.sig.clear();
|
|
assert!(matches!(unsigned.authenticate(&chain).unwrap_err(), Error::UnsignedTx));
|
|
|
|
let mut tampered = register_tx(&k, TEST_SCHEME, digest_of("auth"), 1);
|
|
tampered.nonce = 7;
|
|
assert!(matches!(tampered.authenticate(&chain).unwrap_err(), Error::BadSignature));
|
|
|
|
// Signed by `other` but claiming k's address: the address is derived from the
|
|
// attached public key, so the two cannot be made to agree.
|
|
let mut impersonating = register_tx(&k, TEST_SCHEME, digest_of("auth"), 1);
|
|
other.sign(&mut impersonating);
|
|
assert!(matches!(impersonating.authenticate(&chain).unwrap_err(), Error::PayerMismatch));
|
|
|
|
// Auth bytes that are not a public key at all.
|
|
let mut garbage = register_tx(&k, TEST_SCHEME, digest_of("auth"), 1);
|
|
garbage.auth = b"garbage".to_vec();
|
|
garbage.payer = state::address_of(&garbage.auth);
|
|
assert!(garbage.authenticate(&chain).is_err());
|
|
}
|
|
|
|
/// A payer's address and a committee member's address come from the same one-way
|
|
/// derivation, so membership and payer identity cannot disagree.
|
|
#[test]
|
|
fn a_member_is_recognised_by_the_address_that_authenticates_it() {
|
|
let k = TestKey::new();
|
|
assert_eq!(k.addr, state::address_of(&k.public));
|
|
|
|
let (members, keys) = new_committee(3);
|
|
let rec = lux_fhevm::EpochRecord {
|
|
info: fhe::EpochInfo { committee: Some(members), ..Default::default() },
|
|
attestations: None,
|
|
};
|
|
for (i, key) in keys.iter().enumerate() {
|
|
assert!(rec.member_of(&key.addr), "member {i} must be recognised by its payer address");
|
|
}
|
|
assert!(!rec.member_of(&TestKey::new().addr), "a stranger is not a member");
|
|
}
|
|
|
|
/// Every id F derives comes from signed transaction fields alone, so two
|
|
/// validators applying one block agree — and changing any input changes the id.
|
|
#[test]
|
|
fn every_identifier_is_deterministic_in_the_fields_that_produce_it() {
|
|
let d = digest_of("determinism");
|
|
let a = TestKey::new().addr;
|
|
let b = TestKey::new().addr;
|
|
let scheme = TEST_SCHEME.as_bytes();
|
|
|
|
assert_eq!(derive_handle(&d, scheme), derive_handle(&d, scheme));
|
|
assert_ne!(derive_handle(&d, scheme), derive_handle(&d, b"bfv-n13"));
|
|
assert_ne!(derive_handle(&d, scheme), derive_handle(&digest_of("other"), scheme));
|
|
|
|
let h = derive_handle(&d, scheme);
|
|
assert_eq!(derive_permit_id(&h, &a, &b, 1, 0, 1), derive_permit_id(&h, &a, &b, 1, 0, 1));
|
|
assert_ne!(
|
|
derive_permit_id(&h, &a, &b, 1, 0, 1),
|
|
derive_permit_id(&h, &a, &b, 1, 0, 2),
|
|
"a grantor may re-grant the same capability without colliding"
|
|
);
|
|
assert_ne!(derive_permit_id(&h, &a, &b, 1, 0, 1), derive_permit_id(&h, &b, &a, 1, 0, 1));
|
|
|
|
assert_eq!(derive_request_id(&h, &a, 3), derive_request_id(&h, &a, 3));
|
|
assert_ne!(derive_request_id(&h, &a, 3), derive_request_id(&h, &a, 4));
|
|
assert_ne!(derive_request_id(&h, &a, 3), derive_request_id(&h, &b, 3));
|
|
}
|
|
|
|
/// The value members attest describes the PROPOSAL, not the bytes one client
|
|
/// happened to encode: it changes with every meaningful field and with the members
|
|
/// themselves.
|
|
#[test]
|
|
fn a_committee_digest_is_the_proposal_and_not_one_clients_encoding_of_it() {
|
|
let (c, _) = new_committee(3);
|
|
let pk = b"network-key";
|
|
let base = committee_digest(1, 2, pk, &c);
|
|
|
|
assert_eq!(base, committee_digest(1, 2, pk, &c));
|
|
assert_ne!(base, committee_digest(2, 2, pk, &c), "epoch must bind");
|
|
assert_ne!(base, committee_digest(1, 3, pk, &c), "threshold must bind");
|
|
assert_ne!(base, committee_digest(1, 2, b"other", &c), "network key must bind");
|
|
assert_ne!(base, committee_digest(1, 2, pk, &c[..2]), "membership must bind");
|
|
|
|
// Length-prefixing means a member's fields cannot be re-split across the
|
|
// boundary to forge a colliding digest.
|
|
let mut shifted = c.clone();
|
|
let mut joined = c[0].public_key.clone();
|
|
joined.extend_from_slice(&c[1].public_key);
|
|
shifted[0].public_key = joined;
|
|
assert_ne!(base, committee_digest(1, 2, pk, &shifted));
|
|
}
|
|
|
|
/// The in-flight uniqueness key names the right thing: two votes by one member on
|
|
/// one decision collide, votes by different members do not, and two grants by one
|
|
/// grantor do not.
|
|
#[test]
|
|
fn the_effect_key_names_the_entry_and_where_it_matters_who_writes_it() {
|
|
let k = TestKey::new();
|
|
let other = TestKey::new();
|
|
let req = [7u8; 32];
|
|
|
|
// One member cannot have two attestations to one request in flight, even
|
|
// naming different results — the effect is the vote, not the value.
|
|
let v1 = fulfill_tx(&k, req, [1u8; 32], 1);
|
|
let v2 = fulfill_tx(&k, req, [2u8; 32], 2);
|
|
assert_eq!(v1.effect(), v2.effect());
|
|
|
|
// A different member voting on the same request is a different effect.
|
|
let v3 = fulfill_tx(&other, req, [1u8; 32], 1);
|
|
assert_ne!(v1.effect(), v3.effect());
|
|
|
|
// Registering two different ciphertexts are different effects; registering the
|
|
// same one twice is the same effect.
|
|
let r1 = register_tx(&k, TEST_SCHEME, digest_of("one"), 1);
|
|
let r2 = register_tx(&k, TEST_SCHEME, digest_of("two"), 2);
|
|
let r1_again = register_tx(&other, TEST_SCHEME, digest_of("one"), 1);
|
|
assert_ne!(r1.effect(), r2.effect());
|
|
assert_eq!(r1.effect(), r1_again.effect(), "the handle is the effect, whoever claims it");
|
|
|
|
// Two grants over the same handle by the same grantor are distinct — the nonce
|
|
// distinguishes the permits they create.
|
|
let handle = derive_handle(&digest_of("g"), TEST_SCHEME.as_bytes());
|
|
let g1 = grant_tx(&k, handle, other.addr, fhe::PERMIT_OP_DECRYPT, 0, 1);
|
|
let g2 = grant_tx(&k, handle, other.addr, fhe::PERMIT_OP_DECRYPT, 0, 2);
|
|
assert_ne!(g1.effect(), g2.effect());
|
|
|
|
// An epoch advance is a vote on the DECISION, and exactly one epoch is ever
|
|
// open, so one member's two votes are one effect however they differ. The
|
|
// proposal must not enter the effect: if it did, a member could vote twice,
|
|
// both votes would pass verification against committed state, and acceptance
|
|
// would apply one and refuse the other — a block every validator certifies and
|
|
// no validator can apply.
|
|
let (c_a, _) = new_committee(3);
|
|
let (c_b, _) = new_committee(3);
|
|
let a1 = advance_tx(&k, 1, &c_a, 2, b"k", 1);
|
|
let a2 = advance_tx(&k, 1, &c_b, 2, b"k", 2);
|
|
assert_ne!(a1.subject, a2.subject, "two genuinely different proposals");
|
|
assert_eq!(a1.effect(), a2.effect(), "but one member, one vote");
|
|
|
|
let a3 = advance_tx(&other, 1, &c_a, 2, b"k", 1);
|
|
assert_ne!(a1.effect(), a3.effect());
|
|
|
|
// A vote and an attestation never collide across operation kinds.
|
|
assert_ne!(a1.effect(), v1.effect());
|
|
}
|
|
|
|
#[test]
|
|
fn a_transaction_id_is_the_hash_of_its_content_and_cannot_be_chosen() {
|
|
let k = TestKey::new();
|
|
let tx = register_tx(&k, TEST_SCHEME, digest_of("id"), 1);
|
|
let first = tx.id();
|
|
assert_ne!(first, lux_fhevm::ids::EMPTY);
|
|
assert_eq!(first, tx.id(), "an id is stable");
|
|
assert_ne!(first, register_tx(&k, TEST_SCHEME, digest_of("id"), 2).id());
|
|
}
|
|
|
|
/// Every effect reports a failed write instead of returning success. These are the
|
|
/// errors that abort a block whole: a validator that cannot write cannot proceed,
|
|
/// and a transaction that silently did not apply would leave two validators
|
|
/// holding different state.
|
|
#[test]
|
|
fn every_effect_reports_a_write_it_cannot_make() {
|
|
let owner = TestKey::new();
|
|
let grantee = TestKey::new();
|
|
let (vm, _, members) = new_decrypt_vm(3, 2, &[&owner, &grantee]);
|
|
let (handle, permit_id) = seed_permit(&vm, &owner, &grantee, fhe::PERMIT_OP_DECRYPT, 0);
|
|
accept_one(&vm, &request_tx(&grantee, TEST_SCHEME, handle, permit_id, 0, 1));
|
|
let request_id = request_id_for(handle, &grantee.addr, 1);
|
|
let (next, _) = new_committee(3);
|
|
let now = vm.clock.time().unix();
|
|
|
|
version_layer(&vm).close().expect("the layer closes");
|
|
let mut st = vm.state.write().unwrap();
|
|
|
|
assert!(register_tx(&owner, TEST_SCHEME, digest_of("w"), 9)
|
|
.apply_register(&mut st, now)
|
|
.is_err());
|
|
assert!(grant_tx(&owner, handle, grantee.addr, fhe::PERMIT_OP_DECRYPT, 0, 9)
|
|
.apply_grant(&mut st, now)
|
|
.is_err());
|
|
assert!(revoke_tx(&owner, permit_id, 9).apply_revoke(&mut st).is_err());
|
|
assert!(request_tx(&grantee, TEST_SCHEME, handle, permit_id, 0, 9)
|
|
.apply_request(&mut st, now)
|
|
.is_err());
|
|
assert!(fulfill_tx(&members[0], request_id, digest_of("r"), 9)
|
|
.apply_fulfill(&mut st, now)
|
|
.is_err());
|
|
assert!(advance_tx(&members[0], 1, &next, 2, b"pk", 9)
|
|
.apply_advance(&mut st, now)
|
|
.is_err());
|
|
}
|
|
|
|
/// Each effect re-derives its arguments from the payload and fails closed rather
|
|
/// than applying a zero value. The structural gate has already accepted the
|
|
/// payload by the time a block carries it, so this is the check behind the check —
|
|
/// but a check that only ever runs behind another is one nobody can show still
|
|
/// works, so it is shown here directly.
|
|
#[test]
|
|
fn every_effect_refuses_a_payload_it_cannot_decode_and_a_record_that_is_not_there() {
|
|
let owner = TestKey::new();
|
|
let grantee = TestKey::new();
|
|
let (vm, _, members) = new_decrypt_vm(3, 2, &[&owner, &grantee]);
|
|
let (handle, permit_id) = seed_permit(&vm, &owner, &grantee, fhe::PERMIT_OP_DECRYPT, 0);
|
|
accept_one(&vm, &request_tx(&grantee, TEST_SCHEME, handle, permit_id, 0, 1));
|
|
let request_id = request_id_for(handle, &grantee.addr, 1);
|
|
let now = vm.clock.time().unix();
|
|
|
|
let junk = |tx_type: u8, subject: [u8; 32]| Transaction {
|
|
tx_type,
|
|
scheme: TEST_SCHEME.as_bytes().to_vec(),
|
|
payer: owner.addr,
|
|
subject,
|
|
nonce: 9,
|
|
payload: b"{not json".to_vec(),
|
|
..Default::default()
|
|
};
|
|
|
|
{
|
|
let mut st = vm.state.write().unwrap();
|
|
assert!(junk(TX_REGISTER_CIPHERTEXT, handle).apply_register(&mut st, now).is_err());
|
|
assert!(junk(TX_GRANT_PERMIT, handle).apply_grant(&mut st, now).is_err());
|
|
assert!(junk(TX_REQUEST_DECRYPT, handle).apply_request(&mut st, now).is_err());
|
|
assert!(junk(TX_FULFILL_DECRYPT, request_id).apply_fulfill(&mut st, now).is_err());
|
|
assert!(junk(TX_ADVANCE_EPOCH, [0u8; 32]).apply_advance(&mut st, now).is_err());
|
|
|
|
// And each effect that names a record refuses when the record is not there.
|
|
let missing = [0xaa; 32];
|
|
assert!(matches!(
|
|
revoke_tx(&owner, missing, 9).apply_revoke(&mut st).unwrap_err(),
|
|
Error::PermitNotFound
|
|
));
|
|
assert!(matches!(
|
|
fulfill_tx(&members[0], missing, digest_of("r"), 9)
|
|
.apply_fulfill(&mut st, now)
|
|
.unwrap_err(),
|
|
Error::RequestNotFound
|
|
));
|
|
|
|
// A fulfilment whose epoch has been forgotten is refused rather than
|
|
// tallied against a threshold nobody can name.
|
|
st.decrypts.get_mut(&request_id).expect("the request").request.epoch = 99;
|
|
assert!(matches!(
|
|
fulfill_tx(&members[0], request_id, digest_of("r"), 9)
|
|
.apply_fulfill(&mut st, now)
|
|
.unwrap_err(),
|
|
Error::EpochNotFound
|
|
));
|
|
}
|
|
}
|
|
|
|
/// Authorization fails closed on an operation it does not know, rather than
|
|
/// falling through to an effect. Application has the same default and it is
|
|
/// UNREACHABLE — authorization runs first and refuses every type the switch does
|
|
/// not name — but without it an unknown type would report the transaction APPLIED,
|
|
/// which is the one answer that must never be given by accident.
|
|
#[test]
|
|
fn an_unknown_operation_is_refused_and_reverts_rather_than_halting_the_block() {
|
|
let k = TestKey::new();
|
|
let (committee, _) = new_committee(1);
|
|
let vm = new_test_vm(fund_all(&[&k]), &committee, 1);
|
|
|
|
let alien = Transaction { tx_type: 99, payer: k.addr, nonce: 1, ..Default::default() };
|
|
let mut st = vm.state.write().unwrap();
|
|
assert!(matches!(alien.check_auth(&st, 0).unwrap_err(), Error::InvalidTxType));
|
|
assert!(!alien.apply(&mut st, 0).expect("an unknown operation reverts, it does not halt"));
|
|
}
|
|
|
|
/// The authorization decisions that turn on a record being there. Each is a
|
|
/// refusal rather than a default: authorization that fell through when it could
|
|
/// not find what it was asked about would grant on ignorance.
|
|
#[test]
|
|
fn authorization_refuses_what_it_cannot_resolve() {
|
|
let owner = TestKey::new();
|
|
let grantee = TestKey::new();
|
|
let (vm, _, members) = new_decrypt_vm(3, 2, &[&owner, &grantee]);
|
|
let (handle, permit_id) = seed_permit(&vm, &owner, &grantee, fhe::PERMIT_OP_DECRYPT, 0);
|
|
accept_one(&vm, &request_tx(&grantee, TEST_SCHEME, handle, permit_id, 0, 1));
|
|
let request_id = request_id_for(handle, &grantee.addr, 1);
|
|
let now = vm.clock.time().unix();
|
|
|
|
let mut st = vm.state.write().unwrap();
|
|
|
|
// A request whose payload does not decode cannot be authorized against a
|
|
// permit it does not name.
|
|
let malformed = Transaction {
|
|
tx_type: TX_REQUEST_DECRYPT,
|
|
scheme: TEST_SCHEME.as_bytes().to_vec(),
|
|
payer: grantee.addr,
|
|
subject: handle,
|
|
nonce: 2,
|
|
payload: b"{not json".to_vec(),
|
|
..Default::default()
|
|
};
|
|
assert!(matches!(malformed.check_auth(&st, now).unwrap_err(), Error::InvalidPayload(_)));
|
|
|
|
// So does an epoch proposal.
|
|
let bad_advance = Transaction {
|
|
tx_type: TX_ADVANCE_EPOCH,
|
|
payer: members[0].addr,
|
|
nonce: 2,
|
|
payload: b"{not json".to_vec(),
|
|
..Default::default()
|
|
};
|
|
assert!(matches!(bad_advance.check_auth(&st, now).unwrap_err(), Error::InvalidPayload(_)));
|
|
|
|
// A fulfilment for an epoch the chain has forgotten names no committee, so
|
|
// there is nobody it could be authorized as.
|
|
st.decrypts.get_mut(&request_id).expect("the request").request.epoch = 99;
|
|
assert!(matches!(
|
|
fulfill_tx(&members[0], request_id, digest_of("r"), 1)
|
|
.check_auth(&st, now)
|
|
.unwrap_err(),
|
|
Error::EpochNotFound
|
|
));
|
|
}
|
|
|
|
/// A committee cannot answer a request whose authorizing permit is no longer
|
|
/// there. The permit is what makes the ask legitimate, so a request that outlived
|
|
/// it authorizes nothing — otherwise a deleted grant would still deliver a
|
|
/// plaintext to a callback.
|
|
#[test]
|
|
fn a_fulfilment_needs_the_permit_that_asked_for_it() {
|
|
let owner = TestKey::new();
|
|
let grantee = TestKey::new();
|
|
let (vm, _, members) = new_decrypt_vm(3, 2, &[&owner, &grantee]);
|
|
let (handle, permit_id) = seed_permit(&vm, &owner, &grantee, fhe::PERMIT_OP_DECRYPT, 0);
|
|
accept_one(&vm, &request_tx(&grantee, TEST_SCHEME, handle, permit_id, 0, 1));
|
|
let request_id = request_id_for(handle, &grantee.addr, 1);
|
|
let now = vm.clock.time().unix();
|
|
let fulfil = fulfill_tx(&members[0], request_id, digest_of("r"), 1);
|
|
|
|
{
|
|
let st = vm.state.read().unwrap();
|
|
fulfil.check_auth(&st, now).expect("the control: with the permit there, it is authorized");
|
|
}
|
|
|
|
// Point the request at a permit nothing granted.
|
|
{
|
|
let mut st = vm.state.write().unwrap();
|
|
st.decrypts.get_mut(&request_id).expect("the request").permit_id = [0xab; 32];
|
|
}
|
|
|
|
let st = vm.state.read().unwrap();
|
|
assert!(matches!(fulfil.check_auth(&st, now).unwrap_err(), Error::PermitNotFound));
|
|
}
|