Attestra
Verifying an attestation

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.

On this page