StateCommitment
StateCommitment is the contract that turns "trust our numbers" into "verify our
numbers." The Senticore matching engine runs off-chain in deterministic Rust, but
its output is only trustworthy if it is anchored to something no operator can
quietly rewrite. That anchor is a checkpoint: a signed, monotonic commitment
published on-chain that advances the canonical state root and, in the same
transaction, publishes the Merkle roots the vault
uses to authorize withdrawals and unilateral exits.
Every checkpoint must carry a 7-of-10 publisher quorum of ECDSA signatures over an EIP-712 typed-data hash. No single operator can advance state, and no operator can move funds out from under you: withdrawals settle against roots that a quorum put on-chain, and anyone can recompute the signing hash and re-verify the signatures independently.
Senticore is in capped private beta. No mainnet deployment exists yet, so this
page documents contract behavior, not a live address. When contracts are
deployed, addresses land on the Addresses page. Everything
below is drawn directly from contracts/src/StateCommitment.sol and
IStateCommitment.sol.
Why this design matters
For a venue holding real collateral, the question a market maker or institution
actually asks is: "If the operator turns hostile or gets compromised, can I still
get my funds, and can I prove the books balance?" StateCommitment is engineered
so the answer is yes, for structural reasons rather than promises:
- State cannot advance on one key. A checkpoint needs 7 independent publisher signatures. Compromising fewer than 7 keys cannot forge state; losing up to 3 keys does not halt the venue.
- Withdrawals are proof-backed, not operator-gated. Each checkpoint publishes a
withdrawRoot. The vault only releases funds against a Merkle proof into a root that a quorum committed — see MultiCollateralVault. - Solvency is auditable. Each checkpoint publishes a
solvencyRootbinding the liability set, so protocol- and account-level solvency proofs verify against on-chain data. - Exit is unilateral. Once the one-way
requireExitRootswitch is on, every checkpoint must publish a freshexitRoot, guaranteeing users a standing withdraw-without-cooperation path. - The off-chain log is reproducible. A data-availability (DA) commitment binds the exact off-chain data bundle behind each checkpoint, so anyone can replay the event log and re-derive the committed state root byte-for-byte.
Constants
| Constant | Value | Purpose |
|---|---|---|
PUBLISHER_SET_SIZE | 10 | Fixed number of publisher slots. |
PUBLISHER_QUORUM | 7 | Minimum valid signatures per checkpoint. |
GOVERNANCE_UPDATE_DELAY | 48 hours | Timelock for guardian / emergency-council / publisher-set rotations. |
PUBLISHER_SET_UPDATE_DELAY | 48 hours | Alias of GOVERNANCE_UPDATE_DELAY for standard publisher rotation. |
EMERGENCY_PUBLISHER_SET_DELAY | 8 hours | Shorter timelock for emergency publisher rotation. |
EMERGENCY_PUBLISHER_SET_COOLDOWN | 30 days | Minimum spacing between emergency rotations. |
Why 7-of-10? A quorum of 7 means an attacker must compromise a majority of
independent signing keys to forge a checkpoint — far harder than a single hot key.
A ceiling of 10 and a floor of 7 means the venue keeps publishing even if up to 3
publishers are offline or revoked, so security does not come at the cost of
liveness. A commit accepts between PUBLISHER_QUORUM and PUBLISHER_SET_SIZE
signatures inclusive (7 to 10); fewer than 7 or more than 10 reverts.
The checkpoint
A checkpoint is submitted as a CheckpointInput struct plus an array of
publisher signatures. It describes a contiguous range of local batches, chains one
state root to the next, and carries every root and context hash a verifier needs.
Checkpoint fields (CheckpointInput)
| Field | Type | Meaning |
|---|---|---|
checkpointId | uint256 | Monotonic checkpoint counter; must be latest + 1 (genesis is 1). |
fromLocalBatchId / toLocalBatchId | uint256 | Inclusive local-batch range; must be contiguous with the previous checkpoint. |
fromLocalEndLsn / toLocalEndLsn | uint64 | Durable WAL log-sequence-number range; strictly increasing across checkpoints. |
prevStateRoot | bytes32 | Must equal the last committed newStateRoot (or genesisStateRoot at genesis). |
newStateRoot | bytes32 | New canonical ledger root after applying this range. Non-zero. |
rangeHash | bytes32 | Commitment over the batch range's contents. Non-zero. |
daPointer | string | Human/URI pointer to the off-chain data bundle for this checkpoint. |
daPointerHash | bytes32 | DA commitment hash binding daPointer + manifest + bundle + schema version. |
daManifestHash | bytes32 | Hash of the DA bundle manifest. Non-zero. |
daBundleHash | bytes32 | Hash of the DA bundle payload. Non-zero. |
daCommitmentSchemaVersion | uint64 | DA commitment schema version. Non-zero. |
withdrawRoot | bytes32 | Root the vault uses for withdrawal claim proofs. Non-zero. |
solvencyRoot | bytes32 | Root for liabilities / audit proofs. Non-zero. |
validatorSetVersion | uint64 | Settlement context: engine validator-set version. Non-zero. |
validationPolicyHash | bytes32 | Settlement context: active validation policy. Non-zero. |
engineBuildHash | bytes32 | Settlement context: matching-engine build identity. Non-zero. |
configHash | bytes32 | Settlement context: runtime config identity. Non-zero. |
The optional exitRoot is not part of CheckpointInput; it is passed as a separate
argument to commitCheckpointWithExitRoot and folded into the signed hash.
Strict monotonic validation
Checkpoints form an unbroken chain. _validateCheckpoint enforces:
- Genesis (
latestCheckpointId == 0):checkpointId == 1,fromLocalBatchId == 1,prevStateRoot == genesisStateRoot, andtoLocalEndLsn != 0. - Every subsequent checkpoint:
checkpointId == latestCheckpointId + 1,fromLocalBatchId == latestBatchId + 1(contiguous, no gaps or overlaps),prevStateRoot == latestStateRoot(unbroken hash chain), andtoLocalEndLsn > latestCheckpointEndLsn(LSN strictly increases). - Range sanity: ids and batch ids non-zero,
toLocalBatchId >= fromLocalBatchId,toLocalEndLsn >= fromLocalEndLsn, andrangeHash/newStateRootnon-zero. - Root sanity:
withdrawRoot,solvencyRoot, all four settlement-context fields, and all DA fields non-zero, with the DA commitment self-consistent (below).
A duplicate checkpointId reverts CommitAlreadyExists. Because the chain is
strict, there is no way to skip, reorder, or fork a checkpoint on-chain without
producing a break any verifier can detect.
Four kinds of roots
A single checkpoint commits four distinct roots, each consumed by a different verifier. Keeping them separate lets each proof system evolve independently while all four are anchored by the same quorum signature.
| Root | Getter | Consumed by | What it proves |
|---|---|---|---|
| State root | latestStateRoot, event log | Indexers / replayers | The canonical ledger, chained across checkpoints and reproducible by replaying the published events. |
withdrawRoot | getWithdrawRoot(localBatchId), latestWithdrawRoot | Vault executeWithdrawWithClaim | A user's withdrawal claim is authorized for a given local batch. |
solvencyRoot | getSolvencyRoot(localBatchId), latestSolvencyRoot | Solvency / audit proofs | The liability set behind the venue's reserves. |
exitRoot | getExitRoot(localBatchId), latestExitRoot | Vault unilateral exit | A user can withdraw without operator cooperation. Present only when committed via commitCheckpointWithExitRoot. |
The withdraw, solvency, and exit roots are indexed per local batch
(toLocalBatchId), so the vault can look up the exact root for the batch a claim
references rather than only the latest.
EIP-712 signing
Publishers sign a typed-data hash, not raw bytes, so the payload is human-readable and domain-bound. The EIP-712 domain is:
- name:
SenticoreStateCommitment - version:
1 - chainId: current chain (prevents cross-chain replay)
- verifyingContract: this contract's address
There are two struct typehashes:
CheckpointCommitment(uint256 checkpointId,uint256 fromLocalBatchId,uint256 toLocalBatchId,uint64 fromLocalEndLsn,uint64 toLocalEndLsn,bytes32 rootsHash,bytes32 settlementContextHash)CheckpointWithExitRootCommitment(...,bytes32 rootsHash,bytes32 exitRoot,bytes32 settlementContextHash)
rootsHash is keccak256 over prevStateRoot, newStateRoot, rangeHash,
daPointerHash, withdrawRoot, solvencyRoot (and exitRoot in the exit-root
variant). settlementContextHash is keccak256 over validatorSetVersion,
validationPolicyHash, engineBuildHash, configHash. Folding these into
sub-hashes keeps the top-level struct compact while still binding every field.
Anyone can reproduce the exact bytes a publisher signed using the public helpers, which is what makes signature verification independent of the operator:
| Helper | Returns |
|---|---|
domainSeparator() | The EIP-712 domain separator for this contract/chain. |
hashCheckpoint(input) | The struct hash (no domain). |
checkpointSigningHash(input) | The full EIP-712 digest publishers sign. |
hashCheckpointWithExitRoot(input, exitRoot) | Struct hash for the exit-root variant. |
checkpointWithExitRootSigningHash(input, exitRoot) | Full digest for the exit-root variant. |
Quorum enforcement
On commit, _requirePublisherQuorumHash recovers a signer from each signature via
ECDSA.recover against the EIP-712 digest and requires:
- the signature count is between 7 and 10 inclusive,
- every recovered signer is an active publisher (
isPublisher[signer]), elseNotPublisher, - no signer appears twice (duplicates revert
NotPublisher).
So 7 distinct, currently-active publishers must sign the exact checkpoint. A signature over a different checkpoint, a different exit root, or a different chain does not recover to a valid publisher and is rejected.
Data-availability commitment
A state root is only meaningful if the data behind it is retrievable and
reproducible. Each checkpoint binds its off-chain data bundle through a DA
commitment under the domain SENTICORE_DA_COMMITMENT_V1. The stored daPointerHash
is keccak256(abi.encode(DA_COMMITMENT_DOMAIN_V1, daPointer, daManifestHash, daBundleHash, daCommitmentSchemaVersion)),
and the commit reverts unless the submitted daPointerHash matches that
recomputation — you cannot commit a state root while pointing at a bundle that does
not hash to it.
Verification helpers:
| Function | Purpose |
|---|---|
computeDaCommitmentHash(daPointer, daManifestHash, daBundleHash, schemaVersion) | Recompute the DA commitment hash off-chain. |
verifyDaCommitment(checkpointId, daPointer, daManifestHash, daBundleHash, schemaVersion) | Check a full DA tuple against a stored checkpoint. |
computeDaPointerHash(daPointer) | Plain keccak256(bytes(daPointer)) helper. |
verifyDaPointer(checkpointId, daPointer) | Check that a daPointer reproduces the stored commitment. |
Commit entry points
| Function | Exit root | Notes |
|---|---|---|
commitCheckpoint(input, signatures) | None | Reverts LegacyFlowDisabled once requireExitRoot is enabled. |
commitCheckpointWithExitRoot(input, exitRoot, signatures) | Required | exitRoot must be non-zero and must differ from withdrawRoot. |
Both are whenNotPaused: while the contract is paused, no checkpoint can be
committed. setRequireExitRoot(true) is a one-way, owner-only switch — once on
it cannot be turned off, and every future checkpoint must carry an exit root. This
is the on-chain guarantee that a fresh exit tree always exists, so unilateral exit
can never silently lapse.
Several older entry points now revert LegacyFlowDisabled and exist only for ABI
compatibility: commitCheckpointHashOnly, commitCheckpointHashOnlyWithWithdrawRoot,
commitCheckpointHashOnlyWithRoots, setPublisher, grantPublisher,
revokePublisher, and setGenesisStateRoot. Publishers are not managed
individually — the only way to change the publisher set is the timelocked
set-rotation flow below. The genesis root is immutable after deployment.
Governance and safety machinery
Institutional readers care most about what happens when something goes wrong. The contract layers Ownable2Step ownership, a guardian, and an emergency council, with timelocks sized to the severity of each action.
Roles
| Role | Powers |
|---|---|
Owner (Ownable2Step) | Schedule publisher-set rotations, set guardian / emergency council, setRequireExitRoot, unpause. transferOwnership requires the new owner to be a contract (code.length > 0); renounceOwnership is disabled. |
| Guardian | pause, cancel a pending standard publisher rotation, cancel guardian/council updates, emergency-revoke a publisher. |
| Emergency Council | Schedule an emergency publisher rotation (shorter delay, auto-pause), emergency-revoke a publisher, cancel emergency actions. |
Ownership transfer is two-step (propose then accept) and the new owner must be a
contract — this blocks fat-fingering ownership to an EOA or the zero address, and
renounceOwnership() reverting means the venue can never end up ownerless.
Timelocked rotations
Standard publisher-set rotation is a 48-hour timelock so the ecosystem has time to react before signing power changes:
schedulePublisherSetUpdate(newSet) // owner; validates the 10-address set
│ wait GOVERNANCE_UPDATE_DELAY (48h)
▼
activatePublisherSetUpdate() // permissionless crank after the window
Guardian and emergency-council changes follow the same 48-hour
schedule → wait → activate pattern (the first time a guardian or council is set it
takes effect immediately; subsequent changes are timelocked). cancelPublisherSetUpdate
(guardian or owner) can abort a pending rotation during the window. New publisher
sets are validated for exactly 10 non-zero, non-placeholder, distinct addresses.
Emergency publisher rotation
If keys are actively compromised, waiting 48 hours may be unacceptable.
scheduleEmergencyPublisherSetUpdate (emergency council only) uses the shorter
EMERGENCY_PUBLISHER_SET_DELAY of 8 hours, auto-pauses the contract, and
auto-cancels any pending standard rotation. It is rate-limited by the
EMERGENCY_PUBLISHER_SET_COOLDOWN of 30 days so the fast path cannot be abused as a
routine tool. During the 8-hour window the guardian or owner can cancel it — so
the fast path is powerful but still checkable by another role before it lands.
For a single bad key, emergencyRevokePublisherAndPause (council, guardian, or
owner) revokes one publisher and pauses in a single call — but only while the active
publisher count stays above quorum, so the venue can never be dropped below a
functioning 7-of-10 by a revoke.
Pause
pause() (guardian or owner) blocks all commits; unpause() is owner-only. Pausing
is intentionally easier than unpausing: any guardian can stop the world, but only
the owner can restart it.
Verification path
A user, market maker, or indexer verifies a withdrawal or exit end-to-end without trusting the operator:
Concretely:
- The checkpoint exists on-chain —
getCheckpointCommit(checkpointId)returns a commit with a non-zerocommittedAt(elseCommitNotFound). - It carries a valid quorum — recompute
checkpointSigningHash(input)(or the exit-root variant) and confirm at least 7 distinct signatures recover to active publishers ingetPublisherSet(). - The proof matches the committed root — build a Merkle proof into
getWithdrawRoot(batchId)(orgetExitRoot(batchId)for a unilateral exit). - The vault accepts it —
executeWithdrawWithClaimin MultiCollateralVault re-checks the proof against the same committed root and releases funds.
The DA commitment closes the loop: verifyDaCommitment confirms the off-chain
bundle behind the checkpoint is the one that hashes to the committed data, so the
whole ledger can be replayed and re-derived independently.
Events
Indexers reconstruct state and monitor governance from these events:
| Event | Emitted when |
|---|---|
CheckpointCommitted | A checkpoint commits (full range, roots, DA pointer). |
CheckpointSettlementContextCommitted | Per checkpoint: validator-set version and policy/build/config hashes. |
CheckpointDaCommitmentCommitted | Per checkpoint: DA pointer, manifest, bundle, schema version. |
CheckpointExitRootCommitted | An exit root is committed for a local batch. |
CheckpointQuorumVerified | Signatures verified (checkpoint hash, submitter, signature count). |
BatchCommitted | Legacy-compatible mirror for downstream indexers. |
PublisherSetUpdateScheduled / Cancelled / PublisherSetActivated | Standard publisher rotation lifecycle. |
EmergencyPublisherSetUpdateScheduled / Cancelled / EmergencyPublisherSetActivated | Emergency rotation lifecycle. |
PublisherWhitelisted / PublisherEmergencyRevoked | Individual publisher activation / emergency revoke. |
GuardianSet / Activated / EmergencyCouncil* | Role change lifecycle. |
ExitRootRequirementSet | The one-way exit-root requirement is enabled. |
Test surface
StateCommitment behavior is exercised by dedicated Foundry suites (see
test/StateCommitment.t.sol, test/StateCommitment_Emergency.t.sol, and
test/StateCommitment/Invariants.t.sol), covering: quorum thresholds (6 rejected,
7/10 accepted, 11 rejected), duplicate and non-publisher signatures, EIP-712
cross-chain replay rejection, exit-root commit paths (zero/withdraw-root-alias
rejection, wrong-exit-root signatures), the one-way requireExitRoot switch,
genesis and contiguity/monotonicity rules, DA and settlement-context validation,
48-hour standard rotation, 8-hour emergency rotation with 30-day cooldown and
auto-pause, guardian/owner cancellation of emergency actions, single-publisher
emergency revoke, and ownership-transfer constraints. End-to-end settlement against
the vault is covered under test/FullStack/SettlementRoundTrip.t.sol.
See also
- MultiCollateralVault — consumes the withdraw and exit roots for proof-backed withdrawals and unilateral exits.
- Addresses — deployed contract addresses (once live).
- Trust model — what you must trust vs. verify.
- Architecture — how the engine, sequencer, and contracts fit together.