diff --git a/ERCS/erc-8353.md b/ERCS/erc-8353.md new file mode 100644 index 00000000000..888b5b53e78 --- /dev/null +++ b/ERCS/erc-8353.md @@ -0,0 +1,354 @@ +--- +eip: 8353 +title: Staked Weighted Verification Gate +description: A minimal interface for claims that become trusted only through weighted third-party verification, with optional stake and revocation +author: Cheng Qian (@jamesavechives) +discussions-to: https://ethereum-magicians.org/t/erc-8353-staked-weighted-verification-gate/29194 +status: Draft +type: Standards Track +category: ERC +created: 2026-07-29 +--- + +## Abstract + +This ERC defines a minimal interface for a **verification gate**: a registry in +which a claim about a subject carries no trusted status until it is verified by +third parties. Verification is **weighted** — the force of an endorsement is a +function of the endorser's own verified standing, not a count of endorsements — +and a claim's subject can never verify itself. Claims may be bonded by an +optional **stake**, follow a fixed four-state lifecycle +(`Offered → Verified → Settled`, with `Revoked` reachable from any live state), +and revocation always leaves an auditable trace. The standard specifies four +state-transition functions, two views, and four events; weight functions, +promotion thresholds, slashing, and revocation arbitration are left to +implementations. + +## Motivation + +As autonomous agents produce an increasing share of digital work, *generating* +claims — "this code works", "this person shipped this", "I can perform this +task" — becomes cheap, while *verifying* them remains scarce and expensive. +Systems that gate value on self-description or on endorsement counts are +trivially farmed by Sybil identities and by reciprocal endorsement rings. + +Two design responses recur independently across production systems that face +this problem: + +1. **Verify before settle.** A claim is consumed (settled on, rendered, + routed on) only after third-party verification, never on the claimant's + word. Claimants can be required to stake, so wrong claims cost their maker. +2. **Weight, not count.** An endorsement's force derives from the endorser's + own verified depth. An unverified endorser contributes approximately + nothing, which makes Sybil swarms structurally worthless instead of merely + rate-limited. + +The information-theoretic case for measuring rather than counting trust — an +actor's deliverable value is bounded by measured mutual information, not by +declared confidence — is developed in *A Mathematical Theory of Value* +(arXiv:2606.12502). This ERC standardizes only the interface and lifecycle of +the gate, so that marketplaces, credential registries, and reputation systems +can interoperate on trusted status while competing on verification policy. + +The interface is deliberately minimal, following the precedent of single- +concern introspection standards such as [ERC-8063](./eip-8063.md): anything two conforming +implementations would legitimately do differently is excluded from the +specification. + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be +interpreted as described in RFC 2119 and RFC 8174. + +### State machine + +Every claim is in exactly one of four states: + +``` + offer() verify() settle() + (none) ─────────────▶ Offered ─────────────▶ Verified ─────────────▶ Settled + │ │ (terminal) + │ revoke() │ revoke() + ▼ ▼ + Revoked (terminal) +``` + +- **Offered** — the claim is registered and any required stake is bound. It + MUST NOT be treated as trusted. +- **Verified** — accumulated third-party verification weight has met the + implementation's promotion condition. +- **Settled** — the claim has been finalized and relied upon. Terminal: a + settled claim MUST NOT be revoked. +- **Revoked** — terminal. The claim, its transition history, and its final + state MUST remain queryable indefinitely. + +No other transitions are permitted. In particular, a claim MUST NOT reach +`Verified` or `Settled` without passing through `Offered`, and `settle` MUST +revert unless the claim is `Verified`. + +### Interface + +```solidity +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +/// @title Staked Weighted Verification Gate +interface IVerificationGate { + enum Status { None, Offered, Verified, Settled, Revoked } + + /// @notice A claim was registered and entered the Offered state. + /// @param claimId Unique identifier of the claim + /// @param subject The party the claim is about + /// @param offerer The caller that registered the claim + /// @param claimHash Hash of the claim content (content itself may be off-chain) + /// @param stake Stake bound to the claim (0 if none) + event ClaimOffered(bytes32 indexed claimId, address indexed subject, + address indexed offerer, bytes32 claimHash, uint256 stake); + + /// @notice A third party verified the claim, contributing `weight`. + event ClaimVerified(bytes32 indexed claimId, address indexed verifier, + uint256 weight, bytes32 evidenceHash); + + /// @notice A Verified claim was finalized. + event ClaimSettled(bytes32 indexed claimId); + + /// @notice The claim was revoked. The record persists. + event ClaimRevoked(bytes32 indexed claimId, address indexed revoker, + bytes32 reasonHash); + + /// @notice Register a claim about `subject`. Binds msg.value as stake, if any. + /// @dev MUST emit ClaimOffered. MUST revert if a required stake is missing. + /// @param subject The party the claim is about + /// @param claimHash Hash of the claim content + /// @param data Implementation-defined parameters (opaque to this standard) + function offer(address subject, bytes32 claimHash, bytes calldata data) + external payable returns (bytes32 claimId); + + /// @notice Endorse a claim as a third party. + /// @dev MUST emit ClaimVerified with the weight actually credited. + /// A call where msg.sender is the claim's subject or offerer MUST NOT + /// contribute weight (implementations MAY revert instead). + /// A verifier MUST NOT contribute weight to the same claim more than + /// once (implementations SHOULD revert on repeat calls). + /// MUST revert unless the claim is Offered or Verified. + function verify(bytes32 claimId, bytes32 evidenceHash) external; + + /// @notice Finalize a Verified claim. + /// @dev MUST revert unless the claim is Verified. MUST emit ClaimSettled. + function settle(bytes32 claimId) external; + + /// @notice Revoke a claim. Authorization policy is implementation-defined. + /// @dev MUST revert unless the claim is Offered or Verified — Settled is + /// terminal and MUST NOT be revoked. + /// MUST emit ClaimRevoked. The claim record MUST remain queryable. + function revoke(bytes32 claimId, bytes32 reasonHash) external; + + /// @notice The gate. Consumers MUST check this before relying on a claim. + /// @return status The claim's current state + /// @return weight Total verification weight accumulated by the claim + function statusOf(bytes32 claimId) + external view returns (Status status, uint256 weight); + + /// @notice The verifier's own verified depth, as computed by this implementation. + /// @dev MUST be derived from `verifier`'s own verified state; MUST NOT be + /// a function of raw endorsement counts alone. + function weightOf(address verifier) external view returns (uint256); +} +``` + +### Conformance requirements + +1. **No self-verification.** A `verify` call from the claim's subject or + offerer MUST NOT increase the claim's weight or advance its state. This is + the standard's one absolute red line: trusted status is unobtainable + through self-endorsement, directly or via a single controlling caller. +2. **Weighted promotion.** The condition that moves a claim from `Offered` to + `Verified` MUST be expressed over the weights of its verifiers (as reported + by `weightOf`), not over the number of `verify` calls, and each verifier + MUST be counted at most once per claim — repeat `verify` calls from the + same address MUST NOT accumulate additional weight (implementations SHOULD + revert on them). `weightOf` MUST + derive from the verifier's own verified state (within this contract or an + implementation-declared external source); it MUST NOT be a constant across + all verifiers and MUST NOT be settable by the verifier itself. +3. **Verify before settle.** `settle` MUST revert for any claim that is not + `Verified`. Implementations MUST NOT expose any other path to `Settled`. +4. **Stake binding.** If an implementation requires a stake, it MUST be bound + no later than the `ClaimOffered` event and MUST only be released or reduced + by `settle` or `revoke`. This standard does not define stake sizing, + slashing schedules, or the destination of slashed funds, except that + slashed stake MUST NOT be credited to the party that triggered the + revocation (no bounty for engineering failures). +5. **Auditable revocation.** After `revoke`, `statusOf` MUST return `Revoked` + for that claim indefinitely, and the emitted event history MUST allow + reconstruction of every transition including the revocation reason hash. +6. Implementations SHOULD implement [ERC-165](./eip-165.md); `type(IVerificationGate).interfaceId` + identifies this interface. + +### Left to implementations + +The weight function `f` behind `weightOf`; the promotion threshold; whether a +stake is required and how it is sized and slashed; who may call `revoke` and +under what challenge process; the format of `data`, `evidenceHash`, and +`reasonHash`; and the semantics of the claim itself. Payment for verified work +and the identity of the acting parties are explicitly out of scope. + +## Rationale + +**Why a lifecycle standard rather than a data standard.** Existing attestation +standards define what a signed statement *is*; the recurring interoperability +gap is what a statement is *worth* and *when it may be relied on*. Consumers +need one question answered uniformly — "has this claim passed a third-party +gate, at what weight, and is it still standing?" — which is `statusOf`. The +four transitions exist only to give that answer a well-defined history. + +**Why weight instead of count.** Counting endorsements prices an endorsement +at the cost of an address, which on a public chain is approximately zero. +Weighting by the endorser's own verified depth makes the cost of moving a +claim's status equal to the cost of building verified standing — which is +exactly the resource the system measures. Both known production designs of +this primitive adopted this independently after rejecting count-based scoring; +the standard treats it as essential, not as policy. + +**Why the weight function is not specified.** Verified depth is legitimately +different things in different systems: measured task performance in a +marketplace, identity-verification tier in a credential registry, stake-backed +history elsewhere. Fixing one function would collapse the standard into one +product's policy. The interface fixes only the invariants every honest weight +function shares: it derives from verified state, and it is not self-assigned. + +**Why stake is optional.** One production lineage makes stake load-bearing +(claims are priced confidence, wrong claims burn); another achieves its +anti-abuse goals without any stake. Requiring stake would exclude conforming +gate implementations that bond claims by other means; forbidding it would +exclude the staked design. The interface therefore standardizes only the +binding discipline (bound at offer, resolved at settle/revoke). + +**Why no payment semantics.** Verification gating and payment settlement +compose naturally but vary independently. Every payment-bearing design +surveyed had exactly one source; writing it into this ERC would present +single-source policy as a multiply-evidenced standard. A settlement layer can +consume `statusOf` without this ERC knowing it exists. + +**Relationship to agent registries ([ERC-8004](./eip-8004.md)).** Registry +standards answer *who an actor is* and *where its history is recorded*: an +identity registry, client feedback, and a validation registry in which an +actor requests validation from a designated validator, who posts a response +that is stored and averaged. This ERC answers a different question — *when a +particular claim may be relied upon* — and the two compose rather than +compete. Concretely, a registry records validation outcomes but does not +define a trusted/untrusted status, does not bind stake to the claim, does not +weight validators against each other, and does not constrain who may validate; +this ERC supplies exactly those, and deliberately says nothing about identity +or discovery. An implementation MAY source `weightOf` from a registry's +validation history and MAY use a registry-issued identifier's wallet as a +claim's `subject`. No registry is required: `subject` is a plain address, so +the gate also serves parties that are not registered agents at all. This ERC +therefore lists no `requires` dependency on any registry standard — +composition is available, not mandatory. + +**Why `Settled` is terminal.** Settlement is the reliance event: stakes are +resolved and consequences have occurred, so a post-hoc status flip cannot +undo them — it can only create a claim whose recorded outcome and live status +disagree, and an incentive to engineer late revocations. Systems whose +credentials must remain falsifiable after consumption can model this within +the standard: keep the claim in `Verified` (revocable indefinitely) and treat +`settle` as the explicit, deliberate point of no return, or issue +finite-lifetime claims and re-offer. + +**Why one `verify` method rather than approve/reject voting.** Negative +signals are already expressible: a verifier withholds weight, and an +implementation's revocation process handles contested claims. A binary approve/reject +interface would force every implementation to have a dispute protocol, which +is precisely the kind of policy this standard leaves open. + +## Backwards Compatibility + +No conflicts with existing standards. The interface is self-contained and does +not modify [ERC-20](./eip-20.md), [ERC-721](./eip-721.md), or +[ERC-1155](./eip-1155.md) behavior. Claims MAY reference subjects that are +contracts implementing other standards; attestation-format standards can be +carried opaquely in `claimHash`/`evidenceHash`. Implementations SHOULD expose +ERC-165 introspection. + +## Reference Implementation + +Non-normative. A Solidity +[reference implementation](../assets/eip-8353/README.md) accompanies this +proposal: the interface, a minimal abstract base enforcing every normative +requirement, two adapters, and tests covering both shapes. + +The abstraction was informed by two independent production systems: a +market-side agent task hub (staked bids, verify-before-settle, measured +per-class reputation) and an identity-side works/certificate registry +(offered→claimed→revoked attestations, endorsement weight equal to the +endorser's verification depth). The two adapters in the reference +implementation correspond to those shapes and differ only in the policy hooks +— the same interface serves a staked marketplace and a zero-stake credential +registry. Theoretical background: *A Mathematical Theory of Value*, +arXiv:2606.12502. + +## Security Considerations + +**Sybil verifiers.** The primary attack is manufacturing many verifier +identities. The weight requirement is the structural defense: fresh identities +have no verified depth, so their aggregate weight is ≈ 0 regardless of count. +Implementations MUST ensure `weightOf` cannot be bootstrapped by +self-verification loops among an attacker's own identities (e.g., by deriving +depth only from claims verified by already-weighted parties, or from +system-external verified state). + +**Authenticity re-derivation is not verification weight.** Where a +verification is accompanied by evidence that anyone can re-derive — a signed, +deterministic verdict, for instance — consumers and implementations MUST NOT +treat the number of successful independent re-derivations as verification +weight. Re-deriving evidence confirms that a verifier issued what it claims to +have issued; it says nothing about whether the judgment was correct, and a +single-issuer verdict re-checked any number of times still carries exactly one +independent judgment. Because re-derivation is cheap, deterministic, and +performable by the issuer's own addresses, such a count is even easier to +inflate than raw endorsement counts. Recomputable evidence is a precondition +for trusting a verification, never a substitute for independent weighted +judgment of the claim. + +**Evidence commitments are only as useful as their preimages.** `evidenceHash` +commits a verifier to material; it does not make that material available. A +consumer that cannot obtain the preimage cannot re-derive anything from it, so +for that consumer the commitment is indistinguishable from an assertion, +however sound the verifier's method. Implementations that expect consumers to +audit verifications SHOULD ensure the committed material is retrievable — by +publishing it, by using a content-addressed location, or by any means that +does not require the verifier to be responsive and honest at audit time — and +consumers SHOULD treat a commitment whose preimage they cannot fetch as +unaudited. + +**Collusive endorsement rings.** Established, genuinely weighted verifiers may +trade endorsements. Mitigations are implementation policy but the standard +enables them: stake on the *claim* makes a colluded promotion costly to the +claimant when revoked; `weightOf` implementations SHOULD make a verifier's +depth degradable when claims it verified are later revoked, so lending one's +weight to bad claims is self-consuming. + +**Stake runs and griefing.** Where stakes are held by the gate contract, +implementations must guard standard escrow risks: re-entrancy on +release, stakes stranded by claims that never verify (a timeout/expiry policy +is RECOMMENDED), and griefing by revocation-triggering third parties — hence +the requirement that slashed funds never flow to the revocation trigger. + +**Revocation races.** A consumer may read `Verified` and act while a `revoke` +lands in the same block. Consumers SHOULD treat `statusOf` as authoritative at +the moment of settlement, not at the moment of quoting; high-value consumers +SHOULD re-check status in the transaction that relies on the claim. +Conversely, front-running `settle` with `revoke` is an arbitration-policy +question; implementations with adversarial revokers SHOULD impose a challenge +window rather than instant revocation. + +**Weight oracle manipulation.** Implementations reading verifier depth from an +external source inherit that source's integrity; the external source MUST +itself satisfy the no-self-assignment requirement, or the gate's central +guarantee is void. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/erc-8353/README.md b/assets/erc-8353/README.md new file mode 100644 index 00000000000..676fadb3015 --- /dev/null +++ b/assets/erc-8353/README.md @@ -0,0 +1,25 @@ +# ERC-8353 reference implementation + +Non-normative. Foundry project, solc 0.8.20. + +| Path | What | +|---|---| +| `src/IVerificationGate.sol` | The interface: four transitions, two views, four events | +| `src/VerificationGate.sol` | Minimal abstract base implementing every normative requirement; policy is exposed as virtual hooks | +| `examples/MarketGate.sol` | Adapter: staked-marketplace shape — mandatory stake, arbiter revocation burns it, verifier depth recursive over the gate's own settled claims | +| `examples/IdentityGate.sol` | Adapter: credential-registry shape — zero stake, depth read from an external source, issuer-revocable | +| `test/VerificationGate.t.sol` | 15 tests covering both shapes and every normative requirement | + +The two adapters differ only in the policy hooks (`weightOf`, +`_requiredStake`, `_promotionThreshold`, `_canSettle`, `_canRevoke`, +`_onSettled`) — the same interface serves a staked marketplace and a +zero-stake credential registry. + +Run: + +``` +forge install foundry-rs/forge-std +forge test -vv +``` + +Not audited. This is a reference for implementers, not production code. diff --git a/assets/erc-8353/examples/IdentityGate.sol b/assets/erc-8353/examples/IdentityGate.sol new file mode 100644 index 00000000000..7d96d1aa928 --- /dev/null +++ b/assets/erc-8353/examples/IdentityGate.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +import "../src/VerificationGate.sol"; + +/// @notice External verified-depth source (e.g. an identity registry's +/// verification tier). Per the draft, the source must itself satisfy +/// no-self-assignment or the gate's guarantee is void. +interface IDepthOracle { + function depthOf(address account) external view returns (uint256); +} + +/// @title IdentityGate — identity-side adapter sketch +/// @notice Models the credential-registry shape: an issuer offers an +/// attestation about a subject; no stake anywhere (zero-stake gates +/// conform); endorsement weight is the endorser's identity- +/// verification depth read from an external oracle; claims typically +/// remain in Verified — revocable by the issuer indefinitely — and +/// `settle` is the deliberate, terminal point of no return. +contract IdentityGate is VerificationGate { + IDepthOracle public immutable depthOracle; + uint256 public immutable threshold; + + constructor(IDepthOracle oracle, uint256 threshold_) { + depthOracle = oracle; + threshold = threshold_; + } + + /// Anonymous endorsers (depth 0) weigh nothing, however many there are. + function weightOf(address verifier) public view override returns (uint256) { + return depthOracle.depthOf(verifier); + } + + function _promotionThreshold(bytes32) internal view override returns (uint256) { + return threshold; + } + + // _requiredStake stays 0; _canRevoke/_canSettle stay offerer-only — + // the issuer controls both revocation and finalization. +} diff --git a/assets/erc-8353/examples/MarketGate.sol b/assets/erc-8353/examples/MarketGate.sol new file mode 100644 index 00000000000..d30bb8367df --- /dev/null +++ b/assets/erc-8353/examples/MarketGate.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +import "../src/VerificationGate.sol"; + +/// @title MarketGate — market-side adapter sketch +/// @notice Models the staked-marketplace shape: every claim (a capability bid) +/// MUST bond a fixed stake; wrong claims are revoked by a designated +/// arbiter and the stake burns; settlement is terminal and feeds the +/// subject's own verified depth. Verifier depth is recursive over this +/// gate's own settled claims: `weightOf` = settled claims as subject +/// (+ a one-time genesis seed so the system can bootstrap). +contract MarketGate is VerificationGate { + uint256 public immutable stakePerClaim; + uint256 public immutable threshold; + address public immutable arbiter; + + mapping(address => uint256) public settledAsSubject; + mapping(address => uint256) public genesisWeight; + + constructor(uint256 stakePerClaim_, uint256 threshold_, address arbiter_, + address[] memory genesisVerifiers, uint256 genesisWeight_) + { + stakePerClaim = stakePerClaim_; + threshold = threshold_; + arbiter = arbiter_; + for (uint256 i = 0; i < genesisVerifiers.length; i++) + genesisWeight[genesisVerifiers[i]] = genesisWeight_; + } + + /// Depth = measured verified history in this gate, never a raw count of + /// endorsements received. + function weightOf(address verifier) public view override returns (uint256) { + return genesisWeight[verifier] + settledAsSubject[verifier]; + } + + function _requiredStake(address, bytes32, bytes calldata) + internal view override returns (uint256) { return stakePerClaim; } + + function _promotionThreshold(bytes32) internal view override returns (uint256) { + return threshold; + } + + /// Only the arbiter revokes (the marketplace's dispute outcome); the + /// claimant's stake burns via the base contract. + function _canRevoke(bytes32, address caller) internal view override returns (bool) { + return caller == arbiter; + } + + /// A settled claim deepens its subject's own verified depth — the + /// recursion that makes weight expensive to farm. + function _onSettled(bytes32 claimId) internal override { + settledAsSubject[_claims[claimId].subject] += 1; + } +} diff --git a/assets/erc-8353/foundry.toml b/assets/erc-8353/foundry.toml new file mode 100644 index 00000000000..9a2dcc71900 --- /dev/null +++ b/assets/erc-8353/foundry.toml @@ -0,0 +1,16 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +libs = ["lib"] +solc = "0.8.20" +optimizer = true +optimizer_runs = 200 +verbosity = 2 +fuzz = { runs = 256 } + +# Disable file-system access in tests (security default) +fs_permissions = [] + +# Strict re-entrancy + invariant settings +remappings = [] diff --git a/assets/erc-8353/src/IVerificationGate.sol b/assets/erc-8353/src/IVerificationGate.sol new file mode 100644 index 00000000000..bd13e1c7206 --- /dev/null +++ b/assets/erc-8353/src/IVerificationGate.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +/// @title Staked Weighted Verification Gate (interface per draft/erc-draft.md) +/// @notice A claim carries no trusted status until verified by third parties; +/// verification is weighted by the verifier's own verified depth. +interface IVerificationGate { + enum Status { None, Offered, Verified, Settled, Revoked } + + /// @notice A claim was registered and entered the Offered state. + event ClaimOffered(bytes32 indexed claimId, address indexed subject, + address indexed offerer, bytes32 claimHash, uint256 stake); + + /// @notice A third party verified the claim, contributing `weight`. + event ClaimVerified(bytes32 indexed claimId, address indexed verifier, + uint256 weight, bytes32 evidenceHash); + + /// @notice A Verified claim was finalized. Settled is terminal. + event ClaimSettled(bytes32 indexed claimId); + + /// @notice The claim was revoked. The record persists. + event ClaimRevoked(bytes32 indexed claimId, address indexed revoker, + bytes32 reasonHash); + + /// @notice Register a claim about `subject`. Binds msg.value as stake, if any. + /// @dev MUST emit ClaimOffered. MUST revert if a required stake is missing. + function offer(address subject, bytes32 claimHash, bytes calldata data) + external payable returns (bytes32 claimId); + + /// @notice Endorse a claim as a third party. + /// @dev MUST emit ClaimVerified with the weight actually credited. + /// Calls from the claim's subject or offerer MUST NOT contribute + /// weight (this implementation reverts). A verifier MUST NOT + /// contribute weight to the same claim more than once (this + /// implementation reverts on repeat calls). MUST revert unless the + /// claim is Offered or Verified. + function verify(bytes32 claimId, bytes32 evidenceHash) external; + + /// @notice Finalize a Verified claim. MUST revert unless Verified. + function settle(bytes32 claimId) external; + + /// @notice Revoke a claim. MUST revert unless Offered or Verified — + /// Settled is terminal and cannot be revoked. + function revoke(bytes32 claimId, bytes32 reasonHash) external; + + /// @notice The gate: consumers MUST check this before relying on a claim. + function statusOf(bytes32 claimId) + external view returns (Status status, uint256 weight); + + /// @notice The verifier's own verified depth, as computed by this implementation. + function weightOf(address verifier) external view returns (uint256); +} diff --git a/assets/erc-8353/src/VerificationGate.sol b/assets/erc-8353/src/VerificationGate.sol new file mode 100644 index 00000000000..2e529d79eaf --- /dev/null +++ b/assets/erc-8353/src/VerificationGate.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +import "./IVerificationGate.sol"; + +/// @title VerificationGate — minimal reference implementation +/// @notice Implements the four-state lifecycle and every MUST of the draft. +/// Policy (weight function, stake sizing, promotion threshold, +/// revocation authorization) is exposed as virtual hooks — see +/// examples/ for the market-side and identity-side adapters. +abstract contract VerificationGate is IVerificationGate { + struct Claim { + address subject; + address offerer; + bytes32 claimHash; + uint256 stake; + uint256 weight; + Status status; + } + + /// Slashed stake is burned here — never redistributed, and in particular + /// never to the party that triggered the revocation. + address public constant BURN = address(0xdEaD); + + mapping(bytes32 => Claim) internal _claims; + mapping(bytes32 => mapping(address => bool)) internal _verified; + uint256 private _nonce; + + error BadStatus(bytes32 claimId, Status actual); + error SelfVerification(bytes32 claimId, address caller); + error AlreadyVerified(bytes32 claimId, address caller); + error InsufficientStake(uint256 required, uint256 provided); + error NotAuthorized(bytes32 claimId, address caller); + error TransferFailed(); + + // ── transitions ────────────────────────────────────────────────────── + + function offer(address subject, bytes32 claimHash, bytes calldata data) + external payable returns (bytes32 claimId) + { + uint256 required = _requiredStake(subject, claimHash, data); + if (msg.value < required) revert InsufficientStake(required, msg.value); + + claimId = keccak256(abi.encode(subject, msg.sender, claimHash, _nonce++)); + _claims[claimId] = Claim({ + subject: subject, + offerer: msg.sender, + claimHash: claimHash, + stake: msg.value, + weight: 0, + status: Status.Offered + }); + emit ClaimOffered(claimId, subject, msg.sender, claimHash, msg.value); + } + + function verify(bytes32 claimId, bytes32 evidenceHash) external { + Claim storage c = _claims[claimId]; + if (c.status != Status.Offered && c.status != Status.Verified) + revert BadStatus(claimId, c.status); + if (msg.sender == c.subject || msg.sender == c.offerer) + revert SelfVerification(claimId, msg.sender); + if (_verified[claimId][msg.sender]) + revert AlreadyVerified(claimId, msg.sender); + + _verified[claimId][msg.sender] = true; + uint256 w = weightOf(msg.sender); + c.weight += w; + emit ClaimVerified(claimId, msg.sender, w, evidenceHash); + + if (c.status == Status.Offered && c.weight >= _promotionThreshold(claimId)) + c.status = Status.Verified; + } + + function settle(bytes32 claimId) external { + Claim storage c = _claims[claimId]; + if (c.status != Status.Verified) revert BadStatus(claimId, c.status); + if (!_canSettle(claimId, msg.sender)) revert NotAuthorized(claimId, msg.sender); + + c.status = Status.Settled; // effects before interaction + emit ClaimSettled(claimId); + _onSettled(claimId); + _payout(c.offerer, _releaseStake(claimId)); + } + + function revoke(bytes32 claimId, bytes32 reasonHash) external { + Claim storage c = _claims[claimId]; + if (c.status != Status.Offered && c.status != Status.Verified) + revert BadStatus(claimId, c.status); // Settled is terminal + if (!_canRevoke(claimId, msg.sender)) revert NotAuthorized(claimId, msg.sender); + + c.status = Status.Revoked; // record persists forever + emit ClaimRevoked(claimId, msg.sender, reasonHash); + _payout(BURN, _releaseStake(claimId)); // slash: burn, never redistribute + } + + // ── views ──────────────────────────────────────────────────────────── + + function statusOf(bytes32 claimId) + external view returns (Status status, uint256 weight) + { + Claim storage c = _claims[claimId]; + return (c.status, c.weight); + } + + /// @inheritdoc IVerificationGate + function weightOf(address verifier) public view virtual returns (uint256); + + // ── policy hooks (implementation freedom per the draft) ────────────── + + /// Stake required at offer. Default: none (zero-stake gates conform). + function _requiredStake(address, bytes32, bytes calldata) + internal view virtual returns (uint256) { return 0; } + + /// Accumulated verifier weight needed for Offered → Verified. + function _promotionThreshold(bytes32) internal view virtual returns (uint256) { + return 1; + } + + /// Who may finalize. Default: the offerer. + function _canSettle(bytes32 claimId, address caller) + internal view virtual returns (bool) + { + return caller == _claims[claimId].offerer; + } + + /// Who may revoke. Default: the offerer. + function _canRevoke(bytes32 claimId, address caller) + internal view virtual returns (bool) + { + return caller == _claims[claimId].offerer; + } + + /// Post-settlement hook (e.g. update the settled party's depth). + function _onSettled(bytes32 claimId) internal virtual {} + + // ── internals ──────────────────────────────────────────────────────── + + function _releaseStake(bytes32 claimId) private returns (uint256 amount) { + amount = _claims[claimId].stake; + _claims[claimId].stake = 0; + } + + function _payout(address to, uint256 amount) private { + if (amount == 0) return; + (bool ok, ) = payable(to).call{value: amount}(""); + if (!ok) revert TransferFailed(); + } +} diff --git a/assets/erc-8353/test/VerificationGate.t.sol b/assets/erc-8353/test/VerificationGate.t.sol new file mode 100644 index 00000000000..8eeb8729a8c --- /dev/null +++ b/assets/erc-8353/test/VerificationGate.t.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/IVerificationGate.sol"; +import "../examples/MarketGate.sol"; +import "../examples/IdentityGate.sol"; + +contract MockDepthOracle is IDepthOracle { + mapping(address => uint256) public depth; + function set(address a, uint256 d) external { depth[a] = d; } + function depthOf(address a) external view returns (uint256) { return depth[a]; } +} + +/// @dev Run with: forge test -vv +contract MarketGateTest is Test { + event ClaimRevoked(bytes32 indexed claimId, address indexed revoker, + bytes32 reasonHash); + + MarketGate internal gate; + + address internal arbiter = address(0xA11CE); + address internal server = address(0x51); // claimant (offerer == subject) + address internal genesis = address(0x61); // seeded verifier + address internal nobody = address(0x71); // no verified depth + uint256 internal constant STAKE = 1 ether; + uint256 internal constant THRESHOLD = 3; + + function setUp() public { + address[] memory g = new address[](1); + g[0] = genesis; + gate = new MarketGate(STAKE, THRESHOLD, arbiter, g, 5); + vm.deal(server, 10 ether); + } + + function _offer() internal returns (bytes32 id) { + vm.prank(server); + id = gate.offer{value: STAKE}(server, keccak256("claim"), ""); + } + + // ── offer / stake binding ──────────────────────────────────────────── + + function test_offer_binds_stake_and_emits() public { + vm.prank(server); + bytes32 id = gate.offer{value: STAKE}(server, keccak256("claim"), ""); + (IVerificationGate.Status s, uint256 w) = gate.statusOf(id); + assertEq(uint8(s), uint8(IVerificationGate.Status.Offered)); + assertEq(w, 0); + assertEq(address(gate).balance, STAKE); + } + + function test_offer_reverts_without_required_stake() public { + vm.prank(server); + vm.expectRevert( + abi.encodeWithSelector(VerificationGate.InsufficientStake.selector, STAKE, 0) + ); + gate.offer(server, keccak256("claim"), ""); + } + + // ── verify: red lines ──────────────────────────────────────────────── + + function test_self_verification_reverts() public { + bytes32 id = _offer(); + vm.prank(server); // subject and offerer + vm.expectRevert( + abi.encodeWithSelector(VerificationGate.SelfVerification.selector, id, server) + ); + gate.verify(id, keccak256("ev")); + } + + function test_zero_weight_verifiers_never_promote() public { + bytes32 id = _offer(); + // a Sybil swarm of fresh identities + for (uint160 i = 1; i <= 50; i++) { + vm.prank(address(uint160(0x1000) + i)); + gate.verify(id, keccak256("ev")); + } + (IVerificationGate.Status s, uint256 w) = gate.statusOf(id); + assertEq(uint8(s), uint8(IVerificationGate.Status.Offered)); // still untrusted + assertEq(w, 0); + } + + function test_weighted_promotion() public { + bytes32 id = _offer(); + vm.prank(genesis); // weight 5 >= threshold 3 + gate.verify(id, keccak256("ev")); + (IVerificationGate.Status s, uint256 w) = gate.statusOf(id); + assertEq(uint8(s), uint8(IVerificationGate.Status.Verified)); + assertEq(w, 5); + } + + function test_double_verify_reverts() public { + bytes32 id = _offer(); + vm.prank(genesis); + gate.verify(id, keccak256("ev")); + vm.prank(genesis); + vm.expectRevert( + abi.encodeWithSelector(VerificationGate.AlreadyVerified.selector, id, genesis) + ); + gate.verify(id, keccak256("ev")); + } + + // ── settle ─────────────────────────────────────────────────────────── + + function test_settle_reverts_unless_verified() public { + bytes32 id = _offer(); + vm.prank(server); + vm.expectRevert( + abi.encodeWithSelector(VerificationGate.BadStatus.selector, id, + IVerificationGate.Status.Offered) + ); + gate.settle(id); + } + + function test_settle_releases_stake_and_deepens_subject() public { + bytes32 id = _offer(); + vm.prank(genesis); + gate.verify(id, keccak256("ev")); + + uint256 before = server.balance; + assertEq(gate.weightOf(server), 0); + vm.prank(server); + gate.settle(id); + + (IVerificationGate.Status s, ) = gate.statusOf(id); + assertEq(uint8(s), uint8(IVerificationGate.Status.Settled)); + assertEq(server.balance, before + STAKE); // stake returned + assertEq(gate.weightOf(server), 1); // depth recursion + } + + function test_settled_is_terminal() public { + bytes32 id = _offer(); + vm.prank(genesis); + gate.verify(id, keccak256("ev")); + vm.prank(server); + gate.settle(id); + + // no revoke after settlement — even by the arbiter + vm.prank(arbiter); + vm.expectRevert( + abi.encodeWithSelector(VerificationGate.BadStatus.selector, id, + IVerificationGate.Status.Settled) + ); + gate.revoke(id, keccak256("reason")); + + // no further verification either + vm.prank(genesis); + vm.expectRevert(); + gate.verify(id, keccak256("ev2")); + } + + // ── revoke ─────────────────────────────────────────────────────────── + + function test_revoke_burns_stake_and_keeps_record() public { + bytes32 id = _offer(); + uint256 burnBefore = gate.BURN().balance; + + vm.prank(arbiter); + vm.expectEmit(true, true, false, true); + emit ClaimRevoked(id, arbiter, keccak256("bad")); + gate.revoke(id, keccak256("bad")); + + (IVerificationGate.Status s, ) = gate.statusOf(id); + assertEq(uint8(s), uint8(IVerificationGate.Status.Revoked)); // queryable forever + assertEq(gate.BURN().balance, burnBefore + STAKE); // burned, not paid out + assertEq(arbiter.balance, 0); // no bounty for the trigger + + vm.prank(arbiter); + vm.expectRevert(); // terminal + gate.revoke(id, keccak256("again")); + } + + function test_revoke_only_by_arbiter() public { + bytes32 id = _offer(); + vm.prank(nobody); + vm.expectRevert( + abi.encodeWithSelector(VerificationGate.NotAuthorized.selector, id, nobody) + ); + gate.revoke(id, keccak256("reason")); + } +} + +contract IdentityGateTest is Test { + IdentityGate internal gate; + MockDepthOracle internal oracle; + + address internal issuer = address(0xC0); // offerer (verified company) + address internal recipient = address(0xD0); // subject + address internal deepPeer = address(0xE0); // oracle depth 4 + address internal anon = address(0xF0); // oracle depth 0 + + function setUp() public { + oracle = new MockDepthOracle(); + oracle.set(deepPeer, 4); + gate = new IdentityGate(oracle, 3); + } + + function test_zero_stake_conforms() public { + vm.prank(issuer); + bytes32 id = gate.offer(recipient, keccak256("attestation"), ""); + (IVerificationGate.Status s, ) = gate.statusOf(id); + assertEq(uint8(s), uint8(IVerificationGate.Status.Offered)); + } + + function test_oracle_weight_promotes_and_anon_does_not() public { + vm.prank(issuer); + bytes32 id = gate.offer(recipient, keccak256("attestation"), ""); + + vm.prank(anon); + gate.verify(id, keccak256("ev")); + (IVerificationGate.Status s0, uint256 w0) = gate.statusOf(id); + assertEq(uint8(s0), uint8(IVerificationGate.Status.Offered)); + assertEq(w0, 0); + + vm.prank(deepPeer); + gate.verify(id, keccak256("ev")); + (IVerificationGate.Status s1, uint256 w1) = gate.statusOf(id); + assertEq(uint8(s1), uint8(IVerificationGate.Status.Verified)); + assertEq(w1, 4); + } + + function test_issuer_revokes_verified_claim_with_trace() public { + vm.prank(issuer); + bytes32 id = gate.offer(recipient, keccak256("attestation"), ""); + vm.prank(deepPeer); + gate.verify(id, keccak256("ev")); + + // identity-side shape: stays Verified, revocable by issuer indefinitely + vm.prank(issuer); + gate.revoke(id, keccak256("employment ended")); + (IVerificationGate.Status s, uint256 w) = gate.statusOf(id); + assertEq(uint8(s), uint8(IVerificationGate.Status.Revoked)); + assertEq(w, 4); // history preserved + + // recipient (subject) can neither verify nor revoke + vm.prank(recipient); + vm.expectRevert(); + gate.revoke(id, keccak256("no")); + } + + function test_settle_is_the_point_of_no_return() public { + vm.prank(issuer); + bytes32 id = gate.offer(recipient, keccak256("attestation"), ""); + vm.prank(deepPeer); + gate.verify(id, keccak256("ev")); + vm.prank(issuer); + gate.settle(id); + + vm.prank(issuer); + vm.expectRevert( + abi.encodeWithSelector(VerificationGate.BadStatus.selector, id, + IVerificationGate.Status.Settled) + ); + gate.revoke(id, keccak256("too late")); + } +}