On Ethereum
The Solidity verifier, the signer registry it consults, and how to call both from your own contract.
The pull-side verifier is contracts/src/AttestationVerifier.sol. It is a
view contract: it holds no attestations, writes nothing, and consults a
SignerRegistry at the address it was constructed with. Its only job is to
answer whether a bundle of signatures over an attestation meets the quorum.
The verifier
interface ISignerRegistry {
function isAuthorized(address signer) external view returns (bool);
function threshold() external view returns (uint256);
}
contract AttestationVerifier {
ISignerRegistry public immutable registry;The registry is immutable, so a verifier is bound to one registry for its
lifetime. Changing the authorized set is a registry operation, not a
redeployment of the verifier.
verify
// verify accepts an attestation only when at least `threshold` DISTINCT
// authorized oracles signed it. There is deliberately no single-signature
// overload: one would let any caller bypass the quorum entirely.
//
// sigs must be ordered by recovered signer address, strictly ascending. That
// ordering proves distinctness in one pass instead of an O(n^2) comparison,
// and it makes malleability a non-issue: a malleated signature recovers to
// the same address and so fails the strict ordering check as a repeat.
function verify(Attestation memory a, bytes[] memory sigs) public view returns (bool) {
bytes32 d = digest(a);
address last = address(0);
uint256 count = 0;
for (uint256 i = 0; i < sigs.length; i++) {
address rec = _recover(d, sigs[i]);
if (rec == address(0)) return false;
if (rec <= last) return false;
if (!registry.isAuthorized(rec)) return false;
last = rec;
count++;
}
return count >= registry.threshold();
}Read against the pseudocode on the rule page, the
correspondence is line for line. rec == address(0) is the recovery failure
case. rec <= last is require rec > last, the strict ascent that carries
both ordering and distinctness. The registry is consulted per signature, not
once at the end, and the threshold is read from the registry rather than stored
here.
Note that verify returns false rather than reverting. A caller reading this
contract gets a boolean and decides what to do with it.
_recover
function _recover(bytes32 d, bytes memory sig) internal pure returns (address) {
if (sig.length != 65) return address(0);
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
if (v < 27) v += 27;
return ecrecover(d, v, r, s);
}Two details worth noting. A signature of the wrong length returns the zero
address rather than reverting, and the zero address is rejected by verify on
the next line. And v is normalised: the oracle emits V in {0, 1}, while
ecrecover expects {27, 28}, so if (v < 27) v += 27 accepts either
convention.
The Attestation struct
struct Attestation {
string did;
string credentialId;
string credType;
string issuer;
string status;
string sourceChain;
uint64 issuedAt;
uint64 expiresAt;
bytes32 nonce;
}The field order is not cosmetic. It is the word order of the digest preimage,
in sequence, and digest walks the struct in exactly this order. Reordering
the struct, or inserting a field, produces different 32 bytes and invalidates
every signature ever issued. See The canonical
digest.
Calling it from another contract
verify is public view, so any contract can call it and pay only for the
call. Declare the interface and the struct, then call:
interface IAttestationVerifier {
struct Attestation {
string did;
string credentialId;
string credType;
string issuer;
string status;
string sourceChain;
uint64 issuedAt;
uint64 expiresAt;
bytes32 nonce;
}
function digest(Attestation memory a) external pure returns (bytes32);
function verify(Attestation memory a, bytes[] memory sigs) external view returns (bool);
}
contract Gate {
IAttestationVerifier public immutable verifier;
constructor(address _verifier) {
verifier = IAttestationVerifier(_verifier);
}
function admit(IAttestationVerifier.Attestation calldata a, bytes[] calldata sigs) external view returns (bool) {
return verifier.verify(a, sigs);
}
}The struct declaration must match the one above field for field, for the same
reason the digest depends on order. Your contract remains responsible for the
things verify does not check: that a.expiresAt has not passed, that a.did
is the subject you care about, and that a.status is the value you require.
verify answers one question only, which is whether enough authorized oracles
signed these exact fields.
The signer registry
contracts/src/SignerRegistry.sol is the list verify consults. It is short
enough to read in full.
// SignerRegistry is the on-chain list of authorized oracle signer addresses and
// the M-of-N threshold. It starts at 1-of-1 (one signer, threshold 1) and lets
// the owner rotate keys or onboard more operators without redeploying the
// verifiers that consult it. A verifier checks a recovered signer against
// isAuthorized rather than trusting a single hardcoded address.
contract SignerRegistry {
address public owner;
mapping(address => bool) public isSigner;
uint256 public signerCount;
uint256 public threshold;
event SignerAdded(address indexed signer);
event SignerRemoved(address indexed signer);
event ThresholdChanged(uint256 threshold);
event OwnerChanged(address indexed newOwner);
constructor(address[] memory initialSigners, uint256 initialThreshold) {
owner = msg.sender;
for (uint256 i = 0; i < initialSigners.length; i++) {
_add(initialSigners[i]);
}
require(initialThreshold >= 1 && initialThreshold <= signerCount, "bad threshold");
threshold = initialThreshold;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function isAuthorized(address signer) external view returns (bool) {
return isSigner[signer];
}
function addSigner(address signer) external onlyOwner {
_add(signer);
}
function removeSigner(address signer) external onlyOwner {
require(isSigner[signer], "not a signer");
isSigner[signer] = false;
signerCount--;
require(signerCount >= threshold, "would drop below threshold");
emit SignerRemoved(signer);
}
function setThreshold(uint256 t) external onlyOwner {
require(t >= 1 && t <= signerCount, "bad threshold");
threshold = t;
emit ThresholdChanged(t);
}
function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "zero owner");
owner = newOwner;
emit OwnerChanged(newOwner);
}
function _add(address signer) internal {
require(signer != address(0), "zero signer");
require(!isSigner[signer], "duplicate signer");
isSigner[signer] = true;
signerCount++;
emit SignerAdded(signer);
}
}Administration surface
addSigner, removeSigner, setThreshold and transferOwnership are the
whole of it, and all four are onlyOwner. Everything else is a public read.
Guards
_addrefuses the zero address, which is whatecrecoverreturns on failure, so a failed recovery can never be an authorized signer._addrefuses a duplicate, sosignerCountis an accurate count of distinct signers rather than a count of calls.removeSignerdecrements first and then requiressignerCount >= threshold, so a removal that would leave the registry unable to reach its own threshold reverts. The registry cannot be walked into a state where nothing verifies.setThresholdrequires1 <= t <= signerCount. A threshold of zero would accept an empty bundle; a threshold above the signer count would accept nothing.
The constructor applies the same threshold bound at deployment.
Push and pull agree
contracts/src/AttestationStore.sol is the push-side counterpart, and it
performs the same quorum check inline before it writes anything: the same
strict ascent, the same isAuthorized call per signature, the same
count >= registry.threshold(). An attestation that AttestationVerifier
accepts is an attestation AttestationStore will record, and one it rejects is
one the store will refuse. The difference is only in reporting: the store
reverts with a reason string, where the verifier returns false.