On Substrate
The Rust verifier in the eIDChain pallet, its 20-byte address space, and the externalities it requires.
The eIDChain side lives in pallet/src/lib.rs. It is two free
functions: one recovers an address from a signature, one applies the quorum
rule. Both are plain Rust, callable from an extrinsic, a benchmark or a test,
provided the externalities requirement below is met.
Recovering an address
/// Recover the 20-byte ethereum-style address from a 65-byte sig (R||S||V, V in {0,1}).
/// Returns None on failure. Must run inside externalities (host function).
pub fn recover_address(digest: &[u8; 32], sig: &[u8; 65]) -> Option<[u8; 20]> {
let pubkey = sp_io::crypto::secp256k1_ecdsa_recover(sig, digest).ok()?;
let h = keccak(&pubkey);
let mut addr = [0u8; 20];
addr.copy_from_slice(&h[12..]);
Some(addr)
}The address is the low 20 bytes of the keccak256 of the recovered
uncompressed public key, which is the Ethereum derivation exactly. Failure is
None, not a panic and not a zero address, so the caller cannot mistake a
failed recovery for a successful one.
The quorum
pub fn verify_quorum(
digest: &[u8; 32],
sigs: &[[u8; 65]],
authorized: &[[u8; 20]],
threshold: usize,
) -> bool {
if threshold == 0 {
return false;
}
let mut last = [0u8; 20];
let mut count = 0usize;
for sig in sigs {
let rec = match recover_address(digest, sig) {
Some(a) => a,
None => return false,
};
if rec <= last {
return false; // unsorted or duplicate
}
if !authorized.contains(&rec) {
return false;
}
last = rec;
count += 1;
}
count >= threshold
}This is the same rule as the Solidity verifier, in the same order. last
starts at the twenty zero bytes, so rec <= last also rejects a recovery that
somehow produced the zero address. Array comparison in Rust is lexicographic
over the bytes, which is the same ordering Solidity uses when it compares two
address values, so a bundle sorted for one chain is sorted for the other.
The source doc comment states the intent plainly: sigs must be ordered by
recovered address, strictly ascending, the same rule the Solidity and Go
verifiers enforce, so a bundle accepted on one chain is accepted on all three.
One addition the other implementations express differently: threshold == 0
returns false up front. A zero threshold would otherwise accept an empty
bundle, since 0 >= 0 holds.
Addresses are 20 bytes, even here
authorized: &[[u8; 20]] is an array of Ethereum-style 20-byte addresses, on a
Substrate chain, and that is deliberate rather than an oversight. The signature
scheme is secp256k1 throughout Attestra: the oracle signs with a secp256k1 key,
and every verifier recovers a secp256k1 public key and derives the same
20-byte identifier from it. Using the pallet's native account identifier here
would mean maintaining a mapping between two identity spaces, and the mapping
would become a second thing to get right. There is one signer identity in this
system, and it is 20 bytes wide on all three chains.
The authorized set is passed in as an argument rather than read inside
verify_quorum. The pallet holds the set in its own storage and hands it to
the function, which keeps the verification logic pure and testable against a
literal slice.
The externalities requirement
Both functions reach sp_io::crypto::secp256k1_ecdsa_recover, which is a host
function, not ordinary Rust. Host functions are provided by the runtime
environment, so both functions must run inside externalities. Calling
verify_quorum or recover_address from a bare #[test] with no externalities
in scope will not fail the quorum check; it will fail at the host function
boundary.
Anyone writing tests against the pallet needs to wrap the call. This is how
pallet/tests/fixture_recover.rs does it:
sp_io::TestExternalities::default().execute_with(|| {
assert!(verify_quorum(&digest, &sigs[..2], &authorized, 2), "2 of 3 must pass");
assert!(verify_quorum(&digest, &sigs, &authorized, 2), "3 of 3 must pass");
assert!(!verify_quorum(&digest, &sigs[..1], &authorized, 2), "1 of 3 must fail");
let dup = [sigs[0], sigs[0]];
assert!(!verify_quorum(&digest, &dup, &authorized, 2), "duplicate signer must fail");
let unsorted = [sigs[1], sigs[0]];
assert!(!verify_quorum(&digest, &unsorted, &authorized, 2), "unsorted must fail");
assert!(!verify_quorum(&digest, &sigs, &authorized[..1], 2), "unauthorized signer must fail");That test is also a compact statement of the rule's four rejection cases: below threshold, duplicate signer, unsorted, unauthorized. The same externalities requirement applies to any benchmark or helper that calls into these functions. Inside an extrinsic it is already satisfied, since extrinsics execute within the runtime.