Deploy the contracts
The five Ethereum contracts, the order they must go in, and how to configure the signer registry after deployment.
This page covers the Ethereum side of a self-hosted deployment: which contracts exist, what each one is for, the order they have to be deployed in, and how to operate the signer registry once it is live. For the equivalent setup on the other two chains, see the section near the bottom of this page.
The five contracts
| Contract | Purpose |
|---|---|
SignerRegistry | The authorized oracle signer addresses and the M-of-N threshold. Every other contract on this page consults it rather than trusting a hardcoded address. |
AttestationVerifier | The pull-side check: given an attestation and its signatures, confirms quorum at the point of use. Nothing is written to storage. |
AttestationStore | The push target: the oracle (or any relayer) submits a signed attestation, the contract verifies quorum, stores the record, and emits an event for indexers and other contracts to read locally. |
SchemaRegistry | Credential schemas stored on chain, keyed by hash. A hash is write-once; a second registration under the same hash reverts. |
EthereumDIDRegistry | ERC-1056 identity ownership (did:ethr). Tracks who owns which identity address, independent of the attestation flow. |
AttestationVerifier and AttestationStore share the identical digest
function and the identical quorum rule; they differ only in what they do with
a verified attestation, one returns a boolean, the other writes a record and
emits an event. See The canonical digest and
Verifying an attestation for the shared logic
itself.
Deployment order
SignerRegistry must be deployed first. This is not a convenience ordering,
it is a constructor requirement: both AttestationVerifier and
AttestationStore take the registry's address as an immutable constructor
argument.
contract AttestationVerifier {
ISignerRegistry public immutable registry;
constructor(address _registry) {
registry = ISignerRegistry(_registry);
}
}Immutable means the address is baked into the deployed bytecode and cannot be
changed afterward, there is no setRegistry function on either consumer.
State this plainly because it is the single most expensive mistake available
on this page: if the registry needs to move to a new address, both
AttestationVerifier and AttestationStore have to be redeployed alongside
it. Every contract or integration that recorded the old verifier or store
address then has to be repointed too. Deploying the registry once, correctly,
and operating it in place with addSigner / removeSigner / setThreshold
is the intended path; redeploying it is a last resort.
SchemaRegistry and EthereumDIDRegistry take no constructor arguments and
have no dependency on the registry, so they can be deployed at any point,
before or after the other three.
The order that avoids rework:
SignerRegistryAttestationVerifierandAttestationStore(either order, both depend only on the registry)SchemaRegistryandEthereumDIDRegistry(either order, no dependencies)
Constructor arguments
| Contract | Constructor arguments |
|---|---|
SignerRegistry | address[] initialSigners, uint256 initialThreshold |
AttestationVerifier | address _registry |
AttestationStore | address _registry |
SchemaRegistry | none |
EthereumDIDRegistry | none |
SignerRegistry's constructor adds every address in initialSigners, then
requires 1 <= initialThreshold <= signerCount; an empty signer array with a
nonzero threshold reverts at deployment.
The commands below follow the shape used in the project's own deployment
script (scripts/demo.sh). They read the deployer key from an environment
variable rather than the command line; never pass a private key as a literal
argument, and never commit one into a script or a shell history file.
export DEPLOY_KEY=<your-deployer-private-key>
export RPC_URL=<your-rpc-endpoint>
cd contracts
# 1. SignerRegistry: one signer, threshold 1, as a starting point.
forge create src/SignerRegistry.sol:SignerRegistry \
--rpc-url "$RPC_URL" --private-key "$DEPLOY_KEY" --broadcast \
--constructor-args "[<signer-1-address>]" 1
# 2a. AttestationVerifier, pointed at the registry from step 1.
forge create src/AttestationVerifier.sol:AttestationVerifier \
--rpc-url "$RPC_URL" --private-key "$DEPLOY_KEY" --broadcast \
--constructor-args <signer-registry-address>
# 2b. AttestationStore, pointed at the same registry.
forge create src/AttestationStore.sol:AttestationStore \
--rpc-url "$RPC_URL" --private-key "$DEPLOY_KEY" --broadcast \
--constructor-args <signer-registry-address>
# 3a. SchemaRegistry: no constructor arguments.
forge create src/SchemaRegistry.sol:SchemaRegistry \
--rpc-url "$RPC_URL" --private-key "$DEPLOY_KEY" --broadcast
# 3b. EthereumDIDRegistry: no constructor arguments.
forge create src/EthereumDIDRegistry.sol:EthereumDIDRegistry \
--rpc-url "$RPC_URL" --private-key "$DEPLOY_KEY" --broadcastWhether a real deployment starts at 1-of-1 and grows, or starts already at its
intended threshold with several signers listed in initialSigners, is a
choice for the operator; the constructor accepts either as long as the
threshold bound holds.
Operating the registry after deployment
SignerRegistry exposes four owner-only functions:
| Function | Effect |
|---|---|
addSigner(address signer) | Adds an address to the authorized set. |
removeSigner(address signer) | Removes an address from the authorized set. |
setThreshold(uint256 t) | Changes the quorum threshold. |
transferOwnership(address newOwner) | Hands owner-only control to a new address. |
All four revert with "not owner" if called by anything other than the
current owner. Beyond that, the guards worth listing, because they are the
ones that will reject a transaction:
- The zero address is refused.
addSigner(address(0))reverts with"zero signer", andtransferOwnership(address(0))reverts with"zero owner". - A duplicate signer is refused. Adding an address already in the
authorized set reverts with
"duplicate signer". removeSignerrefuses to drop the signer count below the threshold. It decrementssignerCountfirst, then requiressignerCount >= threshold; removing a signer when the count already equals the threshold reverts with"would drop below threshold". Lower the threshold first if the intent is to shrink the signer set below its current threshold.setThresholdrequires the new value to be between 1 and the current signer count.setThreshold(0)or a value abovesignerCountboth revert with"bad threshold".
The most likely misconfiguration
The threshold configured in the on-chain SignerRegistry must match the
threshold configured on the collecting oracle. These are two separate values,
set in two separate places, and nothing enforces that they agree.
The on-chain value is authoritative: AttestationVerifier.verify and
AttestationStore.record both check count >= registry.threshold() directly
against the registry, on every call. The oracle's configured threshold is only
a local expectation, used by the collector to decide when it has gathered
enough signatures to consider a bundle complete.
If the oracle's threshold is set lower than the registry's, the oracle will
happily assemble and return a bundle it considers sufficient, and every
on-chain call submitting that bundle will revert with "quorum not met" (on
AttestationStore) or simply return false (from AttestationVerifier).
The bundle looks complete from the oracle's side and fails only once it
reaches the chain. Setting the oracle's threshold higher than the registry's
does not cause reverts, but it makes the oracle wait for signatures it does
not strictly need and increases the chance a round fails on peer
unavailability alone.
Check both values whenever either changes. For how the oracle's own threshold is configured and counted, see Join a quorum.
The other two chains
This page covers Ethereum because it is the only one of the three with a
deployment step in the sense of forge create. Hyperledger Fabric and
eIDChain keep their signer sets somewhere else, and each has its own
seeding step before it will accept anything.
Hyperledger Fabric keeps its signer set as a record in the registry
chaincode's own state, not in a separate contract. It is seeded with
PutSigners(signersJSON, threshold) and read back with GetSigners. A
freshly deployed, unseeded chaincode has no signer set and therefore accepts
nothing: this is a fail-closed default, not an omission to work around. Run
PutSigners before expecting any attestation to be accepted on Fabric.
eIDChain holds its authorized signer set in the pallet's own
storage, not in a separate contract or chaincode record; it is passed into
verify_quorum as authorized: &[[u8; 20]] at the call site.
Both keep the same shape of guard as SignerRegistry: threshold bounded
between 1 and the number of signers, no unbounded set. For the per-chain
verification detail, see
Verifying an attestation.
Verifying a deployment
After deploying, run the project's acceptance gate,
scripts/verify-all.sh. It regenerates the shared fixture and runs all four
verifier suites, Go, the two Solidity contracts, Rust, and the Fabric
chaincode, against it. A deployment that passes this gate is confirmed to
agree with the canonical digest that every other implementation produces; one
that fails it should not be trusted regardless of how the deployment itself
went. See The canonical digest for what the fixture
checks and why the digest cannot change without a new deployment.