Attestra

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

ContractPurpose
SignerRegistryThe authorized oracle signer addresses and the M-of-N threshold. Every other contract on this page consults it rather than trusting a hardcoded address.
AttestationVerifierThe pull-side check: given an attestation and its signatures, confirms quorum at the point of use. Nothing is written to storage.
AttestationStoreThe 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.
SchemaRegistryCredential schemas stored on chain, keyed by hash. A hash is write-once; a second registration under the same hash reverts.
EthereumDIDRegistryERC-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:

  1. SignerRegistry
  2. AttestationVerifier and AttestationStore (either order, both depend only on the registry)
  3. SchemaRegistry and EthereumDIDRegistry (either order, no dependencies)

Constructor arguments

ContractConstructor arguments
SignerRegistryaddress[] initialSigners, uint256 initialThreshold
AttestationVerifieraddress _registry
AttestationStoreaddress _registry
SchemaRegistrynone
EthereumDIDRegistrynone

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" --broadcast

Whether 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:

FunctionEffect
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", and transferOwnership(address(0)) reverts with "zero owner".
  • A duplicate signer is refused. Adding an address already in the authorized set reverts with "duplicate signer".
  • removeSigner refuses to drop the signer count below the threshold. It decrements signerCount first, then requires signerCount >= 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.
  • setThreshold requires the new value to be between 1 and the current signer count. setThreshold(0) or a value above signerCount both 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.

On this page