From 6a32003cbd39eae0df526cd80eff8d22690687a4 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:57:50 +0300 Subject: [PATCH 01/19] v2: contracts (AgentJob, ValidatorBond, RefundArbiter, Registry, Settlement) with Foundry unit, fuzz, invariant and PoC tests --- .gitmodules | 3 + contracts/v2/AgentJobV2.sol | 182 ++++++++ contracts/v2/PredgeAgentValidatorV2.sol | 211 ++++++++++ contracts/v2/PredgeRefundArbiterV2.sol | 319 ++++++++++++++ contracts/v2/PredgeSettlementV2.sol | 49 +++ contracts/v2/PredgeValidatorBondV2.sol | 270 ++++++++++++ contracts/v2/access/PredgeAccess.sol | 86 ++++ contracts/v2/access/PullCredit.sol | 49 +++ contracts/v2/interfaces/IAgentJobV2.sol | 33 ++ contracts/v2/mocks/MockRefundProtocolV2.sol | 75 ++++ foundry-lib/forge-std | 1 + foundry-v2.toml | 21 + test/foundry-v2/AgentJobV2.t.sol | 300 ++++++++++++++ test/foundry-v2/Base.sol | 89 ++++ test/foundry-v2/Invariants.t.sol | 416 +++++++++++++++++++ test/foundry-v2/Misc.t.sol | 420 +++++++++++++++++++ test/foundry-v2/Pocs.t.sol | 271 ++++++++++++ test/foundry-v2/PredgeRefundArbiterV2.t.sol | 434 ++++++++++++++++++++ test/foundry-v2/PredgeValidatorBondV2.t.sol | 406 ++++++++++++++++++ 19 files changed, 3635 insertions(+) create mode 100644 .gitmodules create mode 100644 contracts/v2/AgentJobV2.sol create mode 100644 contracts/v2/PredgeAgentValidatorV2.sol create mode 100644 contracts/v2/PredgeRefundArbiterV2.sol create mode 100644 contracts/v2/PredgeSettlementV2.sol create mode 100644 contracts/v2/PredgeValidatorBondV2.sol create mode 100644 contracts/v2/access/PredgeAccess.sol create mode 100644 contracts/v2/access/PullCredit.sol create mode 100644 contracts/v2/interfaces/IAgentJobV2.sol create mode 100644 contracts/v2/mocks/MockRefundProtocolV2.sol create mode 160000 foundry-lib/forge-std create mode 100644 foundry-v2.toml create mode 100644 test/foundry-v2/AgentJobV2.t.sol create mode 100644 test/foundry-v2/Base.sol create mode 100644 test/foundry-v2/Invariants.t.sol create mode 100644 test/foundry-v2/Misc.t.sol create mode 100644 test/foundry-v2/Pocs.t.sol create mode 100644 test/foundry-v2/PredgeRefundArbiterV2.t.sol create mode 100644 test/foundry-v2/PredgeValidatorBondV2.t.sol diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..248c35a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "foundry-lib/forge-std"] + path = foundry-lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/contracts/v2/AgentJobV2.sol b/contracts/v2/AgentJobV2.sol new file mode 100644 index 0000000..45b5ae4 --- /dev/null +++ b/contracts/v2/AgentJobV2.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; +import {PullCredit} from "./access/PullCredit.sol"; + +/// @title AgentJobV2 +/// @notice A minimal ERC-8183-style agent job with escrowed native value and an INDEPENDENT +/// evaluator named at creation. In Predge's deployment the evaluator is the +/// `PredgeValidatorBondV2` contract, so the evaluator's verdict is backed by a bond and +/// moves the money in the same transaction. +/// +/// Lifecycle: +/// createJob (client escrows value, names provider, evaluator, spec and an expiry) +/// submit (provider, strictly before `expiredAt`) +/// complete / reject (evaluator, strictly before `submittedAt + evaluationWindow`) +/// claimRefund (anyone may trigger; the CLIENT is credited) when +/// - the job is still Open at or after `expiredAt`, or +/// - the job is Submitted and the evaluator stayed silent until +/// `submittedAt + evaluationWindow`. +/// +/// Timeout policy (documented choice): if the evaluator never decides, the CLIENT is +/// refunded, as ERC-8183 does on expiry. The alternative (pay the provider by default) +/// would let a silent or bribed evaluator pay out unverified work. The cost of this +/// choice is borne by the provider, who did the work; providers should only work for +/// evaluators they trust to answer inside the window. +/// +/// Money never leaves by push. Every payout, whether to provider or client, is credited +/// to `credit[account]` and pulled with `withdraw` / `withdrawTo`, so a reverting or +/// blocklisted recipient can neither force an outcome nor lock another party's funds. +/// +/// @dev Native value only; amounts are in the chain's native wei. There is no owner, no +/// admin, no upgrade path and no mutable parameter: `evaluationWindow` is immutable. +/// Deliverable hashing: `sha256`. When the provider supplies the output bytes, the hash is +/// computed on-chain with the sha256 precompile (so the stored hash provably belongs to +/// those bytes); otherwise the provider's claimed hash is stored as given. +contract AgentJobV2 is IAgentJobV2, PullCredit { + /// @notice Seconds the evaluator has to decide after a submission. Immutable, so it cannot + /// be changed under a job that is already running. + uint64 public immutable evaluationWindow; + + uint256 public jobCount; + /// @notice Sum of every job's escrow that has not been credited yet (Open or Submitted). + uint256 public totalEscrowed; + + mapping(uint256 => Job) private _jobs; + + event JobCreated( + uint256 indexed jobId, + address indexed client, + address indexed evaluator, + address provider, + uint256 escrow, + uint64 expiredAt, + bytes32 specHash + ); + event Submitted(uint256 indexed jobId, address indexed provider, bytes32 deliverable, bool hashedOnChain); + event Completed(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason); + event Rejected(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason); + event Refunded(uint256 indexed jobId, address indexed caller, address indexed recipient, uint256 amount); + + error ZeroAddress(); + error ZeroEscrow(); + error BadExpiry(); + error BadWindow(); + error NotProvider(); + error NotEvaluator(); + error BadState(); + error PastDeadline(); + error NotExpired(); + error HashMismatch(); + error ZeroDeliverable(); + + /// @param evaluationWindow_ seconds the evaluator has after a submission; must be non-zero. + constructor(uint64 evaluationWindow_) { + if (evaluationWindow_ == 0) revert BadWindow(); + evaluationWindow = evaluationWindow_; + } + + /// @notice Client opens a job, escrows `msg.value`, and names the provider, the independent + /// evaluator, the acceptance-test commitment and the expiry. + /// @param provider the worker; paid on `complete` + /// @param evaluator the only address that can complete or reject + /// @param specHash the acceptance test commitment (the Predge `requestHash`) + /// @param expiredAt unix time; the provider must submit strictly before it, after it the + /// client can reclaim the escrow of a job that was never submitted + function createJob(address provider, address evaluator, bytes32 specHash, uint64 expiredAt) + external + payable + returns (uint256 jobId) + { + if (provider == address(0) || evaluator == address(0)) revert ZeroAddress(); + if (msg.value == 0) revert ZeroEscrow(); + if (expiredAt <= block.timestamp) revert BadExpiry(); + jobId = ++jobCount; + totalEscrowed += msg.value; + Job storage j = _jobs[jobId]; + j.client = msg.sender; + j.provider = provider; + j.evaluator = evaluator; + j.escrow = msg.value; + j.expiredAt = expiredAt; + j.state = State.Open; + j.specHash = specHash; + emit JobCreated(jobId, msg.sender, evaluator, provider, msg.value, expiredAt, specHash); + } + + /// @notice Provider submits the deliverable, strictly before `expiredAt`. + /// @param deliverable sha256 of the output. Must be non-zero. + /// @param output optional output bytes. If non-empty, `sha256(output)` must equal + /// `deliverable` (reverts `HashMismatch` otherwise) and the job records that + /// the hash was verified on-chain. If empty, the claimed hash is stored. + function submit(uint256 jobId, bytes32 deliverable, bytes calldata output) external { + Job storage j = _jobs[jobId]; + if (j.state != State.Open) revert BadState(); + if (msg.sender != j.provider) revert NotProvider(); + if (block.timestamp >= j.expiredAt) revert PastDeadline(); + if (deliverable == bytes32(0)) revert ZeroDeliverable(); + bool onChain = output.length != 0; + if (onChain && sha256(output) != deliverable) revert HashMismatch(); + j.deliverable = deliverable; + j.hashedOnChain = onChain; + j.submittedAt = uint64(block.timestamp); + j.state = State.Submitted; + emit Submitted(jobId, msg.sender, deliverable, onChain); + } + + /// @notice ERC-8183 `complete`. Evaluator only, while the job is Submitted and the + /// evaluation window is still running. Credits the provider. + /// @param reason the evaluator's attestation commitment (Predge: keccak256 of the signed verdict) + function complete(uint256 jobId, bytes32 reason, bytes calldata) external { + Job storage j = _decide(jobId, reason); + j.state = State.Completed; + uint256 amt = _release(j, j.provider); + emit Completed(jobId, msg.sender, j.provider, amt, reason); + } + + /// @notice ERC-8183 `reject`. Evaluator only, same timing as `complete`. Credits the client. + function reject(uint256 jobId, bytes32 reason, bytes calldata) external { + Job storage j = _decide(jobId, reason); + j.state = State.Rejected; + uint256 amt = _release(j, j.client); + emit Rejected(jobId, msg.sender, j.client, amt, reason); + } + + /// @notice Refund the client of a job that ran out of time. Callable by anyone (the credit + /// always goes to the client): see the contract notes for the two cases. + function claimRefund(uint256 jobId) external { + Job storage j = _jobs[jobId]; + State s = j.state; + if (s == State.Open) { + if (block.timestamp < j.expiredAt) revert NotExpired(); + } else if (s == State.Submitted) { + if (block.timestamp < uint256(j.submittedAt) + evaluationWindow) revert NotExpired(); + } else { + revert BadState(); + } + j.state = State.Expired; + uint256 amt = _release(j, j.client); + emit Refunded(jobId, msg.sender, j.client, amt); + } + + /// @inheritdoc IAgentJobV2 + function getJob(uint256 jobId) external view returns (Job memory) { + return _jobs[jobId]; + } + + function _decide(uint256 jobId, bytes32 reason) private returns (Job storage j) { + j = _jobs[jobId]; + if (msg.sender != j.evaluator) revert NotEvaluator(); + if (j.state != State.Submitted) revert BadState(); + if (block.timestamp >= uint256(j.submittedAt) + evaluationWindow) revert PastDeadline(); + j.reason = reason; + } + + function _release(Job storage j, address to) private returns (uint256 amt) { + amt = j.escrow; + j.escrow = 0; + totalEscrowed -= amt; + _addCredit(to, amt); + } +} diff --git a/contracts/v2/PredgeAgentValidatorV2.sol b/contracts/v2/PredgeAgentValidatorV2.sol new file mode 100644 index 0000000..7bcb7ee --- /dev/null +++ b/contracts/v2/PredgeAgentValidatorV2.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Operated} from "./access/PredgeAccess.sol"; + +/// @notice The one ERC-721 call the registry needs from an ERC-8004 Identity Registry. +interface IIdentityOwner { + function ownerOf(uint256 agentId) external view returns (address); +} + +/// @title PredgeAgentValidatorV2 +/// @notice An ERC-8004 Validation Registry shaped log of validation requests and verdicts, with +/// one property enforced by the chain: a request is timestamped BEFORE its response, and a +/// response is written once and never changes. +/// +/// What this contract does NOT do, so nobody reads more into it than is there: +/// * It does not verify the ed25519 attestation. The operator verifies it off-chain and +/// commits `responseHash`, the keccak256 of the signed bytes. +/// * It is not linked to `PredgeValidatorBondV2`. A registry response is a statement; the +/// bonded, slashable verdict is the bond's `Scored` event and the job it settled. +/// Consumers that want the bonded one must read the bond. +/// +/// Authorisation (fixes the v1 permissionless `validationRequest`): a request may be made +/// by the operator, or, when an Identity Registry address was set at construction, by the +/// current owner of the agent NFT. With no identity registry only the operator may +/// request. +/// +/// Domain separation: the caller supplies an acceptance-test DIGEST and the registry +/// derives `requestHash = keccak256(abi.encode(block.chainid, address(this), digest))`. +/// The same digest therefore yields a different request on every chain and registry, so a +/// request or verdict cannot be replayed across deployments. This is the one deviation from +/// the ERC-8004 reference signature: the fourth argument is the digest, and the derived +/// hash is returned and emitted in the canonical event position. Use `requestHashFor` to +/// compute it off-chain, and use that value as the `AgentJobV2.specHash`. +/// +/// @dev Roles: `owner` (Safe) rotates the operator; `operator` (hot key) answers requests. +/// Rotating the operator never rewrites a past verdict. +contract PredgeAgentValidatorV2 is Operated { + struct Validation { + address validator; // the operator that was current when the request was made + uint256 agentId; + bytes32 responseHash; + uint64 requestedAt; + uint64 respondedAt; // 0 while pending + uint8 response; // 0-100 + bool hasResponse; + string tag; + } + + /// @notice Optional ERC-8004 Identity Registry (the agent NFT). Zero = only the operator requests. + address public immutable identityRegistry; + + uint64 public requestCount; + uint64 public responseCount; + + /// @dev `getSummary` is O(n^2) in the number of hashes (it dedupes); cap the input. + uint256 public constant MAX_SUMMARY = 100; + + mapping(bytes32 => Validation) private _v; + mapping(bytes32 => string) public requestURIOf; + mapping(bytes32 => string) public responseURIOf; + + event ValidationRequest(address indexed validatorAddress, uint256 indexed agentId, string requestURI, bytes32 indexed requestHash); + event ValidationResponse( + address indexed validatorAddress, + uint256 indexed agentId, + bytes32 indexed requestHash, + uint8 response, + string responseURI, + bytes32 responseHash, + string tag + ); + /// @notice Extra context the canonical events lack: who asked, and the digest behind the hash. + event RequestRecorded(bytes32 indexed requestHash, bytes32 digest, address indexed requester); + /// @notice Extra context the canonical event lacks: who answered, and when. + event ResponseRecorded(bytes32 indexed requestHash, address indexed responder, uint64 respondedAt); + + error NotAuthorized(); + error WrongValidator(); + error ZeroHash(); + error AlreadyRequested(); + error NotRequested(); + error AlreadyResponded(); + error BadScore(); + error TooManyHashes(); + + /// @param owner_ the Safe + /// @param operator_ the hot key that answers requests + /// @param identityRegistry_ optional ERC-8004 Identity Registry; zero disables agent-owner requests + constructor(address owner_, address operator_, address identityRegistry_) Operated(owner_, operator_) { + identityRegistry = identityRegistry_; + } + + /// @notice The request hash this registry derives for an acceptance-test digest. + function requestHashFor(bytes32 digest) public view returns (bytes32) { + return keccak256(abi.encode(block.chainid, address(this), digest)); + } + + /// @notice Record a validation request before the outcome is knowable. + /// @param validatorAddress must equal the current `operator` (kept for ERC-8004 compatibility) + /// @param agentId the agent under validation (the NFT id when an identity registry is set) + /// @param requestURI pointer to the signed acceptance test + /// @param digest keccak256 of the signed acceptance-test bytes; must be non-zero + /// @return requestHash the domain-separated hash under which the request is stored + function validationRequest(address validatorAddress, uint256 agentId, string calldata requestURI, bytes32 digest) + external + returns (bytes32 requestHash) + { + if (digest == bytes32(0)) revert ZeroHash(); + if (msg.sender != operator && !_isAgentOwner(agentId)) revert NotAuthorized(); + if (validatorAddress != operator) revert WrongValidator(); + requestHash = requestHashFor(digest); + Validation storage v = _v[requestHash]; + if (v.requestedAt != 0) revert AlreadyRequested(); + + v.validator = validatorAddress; + v.agentId = agentId; + v.requestedAt = uint64(block.timestamp); + requestURIOf[requestHash] = requestURI; + requestCount += 1; + + emit ValidationRequest(validatorAddress, agentId, requestURI, requestHash); + emit RequestRecorded(requestHash, digest, msg.sender); + } + + /// @notice Record the verdict, once, for a request that exists. Operator only. + /// @param response 0-100 (Predge: 100 delivered, 0 failed, 50 void) + function validationResponse( + bytes32 requestHash, + uint8 response, + string calldata responseURI, + bytes32 responseHash, + string calldata tag + ) external onlyOperator { + if (response > 100) revert BadScore(); + if (responseHash == bytes32(0)) revert ZeroHash(); + Validation storage v = _v[requestHash]; + if (v.requestedAt == 0) revert NotRequested(); + if (v.hasResponse) revert AlreadyResponded(); + + v.response = response; + v.responseHash = responseHash; + v.respondedAt = uint64(block.timestamp); + v.hasResponse = true; + v.tag = tag; + responseURIOf[requestHash] = responseURI; + responseCount += 1; + + emit ValidationResponse(v.validator, v.agentId, requestHash, response, responseURI, responseHash, tag); + emit ResponseRecorded(requestHash, msg.sender, uint64(block.timestamp)); + } + + /// @notice ERC-8004 `getValidationStatus`. + function getValidationStatus(bytes32 requestHash) + external + view + returns (address validatorAddress, uint256 agentId, uint8 response, bytes32 responseHash, string memory tag, uint256 lastUpdate) + { + Validation storage v = _v[requestHash]; + return (v.validator, v.agentId, v.response, v.responseHash, v.tag, v.respondedAt != 0 ? v.respondedAt : v.requestedAt); + } + + /// @notice True once a verdict is permanently recorded. + function isValidated(bytes32 requestHash) external view returns (bool) { + return _v[requestHash].hasResponse; + } + + /// @notice Seconds the request stayed committed before its verdict; 0 while pending. + function commitLeadTime(bytes32 requestHash) external view returns (uint64) { + Validation storage v = _v[requestHash]; + if (!v.hasResponse) return 0; + return v.respondedAt - v.requestedAt; + } + + /// @notice ERC-8004 `getSummary` for one agent over the supplied hashes. A hash that appears + /// more than once is counted once. + function getSummary(uint256 agentId, bytes32[] calldata requestHashes) + external + view + returns (uint64 count, uint8 averageResponse) + { + uint256 n = requestHashes.length; + if (n > MAX_SUMMARY) revert TooManyHashes(); + uint256 sum; + for (uint256 i = 0; i < n; i++) { + bool seen; + for (uint256 k = 0; k < i; k++) { + if (requestHashes[k] == requestHashes[i]) { + seen = true; + break; + } + } + if (seen) continue; + Validation storage v = _v[requestHashes[i]]; + if (v.hasResponse && v.agentId == agentId) { + sum += v.response; + count += 1; + } + } + averageResponse = count == 0 ? 0 : uint8(sum / count); + } + + function _isAgentOwner(uint256 agentId) private view returns (bool) { + if (identityRegistry == address(0)) return false; + try IIdentityOwner(identityRegistry).ownerOf(agentId) returns (address o) { + return o == msg.sender; + } catch { + return false; + } + } +} diff --git a/contracts/v2/PredgeRefundArbiterV2.sol b/contracts/v2/PredgeRefundArbiterV2.sol new file mode 100644 index 0000000..163db87 --- /dev/null +++ b/contracts/v2/PredgeRefundArbiterV2.sol @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Operated} from "./access/PredgeAccess.sol"; +import {PullCredit} from "./access/PullCredit.sol"; + +/// @notice What the arbiter needs from a Refund Protocol. `paymentInfo` is the parties-and-amount +/// view the arbiter binds evidence and bond size to. Circle's production contract is not +/// known to expose exactly this function: against it, a thin read-only adapter that +/// implements `paymentInfo` and forwards the two write calls is required, and must be +/// reviewed before use. On our chains only `MockRefundProtocolV2` exists. +interface IRefundProtocolV2 { + /// @return payer the party refunded by `refundByArbiter` + /// @return payee the recipient of the payment + /// @return amount the payment amount, in the same native wei the bond is sized in + /// @return releaseTimestamp when the payee may withdraw; a refund is impossible after it + function paymentInfo(uint256 paymentID) + external + view + returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp); + function refundByArbiter(uint256 paymentID) external; + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external; +} + +/// @title PredgeRefundArbiterV2 +/// @notice A bonded, OPTIMISTIC arbiter for a Refund Protocol. The operator rules; nothing moves +/// until a challenge window has passed; the harmed party can freeze the ruling and escalate +/// it to the owner (a Safe); and a ruling the owner overturns costs the operator its bond. +/// +/// Flow, per payment: +/// 1. `fileEvidence`: the PAYER or the PAYEE (read from the Refund Protocol, not +/// chosen by the caller) files the hash of its own evidence. Write-once per party, and +/// only before a ruling exists. +/// 2. `rule` (operator): cites one party's filed evidence, picks Refund or Release, and +/// posts a bond of at least `max(minBond, ceil(amount * bondBps / 10000))`. The +/// operator may not itself be a party. Nothing executes yet. +/// 3. Within `window` seconds of the ruling, the party the ruling HARMS (the payee on a +/// Refund, the payer on a Release) may call `dispute` with counter-evidence. That +/// freezes the ruling and starts the owner's `RESOLUTION_TIMEOUT`. +/// 4a. Undisputed: after the window anyone calls `finalize`. The bond is credited back to +/// the staker and, for a Refund, `refundByArbiter` is called on the Refund Protocol. +/// 4b. Disputed: the owner calls `resolve(paymentID, finalDirection)`. If it equals the +/// ruling, the ruling is upheld exactly as in 4a. If not, the ruling is overturned: +/// the bond is credited to the harmed party (the disputer), and the owner's +/// direction is executed (a Refund is sent to the Refund Protocol). +/// 4c. If neither happens before `expiresAt` (owner silent, or the Refund Protocol keeps +/// reverting), anyone calls `expire`: the ruling is voided, NOTHING is executed, and +/// the bond returns to the staker. +/// +/// Trust, stated plainly: for disputed rulings the owner Safe is the court of last +/// resort. An operator that rules wrongly is only punished if the harmed party notices and +/// disputes inside the window, and the owner then agrees. The window must close before the +/// Refund Protocol's lockup ends, which `rule` enforces for a Refund. +/// +/// @dev Native value only, in the chain's native wei. The contract holds `totalBonded + +/// totalCredit` and never user payments. Parameters are bounded and not retroactive: the +/// window is snapshotted into each ruling, and the bond is fixed when posted. +contract PredgeRefundArbiterV2 is Operated, PullCredit { + enum Direction { + None, + Refund, + Release + } + + enum Status { + None, + Ruled, // window running, or elapsed and not yet finalized + Disputed, // frozen, waiting for the owner + Finalized, // upheld; bond returned; refund executed if Refund (final) + Overturned, // owner disagreed; bond paid to the harmed party (final) + Voided // expired without a decision; nothing executed; bond returned (final) + } + + struct Ruling { + address staker; // the operator key that ruled; the only address its bond is ever returned to + Status status; + Direction direction; + uint64 ruledAt; + uint64 window; // snapshot of `challengeWindow` + uint64 expiresAt; // after this, `expire` voids the ruling + address evidenceFrom; + address payer; + address payee; + uint256 bond; + bytes32 evidenceHash; + bytes32 counterEvidence; + } + + uint64 public constant MIN_WINDOW = 1 hours; + uint64 public constant MAX_WINDOW = 30 days; + uint64 public constant RESOLUTION_TIMEOUT = 14 days; + uint16 public constant MAX_BPS = 10_000; + + IRefundProtocolV2 public immutable refundProtocol; + + uint64 public challengeWindow; + uint256 public minBond; + uint16 public bondBps; + + uint256 public totalBonded; + uint64 public rulingCount; + uint64 public overturnCount; + + mapping(uint256 => Ruling) private _rulings; + /// @notice paymentID => party => hash of the evidence that party filed. Write-once. + mapping(uint256 => mapping(address => bytes32)) public filedEvidence; + + event EvidenceFiled(uint256 indexed paymentID, address indexed party, bytes32 evidenceHash); + event Ruled( + uint256 indexed paymentID, + address indexed staker, + address indexed evidenceFrom, + bytes32 evidenceHash, + Direction direction, + uint256 bond, + uint64 windowEnd + ); + event Disputed(uint256 indexed paymentID, address indexed disputer, bytes32 counterEvidence, uint64 expiresAt); + event Finalized(uint256 indexed paymentID, address indexed staker, Direction direction, uint256 bond); + event Overturned(uint256 indexed paymentID, address indexed harmed, Direction finalDirection, uint256 bond); + event Voided(uint256 indexed paymentID, address indexed staker, uint256 bond); + event ParamsUpdated(uint64 challengeWindow, uint256 minBond, uint16 bondBps); + + error ZeroHash(); + error BadParams(); + error BadDirection(); + error UnknownPayment(); + error NotAParty(); + error OperatorIsParty(); + error EvidenceClosed(); + error EvidenceAlreadyFiled(uint256 paymentID, address party); + error NoEvidenceFiled(uint256 paymentID, address party); + error AlreadyRuled(uint256 paymentID); + error BondTooSmall(uint256 required); + error LockupTooShort(); + error BadStatus(); + error WindowOpen(); + error WindowClosed(); + error NotHarmedParty(); + error NotExpired(); + error ResolutionExpired(); + + /// @param owner_ the Safe: resolves disputes, sets bounded parameters, rotates the operator + /// @param operator_ the hot key that rules + /// @param refundProtocol_ the Refund Protocol (or adapter) this arbiter drives; immutable + /// @param challengeWindow_ seconds a ruling stays disputable, within [MIN_WINDOW, MAX_WINDOW] + /// @param minBond_ absolute bond floor in native wei, non-zero + /// @param bondBps_ bond as basis points of the payment amount, at most 10000 + constructor(address owner_, address operator_, address refundProtocol_, uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) + Operated(owner_, operator_) + { + if (refundProtocol_ == address(0)) revert ZeroAddress(); + refundProtocol = IRefundProtocolV2(refundProtocol_); + _setParams(challengeWindow_, minBond_, bondBps_); + } + + /// @notice Owner-only, bounded, not retroactive: existing rulings keep their window and bond. + function setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner { + _setParams(challengeWindow_, minBond_, bondBps_); + } + + /// @notice Pass a recipient lockup change through to the Refund Protocol (owner only). + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyOwner { + refundProtocol.setLockupSeconds(recipient, recipientLockupSeconds); + } + + /// @notice The bond a ruling on a payment of `amount` must post right now. + function requiredBond(uint256 amount) public view returns (uint256) { + uint256 scaled = (amount * bondBps + MAX_BPS - 1) / MAX_BPS; + return scaled > minBond ? scaled : minBond; + } + + /// @notice A party to the payment files the hash of its own evidence. Write-once, and only + /// before the ruling. The caller must be the payer or the payee per the Refund Protocol. + function fileEvidence(uint256 paymentID, bytes32 evidenceHash) external { + if (evidenceHash == bytes32(0)) revert ZeroHash(); + if (_rulings[paymentID].status != Status.None) revert EvidenceClosed(); + (address payer, address payee,,) = refundProtocol.paymentInfo(paymentID); + if (msg.sender != payer && msg.sender != payee) revert NotAParty(); + if (filedEvidence[paymentID][msg.sender] != bytes32(0)) revert EvidenceAlreadyFiled(paymentID, msg.sender); + filedEvidence[paymentID][msg.sender] = evidenceHash; + emit EvidenceFiled(paymentID, msg.sender, evidenceHash); + } + + /// @notice Rule on a payment: cite a party's filed evidence, choose a direction, post the bond. + /// Nothing executes until `finalize` or `resolve`. + /// @param evidenceFrom the payer or payee whose filed evidence the ruling rests on + function rule(uint256 paymentID, address evidenceFrom, bytes32 evidenceHash, Direction direction) + external + payable + onlyOperator + { + if (evidenceHash == bytes32(0)) revert ZeroHash(); + if (direction == Direction.None) revert BadDirection(); + Ruling storage r = _rulings[paymentID]; + if (r.status != Status.None) revert AlreadyRuled(paymentID); + + (address payer, address payee, uint256 amount, uint256 releaseTimestamp) = refundProtocol.paymentInfo(paymentID); + if (payee == address(0)) revert UnknownPayment(); + if (msg.sender == payer || msg.sender == payee) revert OperatorIsParty(); + if (evidenceFrom != payer && evidenceFrom != payee) revert NotAParty(); + if (filedEvidence[paymentID][evidenceFrom] != evidenceHash) revert NoEvidenceFiled(paymentID, evidenceFrom); + uint256 required = requiredBond(amount); + if (msg.value < required) revert BondTooSmall(required); + uint64 window = challengeWindow; + // A refund must still be possible when the window closes, with room for finalize. + if (direction == Direction.Refund && releaseTimestamp <= block.timestamp + window) revert LockupTooShort(); + + r.staker = msg.sender; + r.status = Status.Ruled; + r.direction = direction; + r.ruledAt = uint64(block.timestamp); + r.window = window; + r.expiresAt = uint64(block.timestamp) + window + RESOLUTION_TIMEOUT; + r.evidenceFrom = evidenceFrom; + r.payer = payer; + r.payee = payee; + r.bond = msg.value; + r.evidenceHash = evidenceHash; + totalBonded += msg.value; + rulingCount += 1; + emit Ruled(paymentID, msg.sender, evidenceFrom, evidenceHash, direction, msg.value, uint64(block.timestamp) + window); + } + + /// @notice The harmed party (payee on a Refund, payer on a Release) files counter-evidence + /// inside the window. This freezes the ruling and hands it to the owner. + function dispute(uint256 paymentID, bytes32 counterEvidence) external { + if (counterEvidence == bytes32(0)) revert ZeroHash(); + Ruling storage r = _rulings[paymentID]; + if (r.status != Status.Ruled) revert BadStatus(); + if (block.timestamp >= uint256(r.ruledAt) + r.window) revert WindowClosed(); + if (msg.sender != _harmed(r)) revert NotHarmedParty(); + r.status = Status.Disputed; + r.counterEvidence = counterEvidence; + r.expiresAt = uint64(block.timestamp) + RESOLUTION_TIMEOUT; + emit Disputed(paymentID, msg.sender, counterEvidence, r.expiresAt); + } + + /// @notice After an undisputed window, return the bond to the staker and execute a Refund. + /// Anyone may call. If the Refund Protocol reverts, this reverts too and the ruling + /// can only be voided via `expire`. + function finalize(uint256 paymentID) external { + Ruling storage r = _rulings[paymentID]; + if (r.status != Status.Ruled) revert BadStatus(); + if (block.timestamp < uint256(r.ruledAt) + r.window) revert WindowOpen(); + _uphold(paymentID, r); + } + + /// @notice Owner decides a disputed ruling. `finalDirection` equal to the ruling upholds it; + /// any other direction overturns it and slashes the bond to the harmed party. The + /// final direction is executed either way (a Refund calls the Refund Protocol). + function resolve(uint256 paymentID, Direction finalDirection) external onlyOwner { + if (finalDirection == Direction.None) revert BadDirection(); + Ruling storage r = _rulings[paymentID]; + if (r.status != Status.Disputed) revert BadStatus(); + if (block.timestamp >= r.expiresAt) revert ResolutionExpired(); + if (finalDirection == r.direction) { + _uphold(paymentID, r); + return; + } + address harmed = _harmed(r); + uint256 bond = r.bond; + r.status = Status.Overturned; + r.bond = 0; + totalBonded -= bond; + overturnCount += 1; + _addCredit(harmed, bond); + emit Overturned(paymentID, harmed, finalDirection, bond); + if (finalDirection == Direction.Refund) refundProtocol.refundByArbiter(paymentID); + } + + /// @notice Void a ruling nobody decided in time. Nothing is executed; the bond returns to the + /// staker. Anyone may call once `expiresAt` has passed. + function expire(uint256 paymentID) external { + Ruling storage r = _rulings[paymentID]; + if (r.status != Status.Ruled && r.status != Status.Disputed) revert BadStatus(); + if (block.timestamp < r.expiresAt) revert NotExpired(); + uint256 bond = r.bond; + r.status = Status.Voided; + r.bond = 0; + totalBonded -= bond; + _addCredit(r.staker, bond); + emit Voided(paymentID, r.staker, bond); + } + + /// @notice The full ruling record for a payment (all-zero if none). + function rulings(uint256 paymentID) external view returns (Ruling memory) { + return _rulings[paymentID]; + } + + /// @notice The party the ruling in `paymentID` would harm, or zero if there is no ruling. + function harmedParty(uint256 paymentID) external view returns (address) { + Ruling storage r = _rulings[paymentID]; + return r.status == Status.None ? address(0) : _harmed(r); + } + + function _uphold(uint256 paymentID, Ruling storage r) private { + uint256 bond = r.bond; + r.status = Status.Finalized; + r.bond = 0; + totalBonded -= bond; + _addCredit(r.staker, bond); + emit Finalized(paymentID, r.staker, r.direction, bond); + if (r.direction == Direction.Refund) refundProtocol.refundByArbiter(paymentID); + } + + function _harmed(Ruling storage r) private view returns (address) { + return r.direction == Direction.Refund ? r.payee : r.payer; + } + + function _setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) private { + if (challengeWindow_ < MIN_WINDOW || challengeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS) + revert BadParams(); + challengeWindow = challengeWindow_; + minBond = minBond_; + bondBps = bondBps_; + emit ParamsUpdated(challengeWindow_, minBond_, bondBps_); + } +} diff --git a/contracts/v2/PredgeSettlementV2.sol b/contracts/v2/PredgeSettlementV2.sol new file mode 100644 index 0000000..3382372 --- /dev/null +++ b/contracts/v2/PredgeSettlementV2.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Owned2Step} from "./access/PredgeAccess.sol"; + +/// @title PredgeSettlementV2 +/// @notice Receipt layer for Predge's pay-per-call data API: an agent pays for a route in the +/// chain's native token and this contract emits a `Paid` receipt. +/// @dev Amounts are in native wei of the chain (on Arc the native token is USDC with 18 +/// decimals at the protocol level; the ERC-20 interface of Arc's USDC uses 6, so never +/// mix the two units). The owner (a Safe) withdraws accumulated payments to a non-zero +/// address; there is no other admin power and no upgrade path. Forced value (selfdestruct +/// or coinbase) is included in `withdraw`, so it cannot be stranded. +contract PredgeSettlementV2 is Owned2Step { + /// @param payer who paid + /// @param route keccak256 of the route id + /// @param amount native wei paid + /// @param timestamp block time + /// @param meta optional request reference + event Paid(address indexed payer, bytes32 indexed route, uint256 amount, uint256 timestamp, string meta); + event Withdrawn(address indexed to, uint256 amount); + + error ZeroPayment(); + error WithdrawFailed(); + + /// @param owner_ the Safe that may withdraw + constructor(address owner_) Owned2Step(owner_) {} + + /// @notice Pay for a route; emits a `Paid` receipt. + function payForRoute(bytes32 route, string calldata meta) external payable { + if (msg.value == 0) revert ZeroPayment(); + emit Paid(msg.sender, route, msg.value, block.timestamp, meta); + } + + /// @notice Owner withdraws the whole balance to `to`. Reverts for the zero address. + function withdraw(address payable to) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + uint256 bal = address(this).balance; + emit Withdrawn(to, bal); + (bool ok,) = to.call{value: bal}(""); + if (!ok) revert WithdrawFailed(); + } + + /// @notice A bare transfer still produces a receipt, under the route `bytes32("direct")`. + receive() external payable { + if (msg.value == 0) revert ZeroPayment(); + emit Paid(msg.sender, bytes32("direct"), msg.value, block.timestamp, ""); + } +} diff --git a/contracts/v2/PredgeValidatorBondV2.sol b/contracts/v2/PredgeValidatorBondV2.sol new file mode 100644 index 0000000..6abae2a --- /dev/null +++ b/contracts/v2/PredgeValidatorBondV2.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Operated} from "./access/PredgeAccess.sol"; +import {PullCredit} from "./access/PullCredit.sol"; +import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; + +/// @title PredgeValidatorBondV2 +/// @notice Capital behind a verdict, and the EVALUATOR of an `AgentJobV2`. The job names this +/// contract as its evaluator, so the verdict recorded here is the thing that moves the +/// escrow, and the same transaction puts the operator's bond at risk. +/// +/// The acceptance test is deterministic: the provider's deliverable hash must equal the +/// `expected` hash the operator committed to. That makes a false verdict provable +/// on-chain with no judge, by comparing two hashes written by two different parties. +/// +/// Flow: +/// 1. `stakeAndCommit(requestHash, commitment, jobId)` (operator). Allowed ONLY while +/// the job is Open, i.e. before any deliverable exists. `commitment` is +/// `keccak256(abi.encode(expected, salt))`: `expected` stays hidden, so a provider +/// cannot read it and submit it, and the operator cannot choose it after seeing the +/// deliverable. The bond must be at least `max(minBond, bondBps * escrow / 10000)`. +/// 2. The provider submits to the job. +/// 3. `recordScore(requestHash, score, expected, salt, reason)` (operator). Requires the +/// job to be Submitted and the reveal to match the commitment. It then calls +/// `complete` (score 100) or `reject` (score 0) on the job, so the verdict and the +/// money move together. The dispute window starts at `scoredAt`, which is always at +/// or after the job's `submittedAt` because scoring requires a submission. +/// 4. `challenge(requestHash)` (anyone) while the window is open. Slashes iff the +/// verdict contradicts the deliverable: 100 but deliverable != expected, or 0 but +/// deliverable == expected. The whole bond is CREDITED TO THE HARMED PARTY (the +/// job's client on a false pass, its provider on a false fail), never to the caller, +/// so a self-challenge pays the operator nothing and a front-runner earns nothing. +/// 5. `reclaim(requestHash)` (anyone) once the window has closed unchallenged. The bond is +/// credited to the staker recorded at stake time. +/// 6. `release(requestHash)` (anyone) if the job expired with no verdict. No verdict was +/// given, so nothing was lied about, and the bond goes back to the staker. +/// +/// Honest limits, stated so they are not mistaken for guarantees: +/// * The bond only covers `bondBps` of the escrow. At `bondBps = 10000` a colluding +/// operator and provider cannot profit from a false pass; below that they can, +/// up to the uncovered fraction. +/// * A false verdict is only slashable if the harmed party (or someone for them) calls +/// `challenge` inside the window. Nobody is paid to watch except the harmed party. +/// * Only the deterministic `sha256(deliverable) == expected` test is covered. Subjective +/// or "void" outcomes are not bonded. +/// +/// @dev Native value only, in the chain's native wei. Roles: `owner` (Safe) sets bounded +/// parameters and rotates `operator`; `operator` (hot key) stakes and scores. Parameters +/// are not retroactive: the dispute window is snapshotted into each stake and the bond +/// amount is fixed when staked. Payouts are pull-based. The contract holds, at all times, +/// at least `totalBonded + totalCredit`. +contract PredgeValidatorBondV2 is Operated, PullCredit { + enum Status { + None, + Committed, // bond posted, no verdict yet + Scored, // verdict recorded, window running or elapsed, bond still held + Slashed, // bond credited to the harmed party (final) + Reclaimed, // bond credited back to the staker after the window (final) + Released // job expired with no verdict, bond credited back to the staker (final) + } + + struct Stake { + address staker; // the operator key that posted the bond; the only party it is ever paid back to + Status status; + uint8 score; // 100 delivered / 0 failed, set when scored + uint64 stakedAt; + uint64 scoredAt; // set when scored; always >= the job's submittedAt + uint64 window; // snapshot of `disputeWindow` at stake time + uint256 jobId; + uint256 bond; + bytes32 commitment; // keccak256(abi.encode(expected, salt)) + bytes32 expected; // zero until revealed at recordScore + } + + uint64 public constant MIN_WINDOW = 1 hours; + uint64 public constant MAX_WINDOW = 30 days; + uint16 public constant MAX_BPS = 10_000; + + IAgentJobV2 public immutable job; + + /// @notice Seconds a verdict stays challengeable. Applies to stakes made after a change. + uint64 public disputeWindow; + /// @notice Absolute floor for a bond, in native wei. Applies to stakes made after a change. + uint256 public minBond; + /// @notice Bond as basis points of the job escrow (10000 = 100%). Applies to future stakes. + uint16 public bondBps; + + /// @notice Sum of bonds currently held (Committed or Scored). + uint256 public totalBonded; + uint64 public slashCount; + + mapping(bytes32 => Stake) public stakes; + + event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond, uint64 window); + event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, uint64 scoredAt, uint64 windowEnd); + event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered); + event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps); + + error ZeroHash(); + error BadParams(); + error AlreadyCommitted(); + error NotCommitted(); + error NotScored(); + error BadStatus(); + error NotOpen(); + error NotSubmitted(); + error JobNotBound(); + error ProviderIsOperator(); + error BondTooSmall(uint256 required); + error BadScore(); + error BadReveal(); + error VerdictHonest(); + error WindowOpen(); + error WindowClosed(); + error JobNotExpired(); + + /// @param owner_ the Safe: sets bounded parameters, rotates the operator + /// @param operator_ the hot key that stakes and scores + /// @param job_ the `AgentJobV2` this bond evaluates; immutable + /// @param disputeWindow_ seconds a verdict stays challengeable, within [MIN_WINDOW, MAX_WINDOW] + /// @param minBond_ absolute floor for a bond, non-zero + /// @param bondBps_ bond as basis points of escrow, at most 10000 + constructor(address owner_, address operator_, address job_, uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) + Operated(owner_, operator_) + { + if (job_ == address(0)) revert ZeroAddress(); + job = IAgentJobV2(job_); + _setParams(disputeWindow_, minBond_, bondBps_); + } + + /// @notice Owner-only parameter change, bounded and NOT retroactive: existing stakes keep + /// the window and bond they were made with. + function setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner { + _setParams(disputeWindow_, minBond_, bondBps_); + } + + /// @notice The bond a stake against `escrow` must post right now: `max(minBond, ceil(escrow * bondBps / 10000))`. + function requiredBond(uint256 escrow) public view returns (uint256) { + uint256 scaled = (escrow * bondBps + MAX_BPS - 1) / MAX_BPS; + return scaled > minBond ? scaled : minBond; + } + + /// @notice Post the bond and commit the hidden acceptance test, while the job is still Open. + /// @param requestHash the job's `specHash` (the domain-separated Predge request hash) + /// @param commitment `keccak256(abi.encode(expected, salt))`; use a fresh random 32-byte salt + /// @param jobId the job this verdict will settle; this contract must be its evaluator + function stakeAndCommit(bytes32 requestHash, bytes32 commitment, uint256 jobId) external payable onlyOperator { + if (requestHash == bytes32(0) || commitment == bytes32(0)) revert ZeroHash(); + Stake storage s = stakes[requestHash]; + if (s.status != Status.None) revert AlreadyCommitted(); + + IAgentJobV2.Job memory j = job.getJob(jobId); + // Only while Open and unexpired: once a deliverable exists, a commitment could be made to match it. + if (j.state != IAgentJobV2.State.Open || block.timestamp >= j.expiredAt) revert NotOpen(); + if (j.specHash != requestHash || j.evaluator != address(this)) revert JobNotBound(); + // A provider that is also the operator writes both sides of the comparison. + if (j.provider == msg.sender) revert ProviderIsOperator(); + uint256 required = requiredBond(j.escrow); + if (msg.value < required) revert BondTooSmall(required); + + s.staker = msg.sender; + s.status = Status.Committed; + s.stakedAt = uint64(block.timestamp); + s.window = disputeWindow; + s.jobId = jobId; + s.bond = msg.value; + s.commitment = commitment; + totalBonded += msg.value; + emit Committed(requestHash, jobId, msg.sender, commitment, msg.value, s.window); + } + + /// @notice Reveal the committed test, record the verdict, and settle the job with it. + /// @param score 100 = delivered (job `complete`s, provider is credited), 0 = failed + /// (job `reject`s, client is credited) + /// @param expected,salt the preimage of the commitment + /// @param reason the attestation commitment passed to the job (Predge: keccak256 of the signed verdict) + function recordScore(bytes32 requestHash, uint8 score, bytes32 expected, bytes32 salt, bytes32 reason) + external + onlyOperator + { + if (score != 0 && score != 100) revert BadScore(); + Stake storage s = stakes[requestHash]; + if (s.status == Status.None) revert NotCommitted(); + if (s.status != Status.Committed) revert BadStatus(); + if (keccak256(abi.encode(expected, salt)) != s.commitment) revert BadReveal(); + // Scoring needs a submission, so `scoredAt >= submittedAt` and the dispute window can never + // start before the deliverable it is judged against exists. + if (job.getJob(s.jobId).state != IAgentJobV2.State.Submitted) revert NotSubmitted(); + + s.expected = expected; + s.score = score; + s.scoredAt = uint64(block.timestamp); + s.status = Status.Scored; + emit Scored(requestHash, s.jobId, score, expected, s.scoredAt, s.scoredAt + s.window); + + if (score == 100) job.complete(s.jobId, reason, ""); + else job.reject(s.jobId, reason, ""); + } + + /// @notice Prove the recorded verdict contradicts the deliverable and slash the bond to the + /// harmed party. Anyone may call; the caller receives nothing. Takes no evidence. + function challenge(bytes32 requestHash) external { + Stake storage s = stakes[requestHash]; + if (s.status != Status.Scored) revert NotScored(); + if (block.timestamp >= uint256(s.scoredAt) + s.window) revert WindowClosed(); + IAgentJobV2.Job memory j = job.getJob(s.jobId); + if (!_lied(s, j.deliverable)) revert VerdictHonest(); + + address harmed = s.score == 100 ? j.client : j.provider; + uint256 bond = s.bond; + s.status = Status.Slashed; + s.bond = 0; + totalBonded -= bond; + slashCount += 1; + _addCredit(harmed, bond); + emit Slashed(requestHash, msg.sender, harmed, bond, s.score, j.deliverable); + } + + /// @notice Credit the bond back to its staker once the dispute window has closed unchallenged. + /// Anyone may trigger it; the credit always goes to the staker. + function reclaim(bytes32 requestHash) external { + Stake storage s = stakes[requestHash]; + if (s.status != Status.Scored) revert NotScored(); + if (block.timestamp < uint256(s.scoredAt) + s.window) revert WindowOpen(); + uint256 bond = _close(s, Status.Reclaimed); + emit Reclaimed(requestHash, s.staker, bond); + } + + /// @notice Credit the bond back to its staker if the job expired without a verdict. + function release(bytes32 requestHash) external { + Stake storage s = stakes[requestHash]; + if (s.status == Status.None) revert NotCommitted(); + if (s.status != Status.Committed) revert BadStatus(); + if (job.getJob(s.jobId).state != IAgentJobV2.State.Expired) revert JobNotExpired(); + uint256 bond = _close(s, Status.Released); + emit Released(requestHash, s.staker, bond); + } + + /// @notice Is this verdict slashable right now? Free check before spending gas. + function wouldSlash(bytes32 requestHash) external view returns (bool) { + Stake storage s = stakes[requestHash]; + if (s.status != Status.Scored || block.timestamp >= uint256(s.scoredAt) + s.window) return false; + return _lied(s, job.getJob(s.jobId).deliverable); + } + + function _lied(Stake storage s, bytes32 delivered) private view returns (bool) { + bool matches = delivered == s.expected; + return (s.score == 100 && !matches) || (s.score == 0 && matches); + } + + function _close(Stake storage s, Status to) private returns (uint256 bond) { + bond = s.bond; + s.status = to; + s.bond = 0; + totalBonded -= bond; + _addCredit(s.staker, bond); + } + + function _setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) private { + if (disputeWindow_ < MIN_WINDOW || disputeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS) + revert BadParams(); + disputeWindow = disputeWindow_; + minBond = minBond_; + bondBps = bondBps_; + emit ParamsUpdated(disputeWindow_, minBond_, bondBps_); + } +} diff --git a/contracts/v2/access/PredgeAccess.sol b/contracts/v2/access/PredgeAccess.sol new file mode 100644 index 0000000..6ca3b7c --- /dev/null +++ b/contracts/v2/access/PredgeAccess.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @title Owned2Step +/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`. +/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the +/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a +/// renounced owner could never rotate a compromised operator or resolve a dispute. +/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`) +/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can +/// therefore never take ownership, because it has to prove it can sign. +abstract contract Owned2Step { + /// @notice Current owner. Config and dispute resolution only; never moves user funds. + address public owner; + /// @notice Nominated owner, or zero. + address public pendingOwner; + + event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + error NotOwner(); + error NotPendingOwner(); + error ZeroAddress(); + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + /// @param owner_ initial owner; must not be zero. + constructor(address owner_) { + if (owner_ == address(0)) revert ZeroAddress(); + owner = owner_; + emit OwnershipTransferred(address(0), owner_); + } + + /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination. + function transferOwnership(address newOwner) external onlyOwner { + pendingOwner = newOwner; + emit OwnershipTransferStarted(owner, newOwner); + } + + /// @notice Complete the hand-over. Only the nominated address can call this. + function acceptOwnership() external { + if (msg.sender != pendingOwner) revert NotPendingOwner(); + emit OwnershipTransferred(owner, msg.sender); + owner = msg.sender; + pendingOwner = address(0); + } +} + +/// @title Operated +/// @notice Adds the hot "operator" role (the day-to-day validator / arbiter key) under an +/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch +/// ownership or configuration. +/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker +/// address on each stake or ruling, so rotating this key never lets a new key take an old +/// key's funds, and never strands them either. +abstract contract Operated is Owned2Step { + /// @notice The hot operations key. Rotatable by the owner. + address public operator; + + event OperatorUpdated(address indexed previousOperator, address indexed newOperator); + + error NotOperator(); + + modifier onlyOperator() { + if (msg.sender != operator) revert NotOperator(); + _; + } + + /// @param owner_ initial owner (Safe); must not be zero. + /// @param operator_ initial operator; must not be zero. + constructor(address owner_, address operator_) Owned2Step(owner_) { + if (operator_ == address(0)) revert ZeroAddress(); + operator = operator_; + emit OperatorUpdated(address(0), operator_); + } + + /// @notice Rotate the operator key. Owner only; the new key must be non-zero. + function setOperator(address newOperator) external onlyOwner { + if (newOperator == address(0)) revert ZeroAddress(); + emit OperatorUpdated(operator, newOperator); + operator = newOperator; + } +} diff --git a/contracts/v2/access/PullCredit.sol b/contracts/v2/access/PullCredit.sol new file mode 100644 index 0000000..ab3353c --- /dev/null +++ b/contracts/v2/access/PullCredit.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @title PullCredit +/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is +/// credited here and withdraws it themselves, so a recipient that reverts or is +/// blocklisted can never force an outcome or lock anybody else's funds. +/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native +/// balance is always at least `totalCredit` plus whatever it still escrows or bonds. +/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call. +abstract contract PullCredit { + /// @notice Withdrawable balance per account. + mapping(address => uint256) public credit; + /// @notice Sum of all credits not yet withdrawn. + uint256 public totalCredit; + + event Withdrawn(address indexed account, address indexed to, uint256 amount); + + error NothingToWithdraw(); + error TransferFailed(); + error ZeroRecipient(); + + /// @notice Withdraw the caller's whole credit to the caller. + function withdraw() external { + _withdraw(payable(msg.sender)); + } + + /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address + /// cannot receive value (blocklisted or a contract without a receive function). + function withdrawTo(address payable to) external { + if (to == address(0)) revert ZeroRecipient(); + _withdraw(to); + } + + function _addCredit(address to, uint256 amount) internal { + credit[to] += amount; + totalCredit += amount; + } + + function _withdraw(address payable to) private { + uint256 amt = credit[msg.sender]; + if (amt == 0) revert NothingToWithdraw(); + credit[msg.sender] = 0; + totalCredit -= amt; + emit Withdrawn(msg.sender, to, amt); + (bool ok,) = to.call{value: amt}(""); + if (!ok) revert TransferFailed(); + } +} diff --git a/contracts/v2/interfaces/IAgentJobV2.sol b/contracts/v2/interfaces/IAgentJobV2.sol new file mode 100644 index 0000000..e83c3fe --- /dev/null +++ b/contracts/v2/interfaces/IAgentJobV2.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive. +interface IAgentJobV2 { + /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`. + enum State { + None, + Open, + Submitted, + Completed, + Rejected, + Expired + } + + struct Job { + address client; // pays; refunded on reject / expiry + address provider; // does the work; paid on complete + address evaluator; // the only address that can complete or reject + uint256 escrow; // native value held for this job until it ends + uint64 expiredAt; // the provider must submit strictly before this + uint64 submittedAt; // 0 until submitted + State state; + bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes + bytes32 specHash; // the committed acceptance test (the Predge requestHash) + bytes32 deliverable; // sha256 of the provider's output (zero until submitted) + bytes32 reason; // the evaluator's attestation commitment (zero until decided) + } + + function getJob(uint256 jobId) external view returns (Job memory); + function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external; + function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external; +} diff --git a/contracts/v2/mocks/MockRefundProtocolV2.sol b/contracts/v2/mocks/MockRefundProtocolV2.sol new file mode 100644 index 0000000..daed788 --- /dev/null +++ b/contracts/v2/mocks/MockRefundProtocolV2.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice DEMO / TEST ONLY stand-in for a Refund Protocol. It holds NO funds and moves NO +/// money: `refundByArbiter` only flips a flag and emits an event, so a "refund" here +/// proves the arbiter made the call and nothing more. It exposes the payment parties the +/// v2 arbiter binds evidence to. Anyone can create a payment record; that is intended for +/// demos and is the reason this contract must never be treated as a payment rail. +contract MockRefundProtocolV2 { + struct Payment { + address payer; + address payee; + uint256 amount; + uint256 releaseTimestamp; + bool refunded; + } + + address public immutable arbiter; + uint256 public paymentCount; + uint256 public refundCount; + mapping(uint256 => Payment) public payments; + mapping(address => uint256) public lockupSeconds; + + event PaymentRecorded(uint256 indexed paymentID, address indexed payer, address indexed payee, uint256 amount, uint256 releaseTimestamp); + event RefundByArbiter(uint256 indexed paymentID, address indexed caller); + event LockupSet(address indexed recipient, uint256 lockupSeconds); + + error CallerNotArbiter(); + error UnknownPayment(); + error AlreadyRefunded(); + error AlreadyReleased(); + error ZeroAddress(); + + constructor(address arbiter_) { + if (arbiter_ == address(0)) revert ZeroAddress(); + arbiter = arbiter_; + } + + /// @notice Record a payment's parties (no value moves). Demo only. + function recordPayment(address payer, address payee, uint256 amount, uint256 releaseTimestamp) + external + returns (uint256 paymentID) + { + if (payer == address(0) || payee == address(0)) revert ZeroAddress(); + paymentID = ++paymentCount; + payments[paymentID] = Payment(payer, payee, amount, releaseTimestamp, false); + emit PaymentRecorded(paymentID, payer, payee, amount, releaseTimestamp); + } + + function paymentInfo(uint256 paymentID) + external + view + returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp) + { + Payment storage p = payments[paymentID]; + return (p.payer, p.payee, p.amount, p.releaseTimestamp); + } + + function refundByArbiter(uint256 paymentID) external { + if (msg.sender != arbiter) revert CallerNotArbiter(); + Payment storage p = payments[paymentID]; + if (p.payee == address(0)) revert UnknownPayment(); + if (p.refunded) revert AlreadyRefunded(); + if (block.timestamp >= p.releaseTimestamp) revert AlreadyReleased(); + p.refunded = true; + refundCount += 1; + emit RefundByArbiter(paymentID, msg.sender); + } + + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external { + if (msg.sender != arbiter) revert CallerNotArbiter(); + lockupSeconds[recipient] = recipientLockupSeconds; + emit LockupSet(recipient, recipientLockupSeconds); + } +} diff --git a/foundry-lib/forge-std b/foundry-lib/forge-std new file mode 160000 index 0000000..8e40513 --- /dev/null +++ b/foundry-lib/forge-std @@ -0,0 +1 @@ +Subproject commit 8e40513d678f392f398620b3ef2b418648b33e89 diff --git a/foundry-v2.toml b/foundry-v2.toml new file mode 100644 index 0000000..4bd763b --- /dev/null +++ b/foundry-v2.toml @@ -0,0 +1,21 @@ +# Foundry config for the Predge v2 contracts (contracts/v2). Kept separate from any v1 foundry.toml so the +# two suites never collide. Use: FOUNDRY_CONFIG=foundry-v2.toml forge test (or `npm run v2:test`) +# Compiler settings mirror lib/compile.mjs exactly: solc 0.8.26, optimizer 200, cancun. +[profile.default] +src = "contracts/v2" +test = "test/foundry-v2" +out = "build-v2/out" +cache_path = "build-v2/cache" +libs = ["foundry-lib"] +remappings = ["forge-std/=foundry-lib/forge-std/src/"] +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 200 +evm_version = "cancun" +bytecode_hash = "ipfs" +fuzz = { runs = 1000 } +invariant = { runs = 256, depth = 64, fail_on_revert = false } + +[profile.ci] +fuzz = { runs = 5000 } +invariant = { runs = 512, depth = 100, fail_on_revert = false } diff --git a/test/foundry-v2/AgentJobV2.t.sol b/test/foundry-v2/AgentJobV2.t.sol new file mode 100644 index 0000000..fa7aa2f --- /dev/null +++ b/test/foundry-v2/AgentJobV2.t.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Base, NoReceive, ReentrantReceiver} from "./Base.sol"; +import {AgentJobV2} from "../../contracts/v2/AgentJobV2.sol"; +import {IAgentJobV2} from "../../contracts/v2/interfaces/IAgentJobV2.sol"; +import {PullCredit} from "../../contracts/v2/access/PullCredit.sol"; + +contract AgentJobV2Test is Base { + address internal evaluator = makeAddr("evaluator"); + bytes32 internal constant SPEC = keccak256("spec"); + bytes32 internal constant DELIV = keccak256("deliverable"); + + event JobCreated(uint256 indexed jobId, address indexed client, address indexed evaluator, address provider, uint256 escrow, uint64 expiredAt, bytes32 specHash); + event Submitted(uint256 indexed jobId, address indexed provider, bytes32 deliverable, bool hashedOnChain); + event Completed(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason); + event Rejected(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason); + event Refunded(uint256 indexed jobId, address indexed caller, address indexed recipient, uint256 amount); + event Withdrawn(address indexed account, address indexed to, uint256 amount); + + function setUp() public { + job = new AgentJobV2(EVAL_WINDOW); + vm.deal(client, 100 ether); + } + + function _create(uint256 v) internal returns (uint256 id) { + vm.prank(client); + id = job.createJob{value: v}(provider, evaluator, SPEC, uint64(block.timestamp + 1 days)); + } + + function mk(uint64 w) external returns (address) { + return address(new AgentJobV2(w)); + } + + function test_constructor_rejectsZeroWindow() public { + vm.expectRevert(AgentJobV2.BadWindow.selector); + this.mk(0); + assertEq(job.evaluationWindow(), EVAL_WINDOW); + } + + function test_createJob_storesAndEmits() public { + vm.expectEmit(true, true, true, true); + emit JobCreated(1, client, evaluator, provider, 5 ether, uint64(block.timestamp + 1 days), SPEC); + uint256 id = _create(5 ether); + IAgentJobV2.Job memory j = job.getJob(id); + assertEq(id, 1); + assertEq(j.client, client); + assertEq(j.provider, provider); + assertEq(j.evaluator, evaluator); + assertEq(j.escrow, 5 ether); + assertEq(uint8(j.state), uint8(IAgentJobV2.State.Open)); + assertEq(job.totalEscrowed(), 5 ether); + assertEq(job.jobCount(), 1); + } + + function test_createJob_reverts() public { + vm.startPrank(client); + vm.expectRevert(AgentJobV2.ZeroAddress.selector); + job.createJob{value: 1}(address(0), evaluator, SPEC, uint64(block.timestamp + 1)); + vm.expectRevert(AgentJobV2.ZeroAddress.selector); + job.createJob{value: 1}(provider, address(0), SPEC, uint64(block.timestamp + 1)); + vm.expectRevert(AgentJobV2.ZeroEscrow.selector); + job.createJob(provider, evaluator, SPEC, uint64(block.timestamp + 1)); + vm.expectRevert(AgentJobV2.BadExpiry.selector); + job.createJob{value: 1}(provider, evaluator, SPEC, uint64(block.timestamp)); + vm.stopPrank(); + } + + function test_submit_hashOnly() public { + uint256 id = _create(1 ether); + vm.expectEmit(true, true, false, true); + emit Submitted(id, provider, DELIV, false); + vm.prank(provider); + job.submit(id, DELIV, ""); + IAgentJobV2.Job memory j = job.getJob(id); + assertEq(j.deliverable, DELIV); + assertFalse(j.hashedOnChain); + assertEq(j.submittedAt, block.timestamp); + assertEq(uint8(j.state), uint8(IAgentJobV2.State.Submitted)); + } + + function test_submit_bytesHashedOnChain() public { + uint256 id = _create(1 ether); + bytes memory out = "the actual output"; + bytes32 h = sha256(out); + vm.prank(provider); + job.submit(id, h, out); + IAgentJobV2.Job memory j = job.getJob(id); + assertEq(j.deliverable, sha256(out)); + assertTrue(j.hashedOnChain); + } + + function test_submit_bytesMismatchReverts() public { + uint256 id = _create(1 ether); + vm.prank(provider); + vm.expectRevert(AgentJobV2.HashMismatch.selector); + job.submit(id, DELIV, "not matching"); + } + + function test_submit_reverts() public { + uint256 id = _create(1 ether); + vm.prank(stranger); + vm.expectRevert(AgentJobV2.NotProvider.selector); + job.submit(id, DELIV, ""); + vm.startPrank(provider); + vm.expectRevert(AgentJobV2.ZeroDeliverable.selector); + job.submit(id, bytes32(0), ""); + vm.expectRevert(AgentJobV2.BadState.selector); + job.submit(99, DELIV, ""); + job.submit(id, DELIV, ""); + vm.expectRevert(AgentJobV2.BadState.selector); + job.submit(id, DELIV, ""); + vm.stopPrank(); + } + + function test_submit_afterExpiryReverts() public { + uint256 id = _create(1 ether); + vm.warp(block.timestamp + 1 days); + vm.prank(provider); + vm.expectRevert(AgentJobV2.PastDeadline.selector); + job.submit(id, DELIV, ""); + } + + function test_complete_creditsProviderPull() public { + uint256 id = _create(2 ether); + vm.prank(provider); + job.submit(id, DELIV, ""); + vm.expectEmit(true, true, true, true); + emit Completed(id, evaluator, provider, 2 ether, bytes32("r")); + vm.prank(evaluator); + job.complete(id, bytes32("r"), ""); + assertEq(job.credit(provider), 2 ether); + assertEq(job.totalCredit(), 2 ether); + assertEq(job.totalEscrowed(), 0); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Completed)); + assertEq(job.getJob(id).reason, bytes32("r")); + uint256 before_ = provider.balance; + vm.prank(provider); + job.withdraw(); + assertEq(provider.balance, before_ + 2 ether); + assertEq(job.totalCredit(), 0); + } + + function test_reject_creditsClient() public { + uint256 id = _create(2 ether); + vm.prank(provider); + job.submit(id, DELIV, ""); + vm.expectEmit(true, true, true, true); + emit Rejected(id, evaluator, client, 2 ether, bytes32("r")); + vm.prank(evaluator); + job.reject(id, bytes32("r"), ""); + assertEq(job.credit(client), 2 ether); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Rejected)); + } + + function test_decide_reverts() public { + uint256 id = _create(1 ether); + vm.prank(evaluator); + vm.expectRevert(AgentJobV2.BadState.selector); // not submitted yet + job.complete(id, 0, ""); + vm.prank(provider); + job.submit(id, DELIV, ""); + vm.prank(stranger); + vm.expectRevert(AgentJobV2.NotEvaluator.selector); + job.complete(id, 0, ""); + vm.prank(stranger); + vm.expectRevert(AgentJobV2.NotEvaluator.selector); + job.reject(id, 0, ""); + vm.warp(block.timestamp + EVAL_WINDOW); + vm.prank(evaluator); + vm.expectRevert(AgentJobV2.PastDeadline.selector); + job.complete(id, 0, ""); + } + + function test_decide_onlyOnce() public { + uint256 id = _create(1 ether); + vm.prank(provider); + job.submit(id, DELIV, ""); + vm.startPrank(evaluator); + job.complete(id, 0, ""); + vm.expectRevert(AgentJobV2.BadState.selector); + job.reject(id, 0, ""); + vm.expectRevert(AgentJobV2.BadState.selector); + job.complete(id, 0, ""); + vm.stopPrank(); + } + + // ---- expiry (M-03) ---- + + function test_claimRefund_openJobAfterExpiry() public { + uint256 id = _create(3 ether); + vm.expectRevert(AgentJobV2.NotExpired.selector); + job.claimRefund(id); + vm.warp(block.timestamp + 1 days); + vm.expectEmit(true, true, true, true); + emit Refunded(id, stranger, client, 3 ether); + vm.prank(stranger); // anyone can trigger, the client is credited + job.claimRefund(id); + assertEq(job.credit(client), 3 ether); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Expired)); + vm.expectRevert(AgentJobV2.BadState.selector); + job.claimRefund(id); + } + + function test_claimRefund_submittedButEvaluatorSilent() public { + uint256 id = _create(3 ether); + vm.prank(provider); + job.submit(id, DELIV, ""); + vm.warp(block.timestamp + EVAL_WINDOW - 1); + vm.expectRevert(AgentJobV2.NotExpired.selector); + job.claimRefund(id); + vm.warp(block.timestamp + 1); + job.claimRefund(id); + assertEq(job.credit(client), 3 ether); + assertEq(job.credit(provider), 0, "documented: silent evaluator refunds the client"); + } + + function test_claimRefund_badStates() public { + vm.expectRevert(AgentJobV2.BadState.selector); + job.claimRefund(42); + uint256 id = _create(1 ether); + vm.prank(provider); + job.submit(id, DELIV, ""); + vm.prank(evaluator); + job.complete(id, 0, ""); + vm.warp(block.timestamp + 365 days); + vm.expectRevert(AgentJobV2.BadState.selector); + job.claimRefund(id); + } + + // ---- pull payments (L-03) ---- + + function test_withdraw_reverts() public { + vm.expectRevert(PullCredit.NothingToWithdraw.selector); + job.withdraw(); + vm.expectRevert(PullCredit.ZeroRecipient.selector); + job.withdrawTo(payable(address(0))); + } + + function test_revertingRecipientCannotBlockOutcomeAndCanRedirect() public { + NoReceive bad = new NoReceive(); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(address(bad), evaluator, SPEC, uint64(block.timestamp + 1 days)); + vm.prank(address(bad)); + job.submit(id, DELIV, ""); + vm.prank(evaluator); + job.complete(id, 0, ""); // does not revert even though the provider cannot receive + assertEq(job.credit(address(bad)), 1 ether); + vm.expectRevert(PullCredit.TransferFailed.selector); + bad.callWithdraw(address(job)); + vm.prank(address(bad)); + job.withdrawTo(payable(stranger)); + assertEq(job.credit(address(bad)), 0); + } + + function test_withdraw_isReentrancySafe() public { + ReentrantReceiver r = new ReentrantReceiver(); + r.set(address(job)); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(address(r), evaluator, SPEC, uint64(block.timestamp + 1 days)); + vm.prank(address(r)); + job.submit(id, DELIV, ""); + vm.prank(evaluator); + job.complete(id, 0, ""); + r.go(); + assertTrue(r.reentered()); + assertTrue(r.reenterFailed()); + assertEq(address(r).balance, 1 ether); + } + + // ---- fuzz ---- + + function testFuzz_escrowPaidExactlyOnce(uint96 amt, bool pass, bool lateDecision) public { + amt = uint96(bound(amt, 1, 50 ether)); + uint256 id = _create(amt); + vm.prank(provider); + job.submit(id, DELIV, ""); + if (lateDecision) { + vm.warp(block.timestamp + EVAL_WINDOW); + job.claimRefund(id); + assertEq(job.credit(client), amt); + } else { + vm.prank(evaluator); + if (pass) job.complete(id, 0, ""); else job.reject(id, 0, ""); + assertEq(job.credit(pass ? provider : client), amt); + } + assertEq(job.totalEscrowed(), 0); + assertEq(job.totalCredit(), amt); + assertGe(address(job).balance, job.totalCredit()); + } + + function testFuzz_bytesAlwaysHashedOnChain(bytes calldata out) public { + vm.assume(out.length > 0); + uint256 id = _create(1 ether); + bytes32 h = sha256(out); + vm.prank(provider); + job.submit(id, h, out); + assertEq(job.getJob(id).deliverable, h); + } +} diff --git a/test/foundry-v2/Base.sol b/test/foundry-v2/Base.sol new file mode 100644 index 0000000..d4418df --- /dev/null +++ b/test/foundry-v2/Base.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {AgentJobV2} from "../../contracts/v2/AgentJobV2.sol"; +import {IAgentJobV2} from "../../contracts/v2/interfaces/IAgentJobV2.sol"; +import {PredgeValidatorBondV2} from "../../contracts/v2/PredgeValidatorBondV2.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {MockRefundProtocolV2} from "../../contracts/v2/mocks/MockRefundProtocolV2.sol"; + +/// @dev A receiver with no receive/fallback: any value sent to it reverts. +contract NoReceive { + function callWithdraw(address target) external { + (bool ok, bytes memory ret) = target.call(abi.encodeWithSignature("withdraw()")); + if (!ok) { + assembly { revert(add(ret, 32), mload(ret)) } + } + } +} + +/// @dev Re-enters `withdraw()` on receipt; used to prove the credit is zeroed before the call. +contract ReentrantReceiver { + address public target; + bool public reentered; + bool public reenterFailed; + + function set(address t) external { target = t; } + function go() external { (bool ok,) = target.call(abi.encodeWithSignature("withdraw()")); require(ok, "outer"); } + receive() external payable { + reentered = true; + (bool ok,) = target.call(abi.encodeWithSignature("withdraw()")); + reenterFailed = !ok; + } +} + +abstract contract Base is Test { + address internal safe = makeAddr("safe"); + address internal op = makeAddr("operator"); + address internal client = makeAddr("client"); + address internal provider = makeAddr("provider"); + address internal stranger = makeAddr("stranger"); + + uint64 internal constant EVAL_WINDOW = 3 days; + uint64 internal constant WINDOW = 1 days; + uint256 internal constant MIN_BOND = 1e15; + uint16 internal constant BPS = 10_000; + + AgentJobV2 internal job; + PredgeValidatorBondV2 internal bond; + + function _deployJobAndBond() internal { + job = new AgentJobV2(EVAL_WINDOW); + bond = new PredgeValidatorBondV2(safe, op, address(job), WINDOW, MIN_BOND, BPS); + vm.deal(op, 1_000 ether); + vm.deal(client, 1_000 ether); + vm.deal(provider, 10 ether); + vm.deal(stranger, 10 ether); + } + + function _spec(uint256 n) internal pure returns (bytes32) { + return keccak256(abi.encode("spec", n)); + } + + function _commitment(bytes32 expected, bytes32 salt) internal pure returns (bytes32) { + return keccak256(abi.encode(expected, salt)); + } + + /// @dev client opens a job whose evaluator is the bond contract. + function _newJob(uint256 n, uint256 escrow) internal returns (uint256 id, bytes32 spec) { + spec = _spec(n); + vm.prank(client); + id = job.createJob{value: escrow}(provider, address(bond), spec, uint64(block.timestamp + 7 days)); + } + + function _stake(bytes32 spec, bytes32 expected, bytes32 salt, uint256 id, uint256 value) internal { + vm.prank(op); + bond.stakeAndCommit{value: value}(spec, _commitment(expected, salt), id); + } + + function _submit(uint256 id, bytes32 deliverable) internal { + vm.prank(provider); + job.submit(id, deliverable, ""); + } + + function _score(bytes32 spec, uint8 score, bytes32 expected, bytes32 salt) internal { + vm.prank(op); + bond.recordScore(spec, score, expected, salt, keccak256("reason")); + } +} diff --git a/test/foundry-v2/Invariants.t.sol b/test/foundry-v2/Invariants.t.sol new file mode 100644 index 0000000..ed5901b --- /dev/null +++ b/test/foundry-v2/Invariants.t.sol @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {Test} from "forge-std/Test.sol"; +import {AgentJobV2} from "../../contracts/v2/AgentJobV2.sol"; +import {IAgentJobV2} from "../../contracts/v2/interfaces/IAgentJobV2.sol"; +import {PredgeValidatorBondV2} from "../../contracts/v2/PredgeValidatorBondV2.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {MockRefundProtocolV2} from "../../contracts/v2/mocks/MockRefundProtocolV2.sol"; + +/// @dev Drives AgentJobV2 + PredgeValidatorBondV2 through random interleavings and records ghost state. +contract JobBondHandler is Test { + AgentJobV2 public job; + PredgeValidatorBondV2 public bond; + address public op = address(0x0A11); + address public client = address(0xC11E); + address public provider = address(0x9A0D); + address public safe = address(0x5AFE); + + bytes32[] public specs; // request hashes of every job created + uint256[] public jobIds; + mapping(bytes32 => bytes32) public expectedOf; // preimage the operator committed to + mapping(bytes32 => bytes32) public saltOf; + mapping(bytes32 => uint8) public prevStatus; // last status seen, to prove monotonicity + mapping(uint256 => uint8) public prevJobState; + mapping(bytes32 => bytes32) public firstCommitment; + mapping(bytes32 => bytes32) public firstExpected; + mapping(bytes32 => uint256) public firstJobId; + mapping(bytes32 => uint64) public firstScoredAt; + mapping(bytes32 => uint8) public firstScore; + + uint256 public ghostCreditedFromJobs; // everything the job ever credited + uint256 public ghostEscrowedTotal; // everything ever escrowed + bool public violation; // set by in-handler checks that need to see the call itself + + constructor(AgentJobV2 j, PredgeValidatorBondV2 b) { + job = j; + bond = b; + vm.deal(op, type(uint128).max); + vm.deal(client, type(uint128).max); + vm.deal(provider, type(uint128).max); + } + + function specCount() external view returns (uint256) { return specs.length; } + + function createAndStake(uint96 escrow, uint8 salt, bool stakeIt) external { + escrow = uint96(bound(escrow, 1, 1_000 ether)); + bytes32 spec = keccak256(abi.encode("s", specs.length)); + vm.prank(client); + uint256 id = job.createJob{value: escrow}(provider, address(bond), spec, uint64(block.timestamp + 5 days)); + ghostEscrowedTotal += escrow; + specs.push(spec); + jobIds.push(id); + if (stakeIt) { + bytes32 e = keccak256(abi.encode("e", salt)); + bytes32 s = keccak256(abi.encode("salt", salt, specs.length)); + expectedOf[spec] = e; + saltOf[spec] = s; + uint256 need = bond.requiredBond(escrow); + vm.prank(op); + try bond.stakeAndCommit{value: need}(spec, keccak256(abi.encode(e, s)), id) {} catch {} + } + } + + function submit(uint256 i, bool correct, uint8 junk) external { + if (specs.length == 0) return; + i = bound(i, 0, specs.length - 1); + bytes32 spec = specs[i]; + bytes32 d = correct && expectedOf[spec] != 0 ? expectedOf[spec] : keccak256(abi.encode("junk", junk)); + vm.prank(provider); + try job.submit(jobIds[i], d, "") {} catch {} + } + + function score(uint256 i, bool pass) external { + if (specs.length == 0) return; + i = bound(i, 0, specs.length - 1); + bytes32 spec = specs[i]; + vm.prank(op); + try bond.recordScore(spec, pass ? 100 : 0, expectedOf[spec], saltOf[spec], bytes32(0)) {} catch {} + } + + function challenge(uint256 i) external { + if (specs.length == 0) return; + i = bound(i, 0, specs.length - 1); + bool should = bond.wouldSlash(specs[i]); + try bond.challenge(specs[i]) { if (!should) violation = true; } catch { if (should) violation = true; } + } + + function reclaim(uint256 i) external { + if (specs.length == 0) return; + i = bound(i, 0, specs.length - 1); + bytes32 spec = specs[i]; + (, , , , uint64 scoredAt, uint64 window, , , , ) = bond.stakes(spec); + try bond.reclaim(spec) { + // reclaim implies the window had elapsed from the score + if (block.timestamp < uint256(scoredAt) + window) violation = true; + } catch {} + } + + function release(uint256 i) external { + if (specs.length == 0) return; + i = bound(i, 0, specs.length - 1); + try bond.release(specs[i]) {} catch {} + } + + function claimRefund(uint256 i) external { + if (specs.length == 0) return; + i = bound(i, 0, specs.length - 1); + try job.claimRefund(jobIds[i]) {} catch {} + } + + function withdrawAll(uint8 who) external { + address a = who % 3 == 0 ? client : who % 3 == 1 ? provider : op; + vm.prank(a); + try job.withdraw() {} catch {} + vm.prank(a); + try bond.withdraw() {} catch {} + } + + function rotateOperator(bool back) external { + vm.prank(safe); + bond.setOperator(back ? op : address(0xBEEF)); + vm.deal(address(0xBEEF), 1); + } + + function warp(uint32 dt) external { + vm.warp(block.timestamp + bound(dt, 0, 4 days)); + _record(); + } + + /// @dev Snapshot ghost values and check write-once / monotonic properties. + function _record() internal { + for (uint256 i; i < specs.length; ++i) { + _checkStake(specs[i]); + IAgentJobV2.Job memory j = job.getJob(jobIds[i]); + if (uint8(j.state) < prevJobState[jobIds[i]]) violation = true; + prevJobState[jobIds[i]] = uint8(j.state); + } + } + + function _checkStake(bytes32 spec) internal { + (, PredgeValidatorBondV2.Status st, uint8 sc, , uint64 scoredAt, , uint256 jid, , bytes32 c, bytes32 e) = bond.stakes(spec); + uint8 s8 = uint8(st); + if (s8 < prevStatus[spec]) violation = true; // never moves backwards + if (prevStatus[spec] >= 3 && s8 != prevStatus[spec]) violation = true; // final states are final + if (s8 != 0) { + if (firstCommitment[spec] == 0) { firstCommitment[spec] = c; firstJobId[spec] = jid; } + if (c != firstCommitment[spec] || jid != firstJobId[spec]) violation = true; + } + if (s8 >= 2 && s8 != 5) { + if (firstScoredAt[spec] == 0) { firstScoredAt[spec] = scoredAt; firstExpected[spec] = e; firstScore[spec] = sc; } + if (scoredAt != firstScoredAt[spec] || e != firstExpected[spec] || sc != firstScore[spec]) violation = true; + } + prevStatus[spec] = s8; + } + + function snapshot() external { _record(); } +} + +contract JobBondInvariantTest is StdInvariant, Test { + AgentJobV2 job; + PredgeValidatorBondV2 bond; + JobBondHandler h; + + function setUp() public { + job = new AgentJobV2(3 days); + bond = new PredgeValidatorBondV2(address(0x5AFE), address(0x0A11), address(job), 1 days, 1e15, 10_000); + h = new JobBondHandler(job, bond); + targetContract(address(h)); + } + + function invariant_bondSolvent() public view { + assertGe(address(bond).balance, bond.totalBonded() + bond.totalCredit()); + } + + function invariant_jobSolvent() public view { + assertGe(address(job).balance, job.totalEscrowed() + job.totalCredit()); + } + + function invariant_totalBondedEqualsOpenBonds() public view { + uint256 sum; + for (uint256 i; i < h.specCount(); ++i) { + (, PredgeValidatorBondV2.Status st,,,,,, uint256 b,,) = bond.stakes(h.specs(i)); + if (st == PredgeValidatorBondV2.Status.Committed || st == PredgeValidatorBondV2.Status.Scored) sum += b; + else assertEq(b, 0, "closed => bond == 0"); + } + assertEq(bond.totalBonded(), sum); + } + + function invariant_totalEscrowedEqualsLiveEscrows() public view { + uint256 live; + uint256 paid; + for (uint256 i; i < h.specCount(); ++i) { + IAgentJobV2.Job memory j = job.getJob(h.jobIds(i)); + if (j.state == IAgentJobV2.State.Open || j.state == IAgentJobV2.State.Submitted) live += j.escrow; + else assertEq(j.escrow, 0, "settled => escrow == 0"); + if (j.state != IAgentJobV2.State.Open && j.state != IAgentJobV2.State.Submitted) {} + } + assertEq(job.totalEscrowed(), live); + paid = h.ghostEscrowedTotal() - live; + // everything ever escrowed is either live, or sits in credit, or was withdrawn: never double counted + assertLe(job.totalCredit(), paid); + } + + function invariant_honestVerdictNeverSlashed() public view { + for (uint256 i; i < h.specCount(); ++i) { + bytes32 spec = h.specs(i); + (, PredgeValidatorBondV2.Status st, uint8 score,,,,,,,) = bond.stakes(spec); + if (st != PredgeValidatorBondV2.Status.Slashed) continue; + IAgentJobV2.Job memory j = job.getJob(h.jobIds(i)); + bool matches = j.deliverable == h.expectedOf(spec); + assertTrue((score == 100 && !matches) || (score == 0 && matches), "slashed => the verdict contradicted the deliverable"); + } + } + + function invariant_scoredNeverBeforeSubmission() public view { + for (uint256 i; i < h.specCount(); ++i) { + (, PredgeValidatorBondV2.Status st,,, uint64 scoredAt,,,,,) = bond.stakes(h.specs(i)); + if (st == PredgeValidatorBondV2.Status.Committed || st == PredgeValidatorBondV2.Status.None || st == PredgeValidatorBondV2.Status.Released) continue; + assertGe(scoredAt, job.getJob(h.jobIds(i)).submittedAt, "H-01: window starts at or after the submission"); + assertGt(job.getJob(h.jobIds(i)).submittedAt, 0); + } + } + + function invariant_deliverableImmutableOnceSubmitted() public view { + for (uint256 i; i < h.specCount(); ++i) { + IAgentJobV2.Job memory j = job.getJob(h.jobIds(i)); + if (j.state == IAgentJobV2.State.Open) assertEq(j.deliverable, bytes32(0)); + else if (j.state != IAgentJobV2.State.Expired) assertTrue(j.deliverable != bytes32(0)); + } + } + + function invariant_writeOnceAndMonotonic() public { + h.snapshot(); + assertFalse(h.violation(), "write-once / monotonic / challenge-iff-lied / reclaim-after-window"); + } + + function invariant_livenessNoPermanentLock() public view { + // Every job past its latest possible deadline has been resolvable: it is either settled or claimable. + for (uint256 i; i < h.specCount(); ++i) { + IAgentJobV2.Job memory j = job.getJob(h.jobIds(i)); + if (j.state == IAgentJobV2.State.Open && block.timestamp >= j.expiredAt) { + // claimable by anyone: asserted structurally by the state machine; here just ensure the escrow is intact + assertGt(j.escrow, 0); + } + if (j.state == IAgentJobV2.State.Submitted && block.timestamp >= uint256(j.submittedAt) + job.evaluationWindow()) { + assertGt(j.escrow, 0); + } + } + } +} + +/// @dev Arbiter handler. +contract ArbiterHandler is Test { + PredgeRefundArbiterV2 public arb; + MockRefundProtocolV2 public rp; + address public safe = address(0x5AFE); + address public op = address(0x0A11); + address public payer = address(0xA1); + address public payee = address(0xA2); + uint256[] public pids; + mapping(uint256 => uint8) public prevStatus; + mapping(uint256 => bytes32) public firstEv; // payer evidence + mapping(uint256 => bytes32) public firstEvPayee; + uint256 public ghostRefundCalls; + bool public violation; + + constructor(PredgeRefundArbiterV2 a, MockRefundProtocolV2 r) { + arb = a; + rp = r; + vm.deal(op, type(uint128).max); + } + + function n() external view returns (uint256) { return pids.length; } + + function newPayment(uint96 amount) external { + amount = uint96(bound(amount, 1, 500 ether)); + pids.push(rp.recordPayment(payer, payee, amount, block.timestamp + 20 days)); + } + + function file(uint256 i, bool asPayer, bytes32 ev) external { + if (pids.length == 0) return; + uint256 pid = pids[bound(i, 0, pids.length - 1)]; + ev = ev == 0 ? bytes32(uint256(1)) : ev; + address who = asPayer ? payer : payee; + bytes32 before = arb.filedEvidence(pid, who); + vm.prank(who); + try arb.fileEvidence(pid, ev) {} catch {} + bytes32 afterV = arb.filedEvidence(pid, who); + if (before != 0 && afterV != before) violation = true; // write-once + } + + function ruleIt(uint256 i, bool fromPayer, bool refund) external { + if (pids.length == 0) return; + uint256 pid = pids[bound(i, 0, pids.length - 1)]; + address from = fromPayer ? payer : payee; + bytes32 ev = arb.filedEvidence(pid, from); + (, , uint256 amount,) = rp.paymentInfo(pid); + vm.prank(op); + try arb.rule{value: arb.requiredBond(amount)}(pid, from, ev, refund ? PredgeRefundArbiterV2.Direction.Refund : PredgeRefundArbiterV2.Direction.Release) {} catch {} + } + + function disputeIt(uint256 i) external { + if (pids.length == 0) return; + uint256 pid = pids[bound(i, 0, pids.length - 1)]; + address h = arb.harmedParty(pid); + vm.prank(h); + try arb.dispute(pid, bytes32(uint256(7))) {} catch {} + } + + function finalizeIt(uint256 i) external { + if (pids.length == 0) return; + uint256 pid = pids[bound(i, 0, pids.length - 1)]; + PredgeRefundArbiterV2.Ruling memory rl = arb.rulings(pid); + PredgeRefundArbiterV2.Direction d = rl.direction; + uint64 ruledAt = rl.ruledAt; + uint64 window = rl.window; + uint256 before = rp.refundCount(); + try arb.finalize(pid) { + if (block.timestamp < uint256(ruledAt) + window) violation = true; // never inside the window + if (d == PredgeRefundArbiterV2.Direction.Refund && rp.refundCount() != before + 1) violation = true; + } catch {} + } + + function resolveIt(uint256 i, bool refund) external { + if (pids.length == 0) return; + uint256 pid = pids[bound(i, 0, pids.length - 1)]; + vm.prank(safe); + try arb.resolve(pid, refund ? PredgeRefundArbiterV2.Direction.Refund : PredgeRefundArbiterV2.Direction.Release) {} catch {} + } + + function expireIt(uint256 i) external { + if (pids.length == 0) return; + try arb.expire(pids[bound(i, 0, pids.length - 1)]) {} catch {} + } + + function withdrawIt(uint8 who) external { + address a = who % 3 == 0 ? payer : who % 3 == 1 ? payee : op; + vm.prank(a); + try arb.withdraw() {} catch {} + } + + function warp(uint32 dt) external { + vm.warp(block.timestamp + bound(dt, 0, 10 days)); + } + + function snapshot() external { + for (uint256 i; i < pids.length; ++i) { + PredgeRefundArbiterV2.Status st = arb.rulings(pids[i]).status; + uint8 s8 = uint8(st); + if (s8 < prevStatus[pids[i]]) violation = true; + if (prevStatus[pids[i]] >= 3 && s8 != prevStatus[pids[i]]) violation = true; + prevStatus[pids[i]] = s8; + } + } +} + +contract ArbiterInvariantTest is StdInvariant, Test { + PredgeRefundArbiterV2 arb; + MockRefundProtocolV2 rp; + ArbiterHandler h; + + function setUp() public { + address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1); + rp = new MockRefundProtocolV2(predicted); + arb = new PredgeRefundArbiterV2(address(0x5AFE), address(0x0A11), address(rp), 1 days, 1e15, 500); + h = new ArbiterHandler(arb, rp); + targetContract(address(h)); + } + + function invariant_arbiterSolvent() public view { + assertGe(address(arb).balance, arb.totalBonded() + arb.totalCredit()); + } + + function invariant_totalBondedEqualsOpenRulings() public view { + uint256 sum; + uint256 rulings_; + uint256 refunds; + for (uint256 i; i < h.n(); ++i) { + PredgeRefundArbiterV2.Ruling memory rl = arb.rulings(h.pids(i)); + PredgeRefundArbiterV2.Status st = rl.status; + PredgeRefundArbiterV2.Direction d = rl.direction; + uint256 bond = rl.bond; + if (st == PredgeRefundArbiterV2.Status.None) continue; + rulings_++; + if (st == PredgeRefundArbiterV2.Status.Ruled || st == PredgeRefundArbiterV2.Status.Disputed) sum += bond; + else assertEq(bond, 0); + if (st == PredgeRefundArbiterV2.Status.Finalized && d == PredgeRefundArbiterV2.Direction.Refund) refunds++; + } + assertEq(arb.totalBonded(), sum); + assertEq(arb.rulingCount(), rulings_, "one ruling per payment"); + // refunds happen only via a Finalized Refund or an owner-chosen Refund after a dispute (Overturned/Finalized) + assertGe(rp.refundCount(), refunds); + assertLe(rp.refundCount(), rulings_); + } + + function invariant_everyRefundedPaymentWasFiledAndRuled() public view { + for (uint256 i; i < h.n(); ++i) { + (,,, , , bool refunded) = _refundedFlag(h.pids(i)); + if (!refunded) continue; + PredgeRefundArbiterV2.Status st = arb.rulings(h.pids(i)).status; + assertTrue(st == PredgeRefundArbiterV2.Status.Finalized || st == PredgeRefundArbiterV2.Status.Overturned, "refund only after a decision"); + } + } + + function _refundedFlag(uint256 pid) internal view returns (address, address, uint256, uint256, bool, bool) { + (address a, address b, uint256 c, uint256 d, bool r) = rp.payments(pid); + return (a, b, c, d, r, r); + } + + function invariant_writeOnceAndFinality() public { + h.snapshot(); + assertFalse(h.violation()); + } +} diff --git a/test/foundry-v2/Misc.t.sol b/test/foundry-v2/Misc.t.sol new file mode 100644 index 0000000..8c2f988 --- /dev/null +++ b/test/foundry-v2/Misc.t.sol @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PredgeAgentValidatorV2} from "../../contracts/v2/PredgeAgentValidatorV2.sol"; +import {PredgeSettlementV2} from "../../contracts/v2/PredgeSettlementV2.sol"; +import {MockRefundProtocolV2} from "../../contracts/v2/mocks/MockRefundProtocolV2.sol"; +import {Owned2Step, Operated} from "../../contracts/v2/access/PredgeAccess.sol"; +import {NoReceive} from "./Base.sol"; + +contract IdentityMock { + mapping(uint256 => address) public owners; + bool public broken; + function set(uint256 id, address o) external { owners[id] = o; } + function setBroken(bool b) external { broken = b; } + function ownerOf(uint256 id) external view returns (address) { + require(!broken && owners[id] != address(0), "ERC721: invalid token ID"); + return owners[id]; + } +} + +contract PredgeAgentValidatorV2Test is Test { + address safe = makeAddr("safe"); + address op = makeAddr("operator"); + address agentOwner = makeAddr("agentOwner"); + address stranger = makeAddr("stranger"); + PredgeAgentValidatorV2 reg; + PredgeAgentValidatorV2 regId; + IdentityMock idr; + + event ValidationRequest(address indexed validatorAddress, uint256 indexed agentId, string requestURI, bytes32 indexed requestHash); + event ValidationResponse(address indexed validatorAddress, uint256 indexed agentId, bytes32 indexed requestHash, uint8 response, string responseURI, bytes32 responseHash, string tag); + event RequestRecorded(bytes32 indexed requestHash, bytes32 digest, address indexed requester); + event ResponseRecorded(bytes32 indexed requestHash, address indexed responder, uint64 respondedAt); + + function setUp() public { + idr = new IdentityMock(); + idr.set(7, agentOwner); + reg = new PredgeAgentValidatorV2(safe, op, address(0)); + regId = new PredgeAgentValidatorV2(safe, op, address(idr)); + } + + function _req(PredgeAgentValidatorV2 r, uint256 agent, bytes32 d) internal returns (bytes32 h) { + vm.prank(op); + h = r.validationRequest(op, agent, "ipfs://req", d); + } + + function test_requestHash_isDomainSeparated() public { + bytes32 d = keccak256("d"); + assertEq(reg.requestHashFor(d), keccak256(abi.encode(block.chainid, address(reg), d))); + assertTrue(reg.requestHashFor(d) != regId.requestHashFor(d), "differs per registry"); + bytes32 onThisChain = reg.requestHashFor(d); + vm.chainId(999); + assertTrue(reg.requestHashFor(d) != onThisChain, "differs per chain"); + } + + function test_request_response_flow() public { + bytes32 d = keccak256("d"); + bytes32 h = reg.requestHashFor(d); + vm.expectEmit(true, true, true, true); + emit ValidationRequest(op, 7, "ipfs://req", h); + vm.expectEmit(true, true, false, true); + emit RequestRecorded(h, d, op); + assertEq(_req(reg, 7, d), h); + assertEq(reg.requestCount(), 1); + assertEq(reg.requestURIOf(h), "ipfs://req"); + assertFalse(reg.isValidated(h)); + assertEq(reg.commitLeadTime(h), 0); + + vm.warp(block.timestamp + 60); + vm.expectEmit(true, true, true, true); + emit ValidationResponse(op, 7, h, 100, "ipfs://resp", keccak256("a"), "tag"); + vm.expectEmit(true, true, false, true); + emit ResponseRecorded(h, op, uint64(block.timestamp)); + vm.prank(op); + reg.validationResponse(h, 100, "ipfs://resp", keccak256("a"), "tag"); + assertTrue(reg.isValidated(h)); + assertEq(reg.commitLeadTime(h), 60); + assertEq(reg.responseURIOf(h), "ipfs://resp"); + (address v, uint256 a, uint8 r, bytes32 rh, string memory tag, uint256 last) = reg.getValidationStatus(h); + assertEq(v, op); assertEq(a, 7); assertEq(r, 100); assertEq(rh, keccak256("a")); assertEq(tag, "tag"); assertEq(last, block.timestamp); + } + + function test_status_pendingShowsRequestTime() public { + bytes32 h = _req(reg, 1, keccak256("p")); + (,,,,, uint256 last) = reg.getValidationStatus(h); + assertEq(last, block.timestamp); + } + + function test_request_isRestricted_M05() public { + vm.prank(stranger); + vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); + reg.validationRequest(op, 7, "x", keccak256("d")); + vm.prank(agentOwner); // no identity registry set: agent owners have no standing + vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); + reg.validationRequest(op, 7, "x", keccak256("d")); + } + + function test_request_agentOwnerViaIdentityRegistry() public { + vm.prank(agentOwner); + bytes32 h = regId.validationRequest(op, 7, "x", keccak256("d")); + assertEq(h, regId.requestHashFor(keccak256("d"))); + vm.prank(agentOwner); // owner of agent 7 cannot request for agent 8 (unknown token -> ownerOf reverts) + vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); + regId.validationRequest(op, 8, "x", keccak256("e")); + vm.prank(stranger); + vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); + regId.validationRequest(op, 7, "x", keccak256("e")); + idr.setBroken(true); + vm.prank(agentOwner); + vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); + regId.validationRequest(op, 7, "x", keccak256("e")); + } + + function test_request_reverts() public { + vm.startPrank(op); + vm.expectRevert(PredgeAgentValidatorV2.ZeroHash.selector); + reg.validationRequest(op, 1, "x", bytes32(0)); + vm.expectRevert(PredgeAgentValidatorV2.WrongValidator.selector); + reg.validationRequest(stranger, 1, "x", keccak256("d")); + reg.validationRequest(op, 1, "x", keccak256("d")); + vm.expectRevert(PredgeAgentValidatorV2.AlreadyRequested.selector); + reg.validationRequest(op, 1, "x", keccak256("d")); + vm.stopPrank(); + } + + function test_response_reverts() public { + bytes32 h = _req(reg, 1, keccak256("d")); + vm.prank(stranger); + vm.expectRevert(Operated.NotOperator.selector); + reg.validationResponse(h, 1, "", keccak256("a"), ""); + vm.startPrank(op); + vm.expectRevert(PredgeAgentValidatorV2.BadScore.selector); + reg.validationResponse(h, 101, "", keccak256("a"), ""); + vm.expectRevert(PredgeAgentValidatorV2.ZeroHash.selector); + reg.validationResponse(h, 1, "", bytes32(0), ""); + vm.expectRevert(PredgeAgentValidatorV2.NotRequested.selector); + reg.validationResponse(keccak256("never"), 1, "", keccak256("a"), ""); + reg.validationResponse(h, 0, "", keccak256("a"), ""); + vm.expectRevert(PredgeAgentValidatorV2.AlreadyResponded.selector); + reg.validationResponse(h, 1, "", keccak256("b"), ""); + vm.stopPrank(); + assertEq(reg.responseCount(), 1); + } + + function test_rotationNeverRewritesPastVerdict() public { + bytes32 h = _req(reg, 1, keccak256("d")); + vm.prank(op); + reg.validationResponse(h, 100, "", keccak256("a"), ""); + address newOp = makeAddr("new"); + vm.prank(safe); + reg.setOperator(newOp); + vm.prank(newOp); + vm.expectRevert(PredgeAgentValidatorV2.AlreadyResponded.selector); + reg.validationResponse(h, 0, "", keccak256("z"), ""); + (, , uint8 r, , ,) = reg.getValidationStatus(h); + assertEq(r, 100); + } + + function test_getSummary_dedupes_L06() public { + bytes32 a = _req(reg, 1, keccak256("a")); + bytes32 b = _req(reg, 1, keccak256("b")); + bytes32 c = _req(reg, 2, keccak256("c")); + bytes32 pending = _req(reg, 1, keccak256("p")); + vm.startPrank(op); + reg.validationResponse(a, 100, "", keccak256("1"), ""); + reg.validationResponse(b, 0, "", keccak256("2"), ""); + reg.validationResponse(c, 100, "", keccak256("3"), ""); + vm.stopPrank(); + bytes32[] memory hs = new bytes32[](7); + hs[0] = a; hs[1] = a; hs[2] = a; hs[3] = b; hs[4] = c; hs[5] = pending; hs[6] = keccak256("unknown"); + (uint64 n, uint8 avg) = reg.getSummary(1, hs); + assertEq(n, 2, "a counted once, not three times"); + assertEq(avg, 50); + (n, avg) = reg.getSummary(9, hs); + assertEq(n, 0); + assertEq(avg, 0); + } + + function test_getSummary_capped() public { + vm.expectRevert(PredgeAgentValidatorV2.TooManyHashes.selector); + reg.getSummary(1, new bytes32[](101)); + reg.getSummary(1, new bytes32[](100)); + } + + function testFuzz_summaryIgnoresDuplicates(uint8 dup) public { + dup = uint8(bound(dup, 1, 50)); + bytes32 a = _req(reg, 1, keccak256("a")); + vm.prank(op); + reg.validationResponse(a, 100, "", keccak256("1"), ""); + bytes32[] memory hs = new bytes32[](dup); + for (uint256 i; i < dup; ++i) hs[i] = a; + (uint64 n, uint8 avg) = reg.getSummary(1, hs); + assertEq(n, 1); + assertEq(avg, 100); + } +} + +contract AccessTest is Test { + address safe = makeAddr("safe"); + address op = makeAddr("operator"); + address next = makeAddr("next"); + PredgeSettlementV2 s; + PredgeAgentValidatorV2 reg; + + event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event OperatorUpdated(address indexed previousOperator, address indexed newOperator); + + function setUp() public { + s = new PredgeSettlementV2(safe); + reg = new PredgeAgentValidatorV2(safe, op, address(0)); + } + + function mkS(address o) external returns (address) { return address(new PredgeSettlementV2(o)); } + function mkR(address o, address p) external returns (address) { return address(new PredgeAgentValidatorV2(o, p, address(0))); } + + function test_constructorZeroOwnerOrOperator() public { + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mkS(address(0)); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mkR(address(0), op); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mkR(safe, address(0)); + } + + function test_deployerHoldsNoRole() public view { + assertEq(s.owner(), safe); + assertTrue(s.owner() != address(this)); + assertEq(reg.operator(), op); + } + + function test_twoStepOwnership() public { + vm.prank(op); + vm.expectRevert(Owned2Step.NotOwner.selector); + s.transferOwnership(next); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferStarted(safe, next); + vm.prank(safe); + s.transferOwnership(next); + assertEq(s.owner(), safe, "nomination alone changes nothing"); + assertEq(s.pendingOwner(), next); + + vm.prank(op); + vm.expectRevert(Owned2Step.NotPendingOwner.selector); + s.acceptOwnership(); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(safe, next); + vm.prank(next); + s.acceptOwnership(); + assertEq(s.owner(), next); + assertEq(s.pendingOwner(), address(0)); + + vm.prank(safe); + vm.expectRevert(Owned2Step.NotOwner.selector); + s.transferOwnership(safe); + } + + function test_nominationCanBeCancelled() public { + vm.startPrank(safe); + s.transferOwnership(next); + s.transferOwnership(address(0)); + vm.stopPrank(); + vm.prank(next); + vm.expectRevert(Owned2Step.NotPendingOwner.selector); + s.acceptOwnership(); + } + + function test_noRenounce() public { + (bool ok,) = address(s).call(abi.encodeWithSignature("renounceOwnership()")); + assertFalse(ok, "there is no renounceOwnership"); + assertEq(s.owner(), safe); + } + + function test_setOperator() public { + address n = makeAddr("n"); + vm.prank(op); + vm.expectRevert(Owned2Step.NotOwner.selector); + reg.setOperator(n); + vm.startPrank(safe); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + reg.setOperator(address(0)); + vm.expectEmit(true, true, false, false); + emit OperatorUpdated(op, n); + reg.setOperator(n); + vm.stopPrank(); + assertEq(reg.operator(), n); + vm.prank(op); + vm.expectRevert(Operated.NotOperator.selector); + reg.validationResponse(bytes32(uint256(1)), 1, "", bytes32(uint256(1)), ""); + } +} + +contract PredgeSettlementV2Test is Test { + address safe = makeAddr("safe"); + address payer = makeAddr("payer"); + PredgeSettlementV2 s; + + event Paid(address indexed payer, bytes32 indexed route, uint256 amount, uint256 timestamp, string meta); + event Withdrawn(address indexed to, uint256 amount); + + function setUp() public { + s = new PredgeSettlementV2(safe); + vm.deal(payer, 10 ether); + } + + function test_pay_and_receive() public { + vm.expectEmit(true, true, false, true); + emit Paid(payer, bytes32("r"), 5, block.timestamp, "meta"); + vm.prank(payer); + s.payForRoute{value: 5}(bytes32("r"), "meta"); + vm.expectEmit(true, true, false, true); + emit Paid(payer, bytes32("direct"), 7, block.timestamp, ""); + vm.prank(payer); + (bool ok,) = address(s).call{value: 7}(""); + assertTrue(ok); + assertEq(address(s).balance, 12); + } + + function test_zeroPaymentReverts() public { + vm.expectRevert(PredgeSettlementV2.ZeroPayment.selector); + s.payForRoute(bytes32("r"), ""); + (bool ok, bytes memory ret) = address(s).call(""); + assertFalse(ok); + assertEq(bytes4(ret), PredgeSettlementV2.ZeroPayment.selector); + } + + function test_withdraw_ownerOnly_eventBeforeCall() public { + vm.prank(payer); + s.payForRoute{value: 9}(bytes32("r"), ""); + vm.prank(payer); + vm.expectRevert(Owned2Step.NotOwner.selector); + s.withdraw(payable(payer)); + vm.expectEmit(true, false, false, true); + emit Withdrawn(safe, 9); + vm.prank(safe); + s.withdraw(payable(safe)); + assertEq(safe.balance, 9); + assertEq(address(s).balance, 0); + } + + function test_withdraw_zeroAddressRejected_L05() public { + vm.prank(payer); + s.payForRoute{value: 9}(bytes32("r"), ""); + vm.prank(safe); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + s.withdraw(payable(address(0))); + assertEq(address(s).balance, 9, "nothing burned"); + } + + function test_withdraw_failedTransferReverts() public { + NoReceive bad = new NoReceive(); + vm.prank(safe); + vm.expectRevert(PredgeSettlementV2.WithdrawFailed.selector); + s.withdraw(payable(address(bad))); + } + + function test_forcedValueIsWithdrawable() public { + vm.deal(address(s), 3 ether); // simulates forced (selfdestruct / coinbase) value + vm.prank(safe); + s.withdraw(payable(safe)); + assertEq(safe.balance, 3 ether); + } +} + +contract MockRefundProtocolV2Test is Test { + MockRefundProtocolV2 m; + address arbiter = makeAddr("arbiter"); + address a = makeAddr("a"); + address b = makeAddr("b"); + + function setUp() public { + m = new MockRefundProtocolV2(arbiter); + } + + function mkM(address a_) external returns (address) { return address(new MockRefundProtocolV2(a_)); } + + function test_zeroArbiter() public { + vm.expectRevert(MockRefundProtocolV2.ZeroAddress.selector); + this.mkM(address(0)); + } + + function test_record_and_info() public { + vm.expectRevert(MockRefundProtocolV2.ZeroAddress.selector); + m.recordPayment(address(0), b, 1, 1); + vm.expectRevert(MockRefundProtocolV2.ZeroAddress.selector); + m.recordPayment(a, address(0), 1, 1); + uint256 id = m.recordPayment(a, b, 5, 100); + (address p, address q, uint256 amt, uint256 rel) = m.paymentInfo(id); + assertEq(p, a); assertEq(q, b); assertEq(amt, 5); assertEq(rel, 100); + } + + function test_refund_guards() public { + uint256 id = m.recordPayment(a, b, 5, block.timestamp + 10); + vm.expectRevert(MockRefundProtocolV2.CallerNotArbiter.selector); + m.refundByArbiter(id); + vm.startPrank(arbiter); + vm.expectRevert(MockRefundProtocolV2.UnknownPayment.selector); + m.refundByArbiter(99); + m.refundByArbiter(id); + vm.expectRevert(MockRefundProtocolV2.AlreadyRefunded.selector); + m.refundByArbiter(id); + uint256 id2 = m.recordPayment(a, b, 5, block.timestamp + 10); + vm.warp(block.timestamp + 10); + vm.expectRevert(MockRefundProtocolV2.AlreadyReleased.selector); + m.refundByArbiter(id2); + vm.stopPrank(); + assertEq(m.refundCount(), 1); + } + + function test_lockup_arbiterOnly() public { + vm.expectRevert(MockRefundProtocolV2.CallerNotArbiter.selector); + m.setLockupSeconds(a, 1); + vm.prank(arbiter); + m.setLockupSeconds(a, 1); + assertEq(m.lockupSeconds(a), 1); + } +} diff --git a/test/foundry-v2/Pocs.t.sol b/test/foundry-v2/Pocs.t.sol new file mode 100644 index 0000000..ecc7cba --- /dev/null +++ b/test/foundry-v2/Pocs.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Base} from "./Base.sol"; +import {ArbiterBase} from "./PredgeRefundArbiterV2.t.sol"; +import {PredgeValidatorBondV2} from "../../contracts/v2/PredgeValidatorBondV2.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {IAgentJobV2} from "../../contracts/v2/interfaces/IAgentJobV2.sol"; +import {AgentJob as AgentJobV1} from "../../contracts/AgentJob.sol"; +import {PredgeValidatorBond as BondV1} from "../../contracts/PredgeValidatorBond.sol"; +import {PredgeRefundArbiter as ArbiterV1} from "../../contracts/PredgeRefundArbiter.sol"; +import {MockRefundProtocol as MockRPV1} from "../../contracts/MockRefundProtocol.sol"; + +/// @notice Proof-of-concept tests for the self-audit findings. Each finding has two tests: the exact +/// v1 attack sequence run against the v1 contracts (it SUCCEEDS there, which proves the PoC +/// is a real exploit) and the same sequence against v2 (it reverts or is harmless). +contract PocsBondTest is Base { + bytes32 constant E = keccak256("expected"); + bytes32 constant SALT = keccak256("salt"); + bytes32 constant X = keccak256("not the expected thing"); + + function setUp() public { + _deployJobAndBond(); + } + + // ------------------------------------------------------------------ H-01 + + /// @dev v1: score a verdict BEFORE the provider submits, wait out the window, reclaim, then let + /// the provider submit something that contradicts the verdict. The lie is provable but closed. + function test_v1_H01_verdictBeforeSubmissionIsReclaimedUnchallengeable() public { + AgentJobV1 j1 = new AgentJobV1(); + BondV1 b1 = new BondV1(op, address(j1), 1 days); + bytes32 spec = keccak256("spec"); + vm.prank(client); + uint256 id = j1.createJob{value: 1 ether}(provider, op, spec); + vm.startPrank(op); + b1.stakeAndCommit{value: 1 ether}(spec, E, id); + b1.recordScore(spec, 100); // verdict recorded with NO deliverable in existence + vm.warp(block.timestamp + 1 days); + b1.reclaim(spec); // bond back, nothing was ever challengeable + vm.stopPrank(); + vm.prank(provider); + j1.submit(id, X, ""); // the deliverable now contradicts the verdict + vm.expectRevert(BondV1.Closed.selector); + b1.challenge(spec); // provably false, permanently closed + } + + /// @dev v2: the same sequence cannot even start. Scoring needs a submission, so nothing can be + /// scored or reclaimed before the deliverable exists, and the window starts at the score. + function test_v2_H01_verdictBeforeSubmissionReverts() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, E, SALT, id, 1 ether); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.NotSubmitted.selector); + bond.recordScore(spec, 100, E, SALT, 0); + vm.warp(block.timestamp + 1 days); + vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); + bond.reclaim(spec); // nothing to reclaim without a verdict + // The only exit is the job expiring with NO verdict ever given, which lies about nothing. + vm.warp(block.timestamp + 7 days); + job.claimRefund(id); + bond.release(spec); + assertEq(job.credit(client), 1 ether, "client got the escrow back"); + } + + /// @dev v2: a submission that lands after the score attempt is judged inside a full window. + function test_v2_H01_windowStartsAtOrAfterSubmission() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, E, SALT, id, 1 ether); + vm.warp(block.timestamp + 2 days); + _submit(id, X); + _score(spec, 100, E, SALT); // a lie + (,,,, uint64 scoredAt,,,,,) = bond.stakes(spec); + assertGe(scoredAt, job.getJob(id).submittedAt); + vm.warp(block.timestamp + 1 days - 1); + bond.challenge(spec); // still inside its own full window, however long ago the stake was made + assertEq(bond.credit(client), 1 ether); + } + + // ------------------------------------------------------------------ H-02 + + /// @dev v1: `expected` is public from the moment of the stake. The provider submits it with no work; + /// an honest score of 0 is then slashable. (And committing after submission, as the v1 smoke test + /// does, lets the validator copy the deliverable.) + function test_v1_H02_providerCopiesPublicExpectedAndHonestFailBecomesSlashable() public { + AgentJobV1 j1 = new AgentJobV1(); + BondV1 b1 = new BondV1(op, address(j1), 1 days); + bytes32 spec = keccak256("spec"); + vm.prank(client); + uint256 id = j1.createJob{value: 1 ether}(provider, op, spec); + vm.prank(op); + b1.stakeAndCommit{value: 1 ether}(spec, E, id); + (bytes32 leaked,,,,,,,) = b1.stakes(spec); // anyone can read it + assertEq(leaked, E); + vm.prank(provider); + j1.submit(id, leaked, ""); // no work, just the copied answer + vm.prank(op); + b1.recordScore(spec, 0); // the validator's honest view: nothing was really produced + assertTrue(b1.wouldSlash(spec), "an honest-by-intent verdict is now slashable"); + vm.prank(stranger); + b1.challenge(spec); // and the bond is taken + } + + /// @dev v2: the committed value is a hash, so there is nothing to copy. + function test_v2_H02_expectedIsHiddenSoItCannotBeCopied() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, E, SALT, id, 1 ether); + (,,,,,,,, bytes32 commitment, bytes32 expected) = bond.stakes(spec); + assertEq(expected, bytes32(0)); + assertEq(commitment, keccak256(abi.encode(E, SALT))); + // The best the provider can do with public data is submit the commitment itself. + _submit(id, commitment); + _score(spec, 0, E, SALT); // honest: the deliverable is not the expected hash + assertFalse(bond.wouldSlash(spec)); + vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); + bond.challenge(spec); + } + + /// @dev v2: the operator cannot choose `expected` after seeing the deliverable. + function test_v2_H02_cannotCommitAfterSubmission() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _submit(id, X); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); + bond.stakeAndCommit{value: 1 ether}(spec, keccak256(abi.encode(X, SALT)), id); + } + + /// @dev v2: a reveal that does not match the commitment is rejected, so `expected` cannot be swapped. + function test_v2_H02_cannotSwapExpectedAtReveal() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, E, SALT, id, 1 ether); + _submit(id, X); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.BadReveal.selector); + bond.recordScore(spec, 100, X, SALT, 0); // would make the false pass look honest + } + + // ------------------------------------------------------------------ M-02 / M-04 + + /// @dev M-02: the bond is paid to the harmed party; a self-challenge by the operator gains nothing. + function test_v2_M02_selfChallengeIsNotProfitable() public { + (uint256 id, bytes32 spec) = _newJob(1, 5 ether); + _stake(spec, E, SALT, id, 5 ether); + _submit(id, X); + _score(spec, 100, E, SALT); // false pass, provider gets 5 + uint256 opBefore = op.balance; + vm.prank(op); + bond.challenge(spec); + assertEq(bond.credit(op), 0); + assertEq(op.balance, opBefore); + assertEq(bond.credit(client), 5 ether, "the cheated client is made whole"); + } + + /// @dev M-04: the verdict IS the settlement; there is no way to record a verdict that does not move the job. + function test_v2_M04_verdictMovesTheMoney() public { + (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + _stake(spec, E, SALT, id, 2 ether); + _submit(id, E); + _score(spec, 100, E, SALT); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Completed)); + // No other party can settle this job: the bond contract is the only evaluator. + vm.prank(op); + vm.expectRevert(); + job.reject(id, 0, ""); + } + + /// @dev A colluding operator and provider cannot profit from a false pass at the default 100% bond ratio. + function testFuzz_collusionNeverProfitsAtFullBond(uint96 escrow) public { + escrow = uint96(bound(escrow, 1, 100 ether)); + (uint256 id, bytes32 spec) = _newJob(9, escrow); + uint256 b = bond.requiredBond(escrow); + _stake(spec, E, SALT, id, b); + _submit(id, X); + _score(spec, 100, E, SALT); + bond.challenge(spec); + uint256 colludersGain = job.credit(provider); // the escrow they extracted + uint256 colludersLoss = b; // the bond they lost to the client + assertGe(colludersLoss, colludersGain); + assertGe(bond.credit(client), escrow, "client is made whole"); + } +} + +contract PocsArbiterTest is ArbiterBase { + PredgeRefundArbiterV2.Direction constant REFUND = PredgeRefundArbiterV2.Direction.Refund; + PredgeRefundArbiterV2.Direction constant RELEASE = PredgeRefundArbiterV2.Direction.Release; + bytes32 constant EV = keccak256("fabricated evidence"); + + function setUp() public { + _deployArbiter(); + } + + /// @dev v1 (a): any key can file "evidence" for any payment id, and the operator can cite it. + /// (b) the refund executes inside rule(), before any window. (c) challenge can never succeed. + function test_v1_H03_arbitraryRefundAtGasCostAndUnslashable() public { + // arbiter v1 is deployed with owner = this contract; the mock needs the arbiter address. + address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1); + MockRPV1 rp1 = new MockRPV1(predicted); + ArbiterV1 a1 = new ArbiterV1(address(rp1), op, 1 days, 1e15); + assertEq(address(a1), predicted); + uint256 victimPayment = 4242; + vm.prank(stranger); // not a party to anything + a1.fileEvidence(victimPayment, EV); + vm.prank(op); + a1.rule{value: 1e15}(victimPayment, stranger, EV, ArbiterV1.Direction.Refund); + assertTrue(rp1.refunded(victimPayment), "refund executed immediately, on a stranger's say-so"); + assertFalse(a1.wouldSlash(victimPayment)); + vm.expectRevert(); + a1.challenge(victimPayment); // RulingHonest, always + vm.warp(block.timestamp + 1 days); + vm.prank(op); + a1.reclaim(victimPayment); // bond returned: the whole thing cost gas + } + + /// @dev v2 (a): a stranger cannot file evidence for someone else's payment, and cannot be cited. + function test_v2_H03_evidenceBoundToPaymentParties() public { + uint256 pid = _pay(1 ether); + vm.prank(stranger); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); + arb.fileEvidence(pid, EV); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); + arb.rule{value: 1 ether}(pid, stranger, EV, REFUND); + // and a payment id that does not exist cannot be ruled on at all + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.UnknownPayment.selector); + arb.rule{value: 1 ether}(4242, stranger, EV, REFUND); + } + + /// @dev v2 (b): nothing executes inside rule(); the refund only happens after the window. + function test_v2_H03_noRefundInsideRule() public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, REFUND, arb.requiredBond(1 ether)); + assertEq(rp.refundCount(), 0); + vm.warp(block.timestamp + WINDOW - 1); + vm.expectRevert(PredgeRefundArbiterV2.WindowOpen.selector); + arb.finalize(pid); + assertEq(rp.refundCount(), 0); + } + + /// @dev v2 (c): a reachable challenge path. The harmed payee freezes a wrongful refund; the Safe + /// overturns; the refund never executes and the operator's bond goes to the payee. + function test_v2_H03_wrongfulRefundIsStoppedAndSlashed() public { + uint256 pid = _pay(10 ether); + _file(pid); + uint256 bondAmt = arb.requiredBond(10 ether); + _rule(pid, payer, EV_PAYER, REFUND, bondAmt); + vm.prank(payee); + arb.dispute(pid, EV_COUNTER); + vm.prank(safe); + arb.resolve(pid, RELEASE); + assertEq(rp.refundCount(), 0, "the wrongful refund never executed"); + assertEq(arb.credit(payee), bondAmt, "operator's bond paid to the harmed payee"); + assertEq(arb.credit(op), 0); + } + + /// @dev v2: a refund cannot be forced inside the window by anyone, in any order of calls. + function testFuzz_v2_refundOnlyAfterWindowOrOwnerDecision(uint32 dt) public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, REFUND, arb.requiredBond(1 ether)); + dt = uint32(bound(dt, 0, WINDOW - 1)); + vm.warp(block.timestamp + dt); + vm.expectRevert(PredgeRefundArbiterV2.WindowOpen.selector); + arb.finalize(pid); + vm.prank(op); + vm.expectRevert(); + arb.expire(pid); + assertEq(rp.refundCount(), 0); + } +} diff --git a/test/foundry-v2/PredgeRefundArbiterV2.t.sol b/test/foundry-v2/PredgeRefundArbiterV2.t.sol new file mode 100644 index 0000000..09dd59b --- /dev/null +++ b/test/foundry-v2/PredgeRefundArbiterV2.t.sol @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {MockRefundProtocolV2} from "../../contracts/v2/mocks/MockRefundProtocolV2.sol"; +import {Owned2Step, Operated} from "../../contracts/v2/access/PredgeAccess.sol"; +import {PullCredit} from "../../contracts/v2/access/PullCredit.sol"; + +abstract contract ArbiterBase is Test { + address internal safe = makeAddr("safe"); + address internal op = makeAddr("operator"); + address internal payer = makeAddr("payer"); + address internal payee = makeAddr("payee"); + address internal stranger = makeAddr("stranger"); + + uint64 internal constant WINDOW = 1 days; + uint256 internal constant MIN_BOND = 1e15; + uint16 internal constant BPS = 500; // 5% of the payment + + PredgeRefundArbiterV2 internal arb; + MockRefundProtocolV2 internal rp; + + bytes32 internal constant EV_PAYER = keccak256("payer evidence"); + bytes32 internal constant EV_PAYEE = keccak256("payee evidence"); + bytes32 internal constant EV_COUNTER = keccak256("counter evidence"); + + function _deployArbiter() internal { + // The mock needs the arbiter address and vice versa: predict the arbiter (next CREATE after the mock). + uint256 n = vm.getNonce(address(this)); + address predicted = vm.computeCreateAddress(address(this), n + 1); + rp = new MockRefundProtocolV2(predicted); + arb = new PredgeRefundArbiterV2(safe, op, address(rp), WINDOW, MIN_BOND, BPS); + assertEq(address(arb), predicted); + vm.deal(op, 1_000 ether); + vm.deal(payer, 10 ether); + vm.deal(payee, 10 ether); + } + + function _pay(uint256 amount) internal returns (uint256 pid) { + pid = rp.recordPayment(payer, payee, amount, block.timestamp + 30 days); + } + + function _file(uint256 pid) internal { + vm.prank(payer); + arb.fileEvidence(pid, EV_PAYER); + vm.prank(payee); + arb.fileEvidence(pid, EV_PAYEE); + } + + function _rule(uint256 pid, address from, bytes32 ev, PredgeRefundArbiterV2.Direction d, uint256 value) internal { + vm.prank(op); + arb.rule{value: value}(pid, from, ev, d); + } + + function _status(uint256 pid) internal view returns (PredgeRefundArbiterV2.Status st) { + st = arb.rulings(pid).status; + } +} + +contract PredgeRefundArbiterV2Test is ArbiterBase { + PredgeRefundArbiterV2.Direction internal constant REFUND = PredgeRefundArbiterV2.Direction.Refund; + PredgeRefundArbiterV2.Direction internal constant RELEASE = PredgeRefundArbiterV2.Direction.Release; + PredgeRefundArbiterV2.Direction internal constant NONE = PredgeRefundArbiterV2.Direction.None; + + event EvidenceFiled(uint256 indexed paymentID, address indexed party, bytes32 evidenceHash); + event Ruled(uint256 indexed paymentID, address indexed staker, address indexed evidenceFrom, bytes32 evidenceHash, PredgeRefundArbiterV2.Direction direction, uint256 bond, uint64 windowEnd); + event Disputed(uint256 indexed paymentID, address indexed disputer, bytes32 counterEvidence, uint64 expiresAt); + event Finalized(uint256 indexed paymentID, address indexed staker, PredgeRefundArbiterV2.Direction direction, uint256 bond); + event Overturned(uint256 indexed paymentID, address indexed harmed, PredgeRefundArbiterV2.Direction finalDirection, uint256 bond); + event Voided(uint256 indexed paymentID, address indexed staker, uint256 bond); + event ParamsUpdated(uint64 challengeWindow, uint256 minBond, uint16 bondBps); + + function setUp() public { + _deployArbiter(); + } + + /// @dev `new` under `vm.expectRevert` ends the test early in forge; deploy through an external call instead. + function mk(address o, address p, address r, uint64 w, uint256 m, uint16 b) external returns (address) { + return address(new PredgeRefundArbiterV2(o, p, r, w, m, b)); + } + + function test_constructor_and_params() public { + assertEq(arb.owner(), safe); + assertEq(arb.operator(), op); + assertEq(address(arb.refundProtocol()), address(rp)); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mk(safe, op, address(0), WINDOW, MIN_BOND, BPS); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + this.mk(safe, op, address(rp), 1 minutes, MIN_BOND, BPS); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + this.mk(safe, op, address(rp), 31 days, MIN_BOND, BPS); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + this.mk(safe, op, address(rp), WINDOW, 0, BPS); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + this.mk(safe, op, address(rp), WINDOW, MIN_BOND, 10_001); + + vm.prank(op); + vm.expectRevert(Owned2Step.NotOwner.selector); + arb.setParams(2 days, 1, 1); + vm.startPrank(safe); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setParams(1 minutes, 5, 1000); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setParams(2 days, 5, 10_001); + vm.stopPrank(); + vm.prank(safe); + vm.expectEmit(false, false, false, true); + emit ParamsUpdated(2 days, 5, 1000); + arb.setParams(2 days, 5, 1000); + assertEq(arb.requiredBond(1 ether), 0.1 ether); + assertEq(arb.requiredBond(1), 5); + } + + function test_setLockupSeconds_isOwnerOnlyPassThrough() public { + vm.prank(op); + vm.expectRevert(Owned2Step.NotOwner.selector); + arb.setLockupSeconds(payee, 5); + vm.prank(safe); + arb.setLockupSeconds(payee, 5); + assertEq(rp.lockupSeconds(payee), 5); + } + + // ---- evidence ---- + + function test_fileEvidence_onlyPartiesWriteOnce() public { + uint256 pid = _pay(1 ether); + vm.expectEmit(true, true, false, true); + emit EvidenceFiled(pid, payer, EV_PAYER); + vm.prank(payer); + arb.fileEvidence(pid, EV_PAYER); + assertEq(arb.filedEvidence(pid, payer), EV_PAYER); + + vm.prank(payer); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiterV2.EvidenceAlreadyFiled.selector, pid, payer)); + arb.fileEvidence(pid, EV_COUNTER); + vm.prank(stranger); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); + arb.fileEvidence(pid, EV_COUNTER); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); // the arbiter cannot manufacture the record + arb.fileEvidence(pid, EV_COUNTER); + vm.prank(payee); + vm.expectRevert(PredgeRefundArbiterV2.ZeroHash.selector); + arb.fileEvidence(pid, bytes32(0)); + vm.prank(payee); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); // payment that does not exist: no party + arb.fileEvidence(999, EV_PAYEE); + } + + function test_fileEvidence_closedOnceRuled() public { + uint256 pid = _pay(1 ether); + vm.prank(payer); + arb.fileEvidence(pid, EV_PAYER); + _rule(pid, payer, EV_PAYER, REFUND, 1 ether); + vm.prank(payee); + vm.expectRevert(PredgeRefundArbiterV2.EvidenceClosed.selector); + arb.fileEvidence(pid, EV_PAYEE); + } + + // ---- rule ---- + + function test_rule_recordsAndExecutesNothing() public { + uint256 pid = _pay(1 ether); + _file(pid); + uint256 bondNeeded = arb.requiredBond(1 ether); // 5% = 0.05 ether + assertEq(bondNeeded, 0.05 ether); + vm.expectEmit(true, true, true, true); + emit Ruled(pid, op, payer, EV_PAYER, REFUND, bondNeeded, uint64(block.timestamp) + WINDOW); + _rule(pid, payer, EV_PAYER, REFUND, bondNeeded); + assertEq(rp.refundCount(), 0, "optimistic: nothing executes inside rule()"); + assertEq(uint8(_status(pid)), uint8(PredgeRefundArbiterV2.Status.Ruled)); + assertEq(arb.totalBonded(), bondNeeded); + assertEq(arb.rulingCount(), 1); + assertEq(arb.harmedParty(pid), payee); + } + + function test_rule_reverts() public { + uint256 pid = _pay(1 ether); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + vm.deal(stranger, 1 ether); + vm.prank(stranger); + vm.expectRevert(Operated.NotOperator.selector); + arb.rule{value: need}(pid, payer, EV_PAYER, REFUND); + + vm.startPrank(op); + vm.expectRevert(PredgeRefundArbiterV2.ZeroHash.selector); + arb.rule{value: need}(pid, payer, bytes32(0), REFUND); + vm.expectRevert(PredgeRefundArbiterV2.BadDirection.selector); + arb.rule{value: need}(pid, payer, EV_PAYER, NONE); + vm.expectRevert(PredgeRefundArbiterV2.UnknownPayment.selector); + arb.rule{value: need}(777, payer, EV_PAYER, REFUND); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); // evidence from someone who is not a party + arb.rule{value: need}(pid, stranger, EV_PAYER, REFUND); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiterV2.NoEvidenceFiled.selector, pid, payer)); + arb.rule{value: need}(pid, payer, EV_PAYEE, REFUND); // cites a hash the payer never filed + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiterV2.BondTooSmall.selector, need)); + arb.rule{value: need - 1}(pid, payer, EV_PAYER, REFUND); + arb.rule{value: need}(pid, payer, EV_PAYER, REFUND); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiterV2.AlreadyRuled.selector, pid)); + arb.rule{value: need}(pid, payer, EV_PAYER, REFUND); + vm.stopPrank(); + } + + function test_rule_operatorCannotBeAParty() public { + uint256 pid = rp.recordPayment(op, payee, 1 ether, block.timestamp + 30 days); + vm.prank(op); + arb.fileEvidence(pid, EV_PAYER); // the operator is the payer: it may file as a party... + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.OperatorIsParty.selector); // ...but cannot then rule on its own payment + arb.rule{value: 1 ether}(pid, op, EV_PAYER, REFUND); + uint256 pid2 = rp.recordPayment(payer, op, 1 ether, block.timestamp + 30 days); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.OperatorIsParty.selector); + arb.rule{value: 1 ether}(pid2, payer, EV_PAYER, REFUND); + } + + function test_rule_refundNeedsLockupBeyondWindow() public { + uint256 pid = rp.recordPayment(payer, payee, 1 ether, block.timestamp + WINDOW); + _file(pid); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.LockupTooShort.selector); + arb.rule{value: 1 ether}(pid, payer, EV_PAYER, REFUND); + _rule(pid, payer, EV_PAYER, RELEASE, 1 ether); // a Release moves nothing, so the lockup does not matter + } + + function test_bondScalesWithPayment() public { + uint256 pid = _pay(1000 ether); + _file(pid); + assertEq(arb.requiredBond(1000 ether), 50 ether); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiterV2.BondTooSmall.selector, 50 ether)); + arb.rule{value: MIN_BOND}(pid, payer, EV_PAYER, REFUND); + } + + // ---- undisputed path ---- + + function test_finalize_refund_afterWindow() public { + uint256 pid = _pay(1 ether); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + _rule(pid, payer, EV_PAYER, REFUND, need); + vm.expectRevert(PredgeRefundArbiterV2.WindowOpen.selector); + arb.finalize(pid); + vm.warp(block.timestamp + WINDOW); + vm.expectEmit(true, true, false, true); + emit Finalized(pid, op, REFUND, need); + vm.prank(stranger); + arb.finalize(pid); + assertEq(rp.refundCount(), 1); + assertEq(arb.credit(op), need); + assertEq(arb.totalBonded(), 0); + vm.expectRevert(PredgeRefundArbiterV2.BadStatus.selector); + arb.finalize(pid); + } + + function test_finalize_release_executesNothing() public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payee, EV_PAYEE, RELEASE, MIN_BOND + 1 ether); + vm.warp(block.timestamp + WINDOW); + arb.finalize(pid); + assertEq(rp.refundCount(), 0); + assertEq(arb.credit(op), MIN_BOND + 1 ether); + } + + function test_finalize_revertsIfRefundProtocolReverts_thenExpireVoids() public { + uint256 pid = _pay(1 ether); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + _rule(pid, payer, EV_PAYER, REFUND, need); + vm.warp(block.timestamp + WINDOW); + vm.mockCallRevert(address(rp), abi.encodeWithSelector(MockRefundProtocolV2.refundByArbiter.selector, pid), "rp down"); + vm.expectRevert("rp down"); + arb.finalize(pid); + vm.expectRevert(PredgeRefundArbiterV2.NotExpired.selector); + arb.expire(pid); + vm.warp(block.timestamp + arb.RESOLUTION_TIMEOUT()); + vm.expectEmit(true, true, false, true); + emit Voided(pid, op, need); + arb.expire(pid); + assertEq(arb.credit(op), need, "bond is never stuck"); + assertEq(rp.refundCount(), 0, "voided: nothing executed"); + assertEq(uint8(_status(pid)), uint8(PredgeRefundArbiterV2.Status.Voided)); + } + + // ---- dispute path (H-03) ---- + + function test_dispute_freezesAndOwnerOverturnsRefund() public { + uint256 pid = _pay(1 ether); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + _rule(pid, payer, EV_PAYER, REFUND, need); // harms the payee + vm.expectEmit(true, true, false, true); + emit Disputed(pid, payee, EV_COUNTER, uint64(block.timestamp) + arb.RESOLUTION_TIMEOUT()); + vm.prank(payee); + arb.dispute(pid, EV_COUNTER); + assertEq(uint8(_status(pid)), uint8(PredgeRefundArbiterV2.Status.Disputed)); + + vm.warp(block.timestamp + WINDOW); + vm.expectRevert(PredgeRefundArbiterV2.BadStatus.selector); // frozen: cannot finalize + arb.finalize(pid); + + vm.expectEmit(true, true, false, true); + emit Overturned(pid, payee, RELEASE, need); + vm.prank(safe); + arb.resolve(pid, RELEASE); + assertEq(arb.credit(payee), need, "operator's bond goes to the harmed party"); + assertEq(arb.credit(op), 0); + assertEq(rp.refundCount(), 0); + assertEq(arb.overturnCount(), 1); + assertEq(arb.totalBonded(), 0); + } + + function test_dispute_ownerUpholds() public { + uint256 pid = _pay(1 ether); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + _rule(pid, payer, EV_PAYER, REFUND, need); + vm.prank(payee); + arb.dispute(pid, EV_COUNTER); + vm.prank(safe); + arb.resolve(pid, REFUND); + assertEq(arb.credit(op), need); + assertEq(rp.refundCount(), 1); + assertEq(uint8(_status(pid)), uint8(PredgeRefundArbiterV2.Status.Finalized)); + } + + function test_dispute_ownerOverturnsReleaseIntoRefund() public { + uint256 pid = _pay(1 ether); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + _rule(pid, payee, EV_PAYEE, RELEASE, need); // harms the payer + assertEq(arb.harmedParty(pid), payer); + vm.prank(payer); + arb.dispute(pid, EV_COUNTER); + vm.prank(safe); + arb.resolve(pid, REFUND); + assertEq(arb.credit(payer), need); + assertEq(rp.refundCount(), 1); + } + + function test_dispute_reverts() public { + uint256 pid = _pay(1 ether); + _file(pid); + vm.prank(payee); + vm.expectRevert(PredgeRefundArbiterV2.ZeroHash.selector); + arb.dispute(pid, bytes32(0)); + vm.prank(payee); + vm.expectRevert(PredgeRefundArbiterV2.BadStatus.selector); // not ruled yet + arb.dispute(pid, EV_COUNTER); + _rule(pid, payer, EV_PAYER, REFUND, MIN_BOND + 1 ether); + vm.prank(payer); // the party the ruling FAVOURS cannot freeze it + vm.expectRevert(PredgeRefundArbiterV2.NotHarmedParty.selector); + arb.dispute(pid, EV_COUNTER); + vm.prank(stranger); + vm.expectRevert(PredgeRefundArbiterV2.NotHarmedParty.selector); + arb.dispute(pid, EV_COUNTER); + vm.warp(block.timestamp + WINDOW); + vm.prank(payee); + vm.expectRevert(PredgeRefundArbiterV2.WindowClosed.selector); + arb.dispute(pid, EV_COUNTER); + } + + function test_resolve_reverts() public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, REFUND, MIN_BOND + 1 ether); + vm.startPrank(safe); + vm.expectRevert(PredgeRefundArbiterV2.BadDirection.selector); + arb.resolve(pid, NONE); + vm.expectRevert(PredgeRefundArbiterV2.BadStatus.selector); // not disputed + arb.resolve(pid, REFUND); + vm.stopPrank(); + vm.prank(payee); + arb.dispute(pid, EV_COUNTER); + vm.prank(op); + vm.expectRevert(Owned2Step.NotOwner.selector); + arb.resolve(pid, RELEASE); + vm.warp(block.timestamp + arb.RESOLUTION_TIMEOUT()); + vm.prank(safe); + vm.expectRevert(PredgeRefundArbiterV2.ResolutionExpired.selector); + arb.resolve(pid, RELEASE); + arb.expire(pid); // an idle owner cannot freeze the bond forever + assertEq(arb.credit(op), MIN_BOND + 1 ether); + } + + function test_expire_badStatus() public { + vm.expectRevert(PredgeRefundArbiterV2.BadStatus.selector); + arb.expire(1); + } + + function test_harmedParty_unruledIsZero() public view { + assertEq(arb.harmedParty(5), address(0)); + } + + function test_paramChangeIsNotRetroactive() public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, REFUND, MIN_BOND + 1 ether); + vm.prank(safe); + arb.setParams(30 days, 1e18, 10_000); // owner tries to stretch the window / raise the bond afterwards + vm.warp(block.timestamp + WINDOW); + arb.finalize(pid); // still the old 1 day window + assertEq(arb.credit(op), MIN_BOND + 1 ether); + } + + function test_bondWithdraw() public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, RELEASE, 2 ether); + vm.warp(block.timestamp + WINDOW); + arb.finalize(pid); + uint256 b = op.balance; + vm.prank(op); + arb.withdraw(); + assertEq(op.balance, b + 2 ether); + vm.prank(op); + vm.expectRevert(PullCredit.NothingToWithdraw.selector); + arb.withdraw(); + } + + function testFuzz_bondAlwaysAtLeastScaledAndFloor(uint128 amount, uint16 bps, uint128 floor_) public { + bps = uint16(bound(bps, 0, 10_000)); + floor_ = uint128(bound(floor_, 1, 1e24)); + vm.prank(safe); + arb.setParams(WINDOW, floor_, bps); + uint256 need = arb.requiredBond(amount); + assertGe(need, floor_); + assertGe(need * 10_000, uint256(amount) * bps); + assertLe(need, uint256(floor_) + uint256(amount)); // never more than floor + amount at bps <= 100% + } +} diff --git a/test/foundry-v2/PredgeValidatorBondV2.t.sol b/test/foundry-v2/PredgeValidatorBondV2.t.sol new file mode 100644 index 0000000..7584965 --- /dev/null +++ b/test/foundry-v2/PredgeValidatorBondV2.t.sol @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Base, NoReceive} from "./Base.sol"; +import {PredgeValidatorBondV2} from "../../contracts/v2/PredgeValidatorBondV2.sol"; +import {IAgentJobV2} from "../../contracts/v2/interfaces/IAgentJobV2.sol"; +import {PullCredit} from "../../contracts/v2/access/PullCredit.sol"; +import {Owned2Step, Operated} from "../../contracts/v2/access/PredgeAccess.sol"; + +contract PredgeValidatorBondV2Test is Base { + bytes32 internal constant EXPECTED = keccak256("expected output hash"); + bytes32 internal constant SALT = keccak256("salt"); + bytes32 internal constant OTHER = keccak256("something else"); + + event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond, uint64 window); + event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, uint64 scoredAt, uint64 windowEnd); + event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered); + event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps); + + function setUp() public { + _deployJobAndBond(); + } + + // ---- construction / params (M-01, L-01) ---- + + /// @dev `new` under `vm.expectRevert` ends the test early in forge; deploy through an external call instead. + function mk(address o, address p, address j, uint64 w, uint256 m, uint16 b) external returns (address) { + return address(new PredgeValidatorBondV2(o, p, j, w, m, b)); + } + + function test_constructor() public view { + assertEq(bond.owner(), safe); + assertEq(bond.operator(), op); + assertEq(address(bond.job()), address(job)); + assertEq(bond.disputeWindow(), WINDOW); + assertEq(bond.minBond(), MIN_BOND); + assertEq(bond.bondBps(), BPS); + } + + function test_constructor_reverts() public { + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mk(address(0), op, address(job), WINDOW, MIN_BOND, BPS); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mk(safe, address(0), address(job), WINDOW, MIN_BOND, BPS); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mk(safe, op, address(0), WINDOW, MIN_BOND, BPS); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + this.mk(safe, op, address(job), 59 minutes, MIN_BOND, BPS); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + this.mk(safe, op, address(job), 31 days, MIN_BOND, BPS); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + this.mk(safe, op, address(job), WINDOW, 0, BPS); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + this.mk(safe, op, address(job), WINDOW, MIN_BOND, 10_001); + } + + function test_setParams_ownerOnlyAndBounded() public { + vm.prank(op); + vm.expectRevert(Owned2Step.NotOwner.selector); + bond.setParams(2 days, 1, 5000); + vm.startPrank(safe); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + bond.setParams(1 minutes, 1, 5000); + vm.expectEmit(false, false, false, true); + emit ParamsUpdated(2 days, 7, 5000); + bond.setParams(2 days, 7, 5000); + vm.stopPrank(); + assertEq(bond.disputeWindow(), 2 days); + assertEq(bond.minBond(), 7); + assertEq(bond.bondBps(), 5000); + } + + function test_paramChangeIsNotRetroactive() public { + (uint256 id, bytes32 spec) = _newJob(1, 10 ether); + _stake(spec, EXPECTED, SALT, id, 10 ether); + vm.prank(safe); + bond.setParams(30 days, MIN_BOND, BPS); // owner stretches the window afterwards + _submit(id, EXPECTED); + _score(spec, 100, EXPECTED, SALT); + (,,,, , uint64 snap,,,,) = bond.stakes(spec); + assertEq(snap, WINDOW, "stake keeps the window it was made with"); + vm.warp(block.timestamp + WINDOW); + bond.reclaim(spec); // would revert WindowOpen if the new 30d window applied + } + + function test_requiredBond_isMaxOfFloorAndScaled() public { + assertEq(bond.requiredBond(1), MIN_BOND); + assertEq(bond.requiredBond(10 ether), 10 ether); + vm.prank(safe); + bond.setParams(WINDOW, MIN_BOND, 2500); + assertEq(bond.requiredBond(10 ether), 2.5 ether); + assertEq(bond.requiredBond(3), MIN_BOND); + vm.prank(safe); + bond.setParams(WINDOW, 1, 3333); + assertEq(bond.requiredBond(10), 4, "rounds up: ceil(3.333)"); + } + + // ---- stakeAndCommit ---- + + function test_stake_ok() public { + (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + vm.expectEmit(true, true, true, true); + emit Committed(spec, id, op, _commitment(EXPECTED, SALT), 2 ether, WINDOW); + _stake(spec, EXPECTED, SALT, id, 2 ether); + (address staker, PredgeValidatorBondV2.Status st,,,,, uint256 jobId, uint256 b, bytes32 c, bytes32 e) = bond.stakes(spec); + assertEq(staker, op); + assertEq(uint8(st), uint8(PredgeValidatorBondV2.Status.Committed)); + assertEq(jobId, id); + assertEq(b, 2 ether); + assertEq(c, _commitment(EXPECTED, SALT)); + assertEq(e, bytes32(0), "expected is hidden until the reveal"); + assertEq(bond.totalBonded(), 2 ether); + } + + function test_stake_reverts() public { + (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + bytes32 c = _commitment(EXPECTED, SALT); + vm.prank(stranger); + vm.expectRevert(Operated.NotOperator.selector); + bond.stakeAndCommit{value: 2 ether}(spec, c, id); + + vm.startPrank(op); + vm.expectRevert(PredgeValidatorBondV2.ZeroHash.selector); + bond.stakeAndCommit{value: 2 ether}(bytes32(0), c, id); + vm.expectRevert(PredgeValidatorBondV2.ZeroHash.selector); + bond.stakeAndCommit{value: 2 ether}(spec, bytes32(0), id); + vm.expectRevert(abi.encodeWithSelector(PredgeValidatorBondV2.BondTooSmall.selector, 2 ether)); + bond.stakeAndCommit{value: 2 ether - 1}(spec, c, id); + vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); + bond.stakeAndCommit{value: 2 ether}(spec, c, 999); // unknown job + vm.expectRevert(PredgeValidatorBondV2.JobNotBound.selector); + bond.stakeAndCommit{value: 2 ether}(keccak256("other spec"), c, id); + bond.stakeAndCommit{value: 2 ether}(spec, c, id); + vm.expectRevert(PredgeValidatorBondV2.AlreadyCommitted.selector); + bond.stakeAndCommit{value: 2 ether}(spec, c, id); + vm.stopPrank(); + } + + function test_stake_wrongEvaluatorNotBound() public { + bytes32 spec = _spec(5); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(provider, stranger, spec, uint64(block.timestamp + 1 days)); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.JobNotBound.selector); + bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); + } + + function test_stake_operatorCannotBeProvider() public { + bytes32 spec = _spec(5); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(op, address(bond), spec, uint64(block.timestamp + 1 days)); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.ProviderIsOperator.selector); + bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); + } + + function test_stake_notOpenOnceSubmittedOrLapsed() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _submit(id, OTHER); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); + bond.stakeAndCommit{value: 1 ether}(spec, _commitment(OTHER, SALT), id); + + (uint256 id2, bytes32 spec2) = _newJob(2, 1 ether); + vm.warp(block.timestamp + 7 days); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); + bond.stakeAndCommit{value: 1 ether}(spec2, _commitment(OTHER, SALT), id2); + } + + // ---- recordScore ---- + + function test_score_pass_movesTheMoney() public { + (uint256 id, bytes32 spec) = _newJob(1, 4 ether); + _stake(spec, EXPECTED, SALT, id, 4 ether); + _submit(id, EXPECTED); + vm.expectEmit(true, true, false, true); + emit Scored(spec, id, 100, EXPECTED, uint64(block.timestamp), uint64(block.timestamp) + WINDOW); + _score(spec, 100, EXPECTED, SALT); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Completed)); + assertEq(job.credit(provider), 4 ether); + assertEq(job.getJob(id).reason, keccak256("reason")); + (, PredgeValidatorBondV2.Status st,, , uint64 scoredAt,,,,, bytes32 revealed) = bond.stakes(spec); + assertEq(uint8(st), uint8(PredgeValidatorBondV2.Status.Scored)); + assertEq(revealed, EXPECTED); + assertGe(scoredAt, job.getJob(id).submittedAt, "window can never start before the submission"); + } + + function test_score_fail_refundsClient() public { + (uint256 id, bytes32 spec) = _newJob(1, 4 ether); + _stake(spec, EXPECTED, SALT, id, 4 ether); + _submit(id, OTHER); + _score(spec, 0, EXPECTED, SALT); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Rejected)); + assertEq(job.credit(client), 4 ether); + } + + function test_score_reverts() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + vm.startPrank(op); + vm.expectRevert(PredgeValidatorBondV2.BadScore.selector); + bond.recordScore(spec, 50, EXPECTED, SALT, 0); + vm.expectRevert(PredgeValidatorBondV2.NotCommitted.selector); + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); + vm.expectRevert(PredgeValidatorBondV2.BadReveal.selector); + bond.recordScore(spec, 100, OTHER, SALT, 0); + vm.expectRevert(PredgeValidatorBondV2.BadReveal.selector); + bond.recordScore(spec, 100, EXPECTED, OTHER, 0); + vm.expectRevert(PredgeValidatorBondV2.NotSubmitted.selector); + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + vm.stopPrank(); + _submit(id, EXPECTED); + vm.prank(stranger); + vm.expectRevert(Operated.NotOperator.selector); + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + _score(spec, 100, EXPECTED, SALT); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.BadStatus.selector); + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + } + + function test_score_afterEvaluationWindowRevertsAtTheJob() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, EXPECTED, SALT, id, 1 ether); + _submit(id, EXPECTED); + vm.warp(block.timestamp + EVAL_WINDOW); + vm.prank(op); + vm.expectRevert(); // job.complete -> PastDeadline: a verdict after the deadline is impossible + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + } + + // ---- challenge ---- + + function _scored(uint256 n, bytes32 delivered, uint8 score, uint256 escrow) internal returns (uint256 id, bytes32 spec) { + (id, spec) = _newJob(n, escrow); + _stake(spec, EXPECTED, SALT, id, bond.requiredBond(escrow)); + _submit(id, delivered); + _score(spec, score, EXPECTED, SALT); + } + + function test_challenge_falsePass_paysClientNotCaller() public { + (, bytes32 spec) = _scored(1, OTHER, 100, 5 ether); + assertTrue(bond.wouldSlash(spec)); + uint256 callerBefore = stranger.balance; + vm.expectEmit(true, true, true, true); + emit Slashed(spec, stranger, client, 5 ether, 100, OTHER); + vm.prank(stranger); + bond.challenge(spec); + assertEq(bond.credit(client), 5 ether, "harmed client is credited"); + assertEq(bond.credit(stranger), 0, "challenger earns nothing"); + assertEq(stranger.balance, callerBefore); + assertEq(bond.totalBonded(), 0); + assertEq(bond.slashCount(), 1); + assertFalse(bond.wouldSlash(spec)); + vm.prank(client); + bond.withdraw(); + assertEq(client.balance, 1_000 ether - 5 ether + 5 ether, "client lost the escrow and got the bond"); + } + + function test_challenge_falseFail_paysProvider() public { + (, bytes32 spec) = _scored(1, EXPECTED, 0, 5 ether); + vm.prank(stranger); + bond.challenge(spec); + assertEq(bond.credit(provider), 5 ether); + } + + function test_challenge_honestVerdictsRevert() public { + (, bytes32 s1) = _scored(1, EXPECTED, 100, 1 ether); + (, bytes32 s2) = _scored(2, OTHER, 0, 1 ether); + assertFalse(bond.wouldSlash(s1)); + assertFalse(bond.wouldSlash(s2)); + vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); + bond.challenge(s1); + vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); + bond.challenge(s2); + } + + function test_challenge_stateAndWindowGuards() public { + vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); + bond.challenge(keccak256("none")); + (, bytes32 spec) = _scored(1, OTHER, 100, 1 ether); + vm.warp(block.timestamp + WINDOW); + assertFalse(bond.wouldSlash(spec)); + vm.expectRevert(PredgeValidatorBondV2.WindowClosed.selector); + bond.challenge(spec); + bond.reclaim(spec); // a lie nobody challenged in time stands, by design + vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); + bond.challenge(spec); + } + + // ---- reclaim / release ---- + + function test_reclaim_paysStakerAfterWindowEvenAfterRotation() public { + (, bytes32 spec) = _scored(1, EXPECTED, 100, 3 ether); + vm.expectRevert(PredgeValidatorBondV2.WindowOpen.selector); + bond.reclaim(spec); + address newOp = makeAddr("newOp"); + vm.prank(safe); + bond.setOperator(newOp); + vm.warp(block.timestamp + WINDOW); + vm.expectEmit(true, true, false, true); + emit Reclaimed(spec, op, 3 ether); + vm.prank(newOp); // the new key triggers it, the OLD staker is credited (L-04) + bond.reclaim(spec); + assertEq(bond.credit(op), 3 ether); + assertEq(bond.credit(newOp), 0); + assertEq(bond.totalBonded(), 0); + vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); + bond.reclaim(spec); + } + + function test_release_whenJobExpiredWithoutVerdict() public { + (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + _stake(spec, EXPECTED, SALT, id, 2 ether); + vm.expectRevert(PredgeValidatorBondV2.JobNotExpired.selector); + bond.release(spec); + vm.warp(block.timestamp + 7 days); + vm.expectRevert(PredgeValidatorBondV2.JobNotExpired.selector); // lapsed but not yet claimed + bond.release(spec); + job.claimRefund(id); + vm.expectEmit(true, true, false, true); + emit Released(spec, op, 2 ether); + bond.release(spec); + assertEq(bond.credit(op), 2 ether); + vm.expectRevert(PredgeValidatorBondV2.BadStatus.selector); + bond.release(spec); + } + + function test_release_afterSilentEvaluationWindow() public { + (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + _stake(spec, EXPECTED, SALT, id, 2 ether); + _submit(id, EXPECTED); + vm.warp(block.timestamp + EVAL_WINDOW); + job.claimRefund(id); + bond.release(spec); + assertEq(bond.credit(op), 2 ether); + assertEq(job.credit(client), 2 ether); + } + + function test_release_reverts() public { + vm.expectRevert(PredgeValidatorBondV2.NotCommitted.selector); + bond.release(keccak256("none")); + (, bytes32 spec) = _scored(1, EXPECTED, 100, 1 ether); + vm.expectRevert(PredgeValidatorBondV2.BadStatus.selector); + bond.release(spec); + } + + // ---- pull payments ---- + + function test_payoutToRevertingStakerDoesNotBlock() public { + // The harmed party cannot receive value: the slash still lands as credit and can be redirected. + NoReceive bad = new NoReceive(); + bytes32 spec = _spec(9); + vm.deal(address(bad), 1 ether); + vm.prank(address(bad)); + uint256 id = job.createJob{value: 1 ether}(provider, address(bond), spec, uint64(block.timestamp + 1 days)); + _stake(spec, EXPECTED, SALT, id, 1 ether); + _submit(id, OTHER); + _score(spec, 100, EXPECTED, SALT); + bond.challenge(spec); + assertEq(bond.credit(address(bad)), 1 ether); + vm.expectRevert(PullCredit.TransferFailed.selector); + bad.callWithdraw(address(bond)); + vm.prank(address(bad)); + bond.withdrawTo(payable(stranger)); + assertEq(bond.credit(address(bad)), 0); + } + + // ---- fuzz ---- + + /// @dev Whatever the provider submits, the verdict is slashable iff it contradicts the hidden preimage. + function testFuzz_slashableIffContradicted(bytes32 expected, bytes32 delivered, bytes32 salt, bool claimPass) public { + vm.assume(expected != bytes32(0) && delivered != bytes32(0)); + (uint256 id, bytes32 spec) = _newJob(77, 1 ether); + _stake(spec, expected, salt, id, 1 ether); + _submit(id, delivered); + _score(spec, claimPass ? 100 : 0, expected, salt); + bool truth = delivered == expected; + bool lied = claimPass != truth; + assertEq(bond.wouldSlash(spec), lied); + if (lied) { + bond.challenge(spec); + assertEq(bond.credit(claimPass ? client : provider), 1 ether); + } else { + vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); + bond.challenge(spec); + } + } + + /// @dev Self-challenge is never profitable: whoever calls challenge, the operator's net is <= 0 on a lie. + function testFuzz_operatorNeverProfitsFromChallenging(uint96 escrow, bool claimPass) public { + escrow = uint96(bound(escrow, 1, 100 ether)); + (uint256 id, bytes32 spec) = _newJob(88, escrow); + uint256 b = bond.requiredBond(escrow); + _stake(spec, EXPECTED, SALT, id, b); + _submit(id, claimPass ? OTHER : EXPECTED); // the verdict below is the opposite of the truth + _score(spec, claimPass ? 100 : 0, EXPECTED, SALT); + vm.prank(op); + bond.challenge(spec); + assertEq(bond.credit(op), 0); + assertGe(bond.credit(claimPass ? client : provider), b); + } +} From b1cb234b58f576f389e51dc74a7ca7e9ef0ca913 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:09:18 +0300 Subject: [PATCH 02/19] v2: deploy tooling (--set v2), fork e2e with attack sequences, per-chain bond floors, plan tests, mutation script --- README.md | 18 +++ config/set.v2.json | 20 +++ config/v2.chains.json | 127 ++++++++++++++++ config/v2.config.json | 6 + contracts/v2/PredgeAgentValidatorV2.sol | 4 +- contracts/v2/PredgeRefundArbiterV2.sol | 8 +- contracts/v2/PredgeValidatorBondV2.sol | 2 + deploy.mjs | 64 ++++++--- deployments/fork-v2/SUMMARY.json | 182 +++++++++++++++++++++++ deployments/fork-v2/arbitrum.json | 171 ++++++++++++++++++++++ deployments/fork-v2/arc.json | 171 ++++++++++++++++++++++ deployments/fork-v2/avalanche.json | 171 ++++++++++++++++++++++ deployments/fork-v2/base.json | 171 ++++++++++++++++++++++ deployments/fork-v2/bnb.json | 171 ++++++++++++++++++++++ deployments/fork-v2/celo.json | 171 ++++++++++++++++++++++ deployments/fork-v2/ethereum.json | 171 ++++++++++++++++++++++ deployments/fork-v2/gnosis.json | 171 ++++++++++++++++++++++ deployments/fork-v2/linea.json | 171 ++++++++++++++++++++++ deployments/fork-v2/mantle.json | 171 ++++++++++++++++++++++ deployments/fork-v2/monad.json | 171 ++++++++++++++++++++++ deployments/fork-v2/optimism.json | 171 ++++++++++++++++++++++ deployments/fork-v2/plasma.json | 162 +++++++++++++++++++++ deployments/fork-v2/polygon.json | 171 ++++++++++++++++++++++ deployments/fork-v2/robinhood.json | 171 ++++++++++++++++++++++ deployments/fork-v2/rootstock.json | 162 +++++++++++++++++++++ deployments/fork-v2/scroll.json | 171 ++++++++++++++++++++++ deployments/fork-v2/sonic.json | 162 +++++++++++++++++++++ deployments/fork-v2/stable.json | 162 +++++++++++++++++++++ deployments/fork-v2/unichain.json | 162 +++++++++++++++++++++ lib/chains.mjs | 1 + lib/compile.mjs | 34 +++-- lib/key.mjs | 10 +- lib/plan.mjs | 6 +- lib/smoke-v2.mjs | 183 ++++++++++++++++++++++++ lib/v2.mjs | 61 ++++++++ lib/verify.mjs | 4 +- package.json | 6 +- scripts/fork-all.mjs | 13 +- scripts/mutate-v2.mjs | 51 +++++++ scripts/v2-params.mjs | 22 +++ test/plan-v2.test.mjs | 66 +++++++++ 41 files changed, 4218 insertions(+), 45 deletions(-) create mode 100644 config/set.v2.json create mode 100644 config/v2.chains.json create mode 100644 config/v2.config.json create mode 100644 deployments/fork-v2/SUMMARY.json create mode 100644 deployments/fork-v2/arbitrum.json create mode 100644 deployments/fork-v2/arc.json create mode 100644 deployments/fork-v2/avalanche.json create mode 100644 deployments/fork-v2/base.json create mode 100644 deployments/fork-v2/bnb.json create mode 100644 deployments/fork-v2/celo.json create mode 100644 deployments/fork-v2/ethereum.json create mode 100644 deployments/fork-v2/gnosis.json create mode 100644 deployments/fork-v2/linea.json create mode 100644 deployments/fork-v2/mantle.json create mode 100644 deployments/fork-v2/monad.json create mode 100644 deployments/fork-v2/optimism.json create mode 100644 deployments/fork-v2/plasma.json create mode 100644 deployments/fork-v2/polygon.json create mode 100644 deployments/fork-v2/robinhood.json create mode 100644 deployments/fork-v2/rootstock.json create mode 100644 deployments/fork-v2/scroll.json create mode 100644 deployments/fork-v2/sonic.json create mode 100644 deployments/fork-v2/stable.json create mode 100644 deployments/fork-v2/unichain.json create mode 100644 lib/smoke-v2.mjs create mode 100644 lib/v2.mjs create mode 100644 scripts/mutate-v2.mjs create mode 100644 scripts/v2-params.mjs create mode 100644 test/plan-v2.test.mjs diff --git a/README.md b/README.md index d3e1f2b..6552fbf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,24 @@ node deploy.mjs --chain base --fork # full deploy + smoke + end-to-end on a CONFIRM_MAINNET=base node deploy.mjs --chain base --broadcast # the real deploy ``` +## v2 (self-audit fixes, not deployed) + +`contracts/v2/` is the redesigned set that fixes the 20.10.2026 self-audit findings (H-01..H-03, M-01..M-05, L-01..L-07). **Nothing here is deployed.** +The live 16-chain set above is v1 and is untouched. Design, finding-by-finding mapping and owner actions: `SELF-AUDIT-2026-10-20/v2-design.md` (Downloads). + +``` +FOUNDRY_CONFIG=foundry-v2.toml forge test # unit + fuzz + invariant + PoC tests (v1 attacks run against v1 and v2) +FOUNDRY_CONFIG=foundry-v2.toml forge coverage --no-match-coverage "(test/|foundry-lib|contracts/[A-Za-z]+\.sol)" +node --test test/ # plan, v1 bytecode match (network), v2 plan +node deploy.mjs --chain base --set v2 --fork # anvil fork of Base: deploy + honest path + every v1 attack sequence (must revert) +node scripts/fork-all.mjs --set v2 --no-e2e # gas / cost on every chain +node scripts/mutate-v2.mjs # mutation smoke run +node scripts/v2-params.mjs 5 # regenerate per-chain bond floors (about $5 each) from live prices +``` + +Real v2 runs (`--set v2` without `--fork`) refuse to start until the owner fills `config/v2.config.json` (new deployer address, the Safe, the ops key) +and, to broadcast, pass `--key-file `. v2 never reads `~/.predge-deployer`. `--fork` uses throwaway accounts only. + ## The set Order is the deployer's nonce. Contract *i* is created at nonce *i*. diff --git a/config/set.v2.json b/config/set.v2.json new file mode 100644 index 0000000..823b340 --- /dev/null +++ b/config/set.v2.json @@ -0,0 +1,20 @@ +{ + "_comment": "The Predge v2 set (contracts/v2). Same rules as set.json: contract i is created at deployer nonce i, so a fresh v2 deployer + this order gives the same addresses on every chain. Never reorder; append only. $owner is the Safe, $operator the hot ops key, $deployer holds NO role (it only pays gas). Chain-specific values (minBond, arbiterMinBond) come from config/v2.chains.json and do not change the addresses.", + "version": "2026-10-20-v2", + "params": { + "disputeWindow": "86400", + "challengeWindow": "86400", + "evaluationWindow": "259200", + "bondBps": "10000", + "arbiterBondBps": "1000", + "identityRegistry": "0x0000000000000000000000000000000000000000" + }, + "plan": [ + { "name": "PredgeAgentValidatorV2", "file": "v2/PredgeAgentValidatorV2", "args": ["$owner", "$operator", "$identityRegistry"], "role": "ERC-8004 validation registry, request-before-response, domain-separated request hashes" }, + { "name": "AgentJobV2", "file": "v2/AgentJobV2", "args": ["$evaluationWindow"], "role": "ERC-8183-style job escrow with expiry and pull payments; no owner" }, + { "name": "PredgeValidatorBondV2", "file": "v2/PredgeValidatorBondV2", "args": ["$owner", "$operator", "$AgentJobV2", "$disputeWindow", "$minBond", "$bondBps"], "role": "commit-reveal validator bond; the evaluator of AgentJobV2" }, + { "name": "PredgeSettlementV2", "file": "v2/PredgeSettlementV2", "args": ["$owner"], "role": "pay-per-route receipts in the native token" }, + { "name": "MockRefundProtocolV2", "file": "v2/mocks/MockRefundProtocolV2", "args": ["$PredgeRefundArbiterV2"], "role": "DEMO ONLY stand-in Refund Protocol (moves no money); out of audit scope" }, + { "name": "PredgeRefundArbiterV2", "file": "v2/PredgeRefundArbiterV2", "args": ["$owner", "$operator", "$MockRefundProtocolV2", "$challengeWindow", "$arbiterMinBond", "$arbiterBondBps"], "role": "optimistic bonded refund arbiter; Safe is the court of last resort" } + ] +} diff --git a/config/v2.chains.json b/config/v2.chains.json new file mode 100644 index 0000000..1552f31 --- /dev/null +++ b/config/v2.chains.json @@ -0,0 +1,127 @@ +{ + "_comment": "Generated by scripts/v2-params.mjs. minBond / arbiterMinBond in native wei (18 decimals), ~usdTarget dollars each, 1 significant digit.", + "generatedAt": "2026-10-19T22:59:38.461Z", + "usdTarget": 5, + "chains": { + "ethereum": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "base": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "bnb": { + "native": "BNB", + "priceUsd": 731.3, + "minBond": "6000000000000000", + "arbiterMinBond": "6000000000000000" + }, + "celo": { + "native": "CELO", + "priceUsd": 0.081491, + "minBond": "60000000000000000000", + "arbiterMinBond": "60000000000000000000" + }, + "polygon": { + "native": "POL", + "priceUsd": 0.092615, + "minBond": "50000000000000000000", + "arbiterMinBond": "50000000000000000000" + }, + "optimism": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "arbitrum": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "avalanche": { + "native": "AVAX", + "priceUsd": 9.61, + "minBond": "500000000000000000", + "arbiterMinBond": "500000000000000000" + }, + "gnosis": { + "native": "xDAI", + "priceUsd": 1, + "minBond": "5000000000000000000", + "arbiterMinBond": "5000000000000000000" + }, + "linea": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "scroll": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "unichain": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "sonic": { + "native": "S", + "priceUsd": 0.05888, + "minBond": "80000000000000000000", + "arbiterMinBond": "80000000000000000000" + }, + "mantle": { + "native": "MNT", + "priceUsd": 0.533674, + "minBond": "9000000000000000000", + "arbiterMinBond": "9000000000000000000" + }, + "arc": { + "native": "USDC", + "priceUsd": 0.99987, + "minBond": "5000000000000000000", + "arbiterMinBond": "5000000000000000000" + }, + "monad": { + "native": "MON", + "priceUsd": 0.02090424, + "minBond": "200000000000000000000", + "arbiterMinBond": "200000000000000000000" + }, + "robinhood": { + "native": "ETH", + "priceUsd": 2405.16, + "minBond": "2000000000000000", + "arbiterMinBond": "2000000000000000" + }, + "rootstock": { + "native": "RBTC", + "priceUsd": 81996, + "minBond": "60000000000000", + "arbiterMinBond": "60000000000000" + }, + "plasma": { + "native": "XPL", + "priceUsd": 0.076547, + "minBond": "60000000000000000000", + "arbiterMinBond": "60000000000000000000" + }, + "stable": { + "native": "USDT0", + "priceUsd": 0.9991, + "minBond": "5000000000000000000", + "arbiterMinBond": "5000000000000000000" + } + } +} diff --git a/config/v2.config.json b/config/v2.config.json new file mode 100644 index 0000000..88606d6 --- /dev/null +++ b/config/v2.config.json @@ -0,0 +1,6 @@ +{ + "_comment": "Owner-supplied v2 identities. All null on purpose: nothing real exists yet. `deployer` = a NEW key created by the owner (pays gas only, holds no role, nonce 0 on every chain). `owner` = the Safe multisig (on EVERY chain you deploy to, at the same address if you want the same wiring everywhere). `operator` = the hot ops key (rotatable by the owner). dry-run and --broadcast refuse to run while any of these is null. --fork ignores this file and uses throwaway anvil accounts.", + "deployer": null, + "owner": null, + "operator": null +} diff --git a/contracts/v2/PredgeAgentValidatorV2.sol b/contracts/v2/PredgeAgentValidatorV2.sol index 7bcb7ee..fa0a1b2 100644 --- a/contracts/v2/PredgeAgentValidatorV2.sol +++ b/contracts/v2/PredgeAgentValidatorV2.sol @@ -181,9 +181,9 @@ contract PredgeAgentValidatorV2 is Operated { { uint256 n = requestHashes.length; if (n > MAX_SUMMARY) revert TooManyHashes(); - uint256 sum; + uint256 sum = 0; for (uint256 i = 0; i < n; i++) { - bool seen; + bool seen = false; for (uint256 k = 0; k < i; k++) { if (requestHashes[k] == requestHashes[i]) { seen = true; diff --git a/contracts/v2/PredgeRefundArbiterV2.sol b/contracts/v2/PredgeRefundArbiterV2.sol index 163db87..67dfb8d 100644 --- a/contracts/v2/PredgeRefundArbiterV2.sol +++ b/contracts/v2/PredgeRefundArbiterV2.sol @@ -50,7 +50,10 @@ interface IRefundProtocolV2 { /// Trust, stated plainly: for disputed rulings the owner Safe is the court of last /// resort. An operator that rules wrongly is only punished if the harmed party notices and /// disputes inside the window, and the owner then agrees. The window must close before the -/// Refund Protocol's lockup ends, which `rule` enforces for a Refund. +/// Refund Protocol's lockup ends, which `rule` enforces for a Refund. `finalize` should be +/// called promptly (the operator, who wants its bond back, and the payer, who wants the refund, +/// both have a reason to): once `expiresAt` has passed anyone can `expire` the ruling instead, +/// which executes nothing and returns the bond. /// /// @dev Native value only, in the chain's native wei. The contract holds `totalBonded + /// totalCredit` and never user payments. Parameters are bounded and not retroactive: the @@ -203,7 +206,8 @@ contract PredgeRefundArbiterV2 is Operated, PullCredit { uint256 required = requiredBond(amount); if (msg.value < required) revert BondTooSmall(required); uint64 window = challengeWindow; - // A refund must still be possible when the window closes, with room for finalize. + // A refund must still be possible when the window closes. `finalize` must then run before the + // payment's `releaseTimestamp`; if it does not, the Refund Protocol rejects the refund and `expire` voids the ruling. if (direction == Direction.Refund && releaseTimestamp <= block.timestamp + window) revert LockupTooShort(); r.staker = msg.sender; diff --git a/contracts/v2/PredgeValidatorBondV2.sol b/contracts/v2/PredgeValidatorBondV2.sol index 6abae2a..8294d60 100644 --- a/contracts/v2/PredgeValidatorBondV2.sol +++ b/contracts/v2/PredgeValidatorBondV2.sol @@ -42,6 +42,8 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; /// up to the uncovered fraction. /// * A false verdict is only slashable if the harmed party (or someone for them) calls /// `challenge` inside the window. Nobody is paid to watch except the harmed party. +/// * The operator's capital is locked until the job ends. A client picks the job's +/// `expiredAt`, so an operator should read it before staking. /// * Only the deterministic `sha256(deliverable) == expected` test is covered. Subjective /// or "void" outcomes are not bonded. /// diff --git a/deploy.mjs b/deploy.mjs index d6f2ff6..04da439 100755 --- a/deploy.mjs +++ b/deploy.mjs @@ -6,15 +6,22 @@ // CONFIRM_MAINNET=base node deploy.mjs --chain base --broadcast // the real thing: deploy, verify source, read-only smoke, deployments/base.json. // Options: --rpc (override), --no-verify, --no-e2e (fork), --keep-anvil (fork) +// +// --set v2 the v2 contract set (contracts/v2, config/set.v2.json). Roles (deployer / owner Safe / operator) come from +// config/v2.config.json and must be filled in by the owner; --fork uses throwaway anvil accounts and also runs +// the v1 attack sequences, which must revert. A real v2 --broadcast needs --key-file . +// v2 NEVER reads ~/.predge-deployer. Outputs go to deployments/v2/ and deployments/fork-v2/. import { spawn } from "node:child_process"; import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; import { createServer } from "node:net"; import { formatEther, Wallet } from "ethers"; -import { chainConfig, pickRpc, providerFor, withRetry, rawRpc, waitReceipt, SET } from "./lib/chains.mjs"; +import { chainConfig, pickRpc, providerFor, withRetry, rawRpc, waitReceipt, SET, SET_V2 } from "./lib/chains.mjs"; import { loadDeployer } from "./lib/key.mjs"; import { resolvePlan } from "./lib/plan.mjs"; import { maskImmutables, SOLC_VERSION, SETTINGS } from "./lib/compile.mjs"; import { readOnly, e2e } from "./lib/smoke.mjs"; +import { readOnlyV2, e2eV2 } from "./lib/smoke-v2.mjs"; +import { realRoles, forkRoles, chainParams, anvilWallets, v2KeyFile } from "./lib/v2.mjs"; import { verifyStep } from "./lib/verify.mjs"; import { l1FeeWei } from "./lib/fees.mjs"; @@ -22,6 +29,11 @@ const argv = process.argv.slice(2); const flag = (f) => argv.includes(f); const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; }; const ROOT = new URL("./", import.meta.url).pathname; +const V2 = opt("--set") === "v2"; +if (opt("--set") && !V2 && opt("--set") !== "v1") { console.error(`unknown --set ${opt("--set")} (v1 | v2)`); process.exit(2); } +const SETUSED = V2 ? SET_V2 : SET; +const OUT = V2 ? "deployments/v2" : "deployments"; +const OUTFORK = V2 ? "deployments/fork-v2" : "deployments/fork"; const ANVIL = process.env.ANVIL_BIN || (existsSync(`${process.env.HOME}/.foundry/bin/anvil`) ? `${process.env.HOME}/.foundry/bin/anvil` : "anvil"); const ERC8004 = { IdentityRegistry: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", @@ -111,9 +123,16 @@ async function main() { const liveUrl = await pickRpc(chain, opt("--rpc")); const live = providerFor(liveUrl, chain.chainId); - const deployer = loadDeployer(live); - const plan = resolvePlan(deployer.address); - log(`deployer ${deployer.address} rpc ${liveUrl}`); + // v1: the one deployer key. v2: roles from config (real runs) or anvil accounts (fork); a v2 key is only loaded to broadcast. + const roles = V2 ? (mode === "fork" ? forkRoles() : realRoles()) : null; + let deployer; + if (V2) deployer = mode === "broadcast" ? loadDeployer(live, v2KeyFile(opt("--key-file"))) : { address: roles.deployer }; + else deployer = loadDeployer(live); + if (V2 && mode === "broadcast" && deployer.address !== roles.deployer) throw new Error(`--key-file address ${deployer.address} != config deployer ${roles.deployer}`); + const plan = V2 + ? resolvePlan(deployer.address, { owner: roles.owner, operator: roles.operator, ...chainParams(chain.name) }, SET_V2) + : resolvePlan(deployer.address); + log(`deployer ${deployer.address} rpc ${liveUrl}${V2 ? ` (v2: owner ${roles.owner}, operator ${roles.operator})` : ""}`); for (const s of plan) log(` nonce ${s.nonce} ${s.name.padEnd(22)} -> ${s.address}`); if (mode === "broadcast") { @@ -141,25 +160,26 @@ async function main() { if (bal < (gas * price)) { console.error(`Balance too low. Fund ${deployer.address} with >= ${formatEther(need)} ${chain.native} on ${chain.name}.`); process.exit(4); } const results = await deploySet(live, deployer, plan, { log, gasLimitPct: chain.gasLimitPct }); - const smoke = await readOnly(live, plan, deployer.address); + const smoke = V2 ? await readOnlyV2(live, plan, roles) : await readOnly(live, plan, deployer.address); log(`smoke: ${smoke.length} checks passed`); const verification = {}; - mkdirSync(`${ROOT}deployments/verify`, { recursive: true }); + mkdirSync(`${ROOT}${OUT}/verify`, { recursive: true }); for (const s of plan) { - writeFileSync(`${ROOT}deployments/verify/${s.name}.json`, json(s.compiled.input)); + writeFileSync(`${ROOT}${OUT}/verify/${s.name}.json`, json(s.compiled.input)); const r = results.find((x) => x.name === s.name); verification[s.name] = flag("--no-verify") ? { ok: false, todo: "skipped (--no-verify)" } : await verifyStep(chain, s, r?.txHash); log(` verify ${s.name}: ${verification[s.name].ok ? "OK via " + verification[s.name].via : "TODO " + JSON.stringify(verification[s.name].tried ?? verification[s.name].todo).slice(0, 200)}`); } const record = { - chain: chain.name, chainId: chain.chainId, deployer: deployer.address, operator: plan[0].args[0], setVersion: SET.version, + chain: chain.name, chainId: chain.chainId, deployer: deployer.address, operator: V2 ? roles.operator : plan[0].args[0], ...(V2 ? { owner: roles.owner } : {}), setVersion: SETUSED.version, compiler: { solc: plan[0].compiled.compilerVersion, settings: { optimizer: SETTINGS.optimizer, evmVersion: SETTINGS.evmVersion } }, deployedAt: new Date().toISOString(), explorer: chain.explorer, contracts: Object.fromEntries(results.map(({ rawTx, ...r }) => [r.name, { ...r, verified: verification[r.name] }])), erc8004: await registries(live), smoke, legacy: chain.legacy ?? null, }; - writeFileSync(`${ROOT}deployments/${chain.name}.json`, json(record)); - log(`wrote deployments/${chain.name}.json`); + mkdirSync(`${ROOT}${OUT}`, { recursive: true }); + writeFileSync(`${ROOT}${OUT}/${chain.name}.json`, json(record)); + log(`wrote ${OUT}/${chain.name}.json`); return; } @@ -173,15 +193,23 @@ async function main() { const { url: forkUrl, child } = started; try { const fork = providerFor(forkUrl, chain.chainId); - const forkWallet = new Wallet(deployer.privateKey, fork); + // v1 forks with the real deployer key (it is only ever funded on the fork). v2 NEVER does: anvil account 0. + const anvil = V2 ? anvilWallets(fork) : null; + const forkWallet = V2 ? anvil[0] : new Wallet(deployer.privateKey, fork); + if (V2) for (const w of anvil) { await fork.send("anvil_setCode", [w.address, "0x"]); await fork.send("anvil_setNonce", [w.address, "0x0"]); await fork.send("anvil_setBalance", [w.address, "0x3635C9ADC5DEA00000"]); } // clean EOAs at nonce 0 (the public anvil keys are heavily used on mainnets), 1000 native, fork only const startNonce = await fork.getTransactionCount(deployer.address); await fork.send("anvil_setBalance", [deployer.address, "0x56BC75E2D63100000"]); // 100 native, fork only const t0 = Date.now(); const results = await deploySet(fork, forkWallet, plan, { log, gasLimitPct: chain.gasLimitPct }); - const smoke = await readOnly(fork, plan, deployer.address); + const smoke = V2 ? await readOnlyV2(fork, plan, roles) : await readOnly(fork, plan, deployer.address); log(`smoke: ${smoke.length} read-only checks passed`); - let steps = null; - if (!flag("--no-e2e")) { + let steps = null, attacks = null; + if (!flag("--no-e2e") && V2) { + const [, owner, op, client, worker, party] = anvil; + const r = await e2eV2(fork, plan, { owner, op, client, worker, party }, async (s) => { await fork.send("evm_increaseTime", [s]); await fork.send("evm_mine", []); }); + steps = r.steps; attacks = r.attacks; + log(`e2e: ${steps.length} honest-path txs passed; ${attacks.length} attack sequences blocked (H-01, H-02, H-03, M-02, M-03)`); + } else if (!flag("--no-e2e")) { // Fresh throwaway keys, funded on the fork only (the well-known anvil test accounts carry // EIP-7702 delegations on several mainnets, so they are not clean EOAs on a fork). const signers = [0, 1, 2].map(() => Wallet.createRandom().connect(fork)); @@ -206,12 +234,12 @@ async function main() { at: new Date().toISOString(), seconds: Math.round((Date.now() - t0) / 1000), deployer: deployer.address, startNonce, contracts: Object.fromEntries(results.map(({ rawTx, ...r }) => [r.name, r])), totals: { gasUsed: gasUsed.toString(), liveGasPriceWei: gasPrice.toString(), l1FeeWei: l1.toString(), l1FeeMethod: [...l1how].join("; "), costWei: costWei.toString(), cost: formatEther(costWei), native: chain.native }, - smoke: smoke.map((c) => c.check), e2e: steps, erc8004: await registries(live), + smoke: smoke.map((c) => c.check), e2e: steps, ...(V2 ? { attacksBlocked: attacks, setVersion: SETUSED.version } : {}), erc8004: await registries(live), }; - mkdirSync(`${ROOT}deployments/fork`, { recursive: true }); - writeFileSync(`${ROOT}deployments/fork/${chain.name}.json`, json(record)); + mkdirSync(`${ROOT}${OUTFORK}`, { recursive: true }); + writeFileSync(`${ROOT}${OUTFORK}/${chain.name}.json`, json(record)); log(`fork total: gas ${gasUsed} @ live ${gasPrice} wei + L1 ${formatEther(l1)} = ${formatEther(costWei)} ${chain.native}`); - log(`wrote deployments/fork/${chain.name}.json`); + log(`wrote ${OUTFORK}/${chain.name}.json`); } finally { if (!flag("--keep-anvil")) child.kill(); } diff --git a/deployments/fork-v2/SUMMARY.json b/deployments/fork-v2/SUMMARY.json new file mode 100644 index 0000000..fc9100e --- /dev/null +++ b/deployments/fork-v2/SUMMARY.json @@ -0,0 +1,182 @@ +{ + "ethereum": { + "ok": true, + "seconds": 14, + "gasUsed": "6475274", + "cost": "0.00035636084446703", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "base": { + "ok": true, + "seconds": 11, + "gasUsed": "6475274", + "cost": "0.000038928831652427", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "bnb": { + "ok": true, + "seconds": 11, + "gasUsed": "6475274", + "cost": "0.0003237637", + "native": "BNB", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "celo": { + "ok": true, + "seconds": 18, + "gasUsed": "6475322", + "cost": "1.311252705", + "native": "CELO", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "polygon": { + "ok": true, + "seconds": 12, + "gasUsed": "6475322", + "cost": "1.60429179856125992", + "native": "POL", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "optimism": { + "ok": true, + "seconds": 12, + "gasUsed": "6475274", + "cost": "0.000006712887343092", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "arbitrum": { + "ok": true, + "seconds": 17, + "gasUsed": "6475274", + "cost": "0.00013020417144", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "avalanche": { + "ok": true, + "seconds": 9, + "gasUsed": "6475298", + "cost": "0.0161882459712947", + "native": "AVAX", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "gnosis": { + "ok": true, + "seconds": 15, + "gasUsed": "6475298", + "cost": "0.00000000006475298", + "native": "xDAI", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "linea": { + "ok": true, + "seconds": 22, + "gasUsed": "6475274", + "cost": "0.000320988630673464", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "scroll": { + "ok": true, + "seconds": 13, + "gasUsed": "6475274", + "cost": "0.00009141459618385", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "unichain": { + "ok": true, + "seconds": 15, + "gasUsed": "6475274", + "cost": "0.000009789265857029", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "sonic": { + "ok": true, + "seconds": 11, + "gasUsed": "6475322", + "cost": "0.35614271", + "native": "S", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "mantle": { + "ok": true, + "seconds": 30, + "gasUsed": "6475298", + "cost": "0.323768321839730786", + "native": "MNT", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "arc": { + "ok": true, + "seconds": 11, + "gasUsed": "6475298", + "cost": "0.1327436096475298", + "native": "USDC", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "monad": { + "ok": true, + "seconds": 9, + "gasUsed": "6452209", + "cost": "0.658125318", + "native": "MON", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "robinhood": { + "ok": true, + "seconds": 16, + "gasUsed": "6475274", + "cost": "0.000129609084384", + "native": "ETH", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "rootstock": { + "ok": true, + "seconds": 15, + "gasUsed": "6475274", + "cost": "0.0001687819019744", + "native": "RBTC", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "plasma": { + "ok": true, + "seconds": 19, + "gasUsed": "6475322", + "cost": "0.000064128338476356", + "native": "XPL", + "e2eTxs": 0, + "smokeChecks": 28 + }, + "stable": { + "ok": true, + "seconds": 13, + "gasUsed": "6475298", + "cost": "0.00728471025", + "native": "USDT0", + "e2eTxs": 0, + "smokeChecks": 28 + } +} diff --git a/deployments/fork-v2/arbitrum.json b/deployments/fork-v2/arbitrum.json new file mode 100644 index 0000000..49fcc33 --- /dev/null +++ b/deployments/fork-v2/arbitrum.json @@ -0,0 +1,171 @@ +{ + "chain": "arbitrum", + "chainId": 42161, + "mode": "anvil-fork", + "forkedFrom": "https://arb1.arbitrum.io/rpc", + "forkBlock": 516424009, + "at": "2026-10-19T23:05:42.303Z", + "seconds": 10, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xbd7385ace83556e75ffe148f706c1f68e9a29c5417eb686d1124164bcec60057", + "block": 516423970, + "gasUsed": "1162822", + "effectiveGasPrice": "1017500001", + "l1FeeWei": "90954360000" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x251ccca2b174d975d1aa51e7cca32c1bb3a42101be9f5bd28ee1de2db2621d86", + "block": 516423971, + "gasUsed": "904910", + "effectiveGasPrice": "1015312501", + "l1FeeWei": "70159068000" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x7a8052b5d71ea4fea9d5f3b04e015778a032742d1a2430e74ee3141ea6155d76", + "block": 516423972, + "gasUsed": "1711521", + "effectiveGasPrice": "1013398439", + "l1FeeWei": "117459342000" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xedc0ba4b90abbfbe54a105b9a9e780c06a16d528e0f1855dfcaca9f63b29522d", + "block": 516423973, + "gasUsed": "369083", + "effectiveGasPrice": "1011723635", + "l1FeeWei": "33016032000" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x9f1188d533590ee95f1f2244a507e1e2103f1beabed58b30368997118454600a", + "block": 516423974, + "gasUsed": "408763", + "effectiveGasPrice": "1010258181", + "l1FeeWei": "37603818000" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xd393fb8251b9531a0165d068accbccdd310c67e2cf413fa3fa054b7f8435668e", + "block": 516423975, + "gasUsed": "1918175", + "effectiveGasPrice": "1008975909", + "l1FeeWei": "129339504000" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "20034000", + "l1FeeWei": "478532124000", + "l1FeeMethod": "arbitrum NodeInterface: 4540 L1 gas units; arbitrum NodeInterface: 3502 L1 gas units; arbitrum NodeInterface: 5863 L1 gas units; arbitrum NodeInterface: 1648 L1 gas units; arbitrum NodeInterface: 1877 L1 gas units; arbitrum NodeInterface: 6456 L1 gas units", + "costWei": "130204171440000", + "cost": "0.00013020417144", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/arc.json b/deployments/fork-v2/arc.json new file mode 100644 index 0000000..f8ba86c --- /dev/null +++ b/deployments/fork-v2/arc.json @@ -0,0 +1,171 @@ +{ + "chain": "arc", + "chainId": 5042, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.mainnet.arc.io", + "forkBlock": 26848388, + "at": "2026-10-19T23:07:48.519Z", + "seconds": 4, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xbbe1e7b95efb31e97be1f42d796455b77765c34c9121f12a651c161f413fa083", + "block": 26848377, + "gasUsed": "1162822", + "effectiveGasPrice": "18726747167", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x0719ed57f518a2ba2b970ff620fdac1ae72b388959d16ca5dd47cef00ef3c6e9", + "block": 26848378, + "gasUsed": "904910", + "effectiveGasPrice": "16682679202", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "5000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x168680305d43128fadaffe97b47be34a0c8b0e8f0ad849ac12a5ea19708c5e0a", + "block": 26848379, + "gasUsed": "1711533", + "effectiveGasPrice": "14840606079", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xc078e66869bb00f9e3f30f7058bffbb71f5dc03a6c5c860aed09f8e5b58f5d01", + "block": 26848380, + "gasUsed": "369083", + "effectiveGasPrice": "13307935770", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x8d07add0725ed4d7c8f08737c9f38e343750b6c40f258bee312a3a2cb67beb60", + "block": 26848381, + "gasUsed": "408763", + "effectiveGasPrice": "11807299215", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "5000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xc05a6b935e871fdb10f7d2fb19901cfeec6da3337a3c53e6df36a5ba4c9e2886", + "block": 26848382, + "gasUsed": "1918187", + "effectiveGasPrice": "10493200347", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475298", + "liveGasPriceWei": "20500000100", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "132743609647529800", + "cost": "0.1327436096475298", + "native": "USDC" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/avalanche.json b/deployments/fork-v2/avalanche.json new file mode 100644 index 0000000..d8aa3ba --- /dev/null +++ b/deployments/fork-v2/avalanche.json @@ -0,0 +1,171 @@ +{ + "chain": "avalanche", + "chainId": 43114, + "mode": "anvil-fork", + "forkedFrom": "https://api.avax.network/ext/bc/C/rpc", + "forkBlock": 98008349, + "at": "2026-10-19T23:05:52.295Z", + "seconds": 4, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xfeb3f267f925fdb785e776b2492eb0c5585a85cfaf6c29825fc4c74bec8c15b1", + "block": 98008346, + "gasUsed": "1162822", + "effectiveGasPrice": "3193802204", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x896d7c8b881466d418866ac865a666350a603ea00a101868a7b838a9244d7082", + "block": 98008347, + "gasUsed": "904910", + "effectiveGasPrice": "2927548809", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "500000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x6eaabf5321d5f43b7c57c72039ac370ac1198be393896d290cba54e8336a3045", + "block": 98008348, + "gasUsed": "1711533", + "effectiveGasPrice": "2692056015", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x2630a991af27e98cf1bc5694e4bec90e1c577d1d4999b1434e9a77a6b4bf64a0", + "block": 98008349, + "gasUsed": "369083", + "effectiveGasPrice": "2489599044", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xc2118a452ade4e16ccd74ed00a06f9c18589ed798df205b014f6b73b956310b3", + "block": 98008350, + "gasUsed": "408763", + "effectiveGasPrice": "2305117244", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "500000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x5da15ab5f88b34c9dc5f01eaeb9645f9047233a99aa273b5b452d26061e3a426", + "block": 98008351, + "gasUsed": "1918187", + "effectiveGasPrice": "2143644725", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475298", + "liveGasPriceWei": "2500000150", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "16188245971294700", + "cost": "0.0161882459712947", + "native": "AVAX" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/base.json b/deployments/fork-v2/base.json new file mode 100644 index 0000000..392662e --- /dev/null +++ b/deployments/fork-v2/base.json @@ -0,0 +1,171 @@ +{ + "chain": "base", + "chainId": 8453, + "mode": "anvil-fork", + "forkedFrom": "https://base-rpc.publicnode.com", + "forkBlock": 52830862, + "at": "2026-10-19T23:04:32.799Z", + "seconds": 6, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x9a75f35dcb04e032057cbaf460cfd1d5c6d23708069f3a42b79b17ecf8a842b7", + "block": 52830860, + "gasUsed": "1162822", + "effectiveGasPrice": "1004986421", + "l1FeeWei": "14802332849" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x212ae4b97c67f5d68a71d371949be5003998f80a042a17830e7f8338b1280cff", + "block": 52830861, + "gasUsed": "904910", + "effectiveGasPrice": "1004966824", + "l1FeeWei": "11198469167" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xc14838306f2f2a423d04977bfddb3a05773ae8288223a59ec9065ef9da06cb0d", + "block": 52830862, + "gasUsed": "1711521", + "effectiveGasPrice": "1004947227", + "l1FeeWei": "19103474046" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x54bf225199544be651c2e05bddf0cbc74eca9153f84941b05f5ee3bcc00cc116", + "block": 52830863, + "gasUsed": "369083", + "effectiveGasPrice": "1004927947", + "l1FeeWei": "4828232732" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x574f4117a38b4211de935207fff2391574febb4e70bc870347f7d8f374237f91", + "block": 52830864, + "gasUsed": "408763", + "effectiveGasPrice": "1004908345", + "l1FeeWei": "5714987833" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xb428709a60640a85e9394e19cc3f31131bc6505c30965c32a1e4bdbb939785c3", + "block": 52830865, + "gasUsed": "1918175", + "effectiveGasPrice": "1004888833", + "l1FeeWei": "21540155800" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "6000000", + "l1FeeWei": "77187652427", + "l1FeeMethod": "op-stack oracle getL1Fee", + "costWei": "38928831652427", + "cost": "0.000038928831652427", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/bnb.json b/deployments/fork-v2/bnb.json new file mode 100644 index 0000000..358b204 --- /dev/null +++ b/deployments/fork-v2/bnb.json @@ -0,0 +1,171 @@ +{ + "chain": "bnb", + "chainId": 56, + "mode": "anvil-fork", + "forkedFrom": "https://bsc-dataseed.bnbchain.org", + "forkBlock": 128639054, + "at": "2026-10-19T23:04:43.890Z", + "seconds": 5, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x03828af3b0868dddf7e65211140af3d2af04cab69bf95ea7730877636f012fbb", + "block": 128639041, + "gasUsed": "1162822", + "effectiveGasPrice": "1000000000", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0xce577da43d67b57086114c56d2b210a8a5a75ec5c68a159656386c17377063e9", + "block": 128639042, + "gasUsed": "904910", + "effectiveGasPrice": "1000000000", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "6000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e6160000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000000001550f7dca700000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xbb9628fd1ef9131a653f4867dc6ccd67609ebe4e9a1f39ff1973f2f0e3a13c5f", + "block": 128639043, + "gasUsed": "1711521", + "effectiveGasPrice": "1000000000", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x5f5b5db5a8a80a595c617bc2b1ab02c21356e0c35f56bfcbfd1ceeb5e0752fb5", + "block": 128639044, + "gasUsed": "369083", + "effectiveGasPrice": "1000000000", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x3311bb83b7fbf0581ab992304c48719ec4a4469f3d70a7337126b468fe915207", + "block": 128639045, + "gasUsed": "408763", + "effectiveGasPrice": "1000000000", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "6000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a052268370000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000000001550f7dca7000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x5158e42da393afb55dff617ddfd41a968b5c68d4c372aaf84d9ee52cfa5d8c37", + "block": 128639046, + "gasUsed": "1918175", + "effectiveGasPrice": "1000000000", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "50000000", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "323763700000000", + "cost": "0.0003237637", + "native": "BNB" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/celo.json b/deployments/fork-v2/celo.json new file mode 100644 index 0000000..186bd56 --- /dev/null +++ b/deployments/fork-v2/celo.json @@ -0,0 +1,171 @@ +{ + "chain": "celo", + "chainId": 42220, + "mode": "anvil-fork", + "forkedFrom": "https://forno.celo.org", + "forkBlock": 80550342, + "at": "2026-10-19T23:05:01.789Z", + "seconds": 12, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xf203c794837a8e7dbf4beb73bf258e10d0ffd8c5a1c33d8416ea583bc7629d38", + "block": 80550329, + "gasUsed": "1162822", + "effectiveGasPrice": "176543118334", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x56b93ca7320e27aa1986ecf4d8a7769a9502c8659eee4a76da86287081f33151", + "block": 80550330, + "gasUsed": "904910", + "effectiveGasPrice": "156301273542", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "60000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000340aad21b3b7000000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x05e256000a4bae4beaa0752579414e2563930a9c068cbb7efb6da3c5e9a4c063", + "block": 80550331, + "gasUsed": "1711545", + "effectiveGasPrice": "138059728312", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xbaf3a76839ad6a9d696fe14f326327b9cb6349cfb371f9f993218fc1dfd1d162", + "block": 80550332, + "gasUsed": "369083", + "effectiveGasPrice": "122882128046", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x384deb5d765a8712f93bfc5578469b0068993f12f8472d20819e82a4ad5ce6d1", + "block": 80550333, + "gasUsed": "408763", + "effectiveGasPrice": "108021733886", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "60000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000340aad21b3b70000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x0bc70ef96c9e094a042d33e8dfd8b8c37cd40963bf48fe1d072f713d01ffdd7d", + "block": 80550334, + "gasUsed": "1918199", + "effectiveGasPrice": "95008571526", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475322", + "liveGasPriceWei": "202500000000", + "l1FeeWei": "0", + "l1FeeMethod": "op-stack oracle getL1Fee", + "costWei": "1311252705000000000", + "cost": "1.311252705", + "native": "CELO" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/ethereum.json b/deployments/fork-v2/ethereum.json new file mode 100644 index 0000000..7e19c49 --- /dev/null +++ b/deployments/fork-v2/ethereum.json @@ -0,0 +1,171 @@ +{ + "chain": "ethereum", + "chainId": 1, + "mode": "anvil-fork", + "forkedFrom": "https://ethereum-rpc.publicnode.com", + "forkBlock": 26229787, + "at": "2026-10-19T23:04:21.488Z", + "seconds": 8, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x7ebe04deee5d15ad6114b0770aef207543b9c921c1c17edf22ce38079412917d", + "block": 26229787, + "gasUsed": "1162822", + "effectiveGasPrice": "1055031365", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x6081766c27453676430f79e7d76df1784738ee60b546c76bbc5a7fa05dedc2e7", + "block": 26229788, + "gasUsed": "904910", + "effectiveGasPrice": "1048419077", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x317ebb3138d182bc336b636a613ec9a09f32153604b6fb3478f3eb6d5d0bb4f9", + "block": 26229789, + "gasUsed": "1711521", + "effectiveGasPrice": "1042549255", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xb189110e698f908a43b148cadaeb039eb9c41106bf2d48ae7ea34fe9b0fc03aa", + "block": 26229790, + "gasUsed": "369083", + "effectiveGasPrice": "1037534032", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x3d4d1e977240daa14643d5e7d4e0b7a03bed4aa5c759aed79b67993f1cd02c3f", + "block": 26229791, + "gasUsed": "408763", + "effectiveGasPrice": "1032900000", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xd4d1ff30f631d222a1741486e291cea34ca67331ca11e5e13c468194fe8cb038", + "block": 26229792, + "gasUsed": "1918175", + "effectiveGasPrice": "1028843535", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "55034095", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "356360844467030", + "cost": "0.00035636084446703", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/gnosis.json b/deployments/fork-v2/gnosis.json new file mode 100644 index 0000000..aa3d565 --- /dev/null +++ b/deployments/fork-v2/gnosis.json @@ -0,0 +1,171 @@ +{ + "chain": "gnosis", + "chainId": 100, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.gnosischain.com", + "forkBlock": 48844239, + "at": "2026-10-19T23:06:07.423Z", + "seconds": 9, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x290ac8de063f7e8353a9dfcd1082f2040f3b548d9c94aeb9361e4b34fc986733", + "block": 48844238, + "gasUsed": "1162822", + "effectiveGasPrice": "1000000009", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x593babc7886d326a0275d7c3fb9b9235a2b30289082464556a62f94dcd130c1e", + "block": 48844239, + "gasUsed": "904910", + "effectiveGasPrice": "1000000009", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "5000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xd12db0616ff834aecd03a46b60b6fd3ec1a12bf25969f621344c87fa27feaa46", + "block": 48844240, + "gasUsed": "1711533", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x8a355ba2654b527dfdbe9ebc2758ab511c82d4712140f196892ba31a8a51f9de", + "block": 48844241, + "gasUsed": "369083", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x270c4abfc7d5522ae76cc6b5bb0029e646b1bc644a51b88519e8d8742138caa8", + "block": 48844242, + "gasUsed": "408763", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "5000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xcea4172deb59a61eacea98a3ccb4661054d78d54f4ad58a375e1efe231f033b9", + "block": 48844243, + "gasUsed": "1918187", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475298", + "liveGasPriceWei": "10", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "64752980", + "cost": "0.00000000006475298", + "native": "xDAI" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/linea.json b/deployments/fork-v2/linea.json new file mode 100644 index 0000000..1390fd5 --- /dev/null +++ b/deployments/fork-v2/linea.json @@ -0,0 +1,171 @@ +{ + "chain": "linea", + "chainId": 59144, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.linea.build", + "forkBlock": 32370743, + "at": "2026-10-19T23:06:28.854Z", + "seconds": 14, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xf05d43f278f879a39a222c41c6dd54e544f7fe7bc74b80899ac5ea721b662475", + "block": 32370742, + "gasUsed": "1162822", + "effectiveGasPrice": "1000000007", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0xa3671b41395d35e186df7f95a15faad354a0ef325c4775305b834b47d2fe3ac9", + "block": 32370743, + "gasUsed": "904910", + "effectiveGasPrice": "1000000007", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xdb6e6ac485aff4527b1c4bce6d30f0b383a32a8f1634715a51044cacc6439079", + "block": 32370744, + "gasUsed": "1711521", + "effectiveGasPrice": "1000000007", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x1c56d27c09c902760f3ba5ee3567f96ddb8560e9952eec53977c21056ebac2a9", + "block": 32370745, + "gasUsed": "369083", + "effectiveGasPrice": "1000000007", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xae322a474d345a65be20465a2a514c73dee6aac69fdce1dc52af56f8bc8931bb", + "block": 32370746, + "gasUsed": "408763", + "effectiveGasPrice": "1000000007", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xaaba931e05c41fc13303e8f94fcbfd4c0945274f041db404f47f5953022ff55a", + "block": 32370747, + "gasUsed": "1918175", + "effectiveGasPrice": "1000000007", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "49571436", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "320988630673464", + "cost": "0.000320988630673464", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/mantle.json b/deployments/fork-v2/mantle.json new file mode 100644 index 0000000..a04c005 --- /dev/null +++ b/deployments/fork-v2/mantle.json @@ -0,0 +1,171 @@ +{ + "chain": "mantle", + "chainId": 5000, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.mantle.xyz", + "forkBlock": 102160470, + "at": "2026-10-19T23:07:35.071Z", + "seconds": 17, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x1d4f34481c90b6ebef42eb88361715b4be71c91ab6456d35b5395c46f4dd128d", + "block": 102160460, + "gasUsed": "1162822", + "effectiveGasPrice": "44759645625", + "l1FeeWei": "531958727074" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x49907744e5df6323a45c5f9d9da48d28d3e49c4a0dfcdd8eccdc06ffae41629b", + "block": 102160461, + "gasUsed": "904910", + "effectiveGasPrice": "39501709417", + "l1FeeWei": "402478051110" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "9000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000007ce66c50e28400000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x5131576440f506020c2a781e6542ff242a59dfa97017ec3373d10a0e7c990162", + "block": 102160462, + "gasUsed": "1711533", + "effectiveGasPrice": "34834164831", + "l1FeeWei": "686627547153" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xe3e75f6c4233cef641df066fad4667056314c23953dda718ec6176fa514b2fc8", + "block": 102160463, + "gasUsed": "369083", + "effectiveGasPrice": "30846178768", + "l1FeeWei": "173606319989" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x6b3c0aefb9474d43143413a62a2ee695fcd8c6f28bcde92b4d00379f01314b1a", + "block": 102160464, + "gasUsed": "408763", + "effectiveGasPrice": "27161305244", + "l1FeeWei": "205465918491" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "9000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000007ce66c50e284000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xefa137d0b5ed38cc9db5d2c3a5359294eb86d377c07896254009c031e3d02ea4", + "block": 102160465, + "gasUsed": "1918187", + "effectiveGasPrice": "23935699479", + "l1FeeWei": "774173366969" + } + }, + "totals": { + "gasUsed": "6475298", + "liveGasPriceWei": "50000100000", + "l1FeeWei": "2774309930786", + "l1FeeMethod": "op-stack oracle getL1Fee", + "costWei": "323768321839730786", + "cost": "0.323768321839730786", + "native": "MNT" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/monad.json b/deployments/fork-v2/monad.json new file mode 100644 index 0000000..fd32123 --- /dev/null +++ b/deployments/fork-v2/monad.json @@ -0,0 +1,171 @@ +{ + "chain": "monad", + "chainId": 143, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.monad.xyz", + "forkBlock": 114881940, + "at": "2026-10-19T23:07:57.259Z", + "seconds": 3, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x096f64709c619ee83e505a08b31a042ea8721bc9316cf0d8e008ec0fdee6b192", + "block": 114881927, + "gasUsed": "1163394", + "effectiveGasPrice": "91657906334", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0xd023dcf593814464f19b2def2d708de99ef219edda1522231390ea26c2bc1c45", + "block": 114881928, + "gasUsed": "904573", + "effectiveGasPrice": "80501452817", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "200000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000ad78ebc5ac62000000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xcc0bdbddad04096f99ea24048b4e9417649412644ccf23d39c6f42f9aa441aeb", + "block": 114881929, + "gasUsed": "1697094", + "effectiveGasPrice": "70683629328", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xd1ce31ccfe8e48d611462d92ec7c3a8c835c6df04ad041f1317c24965e34d251", + "block": 114881930, + "gasUsed": "374875", + "effectiveGasPrice": "62170275111", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x39e283c6281c07d40fb671c0aa7a66ba80cfc1b8efd0d962f2b05d02f0c6315a", + "block": 114881931, + "gasUsed": "408628", + "effectiveGasPrice": "54562209401", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "200000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000ad78ebc5ac620000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x831eb5241cccc795d62cddfb298ab6c4889ca44d409c80429e14c92e02d1cf30", + "block": 114881932, + "gasUsed": "1903645", + "effectiveGasPrice": "47903411591", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6452209", + "liveGasPriceWei": "102000000000", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "658125318000000000", + "cost": "0.658125318", + "native": "MON" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/optimism.json b/deployments/fork-v2/optimism.json new file mode 100644 index 0000000..97c522c --- /dev/null +++ b/deployments/fork-v2/optimism.json @@ -0,0 +1,171 @@ +{ + "chain": "optimism", + "chainId": 10, + "mode": "anvil-fork", + "forkedFrom": "https://optimism-rpc.publicnode.com", + "forkBlock": 158426174, + "at": "2026-10-19T23:05:25.788Z", + "seconds": 6, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x03dd26c1cbeef37fe348758e20d1648e162d2304cc992bb7a4830fc3f4baf268", + "block": 158426171, + "gasUsed": "1162822", + "effectiveGasPrice": "1000015714", + "l1FeeWei": "26052427225" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x3f257b85f7d45deba0a08359c726c74930ee9f31ed20569bdc3a1211780ba607", + "block": 158426172, + "gasUsed": "904910", + "effectiveGasPrice": "1000015656", + "l1FeeWei": "19706299542" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x48621fb7f28e67014d4f0674ab2030025cb13a20434829688cc88db7a23352d2", + "block": 158426173, + "gasUsed": "1711521", + "effectiveGasPrice": "1000015597", + "l1FeeWei": "33626407162" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xc8199c6e1b7b750ff70dcd21eda7149746bae07b08ef038cea82236360909de9", + "block": 158426174, + "gasUsed": "369083", + "effectiveGasPrice": "1000015540", + "l1FeeWei": "8488801503" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x7530b57d5c323d27f95f77009ec25e959a2caa5c5ce63cfbaca7a73b596710b2", + "block": 158426175, + "gasUsed": "408763", + "effectiveGasPrice": "1000015480", + "l1FeeWei": "10050309261" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x55102162a73da7be0e3cdd7225ccc51872b77dbe56e60f45b905937a8315f3a4", + "block": 158426176, + "gasUsed": "1918175", + "effectiveGasPrice": "1000015420", + "l1FeeWei": "37917216941" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "1015717", + "l1FeeWei": "135841461634", + "l1FeeMethod": "op-stack oracle getL1Fee", + "costWei": "6712887343092", + "cost": "0.000006712887343092", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/plasma.json b/deployments/fork-v2/plasma.json new file mode 100644 index 0000000..4da47f7 --- /dev/null +++ b/deployments/fork-v2/plasma.json @@ -0,0 +1,162 @@ +{ + "chain": "plasma", + "chainId": 9745, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.plasma.to", + "forkBlock": 35509945, + "at": "2026-10-19T23:08:47.203Z", + "seconds": 12, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x2b037cefd10bf9f82b595a21852a9f6e18016338b46d936d6c0ab93b369980b9", + "block": 35509931, + "gasUsed": "1162822", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0xc5e46698aaf96a374b0ef3dfa153fbc11c007453f31dc814f09c0269760dc169", + "block": 35509932, + "gasUsed": "904910", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "60000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000340aad21b3b7000000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x4972e3828848e4005dd3030990bac7254d3ccb7109570fc843261c4ebcbd195e", + "block": 35509933, + "gasUsed": "1711545", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x3b0e436b86e70f804d8cfbe909197b57c19232be167761d9685ad3c4b48a7115", + "block": 35509934, + "gasUsed": "369083", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x86f0609d2da89e6da90ef40e45695e8da62715ee095f7d13b23d012678c4015f", + "block": 35509935, + "gasUsed": "408763", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "60000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000340aad21b3b70000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x1152910a273813e4e6eefa38675eb9ceeac0fd56731c3065d40c7a407d4f0863", + "block": 35509936, + "gasUsed": "1918199", + "effectiveGasPrice": "1000000008", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475322", + "liveGasPriceWei": "9903498", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "64128338476356", + "cost": "0.000064128338476356", + "native": "XPL" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + } +} diff --git a/deployments/fork-v2/polygon.json b/deployments/fork-v2/polygon.json new file mode 100644 index 0000000..f0cc220 --- /dev/null +++ b/deployments/fork-v2/polygon.json @@ -0,0 +1,171 @@ +{ + "chain": "polygon", + "chainId": 137, + "mode": "anvil-fork", + "forkedFrom": "https://polygon-bor-rpc.publicnode.com", + "forkBlock": 95830507, + "at": "2026-10-19T23:05:13.875Z", + "seconds": 6, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x6bda15550c12ab4e08b28e8ef757eb586e025d90fe7a165e7b8170aa890ac18b", + "block": 95830503, + "gasUsed": "1162822", + "effectiveGasPrice": "230087183569", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x0c31637ec9ecc1fbc437590c8d0241b69dd9857f72732e6754df95193f428a28", + "block": 95830504, + "gasUsed": "904910", + "effectiveGasPrice": "204823910450", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "50000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e6160000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000002b5e3af16b18800000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xd877adcf5aa3f0f0d8074057e6fdaf3e2022d51ff6a604d9e8898a85a8c82d18", + "block": 95830505, + "gasUsed": "1711545", + "effectiveGasPrice": "182600178605", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x14647edf0b65246b88a01e5b406bc7fa3c0e2035595b5909df38498e5658d050", + "block": 95830506, + "gasUsed": "369083", + "effectiveGasPrice": "163321624714", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x5c06bfb19d4a2eca0e2b1d7cf2b7ee888cfffebbde4657cb664d7673f3980b41", + "block": 95830507, + "gasUsed": "408763", + "effectiveGasPrice": "146111190626", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "50000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a052268370000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000002b5e3af16b188000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x44a2f03cf2620635c94dde7be78827d3f72ff5285909cc4351ae81992cc4b469", + "block": 95830508, + "gasUsed": "1918199", + "effectiveGasPrice": "131049644570", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475322", + "liveGasPriceWei": "247754752360", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "1604291798561259920", + "cost": "1.60429179856125992", + "native": "POL" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/robinhood.json b/deployments/fork-v2/robinhood.json new file mode 100644 index 0000000..08b8d54 --- /dev/null +++ b/deployments/fork-v2/robinhood.json @@ -0,0 +1,171 @@ +{ + "chain": "robinhood", + "chainId": 4663, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.mainnet.chain.robinhood.com/rpc", + "forkBlock": 92990470, + "at": "2026-10-19T23:08:12.544Z", + "seconds": 9, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xb7b2dd26dfa5c440614cb3516b788e56329455ce64515ef08a880170af40495f", + "block": 92990369, + "gasUsed": "1162822", + "effectiveGasPrice": "1017514001", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0xe1c68530b21225cfa805b2a0f01593982a52ee57a68919e408a3c7936887d594", + "block": 92990370, + "gasUsed": "904910", + "effectiveGasPrice": "1015324751", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xce42dde4b097dbf4ef42066421a3a6d08a5080f050d6ed0ef9e6b97f95ae304a", + "block": 92990371, + "gasUsed": "1711521", + "effectiveGasPrice": "1013409158", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xa5cd1b4f69f0ad1ff6dd6ff0e3e3b591bb284a577f96b4e7795415956c3e2c5e", + "block": 92990372, + "gasUsed": "369083", + "effectiveGasPrice": "1011733014", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x033aa7020481fdd369a60c80b131d35554b5e92a4c465f4591f1fe3c36b813fe", + "block": 92990373, + "gasUsed": "408763", + "effectiveGasPrice": "1010266388", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x58e5d81d0b8f4e77e319b8373a0b918c2329670aae1be6bd48891a723918b951", + "block": 92990374, + "gasUsed": "1918175", + "effectiveGasPrice": "1008983090", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "20016000", + "l1FeeWei": "0", + "l1FeeMethod": "arbitrum NodeInterface: 0 L1 gas units", + "costWei": "129609084384000", + "cost": "0.000129609084384", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/rootstock.json b/deployments/fork-v2/rootstock.json new file mode 100644 index 0000000..8d719bc --- /dev/null +++ b/deployments/fork-v2/rootstock.json @@ -0,0 +1,162 @@ +{ + "chain": "rootstock", + "chainId": 30, + "mode": "anvil-fork", + "forkedFrom": "https://public-node.rsk.co", + "forkBlock": 9339619, + "at": "2026-10-19T23:08:27.539Z", + "seconds": 6, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x00d017432794b934117b15ac688e623691e1a297b04d0240137457cbdd35cdfb", + "block": 9339620, + "gasUsed": "1162822", + "effectiveGasPrice": "2000000000", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0xacda5fb7549044433bc34ecc204cfbafeb86fdaee32cb2984728873064144c8f", + "block": 9339621, + "gasUsed": "904910", + "effectiveGasPrice": "1904070550", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "60000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000003691d6afc0000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xd873619b761ca2d8bbe02544986aecee03295f8d5cf0215f8a47123413d7dbbc", + "block": 9339622, + "gasUsed": "1711521", + "effectiveGasPrice": "1811514294", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xad88606006f65383640f35607e8c1fe398fb8f750f80244adcd57c188198deb7", + "block": 9339623, + "gasUsed": "369083", + "effectiveGasPrice": "1744798102", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xd0b73e18cfd74fc13659b6c16d84f11001332bba81bd25650fd2d3807d908253", + "block": 9339624, + "gasUsed": "408763", + "effectiveGasPrice": "1658570648", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "60000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000003691d6afc00000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0xe81c007c3123e975b790db58c3686c2293c6244cfe2af405fa87fe82e9dc7551", + "block": 9339625, + "gasUsed": "1918175", + "effectiveGasPrice": "1582979300", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "26065600", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "168781901974400", + "cost": "0.0001687819019744", + "native": "RBTC" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + } +} diff --git a/deployments/fork-v2/scroll.json b/deployments/fork-v2/scroll.json new file mode 100644 index 0000000..054b940 --- /dev/null +++ b/deployments/fork-v2/scroll.json @@ -0,0 +1,171 @@ +{ + "chain": "scroll", + "chainId": 534352, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.scroll.io", + "forkBlock": 35434108, + "at": "2026-10-19T23:06:42.094Z", + "seconds": 6, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x97892f37919462ae8f3e44de7611b85d05c68303adcf7dce0a1f6f5ecbea1555", + "block": 35434108, + "gasUsed": "1162822", + "effectiveGasPrice": "1000105069", + "l1FeeWei": "15130847943833" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x05dc34a063577a5a29881189cb5c0f13bf58465d9e26c920520b3024794eed77", + "block": 35434109, + "gasUsed": "904910", + "effectiveGasPrice": "1000093463", + "l1FeeWei": "10726864161564" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x6f5cfececa1ba4e91beebe9dd7b5cfe158be983727791c78f9d5abd371325028", + "block": 35434110, + "gasUsed": "1711521", + "effectiveGasPrice": "1000082838", + "l1FeeWei": "26317743379629" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x636db2652cacc7e6ef7202b056ecf7ad31c0d8a26e1b26f6d29c7a465da28115", + "block": 35434111, + "gasUsed": "369083", + "effectiveGasPrice": "1000074256", + "l1FeeWei": "3479054867203" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x6f8b050da0961b8be79a6b0d47c648db2a6eb1c1525ec34e16682f1853dfc12d", + "block": 35434112, + "gasUsed": "408763", + "effectiveGasPrice": "1000065317", + "l1FeeWei": "4117780274377" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x4c7b833342eda879949f9c036cdce994c0fd624aa38774abbf3bc68ad210bfb4", + "block": 35434113, + "gasUsed": "1918175", + "effectiveGasPrice": "1000057487", + "l1FeeWei": "30864573347652" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "120108", + "l1FeeWei": "90636863974258", + "l1FeeMethod": "scroll oracle getL1Fee", + "costWei": "91414596183850", + "cost": "0.00009141459618385", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + } +} diff --git a/deployments/fork-v2/sonic.json b/deployments/fork-v2/sonic.json new file mode 100644 index 0000000..d87c667 --- /dev/null +++ b/deployments/fork-v2/sonic.json @@ -0,0 +1,162 @@ +{ + "chain": "sonic", + "chainId": 146, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.soniclabs.com", + "forkBlock": 81327451, + "at": "2026-10-19T23:07:08.417Z", + "seconds": 5, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0x14627b93d9717aa562a2daa3457f4835289476b2caaf6fef8ff1e8d61d74acf7", + "block": 81327443, + "gasUsed": "1162822", + "effectiveGasPrice": "44784296225", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x2c25234897ae197540aa6bc76d3b1f1220e3122e35c496210b57ab70c20701d8", + "block": 81327444, + "gasUsed": "904910", + "effectiveGasPrice": "39313804865", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "80000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e6160000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x69bb436d6a8e9614c443117d815ea8749126d137dd97a0fdb6036b063aba8eb0", + "block": 81327445, + "gasUsed": "1711545", + "effectiveGasPrice": "34526312785", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xfa8d0f87f68512cb15a1ce08466ad0791f6dac3ba53d2410b705bddcb1ea37e1", + "block": 81327446, + "gasUsed": "369083", + "effectiveGasPrice": "30338392777", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xfa31df1661c7b3565caa968714f99c51644b15255fe68dd4ade19943ff38d62c", + "block": 81327447, + "gasUsed": "408763", + "effectiveGasPrice": "26671635095", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "80000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a052268370000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000004563918244f40000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x87cf7bfd85c8be710290afc30483592fa66139d2526bba13f060d9c67874010c", + "block": 81327448, + "gasUsed": "1918199", + "effectiveGasPrice": "23463205389", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475322", + "liveGasPriceWei": "55000000000", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "356142710000000000", + "cost": "0.35614271", + "native": "S" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + } +} diff --git a/deployments/fork-v2/stable.json b/deployments/fork-v2/stable.json new file mode 100644 index 0000000..e0caf5e --- /dev/null +++ b/deployments/fork-v2/stable.json @@ -0,0 +1,162 @@ +{ + "chain": "stable", + "chainId": 988, + "mode": "anvil-fork", + "forkedFrom": "https://rpc.stable.xyz", + "forkBlock": 43687424, + "at": "2026-10-19T23:09:00.014Z", + "seconds": 8, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xc4af0c6c848f66d1789cacc2958fef6a980679e59e9811ee834f94843fe5f687", + "block": 43687413, + "gasUsed": "1162822", + "effectiveGasPrice": "1875062500", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x7204789ac9b1bc1c960403b2b2656411bee37eb8687e7d41bda508265ff9f337", + "block": 43687414, + "gasUsed": "904910", + "effectiveGasPrice": "1768223543", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "5000000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0x1c4c86f0ff938fa82e8a4e89aec3b3b056f5324bdb7f20324b3396132c242950", + "block": 43687415, + "gasUsed": "1711533", + "effectiveGasPrice": "1673933534", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xdc2038fdb3f61a8535a3dcc5d33da009cb5e8d7fbb8a113c7c7061fdac07a7f2", + "block": 43687416, + "gasUsed": "369083", + "effectiveGasPrice": "1592575491", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xc02ae8e294fc2102eceb93d619b3713a352d281aa6eb35fd0d850ccaf742633f", + "block": 43687417, + "gasUsed": "408763", + "effectiveGasPrice": "1519050329", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "5000000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x4a56e4373482f429043f05f3ce41737d8139f20e5b3a1bcc1d9bf4f5fe995df2", + "block": 43687418, + "gasUsed": "1918187", + "effectiveGasPrice": "1454699460", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6475298", + "liveGasPriceWei": "1125000000", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "7284710250000000", + "cost": "0.00728471025", + "native": "USDT0" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + } +} diff --git a/deployments/fork-v2/unichain.json b/deployments/fork-v2/unichain.json new file mode 100644 index 0000000..852a5a2 --- /dev/null +++ b/deployments/fork-v2/unichain.json @@ -0,0 +1,162 @@ +{ + "chain": "unichain", + "chainId": 130, + "mode": "anvil-fork", + "forkedFrom": "https://mainnet.unichain.org", + "forkBlock": 61702856, + "at": "2026-10-19T23:06:57.321Z", + "seconds": 8, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", + "txHash": "0xe83ecdc851e68ed1921630608b7f1c14262fe858c108ece6a97a35c246d8cb3d", + "block": 61702845, + "gasUsed": "1162822", + "effectiveGasPrice": "1000500000", + "l1FeeWei": "14615147616" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0xf076db3206a34c46241b3ce8758d19e23020cddb5d445c32bb5b7fe7ee022208", + "block": 61702846, + "gasUsed": "904910", + "effectiveGasPrice": "1000500000", + "l1FeeWei": "11202178770" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "86400", + "2000000000000000", + "10000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", + "txHash": "0xc981d29d0f1bb9109cd68c99d1610b8cf42a891e3d63e8ea4bb5a35c327c02f2", + "block": 61702847, + "gasUsed": "1711521", + "effectiveGasPrice": "1000500000", + "l1FeeWei": "18862985546" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xfc0123da6fc278ad88a07a255ddd743a2d47f4aed1dd8b07f1c0f0e9098f9061", + "block": 61702848, + "gasUsed": "369083", + "effectiveGasPrice": "1000500000", + "l1FeeWei": "4764654725" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x2c9573d8ec42642098325c4e4417733d0b684b0fd4a120bf3404cc97ce216007", + "block": 61702849, + "gasUsed": "408763", + "effectiveGasPrice": "1000500000", + "l1FeeWei": "5640420430" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "2000000000000000", + "1000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", + "txHash": "0x37d530d81192902cbb3deb12de9caac7e2a3452d3f63965593cda8b98805c021", + "block": 61702850, + "gasUsed": "1918175", + "effectiveGasPrice": "1000500000", + "l1FeeWei": "21269469942" + } + }, + "totals": { + "gasUsed": "6475274", + "liveGasPriceWei": "1500000", + "l1FeeWei": "76354857029", + "l1FeeMethod": "op-stack oracle getL1Fee", + "costWei": "9789265857029", + "cost": "0.000009789265857029", + "native": "ETH" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.wouldSlash(unknown) == false", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated" + ], + "e2e": null, + "attacksBlocked": null, + "setVersion": "2026-10-20-v2", + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + } +} diff --git a/lib/chains.mjs b/lib/chains.mjs index d088696..31778ea 100644 --- a/lib/chains.mjs +++ b/lib/chains.mjs @@ -3,6 +3,7 @@ import { JsonRpcProvider, Network } from "ethers"; export const CHAINS = JSON.parse(readFileSync(new URL("../config/chains.json", import.meta.url), "utf8")); export const SET = JSON.parse(readFileSync(new URL("../config/set.json", import.meta.url), "utf8")); +export const SET_V2 = JSON.parse(readFileSync(new URL("../config/set.v2.json", import.meta.url), "utf8")); export function chainConfig(name) { const c = CHAINS[name]; diff --git a/lib/compile.mjs b/lib/compile.mjs index 8fd9b72..33b93d3 100644 --- a/lib/compile.mjs +++ b/lib/compile.mjs @@ -2,6 +2,7 @@ // Arc / Arbitrum / Robinhood / Monad deployments were built with, so the creation code we ship is // byte-identical to what is already verified there (see `npm test`, which checks it against Arc). import { readFileSync } from "node:fs"; +import { posix } from "node:path"; import { createHash } from "node:crypto"; import solc from "solc"; @@ -15,32 +16,49 @@ export const SETTINGS = { const CONTRACTS_DIR = new URL("../contracts/", import.meta.url); const cache = new Map(); -/** Standard-JSON input for one contract, exactly as compiled (also what explorers verify against). */ -export function standardInput(name) { +/** Pulls in every file `entry` imports (relative imports only), so explorers can verify the multi-file v2 contracts. */ +function collectSources(entry, out = {}) { + if (out[entry]) return out; + const content = readFileSync(new URL(entry, CONTRACTS_DIR), "utf8"); + out[entry] = { content }; + for (const m of content.matchAll(/^\s*import\s+(?:\{[^}]*\}\s+from\s+)?"([^"]+)";/gm)) { + if (!m[1].startsWith(".")) throw new Error(`${entry}: only relative imports are supported (${m[1]})`); + collectSources(posix.normalize(posix.join(posix.dirname(entry), m[1])), out); + } + return out; +} + +/** Standard-JSON input for one contract, exactly as compiled (also what explorers verify against). + * v1: one file `.sol`. v2: pass `file` (e.g. "v2/AgentJobV2") and every imported file is included. */ +export function standardInput(name, file) { + if (file) return { language: "Solidity", sources: collectSources(`${file}.sol`), settings: SETTINGS }; const content = readFileSync(new URL(`${name}.sol`, CONTRACTS_DIR), "utf8"); return { language: "Solidity", sources: { [`${name}.sol`]: { content } }, settings: SETTINGS }; } -export function compile(name) { - if (cache.has(name)) return cache.get(name); +export function compile(name, file) { + const ck = file ? `${file}:${name}` : name; + if (cache.has(ck)) return cache.get(ck); if (!solc.version().startsWith(SOLC_VERSION)) throw new Error(`solc ${solc.version()} != ${SOLC_VERSION}`); - const input = standardInput(name); + const input = standardInput(name, file); + const key = file ? `${file}.sol` : `${name}.sol`; const out = JSON.parse(solc.compile(JSON.stringify(input))); const fatal = (out.errors || []).filter((e) => e.severity === "error"); if (fatal.length) throw new Error(fatal.map((e) => e.formattedMessage).join("\n")); - const c = out.contracts[`${name}.sol`][name]; + const c = out.contracts[key][name]; const res = { name, + id: `${key}:${name}`, abi: c.abi, bytecode: "0x" + c.evm.bytecode.object, deployedBytecode: "0x" + c.evm.deployedBytecode.object, immutableReferences: c.evm.deployedBytecode.immutableReferences || {}, metadata: c.metadata, - sourceSha256: createHash("sha256").update(input.sources[`${name}.sol`].content).digest("hex"), + sourceSha256: createHash("sha256").update(input.sources[key].content).digest("hex"), compilerVersion: "v" + solc.version().replace(".Emscripten.clang", ""), input, }; - cache.set(name, res); + cache.set(ck, res); return res; } diff --git a/lib/key.mjs b/lib/key.mjs index 3d57439..02787b6 100644 --- a/lib/key.mjs +++ b/lib/key.mjs @@ -5,13 +5,13 @@ import { Wallet } from "ethers"; export const KEY_FILE = process.env.PREDGE_DEPLOYER_FILE || `${homedir()}/.predge-deployer/deployer.json`; /** Loads the deployer. Refuses a key file other users can read. Never logs the key. */ -export function loadDeployer(provider) { +export function loadDeployer(provider, file = KEY_FILE) { let st; - try { st = statSync(KEY_FILE); } catch { - throw new Error(`no deployer key at ${KEY_FILE}. Run: npm run keygen`); + try { st = statSync(file); } catch { + throw new Error(`no deployer key at ${file}. Run: npm run keygen`); } - if (st.mode & 0o077) throw new Error(`${KEY_FILE} is readable by others (mode ${(st.mode & 0o777).toString(8)}). chmod 600 it.`); - const j = JSON.parse(readFileSync(KEY_FILE, "utf8")); + if (st.mode & 0o077) throw new Error(`${file} is readable by others (mode ${(st.mode & 0o777).toString(8)}). chmod 600 it.`); + const j = JSON.parse(readFileSync(file, "utf8")); const w = new Wallet(j.privateKey, provider); if (w.address !== j.address) throw new Error("deployer.json address does not match its key"); return w; diff --git a/lib/plan.mjs b/lib/plan.mjs index 6973280..f584930 100644 --- a/lib/plan.mjs +++ b/lib/plan.mjs @@ -1,10 +1,10 @@ // Resolves the canonical plan for one deployer: every address is known before anything is sent, // because contract i is created by the deployer's nonce i (CREATE), on every chain. import { getCreateAddress, ContractFactory } from "ethers"; -import { SET } from "./chains.mjs"; +import { SET as SET_V1 } from "./chains.mjs"; import { compile } from "./compile.mjs"; -export function resolvePlan(deployer, overrides = {}) { +export function resolvePlan(deployer, overrides = {}, SET = SET_V1) { const params = { ...SET.params, ...overrides }; const addresses = Object.fromEntries(SET.plan.map((s, i) => [s.name, getCreateAddress({ from: deployer, nonce: i })])); const ctx = { deployer, ...params, ...addresses }; @@ -16,7 +16,7 @@ export function resolvePlan(deployer, overrides = {}) { return v; }; return SET.plan.map((step, nonce) => { - const c = compile(step.name); + const c = compile(step.name, step.file); const args = step.args.map(resolve); const data = new ContractFactory(c.abi, c.bytecode).interface.encodeDeploy(args); return { ...step, nonce, address: addresses[step.name], args, constructorArgs: data, initcode: c.bytecode + data.slice(2), compiled: c }; diff --git a/lib/smoke-v2.mjs b/lib/smoke-v2.mjs new file mode 100644 index 0000000..52f15d0 --- /dev/null +++ b/lib/smoke-v2.mjs @@ -0,0 +1,183 @@ +// v2 smoke checks. `readOnlyV2` runs on a live chain (eth_call only). `e2eV2` runs on a local fork ONLY, with the +// public anvil accounts: the full honest path through every contract, then the v1 attack sequences (H-01, H-02, +// H-03 and the self-challenge) which must now revert or be harmless. +import { Contract, keccak256, toUtf8Bytes, sha256, AbiCoder, ZeroAddress, ZeroHash } from "ethers"; +import { maskImmutables } from "./compile.mjs"; +import { waitReceipt } from "./chains.mjs"; + +const coder = AbiCoder.defaultAbiCoder(); +const h = (s) => keccak256(toUtf8Bytes(s)); +const step = (plan, name) => plan.find((s) => s.name === name); +const at = (plan, name, runner) => new Contract(step(plan, name).address, step(plan, name).compiled.abi, runner); + +export async function readOnlyV2(provider, plan, roles) { + const checks = []; + const ok = (name, cond, detail) => { checks.push({ check: name, ok: !!cond, detail: String(detail ?? "") }); if (!cond) throw new Error(`v2 smoke failed: ${name} (${detail})`); }; + + for (const s of plan) { + const code = await provider.getCode(s.address); + const c = s.compiled; + ok(`${s.name} runtime == compiled`, maskImmutables(code, c.immutableReferences) === maskImmutables(c.deployedBytecode, c.immutableReferences), s.address); + } + const reg = at(plan, "PredgeAgentValidatorV2", provider); + const job = at(plan, "AgentJobV2", provider); + const bond = at(plan, "PredgeValidatorBondV2", provider); + const settle = at(plan, "PredgeSettlementV2", provider); + const arb = at(plan, "PredgeRefundArbiterV2", provider); + const mock = at(plan, "MockRefundProtocolV2", provider); + const [bondArgs, arbArgs] = [step(plan, "PredgeValidatorBondV2").args, step(plan, "PredgeRefundArbiterV2").args]; + + for (const [n, c] of [["registry", reg], ["bond", bond], ["settlement", settle], ["arbiter", arb]]) { + ok(`${n}.owner == owner (not the deployer)`, (await c.owner()) === roles.owner && roles.owner !== roles.deployer, await c.owner()); + ok(`${n}.pendingOwner == 0`, (await c.pendingOwner()) === ZeroAddress, ""); + } + for (const [n, c] of [["registry", reg], ["bond", bond], ["arbiter", arb]]) ok(`${n}.operator == operator`, (await c.operator()) === roles.operator, await c.operator()); + ok("no contract has a renounceOwnership", await noSelector(provider, plan, "renounceOwnership()"), ""); + ok("registry.identityRegistry", (await reg.identityRegistry()) === step(plan, "PredgeAgentValidatorV2").args[2], await reg.identityRegistry()); + ok("job.evaluationWindow", (await job.evaluationWindow()) === BigInt(step(plan, "AgentJobV2").args[0]), ""); + ok("job.jobCount == 0", (await job.jobCount()) === 0n, ""); + ok("bond.job == AgentJobV2", (await bond.job()) === job.target, await bond.job()); + ok("bond params", (await bond.disputeWindow()) === BigInt(bondArgs[3]) && (await bond.minBond()) === BigInt(bondArgs[4]) && (await bond.bondBps()) === BigInt(bondArgs[5]), ""); + ok("bond.wouldSlash(unknown) == false", (await bond.wouldSlash(h("predge/smoke/none"))) === false, ""); + ok("arbiter.refundProtocol == mock", (await arb.refundProtocol()) === mock.target, await arb.refundProtocol()); + ok("mock.arbiter == arbiter", (await mock.arbiter()) === arb.target, await mock.arbiter()); + ok("arbiter params", (await arb.challengeWindow()) === BigInt(arbArgs[3]) && (await arb.minBond()) === BigInt(arbArgs[4]) && (await arb.bondBps()) === BigInt(arbArgs[5]), ""); + ok("registry.requestHashFor is domain separated", (await reg.requestHashFor(h("d"))) === keccak256(coder.encode(["uint256", "address", "bytes32"], [(await provider.getNetwork()).chainId, reg.target, h("d")])), ""); + return checks; +} + +async function noSelector(provider, plan, sig) { + const data = keccak256(toUtf8Bytes(sig)).slice(0, 10); + for (const s of plan) { + if (s.name === "MockRefundProtocolV2" || s.name === "AgentJobV2") continue; + const r = await provider.call({ to: s.address, data }).then(() => "returned", () => "reverted"); + if (r !== "reverted") return false; + } + return true; +} + +/** `s` = { owner, op, client, worker, party } connected signers on the fork. */ +export async function e2eV2(provider, plan, s, increaseTime) { + const steps = []; + const send = async (label, p) => { const tx = await p; const r = await waitReceipt(provider, tx.hash); if (r.status !== 1) throw new Error(`e2e ${label} reverted`); steps.push({ step: label, gasUsed: Number(r.gasUsed) }); return r; }; + const attacks = []; + /** An attack step passes only if the call reverts with the named custom error. */ + const mustRevert = async (label, name, fn) => { + try { await fn(); } catch (e) { + const got = e.revert?.name ?? e.shortMessage ?? e.message; + if (got !== name) throw new Error(`attack "${label}" reverted with ${got}, expected ${name}`); + attacks.push({ attack: label, blockedBy: name }); return; + } + throw new Error(`attack "${label}" SUCCEEDED, expected revert ${name}`); + }; + const addr = async (w) => w.getAddress(); + const [owner, op, client, worker, party] = [s.owner, s.op, s.client, s.worker, s.party]; + const reg = at(plan, "PredgeAgentValidatorV2", op), job = at(plan, "AgentJobV2", client), bond = at(plan, "PredgeValidatorBondV2", op); + const arb = at(plan, "PredgeRefundArbiterV2", op), mock = at(plan, "MockRefundProtocolV2", client), settle = at(plan, "PredgeSettlementV2", client); + const now = async () => (await provider.getBlock("latest")).timestamp; + const ESCROW = 10n ** 15n; + const mkSpec = async (n) => { + const digest = h(`predge/v2-fork/${n}/${Date.now()}`); + await send(`registry.validationRequest #${n}`, reg.validationRequest(op.address, 1n, "ipfs://v2-fork", digest)); + return reg.requestHashFor(digest); + }; + const newJob = async (n, escrow = ESCROW) => { + const spec = await mkSpec(n); + await send(`job.createJob #${n}`, job.createJob(await addr(worker), bond.target, spec, (await now()) + 7 * 86400, { value: escrow })); + return { id: await job.jobCount(), spec }; + }; + const stake = async (spec, id, expected, salt, escrow = ESCROW, label = "bond.stakeAndCommit") => { + const need = await bond.requiredBond(escrow); + return send(label, bond.stakeAndCommit(spec, keccak256(coder.encode(["bytes32", "bytes32"], [expected, salt])), id, { value: need })); + }; + const output = Buffer.from("predge v2 fork e2e output"); + const E = sha256(output), SALT = h("v2-salt"), X = h("not the expected output"); + + // ---------------- honest path ---------------- + const j1 = await newJob(1); + await stake(j1.spec, j1.id, E, SALT); + await send("job.submit (bytes, sha256 on-chain)", job.connect(worker).submit(j1.id, E, "0x" + output.toString("hex"))); + if (!(await job.getJob(j1.id)).hashedOnChain) throw new Error("deliverable was not hashed on-chain"); + await send("bond.recordScore(100) completes the job", bond.recordScore(j1.spec, 100, E, SALT, h("verdict"))); + if (Number((await job.getJob(j1.id)).state) !== 3) throw new Error("job not Completed by the bond"); + await send("registry.validationResponse", reg.validationResponse(j1.spec, 100, "ipfs://v2-fork/verdict", h("verdict"), "predge/v2-fork")); + if (await bond.wouldSlash(j1.spec)) throw new Error("honest verdict is slashable"); + await mustRevert("challenge of an honest verdict", "VerdictHonest", async () => bond.connect(party).challenge.staticCall(j1.spec)); + if ((await job.credit(await addr(worker))) !== ESCROW) throw new Error("provider is not credited the escrow"); + await send("job.withdraw (provider pulls payout)", job.connect(worker).withdraw()); + if ((await job.credit(await addr(worker))) !== 0n) throw new Error("provider credit not cleared"); + await send("settlement.payForRoute", settle.payForRoute(h("/v2/route"), "v2-fork", { value: 1000n })); + await send("settlement.withdraw (owner)", settle.connect(owner).withdraw(await addr(owner))); + + // ---------------- attacks that worked on v1 ---------------- + // H-01: verdict before submission, then wait the window out and reclaim. + const j2 = await newJob(2); + await stake(j2.spec, j2.id, E, SALT); + await mustRevert("H-01 recordScore before the provider submits", "NotSubmitted", async () => bond.recordScore.staticCall(j2.spec, 100, E, SALT, ZeroHash)); + await mustRevert("H-01 reclaim without a verdict", "NotScored", async () => bond.reclaim.staticCall(j2.spec)); + // H-02: commit after the deliverable exists / swap the revealed value / provider copies public data. + const j3 = await newJob(3); + await send("job.submit (j3, wrong output)", job.connect(worker).submit(j3.id, X, "0x")); + await mustRevert("H-02 commit after submission", "NotOpen", async () => bond.stakeAndCommit.staticCall(j3.spec, keccak256(coder.encode(["bytes32", "bytes32"], [X, SALT])), j3.id, { value: ESCROW * 2n })); + const j4 = await newJob(4); + await stake(j4.spec, j4.id, E, SALT); + const staked = await bond.stakes(j4.spec); + if (staked.expected !== ZeroHash) throw new Error("expected is visible on-chain before the reveal"); + await send("job.submit (j4, provider submits the public commitment)", job.connect(worker).submit(j4.id, staked.commitment, "0x")); + await send("bond.recordScore(0) honest fail", bond.recordScore(j4.spec, 0, E, SALT, h("fail"))); + await mustRevert("H-02 honest fail is not slashable", "VerdictHonest", async () => bond.connect(party).challenge.staticCall(j4.spec)); + // M-02: a false pass is slashed to the harmed client; the challenger gets nothing. + const j5 = await newJob(5); + await stake(j5.spec, j5.id, E, SALT); + await send("job.submit (j5, wrong output)", job.connect(worker).submit(j5.id, X, "0x")); + await send("bond.recordScore(100) FALSE pass", bond.recordScore(j5.spec, 100, E, SALT, h("lie"))); + if (!(await bond.wouldSlash(j5.spec))) throw new Error("false pass not slashable"); + await send("bond.challenge by a stranger", bond.connect(party).challenge(j5.spec)); + const clientCredit = await bond.credit(await addr(client)), partyCredit = await bond.credit(await addr(party)); + if (clientCredit < ESCROW || partyCredit !== 0n) throw new Error(`slash routing wrong: client ${clientCredit} challenger ${partyCredit}`); + attacks.push({ attack: "M-02 slash pays the harmed client, challenger earns 0", blockedBy: "routing" }); + await send("bond.withdraw (client)", bond.connect(client).withdraw()); + + // M-03: expiry. An unsubmitted job can be refunded after expiry; the bond is released with no verdict. + await increaseTime(7 * 86400 + 1); + await mustRevert("late submit after expiry", "PastDeadline", async () => job.connect(worker).submit.staticCall(j2.id, E, "0x")); + await send("job.claimRefund (expired, never submitted)", job.connect(party).claimRefund(j2.id)); + await send("bond.release (no verdict was given)", bond.connect(party).release(j2.spec)); + + // reclaim of the honest stake after its window + await send("bond.reclaim (j1, anyone, after window)", bond.connect(party).reclaim(j1.spec)); + await send("bond.withdraw (staker)", bond.withdraw()); + + // ---------------- arbiter ---------------- + const payer = await addr(party), payee = await addr(worker); + const mkPayment = async (amount) => { + await send("mock.recordPayment", mock.recordPayment(payer, payee, amount, (await now()) + 40 * 86400)); + return mock.paymentCount(); + }; + const p1 = await mkPayment(ESCROW); + const EV = h("payer evidence"), CEV = h("payee counter evidence"); + await mustRevert("H-03 stranger files evidence for someone else's payment", "NotAParty", async () => arb.connect(client).fileEvidence.staticCall(p1, EV)); + await send("arbiter.fileEvidence (payer)", arb.connect(party).fileEvidence(p1, EV)); + await mustRevert("H-03 operator cites a non-party", "NotAParty", async () => arb.rule.staticCall(p1, await addr(client), EV, 1, { value: ESCROW })); + await send("arbiter.rule(Refund)", arb.rule(p1, payer, EV, 1, { value: await arb.requiredBond(ESCROW) })); + if ((await mock.payments(p1)).refunded) throw new Error("refund executed inside rule()"); + attacks.push({ attack: "H-03 refund does not execute inside rule()", blockedBy: "optimistic window" }); + await mustRevert("H-03 finalize inside the window", "WindowOpen", async () => arb.connect(client).finalize.staticCall(p1)); + await send("arbiter.dispute (harmed payee)", arb.connect(worker).dispute(p1, CEV)); + await send("arbiter.resolve(Release) overturns", arb.connect(owner).resolve(p1, 2)); + if ((await mock.payments(p1)).refunded) throw new Error("overturned refund executed"); + if ((await arb.credit(payee)) !== (await arb.requiredBond(ESCROW))) throw new Error("operator bond not paid to the harmed payee"); + await send("arbiter.withdraw (payee gets the operator bond)", arb.connect(worker).withdraw()); + const p2 = await mkPayment(ESCROW); + await send("arbiter.fileEvidence (payer, p2)", arb.connect(party).fileEvidence(p2, EV)); + await send("arbiter.rule(Refund, p2)", arb.rule(p2, payer, EV, 1, { value: await arb.requiredBond(ESCROW) })); + await increaseTime(86401); + await send("arbiter.finalize (undisputed, after window)", arb.connect(client).finalize(p2)); + if (!(await mock.payments(p2)).refunded) throw new Error("undisputed refund did not execute"); + await send("arbiter.withdraw (operator bond back)", arb.withdraw()); + + // ---------------- access ---------------- + await mustRevert("non-owner setOperator", "NotOwner", async () => reg.connect(op).setOperator.staticCall(await addr(client))); + await mustRevert("non-owner transferOwnership", "NotOwner", async () => bond.connect(client).transferOwnership.staticCall(await addr(client))); + return { steps, attacks }; +} diff --git a/lib/v2.mjs b/lib/v2.mjs new file mode 100644 index 0000000..50e3f65 --- /dev/null +++ b/lib/v2.mjs @@ -0,0 +1,61 @@ +// v2 deployment inputs: who the roles are, and the per-chain bond floors. +// real runs (dry-run / --broadcast): roles come from config/v2.config.json and MUST be set by the owner. +// --fork: throwaway anvil accounts (the public anvil test keys), never touching ~/.predge-deployer or any real key. +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { getAddress, keccak256, toUtf8Bytes, Wallet } from "ethers"; + +const read = (f) => JSON.parse(readFileSync(new URL(`../config/${f}`, import.meta.url), "utf8")); + +/** Fork-only deployer: derived from a public label, so it is NOT secret and must never hold value anywhere real. The anvil + * default account 0 cannot be used: on mainnets its nonces 0..5 are already taken by other people's contracts (a fork + * deploy at those addresses collides, seen on Arc). */ +export const FORK_DEPLOYER_KEY = keccak256(toUtf8Bytes("predge/v2/fork-deployer")); + +/** The well-known anvil test mnemonic accounts 1..5 as role actors, PUBLIC keys: fork use only, never for a real chain. */ +export const ANVIL_KEYS = [ + FORK_DEPLOYER_KEY, // 0 deployer (fork-only, see above) + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", // 1 owner (stands in for the Safe) + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", // 2 operator + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", // 3 client + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", // 4 provider + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", // 5 payer / party +]; +export const anvilWallets = (provider) => ANVIL_KEYS.map((k) => new Wallet(k, provider)); +const ANVIL_ADDRS = new Set(ANVIL_KEYS.map((k) => new Wallet(k).address)); + +const PLACEHOLDER_HELP = "edit config/v2.config.json (deployer = new key created for v2, owner = the Safe, operator = the ops key)"; + +/** Roles for a real run. Throws until the owner has filled them in, and refuses reuse of the v1 deployer. */ +export function realRoles() { + const c = read("v2.config.json"); + const out = {}; + for (const k of ["deployer", "owner", "operator"]) { + if (!c[k]) throw new Error(`v2 config: "${k}" is not set. ${PLACEHOLDER_HELP}`); + out[k] = getAddress(c[k]); + if (ANVIL_ADDRS.has(out[k])) throw new Error(`v2 config: ${k} is a public anvil test address. Use a real one.`); + } + if (new Set(Object.values(out)).size !== 3) throw new Error("v2 config: deployer, owner and operator must be three different addresses"); + const v1 = (() => { try { return JSON.parse(readFileSync(`${homedir()}/.predge-deployer/deployer.json`, "utf8")).address; } catch { return null; } })(); + if (v1 && Object.values(out).includes(getAddress(v1))) throw new Error("v2 config: the v1 deployer address must not hold any v2 role or deploy v2"); + return out; +} + +export function forkRoles() { + const [d, o, p] = ANVIL_KEYS.map((k) => new Wallet(k).address); + return { deployer: d, owner: o, operator: p }; +} + +/** Per-chain overrides for the plan (bond floors). Refuses a chain with no entry rather than guessing. */ +export function chainParams(chainName) { + const c = read("v2.chains.json").chains[chainName]; + if (!c) throw new Error(`config/v2.chains.json has no entry for ${chainName}; run scripts/v2-params.mjs`); + return { minBond: c.minBond, arbiterMinBond: c.arbiterMinBond }; +} + +/** The key file for a real v2 broadcast: must be given explicitly and must not be the v1 deployer's directory. */ +export function v2KeyFile(path) { + if (!path) throw new Error("v2 --broadcast needs --key-file "); + if (path.includes(".predge-deployer")) throw new Error("refusing to use a key from ~/.predge-deployer for v2"); + return path; +} diff --git a/lib/verify.mjs b/lib/verify.mjs index 74700fd..3938b4e 100644 --- a/lib/verify.mjs +++ b/lib/verify.mjs @@ -11,7 +11,7 @@ async function etherscanLike(base, { chainId, apiKey, step, txHash }) { contractaddress: step.address, sourceCode: JSON.stringify(c.input), codeformat: "solidity-standard-json-input", - contractname: `${step.name}.sol:${step.name}`, + contractname: c.id, compilerversion: c.compilerVersion, constructorArguements: step.constructorArgs.replace(/^0x/, ""), constructorArguments: step.constructorArgs.replace(/^0x/, ""), @@ -39,7 +39,7 @@ async function sourcify({ chainId, step, txHash }) { const c = step.compiled; const r = await fetch(`${SOURCIFY}/v2/verify/${chainId}/${step.address}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ stdJsonInput: c.input, compilerVersion: c.compilerVersion.replace(/^v/, ""), contractIdentifier: `${step.name}.sol:${step.name}`, creationTransactionHash: txHash }), + body: JSON.stringify({ stdJsonInput: c.input, compilerVersion: c.compilerVersion.replace(/^v/, ""), contractIdentifier: c.id, creationTransactionHash: txHash }), }); const j = await r.json().catch(() => ({})); if (r.status === 409) return { ok: true, via: "sourcify", note: "already verified" }; diff --git a/package.json b/package.json index 3f9ae97..f8ef35a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "chains": "node scripts/probe-chains.mjs", "fork-all": "node scripts/fork-all.mjs", "funding": "node scripts/funding.mjs", - "test": "node --test test/" + "test": "node --test test/", + "v2:test": "FOUNDRY_CONFIG=foundry-v2.toml forge test", + "v2:coverage": "FOUNDRY_CONFIG=foundry-v2.toml forge coverage --no-match-coverage \"(test/|foundry-lib|contracts/[A-Za-z]+\\.sol)\"", + "v2:fork": "node deploy.mjs --set v2 --fork --chain", + "v2:mutate": "node scripts/mutate-v2.mjs" }, "engines": { "node": ">=20" }, "dependencies": { diff --git a/scripts/fork-all.mjs b/scripts/fork-all.mjs index 45e9176..a28cd17 100644 --- a/scripts/fork-all.mjs +++ b/scripts/fork-all.mjs @@ -5,21 +5,26 @@ import { writeFileSync, readFileSync, existsSync } from "node:fs"; import { CHAINS } from "../lib/chains.mjs"; const ROOT = new URL("../", import.meta.url).pathname; -const only = process.argv.slice(2); +// usage: node scripts/fork-all.mjs [--set v2] [--no-e2e] [chain ...] +const args = process.argv.slice(2); +const V2 = args.includes("--set") && args[args.indexOf("--set") + 1] === "v2"; +const extra = [...(V2 ? ["--set", "v2"] : []), ...(args.includes("--no-e2e") ? ["--no-e2e"] : [])]; +const DIR = V2 ? "deployments/fork-v2" : "deployments/fork"; +const only = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--set"); const names = only.length ? only : Object.keys(CHAINS); const summary = {}; for (const name of names) { const t = Date.now(); - const r = spawnSync(process.execPath, [`${ROOT}deploy.mjs`, "--chain", name, "--fork"], { encoding: "utf8", timeout: 600000 }); + const r = spawnSync(process.execPath, [`${ROOT}deploy.mjs`, "--chain", name, "--fork", ...extra], { encoding: "utf8", timeout: 600000 }); const out = (r.stdout || "") + (r.stderr || ""); const ok = r.status === 0; - const f = `${ROOT}deployments/fork/${name}.json`; + const f = `${ROOT}${DIR}/${name}.json`; const rec = ok && existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null; summary[name] = ok ? { ok, seconds: Math.round((Date.now() - t) / 1000), gasUsed: rec.totals.gasUsed, cost: rec.totals.cost, native: rec.totals.native, e2eTxs: rec.e2e?.length ?? 0, smokeChecks: rec.smoke.length } : { ok, error: (out.match(/FAILED: .*/) || [out.slice(-300)])[0] }; console.log(`${name.padEnd(10)} ${ok ? "PASS" : "FAIL"} ${ok ? `${summary[name].gasUsed} gas, ${summary[name].cost} ${summary[name].native}` : summary[name].error}`); } -const file = `${ROOT}deployments/fork/SUMMARY.json`; +const file = `${ROOT}${DIR}/SUMMARY.json`; const prev = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}; writeFileSync(file, JSON.stringify({ ...prev, ...summary }, null, 2) + "\n"); diff --git a/scripts/mutate-v2.mjs b/scripts/mutate-v2.mjs new file mode 100644 index 0000000..2621116 --- /dev/null +++ b/scripts/mutate-v2.mjs @@ -0,0 +1,51 @@ +// A small mutation-testing smoke run for contracts/v2, so weak tests are found before an external mutation scanner finds them. +// node scripts/mutate-v2.mjs [--with-invariants] [--only ] [--limit N] +// Each mutant changes ONE line of ONE file, runs the v2 Foundry suite, and is "killed" if any test fails. The original source is +// always restored (and re-checked with git) afterwards. Do not edit contracts/v2 while this runs. +// Operators: == <-> !=, <= < >= > swaps, && <-> ||, true <-> false, += <-> -=, and "disable this guard" (if (c) revert -> if (false && c) revert). +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; + +const ROOT = new URL("../", import.meta.url).pathname; +const args = process.argv.slice(2); +const only = args.includes("--only") ? args[args.indexOf("--only") + 1] : ""; +const limit = args.includes("--limit") ? Number(args[args.indexOf("--limit") + 1]) : Infinity; +const env = { ...process.env, PATH: `${homedir()}/.foundry/bin:${process.env.PATH}`, FOUNDRY_CONFIG: "foundry-v2.toml" }; +const walk = (d) => readdirSync(d).flatMap((n) => { const p = `${d}/${n}`; return statSync(p).isDirectory() ? walk(p) : p.endsWith(".sol") ? [p] : []; }); +const files = walk(`${ROOT}contracts/v2`).filter((f) => !f.includes("/mocks/") && f.includes(only)); + +const swaps = [[/ == /, " != "], [/ != /, " == "], [/ <= /, " < "], [/ < /, " <= "], [/ >= /, " > "], [/ > /, " >= "], [/ && /, " || "], [/ \|\| /, " && "], [/ \+= /, " -= "], [/ -= /, " += "], [/= true;/, "= false;"], [/= false;/, "= true;"]]; +const mutants = []; +for (const f of files) { + const lines = readFileSync(f, "utf8").split("\n"); + lines.forEach((line, i) => { + const t = line.trim(); + if (!t || t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") || t.startsWith("pragma") || t.startsWith("import") || /^(event|error|function|modifier|constructor|struct|enum)\b/.test(t)) return; + if (/=>/.test(line) && !/ == | != |<|>/.test(line.replace(/=>/g, ""))) return; + for (const [re, to] of swaps) { + if (re.test(line)) mutants.push({ f, i, op: `${re.source.trim()} -> ${to.trim()}`, text: line.replace(re, to) }); + } + if (/^if \(.*\) revert /.test(t) || /^if \(.*\)$/.test(t) && lines[i + 1]?.trim().startsWith("revert")) mutants.push({ f, i, op: "guard disabled", text: line.replace("if (", "if (false && ") }); + }); +} +const run = [...mutants].slice(0, limit); +console.log(`${run.length} mutants over ${files.length} files`); +const cmd = ["test", "-q", "--fuzz-runs", "100", ...(args.includes("--with-invariants") ? [] : ["--no-match-path", "test/foundry-v2/Invariants.t.sol"])]; +let killed = 0, invalid = 0; const survivors = []; +for (const m of run) { + const orig = readFileSync(m.f, "utf8"); + const lines = orig.split("\n"); lines[m.i] = m.text; + writeFileSync(m.f, lines.join("\n")); + try { + const r = spawnSync("forge", cmd, { cwd: ROOT, env, encoding: "utf8", timeout: 600000 }); + const out = (r.stdout || "") + (r.stderr || ""); + if (/Compiler run failed|Error: Compiler/.test(out)) invalid++; + else if (r.status !== 0) killed++; + else { survivors.push(m); console.log(`SURVIVED ${m.f.replace(ROOT, "")}:${m.i + 1} [${m.op}] ${m.text.trim()}`); } + } finally { writeFileSync(m.f, orig); } +} +const valid = run.length - invalid; +console.log(`\nkilled ${killed} / ${valid} valid mutants (${invalid} did not compile). score ${(100 * killed / Math.max(1, valid)).toFixed(1)}%. survivors ${survivors.length}`); +const dirty = spawnSync("git", ["diff", "--quiet", "--", "contracts/v2"], { cwd: ROOT }).status; +if (dirty !== 0) { console.error("WARNING: contracts/v2 differs from git after the run"); process.exit(1); } diff --git a/scripts/v2-params.mjs b/scripts/v2-params.mjs new file mode 100644 index 0000000..72267e5 --- /dev/null +++ b/scripts/v2-params.mjs @@ -0,0 +1,22 @@ +// Computes per-chain v2 bond floors from live CoinGecko prices and writes config/v2.chains.json. +// node scripts/v2-params.mjs [usdTarget=5] +// Floors are native wei, rounded to ONE significant digit, aiming at `usdTarget` dollars. The point of a floor +// is to stay above what a challenge costs in gas on that chain (v1 used 1e15 everywhere = $0.0002..$4). +// Review the output; the owner can change a floor later with setParams (it only affects future stakes). +import { writeFileSync } from "node:fs"; +import { CHAINS } from "../lib/chains.mjs"; + +const usd = Number(process.argv[2] ?? 5); +const ids = [...new Set(Object.values(CHAINS).map((c) => c.coingecko))]; +const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${ids.join(",")}&vs_currencies=usd`, { signal: AbortSignal.timeout(20000) }); +const prices = await r.json(); +const oneSig = (wei) => { const s = wei.toString(); return BigInt(s[0] + "0".repeat(s.length - 1)) ; }; +const out = { _comment: "Generated by scripts/v2-params.mjs. minBond / arbiterMinBond in native wei (18 decimals), ~usdTarget dollars each, 1 significant digit.", generatedAt: new Date().toISOString(), usdTarget: usd, chains: {} }; +for (const [name, c] of Object.entries(CHAINS)) { + const p = prices[c.coingecko]?.usd; + if (!p) throw new Error(`no price for ${c.coingecko}`); + const wei = oneSig(BigInt(Math.round((usd / p) * 1e6)) * 10n ** 12n); + out.chains[name] = { native: c.native, priceUsd: p, minBond: wei.toString(), arbiterMinBond: wei.toString() }; +} +writeFileSync(new URL("../config/v2.chains.json", import.meta.url), JSON.stringify(out, null, 2) + "\n"); +console.log(Object.entries(out.chains).map(([k, v]) => `${k.padEnd(10)} ${v.minBond.padStart(24)} ${v.native} @ $${v.priceUsd}`).join("\n")); diff --git a/test/plan-v2.test.mjs b/test/plan-v2.test.mjs new file mode 100644 index 0000000..e3df551 --- /dev/null +++ b/test/plan-v2.test.mjs @@ -0,0 +1,66 @@ +// v2 plan: deterministic addresses, role wiring, config safety, pinned compiler. No network. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { getCreateAddress, Wallet } from "ethers"; +import { resolvePlan } from "../lib/plan.mjs"; +import { SET_V2, CHAINS } from "../lib/chains.mjs"; +import { chainParams, realRoles, v2KeyFile, ANVIL_KEYS, forkRoles } from "../lib/v2.mjs"; + +const D = "0x1111111111111111111111111111111111111111"; // sample only +const OWNER = "0x2222222222222222222222222222222222222222"; +const OP = "0x3333333333333333333333333333333333333333"; +const plan = (chain = "base", d = D) => resolvePlan(d, { owner: OWNER, operator: OP, ...chainParams(chain) }, SET_V2); + +test("addresses depend only on deployer and nonce order, not on the chain or chain params", () => { + const a = plan("base"), b = plan("arc"); + assert.deepEqual(a.map((s) => s.address), b.map((s) => s.address)); + a.forEach((s, i) => assert.equal(s.address, getCreateAddress({ from: D, nonce: i }))); + assert.notDeepEqual(a.map((s) => s.initcode), b.map((s) => s.initcode), "bond floors differ per chain, so initcode does"); +}); + +test("the plan order is fixed: append-only", () => { + assert.deepEqual(SET_V2.plan.map((s) => s.name), ["PredgeAgentValidatorV2", "AgentJobV2", "PredgeValidatorBondV2", "PredgeSettlementV2", "MockRefundProtocolV2", "PredgeRefundArbiterV2"]); +}); + +test("roles are wired from the constructor args and the deployer holds none", () => { + const by = Object.fromEntries(plan().map((s) => [s.name, s])); + assert.equal(by.PredgeAgentValidatorV2.args[0], OWNER); + assert.equal(by.PredgeAgentValidatorV2.args[1], OP); + assert.equal(by.PredgeValidatorBondV2.args[0], OWNER); + assert.equal(by.PredgeValidatorBondV2.args[1], OP); + assert.equal(by.PredgeValidatorBondV2.args[2], by.AgentJobV2.address); + assert.equal(by.PredgeSettlementV2.args[0], OWNER); + assert.equal(by.MockRefundProtocolV2.args[0], by.PredgeRefundArbiterV2.address); + assert.equal(by.PredgeRefundArbiterV2.args[2], by.MockRefundProtocolV2.address); + for (const s of Object.values(by)) assert.ok(!s.args.includes(D), `${s.name} must not take the deployer as an argument`); +}); + +test("every chain has a non-zero bond floor, and the live config refuses to run while unset", () => { + for (const name of Object.keys(CHAINS)) { + const p = chainParams(name); + assert.ok(BigInt(p.minBond) > 0n && BigInt(p.arbiterMinBond) > 0n, name); + } + assert.throws(() => realRoles(), /is not set/); +}); + +test("v2 key handling: never the v1 key dir; fork accounts are not accepted as real roles", () => { + assert.throws(() => v2KeyFile(undefined), /--key-file/); + assert.throws(() => v2KeyFile("/Users/x/.predge-deployer/deployer.json"), /refusing/); + assert.equal(v2KeyFile("/tmp/new-v2.json"), "/tmp/new-v2.json"); + const f = forkRoles(); + assert.equal(new Set(Object.values(f)).size, 3); + assert.equal(new Wallet(ANVIL_KEYS[1]).address, f.owner); +}); + +test("every v2 source pins solc 0.8.26 exactly and has no floating pragma", () => { + const walk = (dir) => readdirSync(dir).flatMap((n) => { const p = `${dir}/${n}`; return statSync(p).isDirectory() ? walk(p) : p.endsWith(".sol") ? [p] : []; }); + const files = walk(new URL("../contracts/v2", import.meta.url).pathname); + assert.ok(files.length >= 8); + for (const f of files) assert.match(readFileSync(f, "utf8"), /^pragma solidity 0\.8\.26;$/m, f); +}); + +test("v2 contracts carry no selfdestruct, delegatecall, tx.origin or inline assembly", () => { + const files = SET_V2.plan.map((s) => readFileSync(new URL(`../contracts/${s.file}.sol`, import.meta.url), "utf8")); + for (const src of files) assert.doesNotMatch(src.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""), /selfdestruct|delegatecall|tx\.origin|assembly/); +}); From 1cf937459aa3037a3a32d128b6caf5fea59e41ca Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:22:52 +0300 Subject: [PATCH 03/19] v2: re-check provider at scoring, explicit challenge deadline, ports of v1 finding tests, boundary tests --- contracts/v2/PredgeAgentValidatorV2.sol | 4 +- contracts/v2/PredgeValidatorBondV2.sol | 14 +- test/foundry-v2/V1Findings.t.sol | 264 ++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 test/foundry-v2/V1Findings.t.sol diff --git a/contracts/v2/PredgeAgentValidatorV2.sol b/contracts/v2/PredgeAgentValidatorV2.sol index fa0a1b2..d2a5aff 100644 --- a/contracts/v2/PredgeAgentValidatorV2.sol +++ b/contracts/v2/PredgeAgentValidatorV2.sol @@ -165,7 +165,9 @@ contract PredgeAgentValidatorV2 is Operated { return _v[requestHash].hasResponse; } - /// @notice Seconds the request stayed committed before its verdict; 0 while pending. + /// @notice Seconds the request stayed committed before its verdict. 0 while pending, and also 0 if the + /// verdict landed in the same block as the request (chain timestamps have block granularity), so + /// a non-zero value proves the lead, but zero does not prove its absence. function commitLeadTime(bytes32 requestHash) external view returns (uint64) { Validation storage v = _v[requestHash]; if (!v.hasResponse) return 0; diff --git a/contracts/v2/PredgeValidatorBondV2.sol b/contracts/v2/PredgeValidatorBondV2.sol index 8294d60..c19348a 100644 --- a/contracts/v2/PredgeValidatorBondV2.sol +++ b/contracts/v2/PredgeValidatorBondV2.sol @@ -10,8 +10,9 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; /// contract as its evaluator, so the verdict recorded here is the thing that moves the /// escrow, and the same transaction puts the operator's bond at risk. /// -/// The acceptance test is deterministic: the provider's deliverable hash must equal the -/// `expected` hash the operator committed to. That makes a false verdict provable +/// The acceptance test is deterministic: the 32-byte deliverable hash stored in the job (sha256 of the +/// output when the provider submitted the bytes, otherwise the hash the provider claimed) is compared by +/// RAW EQUALITY with the `expected` 32-byte value the operator committed to. Nothing is hashed again here. That makes a false verdict provable /// on-chain with no judge, by comparing two hashes written by two different parties. /// /// Flow: @@ -26,7 +27,8 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; /// `complete` (score 100) or `reject` (score 0) on the job, so the verdict and the /// money move together. The dispute window starts at `scoredAt`, which is always at /// or after the job's `submittedAt` because scoring requires a submission. -/// 4. `challenge(requestHash)` (anyone) while the window is open. Slashes iff the +/// 4. `challenge(requestHash)` (anyone) strictly BEFORE `scoredAt + window`; from that instant +/// `reclaim` is possible and `challenge` reverts (no overlap, and no open-ended challenge). Slashes iff the /// verdict contradicts the deliverable: 100 but deliverable != expected, or 0 but /// deliverable == expected. The whole bond is CREDITED TO THE HARMED PARTY (the /// job's client on a false pass, its provider on a false fail), never to the caller, @@ -188,9 +190,13 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { if (s.status == Status.None) revert NotCommitted(); if (s.status != Status.Committed) revert BadStatus(); if (keccak256(abi.encode(expected, salt)) != s.commitment) revert BadReveal(); + // Re-checked here, not only at stake time: after an operator rotation the NEW operator must not be able + // to score a job it is the provider of, while inheriting the old operator's stake. + IAgentJobV2.Job memory j = job.getJob(s.jobId); + if (j.provider == msg.sender) revert ProviderIsOperator(); // Scoring needs a submission, so `scoredAt >= submittedAt` and the dispute window can never // start before the deliverable it is judged against exists. - if (job.getJob(s.jobId).state != IAgentJobV2.State.Submitted) revert NotSubmitted(); + if (j.state != IAgentJobV2.State.Submitted) revert NotSubmitted(); s.expected = expected; s.score = score; diff --git a/test/foundry-v2/V1Findings.t.sol b/test/foundry-v2/V1Findings.t.sol new file mode 100644 index 0000000..5f82683 --- /dev/null +++ b/test/foundry-v2/V1Findings.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Base, NoReceive} from "./Base.sol"; +import {ArbiterBase} from "./PredgeRefundArbiterV2.t.sol"; +import {PredgeValidatorBondV2} from "../../contracts/v2/PredgeValidatorBondV2.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {PredgeAgentValidatorV2} from "../../contracts/v2/PredgeAgentValidatorV2.sol"; +import {PredgeSettlementV2} from "../../contracts/v2/PredgeSettlementV2.sol"; +import {AgentJobV2} from "../../contracts/v2/AgentJobV2.sol"; +import {IAgentJobV2} from "../../contracts/v2/interfaces/IAgentJobV2.sol"; +import {Owned2Step} from "../../contracts/v2/access/PredgeAccess.sol"; +import {PullCredit} from "../../contracts/v2/access/PullCredit.sol"; + +/// @notice v2 versions of the v1 test_BUG_ / test_finding_ tests (PR #2): each v1 attack must revert or be harmless. +contract V1FindingsBondTest is Base { + bytes32 constant E = keccak256("expected"); + bytes32 constant SALT = keccak256("salt"); + bytes32 constant X = keccak256("other"); + + function setUp() public { _deployJobAndBond(); } + + // VB-01 / test_BUG_stake_allowedAfterDeliverableIsPublic, test_finding_postHocCommitMakesFalsePassUnslashable + function test_fixed_VB01_stakeAfterDeliverableReverts() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _submit(id, X); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); + bond.stakeAndCommit{value: 1 ether}(spec, keccak256(abi.encode(X, SALT)), id); + } + + // test_finding_scoreBeforeDeliveryThenReclaim + function test_fixed_scoreBeforeDeliveryThenReclaim() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, E, SALT, id, 1 ether); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.NotSubmitted.selector); + bond.recordScore(spec, 100, E, SALT, 0); + } + + // test_BUG_*_truncates*: amounts are uint256 end to end; a value above 2^96 is stored exactly. + function test_fixed_noUint96Truncation() public { + uint256 big = uint256(type(uint96).max) + 1 ether; + vm.deal(client, big); + vm.prank(client); + uint256 id = job.createJob{value: big}(provider, address(bond), _spec(1), uint64(block.timestamp + 1 days)); + assertEq(job.getJob(id).escrow, big); + assertEq(address(job).balance, big); + vm.deal(op, big); + _stake(_spec(1), E, SALT, id, big); + (,,,,,,, uint256 b,,) = bond.stakes(_spec(1)); + assertEq(b, big); + assertEq(bond.totalBonded(), big); + } + + // test_finding_rotationInheritsStakesAndBypassesProviderCheck (VB-04) + function test_fixed_VB04_rotatedOperatorCannotScoreItsOwnJob() public { + bytes32 spec = _spec(1); + address newOp = makeAddr("newOp"); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(newOp, address(bond), spec, uint64(block.timestamp + 1 days)); + _stake(spec, E, SALT, id, 1 ether); // old op stakes: fine, the provider is not op + vm.prank(newOp); + job.submit(id, X, ""); + vm.prank(safe); + bond.setOperator(newOp); // owner rotates to the provider + vm.prank(newOp); + vm.expectRevert(PredgeValidatorBondV2.ProviderIsOperator.selector); + bond.recordScore(spec, 100, E, SALT, 0); + } + + // test_finding_operatorRotationRedirectsBond (L-04): the bond always goes back to the staker. + function test_fixed_rotationDoesNotRedirectBond() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, E, SALT, id, 1 ether); + _submit(id, E); + _score(spec, 100, E, SALT); + address newOp = makeAddr("newOp"); + vm.prank(safe); + bond.setOperator(newOp); + vm.warp(block.timestamp + WINDOW); + vm.prank(newOp); + bond.reclaim(spec); + assertEq(bond.credit(newOp), 0); + assertEq(bond.credit(op), 1 ether); + } + + // test_finding_comparisonIsRawEquality (VB-05): documented and tested: raw equality of the stored 32 bytes. + function test_fixed_VB05_rawEqualityOfStoredHash() public { + bytes memory out = "output"; + bytes32 h = sha256(out); + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, h, SALT, id, 1 ether); + vm.prank(provider); + job.submit(id, h, out); + _score(spec, 100, h, SALT); + assertFalse(bond.wouldSlash(spec)); + // and a claimed hash that is not the sha256 of anything is just compared as-is + (uint256 id2, bytes32 spec2) = _newJob(2, 1 ether); + _stake(spec2, E, SALT, id2, 1 ether); + _submit(id2, E); + _score(spec2, 100, E, SALT); + assertFalse(bond.wouldSlash(spec2)); + } + + // Intended v2 challenge semantics, explicit: allowed while now < scoredAt+window, impossible from then on. + function test_challengeDeadlineBoundary() public { + (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + _stake(spec, E, SALT, id, 1 ether); + _submit(id, X); + _score(spec, 100, E, SALT); + vm.warp(block.timestamp + WINDOW - 1); + assertTrue(bond.wouldSlash(spec)); + vm.warp(block.timestamp + 1); + assertFalse(bond.wouldSlash(spec)); + vm.expectRevert(PredgeValidatorBondV2.WindowClosed.selector); + bond.challenge(spec); + bond.reclaim(spec); // reclaim opens at exactly the instant challenge closes + } + + // Parameter boundaries are inclusive. + function test_paramBoundaries() public { + vm.startPrank(safe); + bond.setParams(1 hours, 1, 0); + bond.setParams(30 days, 1, 10_000); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + bond.setParams(30 days + 1, 1, 0); + vm.stopPrank(); + } + + function test_requiredBond_floorEqualsScaled() public { + assertEq(bond.requiredBond(MIN_BOND), MIN_BOND); + } +} + +contract V1FindingsArbiterTest is ArbiterBase { + function setUp() public { _deployArbiter(); } + + // test_finding_operatorCanCiteSockPuppetEvidence / test_finding_formerOperatorCanFile + function test_fixed_sockPuppetEvidence() public { + uint256 pid = _pay(1 ether); + address sock = makeAddr("sock"); + vm.prank(sock); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); + arb.fileEvidence(pid, EV_PAYER); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); // the (former) operator is no party either + arb.fileEvidence(pid, EV_PAYER); + } + + // test_BUG_rule_truncatesBondAbove2pow96 + function test_fixed_noUint96Truncation() public { + uint256 big = uint256(type(uint96).max) + 1 ether; + vm.deal(op, big); + uint256 pid = rp.recordPayment(payer, payee, 1 ether, block.timestamp + 30 days); + _file(pid); + _rule(pid, payer, EV_PAYER, PredgeRefundArbiterV2.Direction.Release, big); + assertEq(arb.rulings(pid).bond, big); + assertEq(arb.totalBonded(), big); + } + + // RA-04 / test_finding_setParamsIsRetroactive and RA-07 window overflow + function test_fixed_RA04_RA07_paramsBoundedAndNotRetroactive() public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, PredgeRefundArbiterV2.Direction.Release, 1 ether); + vm.prank(safe); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setParams(type(uint64).max, 1, 0); // no overflow / no bricked reclaim + vm.prank(safe); + arb.setParams(30 days, 1, 0); + vm.warp(block.timestamp + WINDOW); + arb.finalize(pid); + } + + function test_paramBoundaries() public { + vm.startPrank(safe); + arb.setParams(1 hours, 1, 0); + arb.setParams(30 days, 1, 10_000); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setParams(30 days + 1, 1, 0); + vm.stopPrank(); + } + + function test_requiredBond_floorEqualsScaled() public view { + assertEq(arb.requiredBond(MIN_BOND * 10_000 / BPS), MIN_BOND); + } + + function test_expireKeepsAccounting() public { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, PredgeRefundArbiterV2.Direction.Release, 2 ether); + _rule2(); + vm.warp(block.timestamp + WINDOW + arb.RESOLUTION_TIMEOUT()); + arb.expire(pid); + assertEq(arb.totalBonded(), 3 ether, "only the other ruling's bond is still held"); + } + + function _rule2() internal { + uint256 pid = _pay(1 ether); + _file(pid); + _rule(pid, payer, EV_PAYER, PredgeRefundArbiterV2.Direction.Release, 3 ether); + } +} + +contract V1FindingsMiscTest is Base { + function setUp() public { _deployJobAndBond(); } + + // AV-01 / test_BUG_request_frontRunSquatsRequestHash + function test_fixed_AV01_squattingImpossible() public { + PredgeAgentValidatorV2 reg = new PredgeAgentValidatorV2(safe, op, address(0)); + vm.prank(stranger); + vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); + reg.validationRequest(op, 1, "squat", keccak256("spec")); + // and the hash is domain separated, so a squat on another chain/registry cannot collide anyway + assertTrue(reg.requestHashFor(keccak256("spec")) != keccak256("spec")); + } + + // AV-02 / test_BUG_commitLeadTime_zeroWhenSameBlock: documented, not a bug: zero is possible in the same block. + function test_fixed_AV02_sameBlockLeadIsZeroAndDocumented() public { + PredgeAgentValidatorV2 reg = new PredgeAgentValidatorV2(safe, op, address(0)); + vm.startPrank(op); + bytes32 h = reg.validationRequest(op, 1, "x", keccak256("d")); + reg.validationResponse(h, 100, "", keccak256("r"), ""); + vm.stopPrank(); + assertEq(reg.commitLeadTime(h), 0); + } + + // AJ-02 / test_finding_escrowLockedWhenNeitherPartyCanReceive: with pull payments an unreceivable party + // cannot block settlement and can redirect its credit. + function test_fixed_AJ02_unreceivableParties() public { + NoReceive a = new NoReceive(); + NoReceive b = new NoReceive(); + vm.deal(address(a), 1 ether); + bytes32 spec = _spec(1); + vm.prank(address(a)); + uint256 id = job.createJob{value: 1 ether}(address(b), op, spec, uint64(block.timestamp + 1 days)); + vm.prank(address(b)); + job.submit(id, keccak256("d"), ""); + vm.prank(op); + job.reject(id, 0, ""); + assertEq(job.credit(address(a)), 1 ether); + vm.prank(address(a)); + job.withdrawTo(payable(stranger)); + assertEq(address(job).balance, 0); + } + + // test_finding_openJobHasNoTimeoutRefund + function test_fixed_openJobTimeoutRefund() public { + (uint256 id,) = _newJob(1, 1 ether); + vm.warp(block.timestamp + 7 days); + job.claimRefund(id); + assertEq(job.credit(client), 1 ether); + } + + // PS-01 / test_finding_withdrawToZeroBurns + function test_fixed_PS01_withdrawToZeroReverts() public { + PredgeSettlementV2 s = new PredgeSettlementV2(safe); + vm.deal(address(s), 1 ether); + vm.prank(safe); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + s.withdraw(payable(address(0))); + } +} From 94a8f49da74057c752215f167a368bd166a9701c Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:27:00 +0300 Subject: [PATCH 04/19] v2: fork records (Arc and Base full e2e with attack sequences, all chains deploy) --- deployments/fork-v2/arc.json | 282 ++++++++++++++++++++++++++++---- deployments/fork-v2/base.json | 296 +++++++++++++++++++++++++++++----- 2 files changed, 511 insertions(+), 67 deletions(-) diff --git a/deployments/fork-v2/arc.json b/deployments/fork-v2/arc.json index f8ba86c..17f6fe1 100644 --- a/deployments/fork-v2/arc.json +++ b/deployments/fork-v2/arc.json @@ -3,9 +3,9 @@ "chainId": 5042, "mode": "anvil-fork", "forkedFrom": "https://rpc.mainnet.arc.io", - "forkBlock": 26848388, - "at": "2026-10-19T23:07:48.519Z", - "seconds": 4, + "forkBlock": 26850550, + "at": "2026-10-19T23:26:03.680Z", + "seconds": 46, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", "startNonce": 0, "contracts": { @@ -19,11 +19,11 @@ "0x0000000000000000000000000000000000000000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", - "txHash": "0xbbe1e7b95efb31e97be1f42d796455b77765c34c9121f12a651c161f413fa083", - "block": 26848377, + "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", + "txHash": "0x7dff382649311298b1297083d15d5a9728c9ed21056b5c22478e2b9decc306ad", + "block": 26850455, "gasUsed": "1162822", - "effectiveGasPrice": "18726747167", + "effectiveGasPrice": "18592229834", "l1FeeWei": "0" }, "AgentJobV2": { @@ -35,10 +35,10 @@ ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0x0719ed57f518a2ba2b970ff620fdac1ae72b388959d16ca5dd47cef00ef3c6e9", - "block": 26848378, + "txHash": "0xc5145b85136451ceb7d0bc316cd9a76f0b5ad542bd73c503d55ecba967963e5b", + "block": 26850456, "gasUsed": "904910", - "effectiveGasPrice": "16682679202", + "effectiveGasPrice": "16563673038", "l1FeeWei": "0" }, "PredgeValidatorBondV2": { @@ -54,11 +54,11 @@ "10000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000002710", - "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", - "txHash": "0x168680305d43128fadaffe97b47be34a0c8b0e8f0ad849ac12a5ea19708c5e0a", - "block": 26848379, - "gasUsed": "1711533", - "effectiveGasPrice": "14840606079", + "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", + "txHash": "0x72729c9a70f5c9a7975776822f5e1218be69dd9f06cc9749d1386dc113770121", + "block": 26850457, + "gasUsed": "1725783", + "effectiveGasPrice": "14735578270", "l1FeeWei": "0" }, "PredgeSettlementV2": { @@ -70,10 +70,10 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0xc078e66869bb00f9e3f30f7058bffbb71f5dc03a6c5c860aed09f8e5b58f5d01", - "block": 26848380, + "txHash": "0x584cc3124f47ac831a0f78745074c118df5142b7310bd1e9f361fca2b7ac0eb4", + "block": 26850458, "gasUsed": "369083", - "effectiveGasPrice": "13307935770", + "effectiveGasPrice": "13216169549", "l1FeeWei": "0" }, "MockRefundProtocolV2": { @@ -85,10 +85,10 @@ ], "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0x8d07add0725ed4d7c8f08737c9f38e343750b6c40f258bee312a3a2cb67beb60", - "block": 26848381, + "txHash": "0x256a46e162ca92485a8d42331ddd7556334f7314b67341def0dfbbc09ef25f52", + "block": 26850459, "gasUsed": "408763", - "effectiveGasPrice": "11807299215", + "effectiveGasPrice": "11726721527", "l1FeeWei": "0" }, "PredgeRefundArbiterV2": { @@ -104,21 +104,21 @@ "1000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000003e8", - "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", - "txHash": "0xc05a6b935e871fdb10f7d2fb19901cfeec6da3337a3c53e6df36a5ba4c9e2886", - "block": 26848382, + "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", + "txHash": "0x4a0ddec554207aef55db12812e7cb12ad725afff0db1c29966c51137b2b5431c", + "block": 26850460, "gasUsed": "1918187", - "effectiveGasPrice": "10493200347", + "effectiveGasPrice": "10422420394", "l1FeeWei": "0" } }, "totals": { - "gasUsed": "6475298", + "gasUsed": "6489548", "liveGasPriceWei": "20500000100", "l1FeeWei": "0", "l1FeeMethod": "none (execution gas only)", - "costWei": "132743609647529800", - "cost": "0.1327436096475298", + "costWei": "133035734648954800", + "cost": "0.1330357346489548", "native": "USDC" }, "smoke": [ @@ -151,8 +151,230 @@ "arbiter params", "registry.requestHashFor is domain separated" ], - "e2e": null, - "attacksBlocked": null, + "e2e": [ + { + "step": "registry.validationRequest #1", + "gasUsed": 123238 + }, + { + "step": "job.createJob #1", + "gasUsed": 203490 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 190513 + }, + { + "step": "job.submit (bytes, sha256 on-chain)", + "gasUsed": 55445 + }, + { + "step": "bond.recordScore(100) completes the job", + "gasUsed": 159228 + }, + { + "step": "registry.validationResponse", + "gasUsed": 132144 + }, + { + "step": "job.withdraw (provider pulls payout)", + "gasUsed": 32248 + }, + { + "step": "settlement.payForRoute", + "gasUsed": 25567 + }, + { + "step": "settlement.withdraw (owner)", + "gasUsed": 32279 + }, + { + "step": "registry.validationRequest #2", + "gasUsed": 123238 + }, + { + "step": "job.createJob #2", + "gasUsed": 186390 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 173413 + }, + { + "step": "registry.validationRequest #3", + "gasUsed": 123238 + }, + { + "step": "job.createJob #3", + "gasUsed": 169290 + }, + { + "step": "job.submit (j3, wrong output)", + "gasUsed": 54547 + }, + { + "step": "registry.validationRequest #4", + "gasUsed": 123226 + }, + { + "step": "job.createJob #4", + "gasUsed": 169290 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 173413 + }, + { + "step": "job.submit (j4, provider submits the public commitment)", + "gasUsed": 54547 + }, + { + "step": "bond.recordScore(0) honest fail", + "gasUsed": 163942 + }, + { + "step": "registry.validationRequest #5", + "gasUsed": 123238 + }, + { + "step": "job.createJob #5", + "gasUsed": 169290 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 173413 + }, + { + "step": "job.submit (j5, wrong output)", + "gasUsed": 54547 + }, + { + "step": "bond.recordScore(100) FALSE pass", + "gasUsed": 146928 + }, + { + "step": "bond.challenge by a stranger", + "gasUsed": 131748 + }, + { + "step": "bond.withdraw (client)", + "gasUsed": 32262 + }, + { + "step": "job.claimRefund (expired, never submitted)", + "gasUsed": 47142 + }, + { + "step": "bond.release (no verdict was given)", + "gasUsed": 104039 + }, + { + "step": "bond.reclaim (j1, anyone, after window)", + "gasUsed": 47188 + }, + { + "step": "bond.withdraw (staker)", + "gasUsed": 32262 + }, + { + "step": "mock.recordPayment", + "gasUsed": 138491 + }, + { + "step": "arbiter.fileEvidence (payer)", + "gasUsed": 60606 + }, + { + "step": "arbiter.rule(Refund)", + "gasUsed": 250669 + }, + { + "step": "arbiter.dispute (harmed payee)", + "gasUsed": 59538 + }, + { + "step": "arbiter.resolve(Release) overturns", + "gasUsed": 86656 + }, + { + "step": "arbiter.withdraw (payee gets the operator bond)", + "gasUsed": 32316 + }, + { + "step": "mock.recordPayment", + "gasUsed": 121391 + }, + { + "step": "arbiter.fileEvidence (payer, p2)", + "gasUsed": 60606 + }, + { + "step": "arbiter.rule(Refund, p2)", + "gasUsed": 233569 + }, + { + "step": "arbiter.finalize (undisputed, after window)", + "gasUsed": 130353 + }, + { + "step": "arbiter.withdraw (operator bond back)", + "gasUsed": 32316 + } + ], + "attacksBlocked": [ + { + "attack": "challenge of an honest verdict", + "blockedBy": "VerdictHonest" + }, + { + "attack": "H-01 recordScore before the provider submits", + "blockedBy": "NotSubmitted" + }, + { + "attack": "H-01 reclaim without a verdict", + "blockedBy": "NotScored" + }, + { + "attack": "H-02 commit after submission", + "blockedBy": "NotOpen" + }, + { + "attack": "H-02 honest fail is not slashable", + "blockedBy": "VerdictHonest" + }, + { + "attack": "M-02 slash pays the harmed client, challenger earns 0", + "blockedBy": "routing" + }, + { + "attack": "late submit after expiry", + "blockedBy": "PastDeadline" + }, + { + "attack": "H-03 stranger files evidence for someone else's payment", + "blockedBy": "NotAParty" + }, + { + "attack": "H-03 operator cites a non-party", + "blockedBy": "NotAParty" + }, + { + "attack": "H-03 refund does not execute inside rule()", + "blockedBy": "optimistic window" + }, + { + "attack": "H-03 finalize inside the window", + "blockedBy": "WindowOpen" + }, + { + "attack": "non-owner setOperator", + "blockedBy": "NotOwner" + }, + { + "attack": "non-owner transferOwnership", + "blockedBy": "NotOwner" + } + ], "setVersion": "2026-10-20-v2", "erc8004": { "IdentityRegistry": { diff --git a/deployments/fork-v2/base.json b/deployments/fork-v2/base.json index 392662e..0f933d4 100644 --- a/deployments/fork-v2/base.json +++ b/deployments/fork-v2/base.json @@ -3,9 +3,9 @@ "chainId": 8453, "mode": "anvil-fork", "forkedFrom": "https://base-rpc.publicnode.com", - "forkBlock": 52830862, - "at": "2026-10-19T23:04:32.799Z", - "seconds": 6, + "forkBlock": 52831532, + "at": "2026-10-19T23:26:52.507Z", + "seconds": 43, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", "startNonce": 0, "contracts": { @@ -19,12 +19,12 @@ "0x0000000000000000000000000000000000000000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "185b34b837ea7e0689bf599043cc79dba5b183dc0a660bb09879eb8da2cabd70", - "txHash": "0x9a75f35dcb04e032057cbaf460cfd1d5c6d23708069f3a42b79b17ecf8a842b7", - "block": 52830860, + "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", + "txHash": "0xaa509fc65ae9c27fc045b4e2fb2e3085d17ec05474062aef22b9efb06424c8d6", + "block": 52831511, "gasUsed": "1162822", - "effectiveGasPrice": "1004986421", - "l1FeeWei": "14802332849" + "effectiveGasPrice": "1004992422", + "l1FeeWei": "13938652792" }, "AgentJobV2": { "name": "AgentJobV2", @@ -35,11 +35,11 @@ ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0x212ae4b97c67f5d68a71d371949be5003998f80a042a17830e7f8338b1280cff", - "block": 52830861, + "txHash": "0xe86ebd4354ca528f5a17544240f73dfe254386b487b8e7f13f5173fc6c9d9f57", + "block": 52831512, "gasUsed": "904910", - "effectiveGasPrice": "1004966824", - "l1FeeWei": "11198469167" + "effectiveGasPrice": "1004972801", + "l1FeeWei": "10545065775" }, "PredgeValidatorBondV2": { "name": "PredgeValidatorBondV2", @@ -54,12 +54,12 @@ "10000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", - "sourceSha256": "46bede16b7872b00005812a4f6899cfb8f151649a620f740122f81a1bd0fa22d", - "txHash": "0xc14838306f2f2a423d04977bfddb3a05773ae8288223a59ec9065ef9da06cb0d", - "block": 52830862, - "gasUsed": "1711521", - "effectiveGasPrice": "1004947227", - "l1FeeWei": "19103474046" + "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", + "txHash": "0x6c1ba1b64c65f403bd4997574755bae7774a8634b66babbec381e1d95c5f13f1", + "block": 52831513, + "gasUsed": "1725771", + "effectiveGasPrice": "1004953180", + "l1FeeWei": "18049496371" }, "PredgeSettlementV2": { "name": "PredgeSettlementV2", @@ -70,11 +70,11 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0x54bf225199544be651c2e05bddf0cbc74eca9153f84941b05f5ee3bcc00cc116", - "block": 52830863, + "txHash": "0x1b98f8891108aaa98bbaf669d14e63f2fc7de1f05764fd6266c92b460f0379a9", + "block": 52831514, "gasUsed": "369083", - "effectiveGasPrice": "1004927947", - "l1FeeWei": "4828232732" + "effectiveGasPrice": "1004933881", + "l1FeeWei": "4546517116" }, "MockRefundProtocolV2": { "name": "MockRefundProtocolV2", @@ -85,11 +85,11 @@ ], "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0x574f4117a38b4211de935207fff2391574febb4e70bc870347f7d8f374237f91", - "block": 52830864, + "txHash": "0x1b2c43e7114df89893dfc451e306cd9f85481d25bc415f12c2b979d2c0919fcb", + "block": 52831515, "gasUsed": "408763", - "effectiveGasPrice": "1004908345", - "l1FeeWei": "5714987833" + "effectiveGasPrice": "1004914255", + "l1FeeWei": "5381532218" }, "PredgeRefundArbiterV2": { "name": "PredgeRefundArbiterV2", @@ -104,21 +104,21 @@ "1000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", - "sourceSha256": "0d94db694bf2e39bfaca5398e2fcdef5b11942a656fab3de7f4468f40fd78746", - "txHash": "0xb428709a60640a85e9394e19cc3f31131bc6505c30965c32a1e4bdbb939785c3", - "block": 52830865, + "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", + "txHash": "0x164425c4de0c3c7eee3638567fd94fbf4972ff1fb85bd3a537bd3744f0c65e1b", + "block": 52831516, "gasUsed": "1918175", - "effectiveGasPrice": "1004888833", - "l1FeeWei": "21540155800" + "effectiveGasPrice": "1004894719", + "l1FeeWei": "20283340191" } }, "totals": { - "gasUsed": "6475274", + "gasUsed": "6489524", "liveGasPriceWei": "6000000", - "l1FeeWei": "77187652427", + "l1FeeWei": "72744604463", "l1FeeMethod": "op-stack oracle getL1Fee", - "costWei": "38928831652427", - "cost": "0.000038928831652427", + "costWei": "39009888604463", + "cost": "0.000039009888604463", "native": "ETH" }, "smoke": [ @@ -151,8 +151,230 @@ "arbiter params", "registry.requestHashFor is domain separated" ], - "e2e": null, - "attacksBlocked": null, + "e2e": [ + { + "step": "registry.validationRequest #1", + "gasUsed": 123238 + }, + { + "step": "job.createJob #1", + "gasUsed": 203490 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 190513 + }, + { + "step": "job.submit (bytes, sha256 on-chain)", + "gasUsed": 55445 + }, + { + "step": "bond.recordScore(100) completes the job", + "gasUsed": 159228 + }, + { + "step": "registry.validationResponse", + "gasUsed": 132144 + }, + { + "step": "job.withdraw (provider pulls payout)", + "gasUsed": 32248 + }, + { + "step": "settlement.payForRoute", + "gasUsed": 25567 + }, + { + "step": "settlement.withdraw (owner)", + "gasUsed": 32279 + }, + { + "step": "registry.validationRequest #2", + "gasUsed": 123238 + }, + { + "step": "job.createJob #2", + "gasUsed": 186390 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 173413 + }, + { + "step": "registry.validationRequest #3", + "gasUsed": 123238 + }, + { + "step": "job.createJob #3", + "gasUsed": 169290 + }, + { + "step": "job.submit (j3, wrong output)", + "gasUsed": 54547 + }, + { + "step": "registry.validationRequest #4", + "gasUsed": 123238 + }, + { + "step": "job.createJob #4", + "gasUsed": 169290 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 173413 + }, + { + "step": "job.submit (j4, provider submits the public commitment)", + "gasUsed": 54547 + }, + { + "step": "bond.recordScore(0) honest fail", + "gasUsed": 163942 + }, + { + "step": "registry.validationRequest #5", + "gasUsed": 123238 + }, + { + "step": "job.createJob #5", + "gasUsed": 169290 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 173413 + }, + { + "step": "job.submit (j5, wrong output)", + "gasUsed": 54547 + }, + { + "step": "bond.recordScore(100) FALSE pass", + "gasUsed": 146928 + }, + { + "step": "bond.challenge by a stranger", + "gasUsed": 131748 + }, + { + "step": "bond.withdraw (client)", + "gasUsed": 32262 + }, + { + "step": "job.claimRefund (expired, never submitted)", + "gasUsed": 47142 + }, + { + "step": "bond.release (no verdict was given)", + "gasUsed": 104039 + }, + { + "step": "bond.reclaim (j1, anyone, after window)", + "gasUsed": 47188 + }, + { + "step": "bond.withdraw (staker)", + "gasUsed": 32262 + }, + { + "step": "mock.recordPayment", + "gasUsed": 138491 + }, + { + "step": "arbiter.fileEvidence (payer)", + "gasUsed": 60606 + }, + { + "step": "arbiter.rule(Refund)", + "gasUsed": 250669 + }, + { + "step": "arbiter.dispute (harmed payee)", + "gasUsed": 59538 + }, + { + "step": "arbiter.resolve(Release) overturns", + "gasUsed": 86656 + }, + { + "step": "arbiter.withdraw (payee gets the operator bond)", + "gasUsed": 32316 + }, + { + "step": "mock.recordPayment", + "gasUsed": 121391 + }, + { + "step": "arbiter.fileEvidence (payer, p2)", + "gasUsed": 60606 + }, + { + "step": "arbiter.rule(Refund, p2)", + "gasUsed": 233569 + }, + { + "step": "arbiter.finalize (undisputed, after window)", + "gasUsed": 130353 + }, + { + "step": "arbiter.withdraw (operator bond back)", + "gasUsed": 32316 + } + ], + "attacksBlocked": [ + { + "attack": "challenge of an honest verdict", + "blockedBy": "VerdictHonest" + }, + { + "attack": "H-01 recordScore before the provider submits", + "blockedBy": "NotSubmitted" + }, + { + "attack": "H-01 reclaim without a verdict", + "blockedBy": "NotScored" + }, + { + "attack": "H-02 commit after submission", + "blockedBy": "NotOpen" + }, + { + "attack": "H-02 honest fail is not slashable", + "blockedBy": "VerdictHonest" + }, + { + "attack": "M-02 slash pays the harmed client, challenger earns 0", + "blockedBy": "routing" + }, + { + "attack": "late submit after expiry", + "blockedBy": "PastDeadline" + }, + { + "attack": "H-03 stranger files evidence for someone else's payment", + "blockedBy": "NotAParty" + }, + { + "attack": "H-03 operator cites a non-party", + "blockedBy": "NotAParty" + }, + { + "attack": "H-03 refund does not execute inside rule()", + "blockedBy": "optimistic window" + }, + { + "attack": "H-03 finalize inside the window", + "blockedBy": "WindowOpen" + }, + { + "attack": "non-owner setOperator", + "blockedBy": "NotOwner" + }, + { + "attack": "non-owner transferOwnership", + "blockedBy": "NotOwner" + } + ], "setVersion": "2026-10-20-v2", "erc8004": { "IdentityRegistry": { From 4b2016793eb2061d6ad6c1bc8f2b7c483d0063ec Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:37:56 +0300 Subject: [PATCH 05/19] v2 tests: realistic timestamps and bond accounting asserts (kills Gambit survivors) --- test/foundry-v2/AgentJobV2.t.sol | 1 + test/foundry-v2/Base.sol | 1 + test/foundry-v2/PredgeRefundArbiterV2.t.sol | 1 + test/foundry-v2/PredgeValidatorBondV2.t.sol | 6 ++++++ 4 files changed, 9 insertions(+) diff --git a/test/foundry-v2/AgentJobV2.t.sol b/test/foundry-v2/AgentJobV2.t.sol index fa7aa2f..44cac2c 100644 --- a/test/foundry-v2/AgentJobV2.t.sol +++ b/test/foundry-v2/AgentJobV2.t.sol @@ -19,6 +19,7 @@ contract AgentJobV2Test is Base { event Withdrawn(address indexed account, address indexed to, uint256 amount); function setUp() public { + vm.warp(1_760_000_000); job = new AgentJobV2(EVAL_WINDOW); vm.deal(client, 100 ether); } diff --git a/test/foundry-v2/Base.sol b/test/foundry-v2/Base.sol index d4418df..f8a1336 100644 --- a/test/foundry-v2/Base.sol +++ b/test/foundry-v2/Base.sol @@ -49,6 +49,7 @@ abstract contract Base is Test { PredgeValidatorBondV2 internal bond; function _deployJobAndBond() internal { + vm.warp(1_760_000_000); // realistic timestamp: block.timestamp == 1 hides arithmetic mutants job = new AgentJobV2(EVAL_WINDOW); bond = new PredgeValidatorBondV2(safe, op, address(job), WINDOW, MIN_BOND, BPS); vm.deal(op, 1_000 ether); diff --git a/test/foundry-v2/PredgeRefundArbiterV2.t.sol b/test/foundry-v2/PredgeRefundArbiterV2.t.sol index 09dd59b..f4ad7b9 100644 --- a/test/foundry-v2/PredgeRefundArbiterV2.t.sol +++ b/test/foundry-v2/PredgeRefundArbiterV2.t.sol @@ -26,6 +26,7 @@ abstract contract ArbiterBase is Test { bytes32 internal constant EV_COUNTER = keccak256("counter evidence"); function _deployArbiter() internal { + vm.warp(1_760_000_000); // The mock needs the arbiter address and vice versa: predict the arbiter (next CREATE after the mock). uint256 n = vm.getNonce(address(this)); address predicted = vm.computeCreateAddress(address(this), n + 1); diff --git a/test/foundry-v2/PredgeValidatorBondV2.t.sol b/test/foundry-v2/PredgeValidatorBondV2.t.sol index 7584965..d0a642a 100644 --- a/test/foundry-v2/PredgeValidatorBondV2.t.sol +++ b/test/foundry-v2/PredgeValidatorBondV2.t.sol @@ -112,6 +112,8 @@ contract PredgeValidatorBondV2Test is Base { assertEq(c, _commitment(EXPECTED, SALT)); assertEq(e, bytes32(0), "expected is hidden until the reveal"); assertEq(bond.totalBonded(), 2 ether); + (,,, uint64 stakedAt,,,,,,) = bond.stakes(spec); + assertEq(stakedAt, block.timestamp); } function test_stake_reverts() public { @@ -254,6 +256,8 @@ contract PredgeValidatorBondV2Test is Base { assertEq(stranger.balance, callerBefore); assertEq(bond.totalBonded(), 0); assertEq(bond.slashCount(), 1); + (,,,,,,, uint256 left,,) = bond.stakes(spec); + assertEq(left, 0, "slashed stake holds no bond"); assertFalse(bond.wouldSlash(spec)); vm.prank(client); bond.withdraw(); @@ -308,6 +312,8 @@ contract PredgeValidatorBondV2Test is Base { assertEq(bond.credit(op), 3 ether); assertEq(bond.credit(newOp), 0); assertEq(bond.totalBonded(), 0); + (,,,,,,, uint256 left,,) = bond.stakes(spec); + assertEq(left, 0, "reclaimed stake holds no bond"); vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); bond.reclaim(spec); } From edd1c46a2ca6fd6cf577938e03c6892e9d357c51 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:54:45 +0300 Subject: [PATCH 06/19] v2 tests: ruling record assertions (kills remaining Gambit survivors) --- test/foundry-v2/PredgeRefundArbiterV2.t.sol | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/foundry-v2/PredgeRefundArbiterV2.t.sol b/test/foundry-v2/PredgeRefundArbiterV2.t.sol index f4ad7b9..5f9a363 100644 --- a/test/foundry-v2/PredgeRefundArbiterV2.t.sol +++ b/test/foundry-v2/PredgeRefundArbiterV2.t.sol @@ -174,6 +174,56 @@ contract PredgeRefundArbiterV2Test is ArbiterBase { assertEq(arb.totalBonded(), bondNeeded); assertEq(arb.rulingCount(), 1); assertEq(arb.harmedParty(pid), payee); + PredgeRefundArbiterV2.Ruling memory r = arb.rulings(pid); + assertEq(r.evidenceFrom, payer); + assertEq(r.evidenceHash, EV_PAYER); + assertEq(r.staker, op); + assertEq(r.payer, payer); + assertEq(r.payee, payee); + assertEq(r.bond, bondNeeded); + assertEq(r.window, WINDOW); + assertEq(r.ruledAt, block.timestamp); + assertEq(r.expiresAt, block.timestamp + WINDOW + arb.RESOLUTION_TIMEOUT()); + assertEq(uint8(r.direction), uint8(REFUND)); + } + + function test_recordsAreComplete_afterEachTerminalPath() public { + // dispute + overturn + uint256 p1 = _pay(1 ether); + _file(p1); + _rule(p1, payer, EV_PAYER, REFUND, 1 ether); + vm.prank(payee); + arb.dispute(p1, EV_COUNTER); + assertEq(arb.rulings(p1).counterEvidence, EV_COUNTER); + vm.prank(safe); + arb.resolve(p1, RELEASE); + assertEq(uint8(arb.rulings(p1).status), uint8(PredgeRefundArbiterV2.Status.Overturned)); + assertEq(arb.rulings(p1).bond, 0); + // finalize + uint256 p2 = _pay(1 ether); + _file(p2); + _rule(p2, payer, EV_PAYER, RELEASE, 1 ether); + // expire + uint256 p3 = _pay(1 ether); + _file(p3); + _rule(p3, payee, EV_PAYEE, RELEASE, 1 ether); + vm.warp(block.timestamp + WINDOW); + arb.finalize(p2); + assertEq(arb.rulings(p2).bond, 0); + assertEq(uint8(arb.rulings(p2).status), uint8(PredgeRefundArbiterV2.Status.Finalized)); + vm.warp(block.timestamp + arb.RESOLUTION_TIMEOUT()); + arb.expire(p3); + assertEq(arb.rulings(p3).bond, 0); + assertEq(uint8(arb.rulings(p3).status), uint8(PredgeRefundArbiterV2.Status.Voided)); + // a ruling that is upheld after a dispute also zeroes the bond + uint256 p4 = _pay(1 ether); + _file(p4); + _rule(p4, payer, EV_PAYER, REFUND, 1 ether); + vm.prank(payee); + arb.dispute(p4, EV_COUNTER); + vm.prank(safe); + arb.resolve(p4, REFUND); + assertEq(arb.rulings(p4).bond, 0); } function test_rule_reverts() public { From 372b7ea8de8d7c2171dc51228c0b9401a88f3572 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:37:35 +0300 Subject: [PATCH 07/19] v2 config: minimal bonds (bondBps = arbiterBondBps = 0), floors refreshed 20.10 Owner decision 20.10.2026: every bond equals the per-chain floor (~$5); the bps mechanism stays in the contracts and the Safe can raise it via setParams later. Arc 5 USDC (18-dec native), Base 0.002 ETH. Contracts unchanged (scanned edd1c46). Foundry v2 132/132, node 20/20, Arc and Base fork e2e PASS with these params. --- config/set.v2.json | 6 +-- config/v2.chains.json | 40 ++++++++--------- deployments/fork-v2/SUMMARY.json | 16 +++---- deployments/fork-v2/arc.json | 62 +++++++++++++------------- deployments/fork-v2/base.json | 76 ++++++++++++++++---------------- 5 files changed, 100 insertions(+), 100 deletions(-) diff --git a/config/set.v2.json b/config/set.v2.json index 823b340..98ce973 100644 --- a/config/set.v2.json +++ b/config/set.v2.json @@ -1,12 +1,12 @@ { - "_comment": "The Predge v2 set (contracts/v2). Same rules as set.json: contract i is created at deployer nonce i, so a fresh v2 deployer + this order gives the same addresses on every chain. Never reorder; append only. $owner is the Safe, $operator the hot ops key, $deployer holds NO role (it only pays gas). Chain-specific values (minBond, arbiterMinBond) come from config/v2.chains.json and do not change the addresses.", + "_comment": "The Predge v2 set (contracts/v2). Same rules as set.json: contract i is created at deployer nonce i, so a fresh v2 deployer + this order gives the same addresses on every chain. Never reorder; append only. $owner is the Safe, $operator the hot ops key, $deployer holds NO role (it only pays gas). Chain-specific values (minBond, arbiterMinBond) come from config/v2.chains.json and do not change the addresses. Owner decision 20.10.2026: bonds MINIMAL, bondBps = arbiterBondBps = 0, so every bond is exactly the per-chain floor (~$5); the bps mechanism stays in the contracts and the Safe can raise it later with setParams (a paid bonded-verdict tier).", "version": "2026-10-20-v2", "params": { "disputeWindow": "86400", "challengeWindow": "86400", "evaluationWindow": "259200", - "bondBps": "10000", - "arbiterBondBps": "1000", + "bondBps": "0", + "arbiterBondBps": "0", "identityRegistry": "0x0000000000000000000000000000000000000000" }, "plan": [ diff --git a/config/v2.chains.json b/config/v2.chains.json index 1552f31..165546f 100644 --- a/config/v2.chains.json +++ b/config/v2.chains.json @@ -1,53 +1,53 @@ { "_comment": "Generated by scripts/v2-params.mjs. minBond / arbiterMinBond in native wei (18 decimals), ~usdTarget dollars each, 1 significant digit.", - "generatedAt": "2026-10-19T22:59:38.461Z", + "generatedAt": "2026-10-20T13:28:11.380Z", "usdTarget": 5, "chains": { "ethereum": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "base": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "bnb": { "native": "BNB", - "priceUsd": 731.3, + "priceUsd": 730.57, "minBond": "6000000000000000", "arbiterMinBond": "6000000000000000" }, "celo": { "native": "CELO", - "priceUsd": 0.081491, + "priceUsd": 0.081817, "minBond": "60000000000000000000", "arbiterMinBond": "60000000000000000000" }, "polygon": { "native": "POL", - "priceUsd": 0.092615, + "priceUsd": 0.092243, "minBond": "50000000000000000000", "arbiterMinBond": "50000000000000000000" }, "optimism": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "arbitrum": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "avalanche": { "native": "AVAX", - "priceUsd": 9.61, + "priceUsd": 9.46, "minBond": "500000000000000000", "arbiterMinBond": "500000000000000000" }, @@ -59,67 +59,67 @@ }, "linea": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "scroll": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "unichain": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "sonic": { "native": "S", - "priceUsd": 0.05888, + "priceUsd": 0.056326, "minBond": "80000000000000000000", "arbiterMinBond": "80000000000000000000" }, "mantle": { "native": "MNT", - "priceUsd": 0.533674, + "priceUsd": 0.527794, "minBond": "9000000000000000000", "arbiterMinBond": "9000000000000000000" }, "arc": { "native": "USDC", - "priceUsd": 0.99987, + "priceUsd": 0.999869, "minBond": "5000000000000000000", "arbiterMinBond": "5000000000000000000" }, "monad": { "native": "MON", - "priceUsd": 0.02090424, + "priceUsd": 0.02064362, "minBond": "200000000000000000000", "arbiterMinBond": "200000000000000000000" }, "robinhood": { "native": "ETH", - "priceUsd": 2405.16, + "priceUsd": 2392.23, "minBond": "2000000000000000", "arbiterMinBond": "2000000000000000" }, "rootstock": { "native": "RBTC", - "priceUsd": 81996, + "priceUsd": 81371, "minBond": "60000000000000", "arbiterMinBond": "60000000000000" }, "plasma": { "native": "XPL", - "priceUsd": 0.076547, + "priceUsd": 0.075746, "minBond": "60000000000000000000", "arbiterMinBond": "60000000000000000000" }, "stable": { "native": "USDT0", - "priceUsd": 0.9991, + "priceUsd": 0.99895, "minBond": "5000000000000000000", "arbiterMinBond": "5000000000000000000" } diff --git a/deployments/fork-v2/SUMMARY.json b/deployments/fork-v2/SUMMARY.json index fc9100e..b9b330c 100644 --- a/deployments/fork-v2/SUMMARY.json +++ b/deployments/fork-v2/SUMMARY.json @@ -10,11 +10,11 @@ }, "base": { "ok": true, - "seconds": 11, - "gasUsed": "6475274", - "cost": "0.000038928831652427", + "seconds": 52, + "gasUsed": "6449676", + "cost": "0.000038928426407206", "native": "ETH", - "e2eTxs": 0, + "e2eTxs": 42, "smokeChecks": 28 }, "bnb": { @@ -127,11 +127,11 @@ }, "arc": { "ok": true, - "seconds": 11, - "gasUsed": "6475298", - "cost": "0.1327436096475298", + "seconds": 58, + "gasUsed": "6449700", + "cost": "0.1290004497", "native": "USDC", - "e2eTxs": 0, + "e2eTxs": 42, "smokeChecks": 28 }, "monad": { diff --git a/deployments/fork-v2/arc.json b/deployments/fork-v2/arc.json index 17f6fe1..59e811c 100644 --- a/deployments/fork-v2/arc.json +++ b/deployments/fork-v2/arc.json @@ -3,9 +3,9 @@ "chainId": 5042, "mode": "anvil-fork", "forkedFrom": "https://rpc.mainnet.arc.io", - "forkBlock": 26850550, - "at": "2026-10-19T23:26:03.680Z", - "seconds": 46, + "forkBlock": 26950440, + "at": "2026-10-20T13:30:07.593Z", + "seconds": 50, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", "startNonce": 0, "contracts": { @@ -21,9 +21,9 @@ "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", "txHash": "0x7dff382649311298b1297083d15d5a9728c9ed21056b5c22478e2b9decc306ad", - "block": 26850455, + "block": 26950337, "gasUsed": "1162822", - "effectiveGasPrice": "18592229834", + "effectiveGasPrice": "18500000000", "l1FeeWei": "0" }, "AgentJobV2": { @@ -35,10 +35,10 @@ ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0xc5145b85136451ceb7d0bc316cd9a76f0b5ad542bd73c503d55ecba967963e5b", - "block": 26850456, + "txHash": "0xe6b842bc63f39e3cf1110598a217ae85f2ee530d7ba727345073b691f573cb97", + "block": 26950338, "gasUsed": "904910", - "effectiveGasPrice": "16563673038", + "effectiveGasPrice": "16482078209", "l1FeeWei": "0" }, "PredgeValidatorBondV2": { @@ -51,14 +51,14 @@ "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", "86400", "5000000000000000000", - "10000" + "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000002710", + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", - "txHash": "0x72729c9a70f5c9a7975776822f5e1218be69dd9f06cc9749d1386dc113770121", - "block": 26850457, - "gasUsed": "1725783", - "effectiveGasPrice": "14735578270", + "txHash": "0x2388aaba14fbb9691a0486ab5a8302eebca0b9f89169b4383b52587248ac918e", + "block": 26950339, + "gasUsed": "1705859", + "effectiveGasPrice": "14663567495", "l1FeeWei": "0" }, "PredgeSettlementV2": { @@ -70,10 +70,10 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0x584cc3124f47ac831a0f78745074c118df5142b7310bd1e9f361fca2b7ac0eb4", - "block": 26850458, + "txHash": "0x1d41a25185c353ba12a6d54346a55993712155b383d5a00bf6052829bb433a19", + "block": 26950340, "gasUsed": "369083", - "effectiveGasPrice": "13216169549", + "effectiveGasPrice": "13149855888", "l1FeeWei": "0" }, "MockRefundProtocolV2": { @@ -85,10 +85,10 @@ ], "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0x256a46e162ca92485a8d42331ddd7556334f7314b67341def0dfbbc09ef25f52", - "block": 26850459, + "txHash": "0xe5e456f8495d2c95e4a54274852021ce031ec5a25ebc8cead2d8c92139909b84", + "block": 26950341, "gasUsed": "408763", - "effectiveGasPrice": "11726721527", + "effectiveGasPrice": "11668493113", "l1FeeWei": "0" }, "PredgeRefundArbiterV2": { @@ -101,24 +101,24 @@ "0xd01f1026c372749873EA32BDB017434a05226837", "86400", "5000000000000000000", - "1000" + "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000003e8", + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", - "txHash": "0x4a0ddec554207aef55db12812e7cb12ad725afff0db1c29966c51137b2b5431c", - "block": 26850460, - "gasUsed": "1918187", - "effectiveGasPrice": "10422420394", + "txHash": "0x70c9020797932d23bfd5c1f27430937569884f8b3e3346fb52057490fe0ca1e0", + "block": 26950342, + "gasUsed": "1898263", + "effectiveGasPrice": "10371272185", "l1FeeWei": "0" } }, "totals": { - "gasUsed": "6489548", - "liveGasPriceWei": "20500000100", + "gasUsed": "6449700", + "liveGasPriceWei": "20001000000", "l1FeeWei": "0", "l1FeeMethod": "none (execution gas only)", - "costWei": "133035734648954800", - "cost": "0.1330357346489548", + "costWei": "129000449700000000", + "cost": "0.1290004497", "native": "USDC" }, "smoke": [ @@ -214,7 +214,7 @@ }, { "step": "registry.validationRequest #4", - "gasUsed": 123226 + "gasUsed": 123238 }, { "step": "job.createJob #4", diff --git a/deployments/fork-v2/base.json b/deployments/fork-v2/base.json index 0f933d4..1ad3efb 100644 --- a/deployments/fork-v2/base.json +++ b/deployments/fork-v2/base.json @@ -3,9 +3,9 @@ "chainId": 8453, "mode": "anvil-fork", "forkedFrom": "https://base-rpc.publicnode.com", - "forkBlock": 52831532, - "at": "2026-10-19T23:26:52.507Z", - "seconds": 43, + "forkBlock": 52856856, + "at": "2026-10-20T13:30:59.699Z", + "seconds": 45, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", "startNonce": 0, "contracts": { @@ -20,11 +20,11 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", - "txHash": "0xaa509fc65ae9c27fc045b4e2fb2e3085d17ec05474062aef22b9efb06424c8d6", - "block": 52831511, + "txHash": "0xcb42bbb06516724f0b75baada4ccfbe0aae44528b16c0a6e0a6e8311c0e51531", + "block": 52856833, "gasUsed": "1162822", - "effectiveGasPrice": "1004992422", - "l1FeeWei": "13938652792" + "effectiveGasPrice": "1005089475", + "l1FeeWei": "44147957934" }, "AgentJobV2": { "name": "AgentJobV2", @@ -35,11 +35,11 @@ ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0xe86ebd4354ca528f5a17544240f73dfe254386b487b8e7f13f5173fc6c9d9f57", - "block": 52831512, + "txHash": "0xf4a9170a4b6e8c951f982fff5a2bdee7dc26de52a6134a80f3cfc828cbb6583d", + "block": 52856834, "gasUsed": "904910", - "effectiveGasPrice": "1004972801", - "l1FeeWei": "10545065775" + "effectiveGasPrice": "1005069473", + "l1FeeWei": "33399434451" }, "PredgeValidatorBondV2": { "name": "PredgeValidatorBondV2", @@ -51,15 +51,15 @@ "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", "86400", "2000000000000000", - "10000" + "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000002710", + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", - "txHash": "0x6c1ba1b64c65f403bd4997574755bae7774a8634b66babbec381e1d95c5f13f1", - "block": 52831513, - "gasUsed": "1725771", - "effectiveGasPrice": "1004953180", - "l1FeeWei": "18049496371" + "txHash": "0x403780b63bf970b3e7af4218740be9fe52b16b4eccb84b7794a49d6bf6e52f66", + "block": 52856835, + "gasUsed": "1705847", + "effectiveGasPrice": "1005049471", + "l1FeeWei": "57156949027" }, "PredgeSettlementV2": { "name": "PredgeSettlementV2", @@ -70,11 +70,11 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0x1b98f8891108aaa98bbaf669d14e63f2fc7de1f05764fd6266c92b460f0379a9", - "block": 52831514, + "txHash": "0x60c09a889b6a791c5470d3fea4b48ae11bd5e9d320e7ef44df0f500850cf142d", + "block": 52856836, "gasUsed": "369083", - "effectiveGasPrice": "1004933881", - "l1FeeWei": "4546517116" + "effectiveGasPrice": "1005029790", + "l1FeeWei": "14400204192" }, "MockRefundProtocolV2": { "name": "MockRefundProtocolV2", @@ -85,11 +85,11 @@ ], "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0x1b2c43e7114df89893dfc451e306cd9f85481d25bc415f12c2b979d2c0919fcb", - "block": 52831515, + "txHash": "0xb6ebc4c337135a7810ba6a5e4b6d6908cf4e1561122b093ff8b916c76d1ba463", + "block": 52856837, "gasUsed": "408763", - "effectiveGasPrice": "1004914255", - "l1FeeWei": "5381532218" + "effectiveGasPrice": "1005009783", + "l1FeeWei": "17044951295" }, "PredgeRefundArbiterV2": { "name": "PredgeRefundArbiterV2", @@ -101,24 +101,24 @@ "0xd01f1026c372749873EA32BDB017434a05226837", "86400", "2000000000000000", - "1000" + "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000003e8", + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", - "txHash": "0x164425c4de0c3c7eee3638567fd94fbf4972ff1fb85bd3a537bd3744f0c65e1b", - "block": 52831516, - "gasUsed": "1918175", - "effectiveGasPrice": "1004894719", - "l1FeeWei": "20283340191" + "txHash": "0xc2202851607dd728caf96d5eeb0d9b1098ecf0c9e4c87e93d1b634dfe8d36dce", + "block": 52856838, + "gasUsed": "1898251", + "effectiveGasPrice": "1004989867", + "l1FeeWei": "64220910307" } }, "totals": { - "gasUsed": "6489524", + "gasUsed": "6449676", "liveGasPriceWei": "6000000", - "l1FeeWei": "72744604463", + "l1FeeWei": "230370407206", "l1FeeMethod": "op-stack oracle getL1Fee", - "costWei": "39009888604463", - "cost": "0.000039009888604463", + "costWei": "38928426407206", + "cost": "0.000038928426407206", "native": "ETH" }, "smoke": [ @@ -154,7 +154,7 @@ "e2e": [ { "step": "registry.validationRequest #1", - "gasUsed": 123238 + "gasUsed": 123226 }, { "step": "job.createJob #1", From 820cac573068ff4367f397af87f2209795f68ab7 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:37:35 +0300 Subject: [PATCH 08/19] v2 config: new v2 deployer and operator addresses deployer 0xbB7A0c24677339045434273FFC2266B99E1e1301 (gas only, no role) operator 0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292 (hot ops key) Keys generated locally, never committed; nonce 0 on Arc and Base. owner (Safe) still null. --- config/v2.config.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/v2.config.json b/config/v2.config.json index 88606d6..1dceedf 100644 --- a/config/v2.config.json +++ b/config/v2.config.json @@ -1,6 +1,6 @@ { - "_comment": "Owner-supplied v2 identities. All null on purpose: nothing real exists yet. `deployer` = a NEW key created by the owner (pays gas only, holds no role, nonce 0 on every chain). `owner` = the Safe multisig (on EVERY chain you deploy to, at the same address if you want the same wiring everywhere). `operator` = the hot ops key (rotatable by the owner). dry-run and --broadcast refuse to run while any of these is null. --fork ignores this file and uses throwaway anvil accounts.", - "deployer": null, + "_comment": "Owner-supplied v2 identities. 20.10.2026: deployer and operator generated locally (keys in ~/.predge-deployer-v2/, 0600, never committed), nonce 0 on Arc and Base at creation; owner stays null until the Safe exists. `deployer` = a NEW key created by the owner (pays gas only, holds no role, nonce 0 on every chain). `owner` = the Safe multisig (on EVERY chain you deploy to, at the same address if you want the same wiring everywhere). `operator` = the hot ops key (rotatable by the owner). dry-run and --broadcast refuse to run while any of these is null. --fork ignores this file and uses throwaway anvil accounts.", + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", "owner": null, - "operator": null + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" } From ff101c755247fa1be99b8a9adfd1972a9977c683 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:39:43 +0300 Subject: [PATCH 09/19] deploy: v2 key guard matches the v1 directory segment only, not .predge-deployer-v2; temporary owner set --- config/v2.config.json | 5 ++- deployments/fork-v2/SUMMARY.json | 8 ++-- deployments/fork-v2/arc.json | 50 +++++++++++------------ deployments/fork-v2/base.json | 70 ++++++++++++++++---------------- lib/v2.mjs | 3 +- 5 files changed, 69 insertions(+), 67 deletions(-) diff --git a/config/v2.config.json b/config/v2.config.json index 1dceedf..5605d9c 100644 --- a/config/v2.config.json +++ b/config/v2.config.json @@ -1,6 +1,7 @@ { "_comment": "Owner-supplied v2 identities. 20.10.2026: deployer and operator generated locally (keys in ~/.predge-deployer-v2/, 0600, never committed), nonce 0 on Arc and Base at creation; owner stays null until the Safe exists. `deployer` = a NEW key created by the owner (pays gas only, holds no role, nonce 0 on every chain). `owner` = the Safe multisig (on EVERY chain you deploy to, at the same address if you want the same wiring everywhere). `operator` = the hot ops key (rotatable by the owner). dry-run and --broadcast refuse to run while any of these is null. --fork ignores this file and uses throwaway anvil accounts.", "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", - "owner": null, - "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + "owner": "0x9084f5000e07c7133d6da5ee4f271ab6d1821144", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "_owner_note": "20.10.2026: TEMPORARY owner = Amir EOA 0x9084\u20261144 by owner decision; transferOwnership\u2192acceptOwnership to the Safe once created." } diff --git a/deployments/fork-v2/SUMMARY.json b/deployments/fork-v2/SUMMARY.json index b9b330c..5050618 100644 --- a/deployments/fork-v2/SUMMARY.json +++ b/deployments/fork-v2/SUMMARY.json @@ -10,9 +10,9 @@ }, "base": { "ok": true, - "seconds": 52, + "seconds": 51, "gasUsed": "6449676", - "cost": "0.000038928426407206", + "cost": "0.000038898268888678", "native": "ETH", "e2eTxs": 42, "smokeChecks": 28 @@ -127,9 +127,9 @@ }, "arc": { "ok": true, - "seconds": 58, + "seconds": 54, "gasUsed": "6449700", - "cost": "0.1290004497", + "cost": "0.15414783", "native": "USDC", "e2eTxs": 42, "smokeChecks": 28 diff --git a/deployments/fork-v2/arc.json b/deployments/fork-v2/arc.json index 59e811c..204f6b9 100644 --- a/deployments/fork-v2/arc.json +++ b/deployments/fork-v2/arc.json @@ -3,9 +3,9 @@ "chainId": 5042, "mode": "anvil-fork", "forkedFrom": "https://rpc.mainnet.arc.io", - "forkBlock": 26950440, - "at": "2026-10-20T13:30:07.593Z", - "seconds": 50, + "forkBlock": 27007868, + "at": "2026-10-20T21:37:32.818Z", + "seconds": 47, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", "startNonce": 0, "contracts": { @@ -21,9 +21,9 @@ "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", "txHash": "0x7dff382649311298b1297083d15d5a9728c9ed21056b5c22478e2b9decc306ad", - "block": 26950337, + "block": 27007772, "gasUsed": "1162822", - "effectiveGasPrice": "18500000000", + "effectiveGasPrice": "18539309000", "l1FeeWei": "0" }, "AgentJobV2": { @@ -35,10 +35,10 @@ ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0xe6b842bc63f39e3cf1110598a217ae85f2ee530d7ba727345073b691f573cb97", - "block": 26950338, + "txHash": "0x4445cd5bcfcb19d369e681cbef99a02b29c79f8a0a711fe9d5c9628bc2ea2b6f", + "block": 27007773, "gasUsed": "904910", - "effectiveGasPrice": "16482078209", + "effectiveGasPrice": "16516854495", "l1FeeWei": "0" }, "PredgeValidatorBondV2": { @@ -55,10 +55,10 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", - "txHash": "0x2388aaba14fbb9691a0486ab5a8302eebca0b9f89169b4383b52587248ac918e", - "block": 26950339, + "txHash": "0x6bb77a282877e03c7514896b104b8b587e97eb9e595c783522d9a2b9d4f5accf", + "block": 27007774, "gasUsed": "1705859", - "effectiveGasPrice": "14663567495", + "effectiveGasPrice": "14694258990", "l1FeeWei": "0" }, "PredgeSettlementV2": { @@ -70,10 +70,10 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0x1d41a25185c353ba12a6d54346a55993712155b383d5a00bf6052829bb433a19", - "block": 26950340, + "txHash": "0x423ad3db6d7084cb675d6aa5272f674b131158ea7d7cd38c75f1a6680592a523", + "block": 27007775, "gasUsed": "369083", - "effectiveGasPrice": "13149855888", + "effectiveGasPrice": "13177147241", "l1FeeWei": "0" }, "MockRefundProtocolV2": { @@ -85,10 +85,10 @@ ], "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0xe5e456f8495d2c95e4a54274852021ce031ec5a25ebc8cead2d8c92139909b84", - "block": 26950341, + "txHash": "0x4eec0b4ca00998536dc2a38a8ff945a3e72d24bb3fa5515b47e109aa39a31e30", + "block": 27007776, "gasUsed": "408763", - "effectiveGasPrice": "11668493113", + "effectiveGasPrice": "11692456987", "l1FeeWei": "0" }, "PredgeRefundArbiterV2": { @@ -105,20 +105,20 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", - "txHash": "0x70c9020797932d23bfd5c1f27430937569884f8b3e3346fb52057490fe0ca1e0", - "block": 26950342, + "txHash": "0xd9a8464349c27c4fb1dbb880dd1ad1716c084059c90a1ba311d06548cf617fac", + "block": 27007777, "gasUsed": "1898263", - "effectiveGasPrice": "10371272185", + "effectiveGasPrice": "10392322204", "l1FeeWei": "0" } }, "totals": { "gasUsed": "6449700", - "liveGasPriceWei": "20001000000", + "liveGasPriceWei": "23900000000", "l1FeeWei": "0", "l1FeeMethod": "none (execution gas only)", - "costWei": "129000449700000000", - "cost": "0.1290004497", + "costWei": "154147830000000000", + "cost": "0.15414783", "native": "USDC" }, "smoke": [ @@ -154,7 +154,7 @@ "e2e": [ { "step": "registry.validationRequest #1", - "gasUsed": 123238 + "gasUsed": 123226 }, { "step": "job.createJob #1", @@ -202,7 +202,7 @@ }, { "step": "registry.validationRequest #3", - "gasUsed": 123238 + "gasUsed": 123226 }, { "step": "job.createJob #3", diff --git a/deployments/fork-v2/base.json b/deployments/fork-v2/base.json index 1ad3efb..a8e8301 100644 --- a/deployments/fork-v2/base.json +++ b/deployments/fork-v2/base.json @@ -3,8 +3,8 @@ "chainId": 8453, "mode": "anvil-fork", "forkedFrom": "https://base-rpc.publicnode.com", - "forkBlock": 52856856, - "at": "2026-10-20T13:30:59.699Z", + "forkBlock": 52871478, + "at": "2026-10-20T21:38:23.427Z", "seconds": 45, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", "startNonce": 0, @@ -20,11 +20,11 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", - "txHash": "0xcb42bbb06516724f0b75baada4ccfbe0aae44528b16c0a6e0a6e8311c0e51531", - "block": 52856833, + "txHash": "0xaa509fc65ae9c27fc045b4e2fb2e3085d17ec05474062aef22b9efb06424c8d6", + "block": 52871456, "gasUsed": "1162822", - "effectiveGasPrice": "1005089475", - "l1FeeWei": "44147957934" + "effectiveGasPrice": "1004990607", + "l1FeeWei": "38368600787" }, "AgentJobV2": { "name": "AgentJobV2", @@ -35,11 +35,11 @@ ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0xf4a9170a4b6e8c951f982fff5a2bdee7dc26de52a6134a80f3cfc828cbb6583d", - "block": 52856834, + "txHash": "0x80214af61cb9b45ae18ced0571bfb6b7fbc4b5114735511f84f6d815a3b988c7", + "block": 52871457, "gasUsed": "904910", - "effectiveGasPrice": "1005069473", - "l1FeeWei": "33399434451" + "effectiveGasPrice": "1004970993", + "l1FeeWei": "29027153846" }, "PredgeValidatorBondV2": { "name": "PredgeValidatorBondV2", @@ -55,11 +55,11 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", - "txHash": "0x403780b63bf970b3e7af4218740be9fe52b16b4eccb84b7794a49d6bf6e52f66", - "block": 52856835, + "txHash": "0x42f2832ba3298c566ad55ea9b0f1aa6468d120fef32a57f6d333a0644753d2d6", + "block": 52871458, "gasUsed": "1705847", - "effectiveGasPrice": "1005049471", - "l1FeeWei": "57156949027" + "effectiveGasPrice": "1004951379", + "l1FeeWei": "49674600186" }, "PredgeSettlementV2": { "name": "PredgeSettlementV2", @@ -70,11 +70,11 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0x60c09a889b6a791c5470d3fea4b48ae11bd5e9d320e7ef44df0f500850cf142d", - "block": 52856836, + "txHash": "0xcfebf9d01a42635fb54df1749ba6e0369b229b35025e147c1f0e55f3c2dd1142", + "block": 52871459, "gasUsed": "369083", - "effectiveGasPrice": "1005029790", - "l1FeeWei": "14400204192" + "effectiveGasPrice": "1004932081", + "l1FeeWei": "12515090431" }, "MockRefundProtocolV2": { "name": "MockRefundProtocolV2", @@ -85,11 +85,11 @@ ], "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0xb6ebc4c337135a7810ba6a5e4b6d6908cf4e1561122b093ff8b916c76d1ba463", - "block": 52856837, + "txHash": "0xba45e466198a5894b060e7ae449662f405b338739b0a5fc5d72d7be185d46815", + "block": 52871460, "gasUsed": "408763", - "effectiveGasPrice": "1005009783", - "l1FeeWei": "17044951295" + "effectiveGasPrice": "1004912462", + "l1FeeWei": "14813616808" }, "PredgeRefundArbiterV2": { "name": "PredgeRefundArbiterV2", @@ -105,20 +105,20 @@ ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", - "txHash": "0xc2202851607dd728caf96d5eeb0d9b1098ecf0c9e4c87e93d1b634dfe8d36dce", - "block": 52856838, + "txHash": "0x925ce7527d58a5d8c7177fe99eabbf29c496ab9c08c40b5739b9abc5f0ce3aee", + "block": 52871461, "gasUsed": "1898251", - "effectiveGasPrice": "1004989867", - "l1FeeWei": "64220910307" + "effectiveGasPrice": "1004892933", + "l1FeeWei": "55813826620" } }, "totals": { "gasUsed": "6449676", "liveGasPriceWei": "6000000", - "l1FeeWei": "230370407206", + "l1FeeWei": "200212888678", "l1FeeMethod": "op-stack oracle getL1Fee", - "costWei": "38928426407206", - "cost": "0.000038928426407206", + "costWei": "38898268888678", + "cost": "0.000038898268888678", "native": "ETH" }, "smoke": [ @@ -158,11 +158,11 @@ }, { "step": "job.createJob #1", - "gasUsed": 203490 + "gasUsed": 203478 }, { "step": "bond.stakeAndCommit", - "gasUsed": 190513 + "gasUsed": 190501 }, { "step": "job.submit (bytes, sha256 on-chain)", @@ -170,11 +170,11 @@ }, { "step": "bond.recordScore(100) completes the job", - "gasUsed": 159228 + "gasUsed": 159216 }, { "step": "registry.validationResponse", - "gasUsed": 132144 + "gasUsed": 132132 }, { "step": "job.withdraw (provider pulls payout)", @@ -202,7 +202,7 @@ }, { "step": "registry.validationRequest #3", - "gasUsed": 123238 + "gasUsed": 123226 }, { "step": "job.createJob #3", @@ -270,7 +270,7 @@ }, { "step": "bond.reclaim (j1, anyone, after window)", - "gasUsed": 47188 + "gasUsed": 47176 }, { "step": "bond.withdraw (staker)", diff --git a/lib/v2.mjs b/lib/v2.mjs index 50e3f65..8be0674 100644 --- a/lib/v2.mjs +++ b/lib/v2.mjs @@ -56,6 +56,7 @@ export function chainParams(chainName) { /** The key file for a real v2 broadcast: must be given explicitly and must not be the v1 deployer's directory. */ export function v2KeyFile(path) { if (!path) throw new Error("v2 --broadcast needs --key-file "); - if (path.includes(".predge-deployer")) throw new Error("refusing to use a key from ~/.predge-deployer for v2"); + // Block the v1 deployer's directory only (path segment ".predge-deployer"), not ".predge-deployer-v2". + if (path.split(/[\\/]/).includes(".predge-deployer")) throw new Error("refusing to use a key from ~/.predge-deployer for v2"); return path; } From 849d1fff3eb4dcd8fd02b97216b6fa96333077b8 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:52:47 +0300 Subject: [PATCH 10/19] deploy: v2 live on Arc (5042) and Base (8453), temporary owner EOA, Sourcify verified --- deployments/v2/arc.json | 305 ++++++++++++++++++ deployments/v2/base.json | 287 ++++++++++++++++ deployments/v2/verify/AgentJobV2.json | 32 ++ .../v2/verify/MockRefundProtocolV2.json | 26 ++ .../v2/verify/PredgeAgentValidatorV2.json | 29 ++ .../v2/verify/PredgeRefundArbiterV2.json | 32 ++ deployments/v2/verify/PredgeSettlementV2.json | 29 ++ .../v2/verify/PredgeValidatorBondV2.json | 35 ++ 8 files changed, 775 insertions(+) create mode 100644 deployments/v2/arc.json create mode 100644 deployments/v2/base.json create mode 100644 deployments/v2/verify/AgentJobV2.json create mode 100644 deployments/v2/verify/MockRefundProtocolV2.json create mode 100644 deployments/v2/verify/PredgeAgentValidatorV2.json create mode 100644 deployments/v2/verify/PredgeRefundArbiterV2.json create mode 100644 deployments/v2/verify/PredgeSettlementV2.json create mode 100644 deployments/v2/verify/PredgeValidatorBondV2.json diff --git a/deployments/v2/arc.json b/deployments/v2/arc.json new file mode 100644 index 0000000..2ec4d81 --- /dev/null +++ b/deployments/v2/arc.json @@ -0,0 +1,305 @@ +{ + "chain": "arc", + "chainId": 5042, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-20-v2", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-20T21:42:10.287Z", + "explorer": "https://explorer.arc.io", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", + "nonce": 0, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", + "txHash": "0x85281d12e1c9a3db70261f2b2fb8d5b82ec5a88dca4389359934d22df52e790a", + "block": 27008331, + "gasUsed": "1162822", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x860eb93b9a1652275f02f4da2cd07aaf34abe2e4a67640b8742ac7355f5813ef", + "block": 27008334, + "gasUsed": "904910", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", + "nonce": 2, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "86400", + "5000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e292000000000000000000000000550b360840c6614ad70e91cc6673ff3ab2dfd98c00000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", + "txHash": "0x1c56cd30a31eeab6b9b344b9c9aa47c493eb0d25dcf17ec91a48f97b99327b69", + "block": 27008337, + "gasUsed": "1705859", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", + "nonce": 3, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x7f3da7502d81decfb37c3e7498aa8bc4fc42acaca95e6e2f060e19d6b42f8640", + "block": 27008341, + "gasUsed": "369071", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "nonce": 4, + "args": [ + "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + ], + "constructorArgs": "0x00000000000000000000000072faeb8ee70816dbf66722657cc4bea314eb5fe3", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xe28fe22cac1322b91746dd7341b21eb5394c096e309cb52853010d3e92d4afbb", + "block": 27008343, + "gasUsed": "408763", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3", + "nonce": 5, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "86400", + "5000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000002768dbc17728403bae6d8838e4aec63be21a9da700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", + "txHash": "0x111d5cb1b0045dee19d114d44de12565c5336104735d62bb55f28a8bef12ab14", + "block": 27008347, + "gasUsed": "1898263", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.wouldSlash(unknown) == false", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": "predge-arc/deployments/arc-mainnet (deployer 0xEc7A…6223): full set incl. bond v2.1 + arbiter v2" +} diff --git a/deployments/v2/base.json b/deployments/v2/base.json new file mode 100644 index 0000000..349b0b1 --- /dev/null +++ b/deployments/v2/base.json @@ -0,0 +1,287 @@ +{ + "chain": "base", + "chainId": 8453, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-20-v2", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-20T21:52:38.533Z", + "explorer": "https://basescan.org", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", + "nonce": 0, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", + "nonce": 2, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "86400", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e292000000000000000000000000550b360840c6614ad70e91cc6673ff3ab2dfd98c000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", + "nonce": 3, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "nonce": 4, + "args": [ + "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + ], + "constructorArgs": "0x00000000000000000000000072faeb8ee70816dbf66722657cc4bea314eb5fe3", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3", + "nonce": 5, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "86400", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000002768dbc17728403bae6d8838e4aec63be21a9da7000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.wouldSlash(unknown) == false", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/deployments/v2/verify/AgentJobV2.json b/deployments/v2/verify/AgentJobV2.json new file mode 100644 index 0000000..b330112 --- /dev/null +++ b/deployments/v2/verify/AgentJobV2.json @@ -0,0 +1,32 @@ +{ + "language": "Solidity", + "sources": { + "v2/AgentJobV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @title AgentJobV2\n/// @notice A minimal ERC-8183-style agent job with escrowed native value and an INDEPENDENT\n/// evaluator named at creation. In Predge's deployment the evaluator is the\n/// `PredgeValidatorBondV2` contract, so the evaluator's verdict is backed by a bond and\n/// moves the money in the same transaction.\n///\n/// Lifecycle:\n/// createJob (client escrows value, names provider, evaluator, spec and an expiry)\n/// submit (provider, strictly before `expiredAt`)\n/// complete / reject (evaluator, strictly before `submittedAt + evaluationWindow`)\n/// claimRefund (anyone may trigger; the CLIENT is credited) when\n/// - the job is still Open at or after `expiredAt`, or\n/// - the job is Submitted and the evaluator stayed silent until\n/// `submittedAt + evaluationWindow`.\n///\n/// Timeout policy (documented choice): if the evaluator never decides, the CLIENT is\n/// refunded, as ERC-8183 does on expiry. The alternative (pay the provider by default)\n/// would let a silent or bribed evaluator pay out unverified work. The cost of this\n/// choice is borne by the provider, who did the work; providers should only work for\n/// evaluators they trust to answer inside the window.\n///\n/// Money never leaves by push. Every payout, whether to provider or client, is credited\n/// to `credit[account]` and pulled with `withdraw` / `withdrawTo`, so a reverting or\n/// blocklisted recipient can neither force an outcome nor lock another party's funds.\n///\n/// @dev Native value only; amounts are in the chain's native wei. There is no owner, no\n/// admin, no upgrade path and no mutable parameter: `evaluationWindow` is immutable.\n/// Deliverable hashing: `sha256`. When the provider supplies the output bytes, the hash is\n/// computed on-chain with the sha256 precompile (so the stored hash provably belongs to\n/// those bytes); otherwise the provider's claimed hash is stored as given.\ncontract AgentJobV2 is IAgentJobV2, PullCredit {\n /// @notice Seconds the evaluator has to decide after a submission. Immutable, so it cannot\n /// be changed under a job that is already running.\n uint64 public immutable evaluationWindow;\n\n uint256 public jobCount;\n /// @notice Sum of every job's escrow that has not been credited yet (Open or Submitted).\n uint256 public totalEscrowed;\n\n mapping(uint256 => Job) private _jobs;\n\n event JobCreated(\n uint256 indexed jobId,\n address indexed client,\n address indexed evaluator,\n address provider,\n uint256 escrow,\n uint64 expiredAt,\n bytes32 specHash\n );\n event Submitted(uint256 indexed jobId, address indexed provider, bytes32 deliverable, bool hashedOnChain);\n event Completed(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Rejected(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Refunded(uint256 indexed jobId, address indexed caller, address indexed recipient, uint256 amount);\n\n error ZeroAddress();\n error ZeroEscrow();\n error BadExpiry();\n error BadWindow();\n error NotProvider();\n error NotEvaluator();\n error BadState();\n error PastDeadline();\n error NotExpired();\n error HashMismatch();\n error ZeroDeliverable();\n\n /// @param evaluationWindow_ seconds the evaluator has after a submission; must be non-zero.\n constructor(uint64 evaluationWindow_) {\n if (evaluationWindow_ == 0) revert BadWindow();\n evaluationWindow = evaluationWindow_;\n }\n\n /// @notice Client opens a job, escrows `msg.value`, and names the provider, the independent\n /// evaluator, the acceptance-test commitment and the expiry.\n /// @param provider the worker; paid on `complete`\n /// @param evaluator the only address that can complete or reject\n /// @param specHash the acceptance test commitment (the Predge `requestHash`)\n /// @param expiredAt unix time; the provider must submit strictly before it, after it the\n /// client can reclaim the escrow of a job that was never submitted\n function createJob(address provider, address evaluator, bytes32 specHash, uint64 expiredAt)\n external\n payable\n returns (uint256 jobId)\n {\n if (provider == address(0) || evaluator == address(0)) revert ZeroAddress();\n if (msg.value == 0) revert ZeroEscrow();\n if (expiredAt <= block.timestamp) revert BadExpiry();\n jobId = ++jobCount;\n totalEscrowed += msg.value;\n Job storage j = _jobs[jobId];\n j.client = msg.sender;\n j.provider = provider;\n j.evaluator = evaluator;\n j.escrow = msg.value;\n j.expiredAt = expiredAt;\n j.state = State.Open;\n j.specHash = specHash;\n emit JobCreated(jobId, msg.sender, evaluator, provider, msg.value, expiredAt, specHash);\n }\n\n /// @notice Provider submits the deliverable, strictly before `expiredAt`.\n /// @param deliverable sha256 of the output. Must be non-zero.\n /// @param output optional output bytes. If non-empty, `sha256(output)` must equal\n /// `deliverable` (reverts `HashMismatch` otherwise) and the job records that\n /// the hash was verified on-chain. If empty, the claimed hash is stored.\n function submit(uint256 jobId, bytes32 deliverable, bytes calldata output) external {\n Job storage j = _jobs[jobId];\n if (j.state != State.Open) revert BadState();\n if (msg.sender != j.provider) revert NotProvider();\n if (block.timestamp >= j.expiredAt) revert PastDeadline();\n if (deliverable == bytes32(0)) revert ZeroDeliverable();\n bool onChain = output.length != 0;\n if (onChain && sha256(output) != deliverable) revert HashMismatch();\n j.deliverable = deliverable;\n j.hashedOnChain = onChain;\n j.submittedAt = uint64(block.timestamp);\n j.state = State.Submitted;\n emit Submitted(jobId, msg.sender, deliverable, onChain);\n }\n\n /// @notice ERC-8183 `complete`. Evaluator only, while the job is Submitted and the\n /// evaluation window is still running. Credits the provider.\n /// @param reason the evaluator's attestation commitment (Predge: keccak256 of the signed verdict)\n function complete(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Completed;\n uint256 amt = _release(j, j.provider);\n emit Completed(jobId, msg.sender, j.provider, amt, reason);\n }\n\n /// @notice ERC-8183 `reject`. Evaluator only, same timing as `complete`. Credits the client.\n function reject(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Rejected;\n uint256 amt = _release(j, j.client);\n emit Rejected(jobId, msg.sender, j.client, amt, reason);\n }\n\n /// @notice Refund the client of a job that ran out of time. Callable by anyone (the credit\n /// always goes to the client): see the contract notes for the two cases.\n function claimRefund(uint256 jobId) external {\n Job storage j = _jobs[jobId];\n State s = j.state;\n if (s == State.Open) {\n if (block.timestamp < j.expiredAt) revert NotExpired();\n } else if (s == State.Submitted) {\n if (block.timestamp < uint256(j.submittedAt) + evaluationWindow) revert NotExpired();\n } else {\n revert BadState();\n }\n j.state = State.Expired;\n uint256 amt = _release(j, j.client);\n emit Refunded(jobId, msg.sender, j.client, amt);\n }\n\n /// @inheritdoc IAgentJobV2\n function getJob(uint256 jobId) external view returns (Job memory) {\n return _jobs[jobId];\n }\n\n function _decide(uint256 jobId, bytes32 reason) private returns (Job storage j) {\n j = _jobs[jobId];\n if (msg.sender != j.evaluator) revert NotEvaluator();\n if (j.state != State.Submitted) revert BadState();\n if (block.timestamp >= uint256(j.submittedAt) + evaluationWindow) revert PastDeadline();\n j.reason = reason;\n }\n\n function _release(Job storage j, address to) private returns (uint256 amt) {\n amt = j.escrow;\n j.escrow = 0;\n totalEscrowed -= amt;\n _addCredit(to, amt);\n }\n}\n" + }, + "v2/interfaces/IAgentJobV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (the Predge requestHash)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" + }, + "v2/access/PullCredit.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/MockRefundProtocolV2.json b/deployments/v2/verify/MockRefundProtocolV2.json new file mode 100644 index 0000000..9121898 --- /dev/null +++ b/deployments/v2/verify/MockRefundProtocolV2.json @@ -0,0 +1,26 @@ +{ + "language": "Solidity", + "sources": { + "v2/mocks/MockRefundProtocolV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice DEMO / TEST ONLY stand-in for a Refund Protocol. It holds NO funds and moves NO\n/// money: `refundByArbiter` only flips a flag and emits an event, so a \"refund\" here\n/// proves the arbiter made the call and nothing more. It exposes the payment parties the\n/// v2 arbiter binds evidence to. Anyone can create a payment record; that is intended for\n/// demos and is the reason this contract must never be treated as a payment rail.\ncontract MockRefundProtocolV2 {\n struct Payment {\n address payer;\n address payee;\n uint256 amount;\n uint256 releaseTimestamp;\n bool refunded;\n }\n\n address public immutable arbiter;\n uint256 public paymentCount;\n uint256 public refundCount;\n mapping(uint256 => Payment) public payments;\n mapping(address => uint256) public lockupSeconds;\n\n event PaymentRecorded(uint256 indexed paymentID, address indexed payer, address indexed payee, uint256 amount, uint256 releaseTimestamp);\n event RefundByArbiter(uint256 indexed paymentID, address indexed caller);\n event LockupSet(address indexed recipient, uint256 lockupSeconds);\n\n error CallerNotArbiter();\n error UnknownPayment();\n error AlreadyRefunded();\n error AlreadyReleased();\n error ZeroAddress();\n\n constructor(address arbiter_) {\n if (arbiter_ == address(0)) revert ZeroAddress();\n arbiter = arbiter_;\n }\n\n /// @notice Record a payment's parties (no value moves). Demo only.\n function recordPayment(address payer, address payee, uint256 amount, uint256 releaseTimestamp)\n external\n returns (uint256 paymentID)\n {\n if (payer == address(0) || payee == address(0)) revert ZeroAddress();\n paymentID = ++paymentCount;\n payments[paymentID] = Payment(payer, payee, amount, releaseTimestamp, false);\n emit PaymentRecorded(paymentID, payer, payee, amount, releaseTimestamp);\n }\n\n function paymentInfo(uint256 paymentID)\n external\n view\n returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp)\n {\n Payment storage p = payments[paymentID];\n return (p.payer, p.payee, p.amount, p.releaseTimestamp);\n }\n\n function refundByArbiter(uint256 paymentID) external {\n if (msg.sender != arbiter) revert CallerNotArbiter();\n Payment storage p = payments[paymentID];\n if (p.payee == address(0)) revert UnknownPayment();\n if (p.refunded) revert AlreadyRefunded();\n if (block.timestamp >= p.releaseTimestamp) revert AlreadyReleased();\n p.refunded = true;\n refundCount += 1;\n emit RefundByArbiter(paymentID, msg.sender);\n }\n\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external {\n if (msg.sender != arbiter) revert CallerNotArbiter();\n lockupSeconds[recipient] = recipientLockupSeconds;\n emit LockupSet(recipient, recipientLockupSeconds);\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeAgentValidatorV2.json b/deployments/v2/verify/PredgeAgentValidatorV2.json new file mode 100644 index 0000000..6fc2c04 --- /dev/null +++ b/deployments/v2/verify/PredgeAgentValidatorV2.json @@ -0,0 +1,29 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeAgentValidatorV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\n\n/// @notice The one ERC-721 call the registry needs from an ERC-8004 Identity Registry.\ninterface IIdentityOwner {\n function ownerOf(uint256 agentId) external view returns (address);\n}\n\n/// @title PredgeAgentValidatorV2\n/// @notice An ERC-8004 Validation Registry shaped log of validation requests and verdicts, with\n/// one property enforced by the chain: a request is timestamped BEFORE its response, and a\n/// response is written once and never changes.\n///\n/// What this contract does NOT do, so nobody reads more into it than is there:\n/// * It does not verify the ed25519 attestation. The operator verifies it off-chain and\n/// commits `responseHash`, the keccak256 of the signed bytes.\n/// * It is not linked to `PredgeValidatorBondV2`. A registry response is a statement; the\n/// bonded, slashable verdict is the bond's `Scored` event and the job it settled.\n/// Consumers that want the bonded one must read the bond.\n///\n/// Authorisation (fixes the v1 permissionless `validationRequest`): a request may be made\n/// by the operator, or, when an Identity Registry address was set at construction, by the\n/// current owner of the agent NFT. With no identity registry only the operator may\n/// request.\n///\n/// Domain separation: the caller supplies an acceptance-test DIGEST and the registry\n/// derives `requestHash = keccak256(abi.encode(block.chainid, address(this), digest))`.\n/// The same digest therefore yields a different request on every chain and registry, so a\n/// request or verdict cannot be replayed across deployments. This is the one deviation from\n/// the ERC-8004 reference signature: the fourth argument is the digest, and the derived\n/// hash is returned and emitted in the canonical event position. Use `requestHashFor` to\n/// compute it off-chain, and use that value as the `AgentJobV2.specHash`.\n///\n/// @dev Roles: `owner` (Safe) rotates the operator; `operator` (hot key) answers requests.\n/// Rotating the operator never rewrites a past verdict.\ncontract PredgeAgentValidatorV2 is Operated {\n struct Validation {\n address validator; // the operator that was current when the request was made\n uint256 agentId;\n bytes32 responseHash;\n uint64 requestedAt;\n uint64 respondedAt; // 0 while pending\n uint8 response; // 0-100\n bool hasResponse;\n string tag;\n }\n\n /// @notice Optional ERC-8004 Identity Registry (the agent NFT). Zero = only the operator requests.\n address public immutable identityRegistry;\n\n uint64 public requestCount;\n uint64 public responseCount;\n\n /// @dev `getSummary` is O(n^2) in the number of hashes (it dedupes); cap the input.\n uint256 public constant MAX_SUMMARY = 100;\n\n mapping(bytes32 => Validation) private _v;\n mapping(bytes32 => string) public requestURIOf;\n mapping(bytes32 => string) public responseURIOf;\n\n event ValidationRequest(address indexed validatorAddress, uint256 indexed agentId, string requestURI, bytes32 indexed requestHash);\n event ValidationResponse(\n address indexed validatorAddress,\n uint256 indexed agentId,\n bytes32 indexed requestHash,\n uint8 response,\n string responseURI,\n bytes32 responseHash,\n string tag\n );\n /// @notice Extra context the canonical events lack: who asked, and the digest behind the hash.\n event RequestRecorded(bytes32 indexed requestHash, bytes32 digest, address indexed requester);\n /// @notice Extra context the canonical event lacks: who answered, and when.\n event ResponseRecorded(bytes32 indexed requestHash, address indexed responder, uint64 respondedAt);\n\n error NotAuthorized();\n error WrongValidator();\n error ZeroHash();\n error AlreadyRequested();\n error NotRequested();\n error AlreadyResponded();\n error BadScore();\n error TooManyHashes();\n\n /// @param owner_ the Safe\n /// @param operator_ the hot key that answers requests\n /// @param identityRegistry_ optional ERC-8004 Identity Registry; zero disables agent-owner requests\n constructor(address owner_, address operator_, address identityRegistry_) Operated(owner_, operator_) {\n identityRegistry = identityRegistry_;\n }\n\n /// @notice The request hash this registry derives for an acceptance-test digest.\n function requestHashFor(bytes32 digest) public view returns (bytes32) {\n return keccak256(abi.encode(block.chainid, address(this), digest));\n }\n\n /// @notice Record a validation request before the outcome is knowable.\n /// @param validatorAddress must equal the current `operator` (kept for ERC-8004 compatibility)\n /// @param agentId the agent under validation (the NFT id when an identity registry is set)\n /// @param requestURI pointer to the signed acceptance test\n /// @param digest keccak256 of the signed acceptance-test bytes; must be non-zero\n /// @return requestHash the domain-separated hash under which the request is stored\n function validationRequest(address validatorAddress, uint256 agentId, string calldata requestURI, bytes32 digest)\n external\n returns (bytes32 requestHash)\n {\n if (digest == bytes32(0)) revert ZeroHash();\n if (msg.sender != operator && !_isAgentOwner(agentId)) revert NotAuthorized();\n if (validatorAddress != operator) revert WrongValidator();\n requestHash = requestHashFor(digest);\n Validation storage v = _v[requestHash];\n if (v.requestedAt != 0) revert AlreadyRequested();\n\n v.validator = validatorAddress;\n v.agentId = agentId;\n v.requestedAt = uint64(block.timestamp);\n requestURIOf[requestHash] = requestURI;\n requestCount += 1;\n\n emit ValidationRequest(validatorAddress, agentId, requestURI, requestHash);\n emit RequestRecorded(requestHash, digest, msg.sender);\n }\n\n /// @notice Record the verdict, once, for a request that exists. Operator only.\n /// @param response 0-100 (Predge: 100 delivered, 0 failed, 50 void)\n function validationResponse(\n bytes32 requestHash,\n uint8 response,\n string calldata responseURI,\n bytes32 responseHash,\n string calldata tag\n ) external onlyOperator {\n if (response > 100) revert BadScore();\n if (responseHash == bytes32(0)) revert ZeroHash();\n Validation storage v = _v[requestHash];\n if (v.requestedAt == 0) revert NotRequested();\n if (v.hasResponse) revert AlreadyResponded();\n\n v.response = response;\n v.responseHash = responseHash;\n v.respondedAt = uint64(block.timestamp);\n v.hasResponse = true;\n v.tag = tag;\n responseURIOf[requestHash] = responseURI;\n responseCount += 1;\n\n emit ValidationResponse(v.validator, v.agentId, requestHash, response, responseURI, responseHash, tag);\n emit ResponseRecorded(requestHash, msg.sender, uint64(block.timestamp));\n }\n\n /// @notice ERC-8004 `getValidationStatus`.\n function getValidationStatus(bytes32 requestHash)\n external\n view\n returns (address validatorAddress, uint256 agentId, uint8 response, bytes32 responseHash, string memory tag, uint256 lastUpdate)\n {\n Validation storage v = _v[requestHash];\n return (v.validator, v.agentId, v.response, v.responseHash, v.tag, v.respondedAt != 0 ? v.respondedAt : v.requestedAt);\n }\n\n /// @notice True once a verdict is permanently recorded.\n function isValidated(bytes32 requestHash) external view returns (bool) {\n return _v[requestHash].hasResponse;\n }\n\n /// @notice Seconds the request stayed committed before its verdict. 0 while pending, and also 0 if the\n /// verdict landed in the same block as the request (chain timestamps have block granularity), so\n /// a non-zero value proves the lead, but zero does not prove its absence.\n function commitLeadTime(bytes32 requestHash) external view returns (uint64) {\n Validation storage v = _v[requestHash];\n if (!v.hasResponse) return 0;\n return v.respondedAt - v.requestedAt;\n }\n\n /// @notice ERC-8004 `getSummary` for one agent over the supplied hashes. A hash that appears\n /// more than once is counted once.\n function getSummary(uint256 agentId, bytes32[] calldata requestHashes)\n external\n view\n returns (uint64 count, uint8 averageResponse)\n {\n uint256 n = requestHashes.length;\n if (n > MAX_SUMMARY) revert TooManyHashes();\n uint256 sum = 0;\n for (uint256 i = 0; i < n; i++) {\n bool seen = false;\n for (uint256 k = 0; k < i; k++) {\n if (requestHashes[k] == requestHashes[i]) {\n seen = true;\n break;\n }\n }\n if (seen) continue;\n Validation storage v = _v[requestHashes[i]];\n if (v.hasResponse && v.agentId == agentId) {\n sum += v.response;\n count += 1;\n }\n }\n averageResponse = count == 0 ? 0 : uint8(sum / count);\n }\n\n function _isAgentOwner(uint256 agentId) private view returns (bool) {\n if (identityRegistry == address(0)) return false;\n try IIdentityOwner(identityRegistry).ownerOf(agentId) returns (address o) {\n return o == msg.sender;\n } catch {\n return false;\n }\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeRefundArbiterV2.json b/deployments/v2/verify/PredgeRefundArbiterV2.json new file mode 100644 index 0000000..afdf615 --- /dev/null +++ b/deployments/v2/verify/PredgeRefundArbiterV2.json @@ -0,0 +1,32 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeRefundArbiterV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @notice What the arbiter needs from a Refund Protocol. `paymentInfo` is the parties-and-amount\n/// view the arbiter binds evidence and bond size to. Circle's production contract is not\n/// known to expose exactly this function: against it, a thin read-only adapter that\n/// implements `paymentInfo` and forwards the two write calls is required, and must be\n/// reviewed before use. On our chains only `MockRefundProtocolV2` exists.\ninterface IRefundProtocolV2 {\n /// @return payer the party refunded by `refundByArbiter`\n /// @return payee the recipient of the payment\n /// @return amount the payment amount, in the same native wei the bond is sized in\n /// @return releaseTimestamp when the payee may withdraw; a refund is impossible after it\n function paymentInfo(uint256 paymentID)\n external\n view\n returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp);\n function refundByArbiter(uint256 paymentID) external;\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external;\n}\n\n/// @title PredgeRefundArbiterV2\n/// @notice A bonded, OPTIMISTIC arbiter for a Refund Protocol. The operator rules; nothing moves\n/// until a challenge window has passed; the harmed party can freeze the ruling and escalate\n/// it to the owner (a Safe); and a ruling the owner overturns costs the operator its bond.\n///\n/// Flow, per payment:\n/// 1. `fileEvidence`: the PAYER or the PAYEE (read from the Refund Protocol, not\n/// chosen by the caller) files the hash of its own evidence. Write-once per party, and\n/// only before a ruling exists.\n/// 2. `rule` (operator): cites one party's filed evidence, picks Refund or Release, and\n/// posts a bond of at least `max(minBond, ceil(amount * bondBps / 10000))`. The\n/// operator may not itself be a party. Nothing executes yet.\n/// 3. Within `window` seconds of the ruling, the party the ruling HARMS (the payee on a\n/// Refund, the payer on a Release) may call `dispute` with counter-evidence. That\n/// freezes the ruling and starts the owner's `RESOLUTION_TIMEOUT`.\n/// 4a. Undisputed: after the window anyone calls `finalize`. The bond is credited back to\n/// the staker and, for a Refund, `refundByArbiter` is called on the Refund Protocol.\n/// 4b. Disputed: the owner calls `resolve(paymentID, finalDirection)`. If it equals the\n/// ruling, the ruling is upheld exactly as in 4a. If not, the ruling is overturned:\n/// the bond is credited to the harmed party (the disputer), and the owner's\n/// direction is executed (a Refund is sent to the Refund Protocol).\n/// 4c. If neither happens before `expiresAt` (owner silent, or the Refund Protocol keeps\n/// reverting), anyone calls `expire`: the ruling is voided, NOTHING is executed, and\n/// the bond returns to the staker.\n///\n/// Trust, stated plainly: for disputed rulings the owner Safe is the court of last\n/// resort. An operator that rules wrongly is only punished if the harmed party notices and\n/// disputes inside the window, and the owner then agrees. The window must close before the\n/// Refund Protocol's lockup ends, which `rule` enforces for a Refund. `finalize` should be\n/// called promptly (the operator, who wants its bond back, and the payer, who wants the refund,\n/// both have a reason to): once `expiresAt` has passed anyone can `expire` the ruling instead,\n/// which executes nothing and returns the bond.\n///\n/// @dev Native value only, in the chain's native wei. The contract holds `totalBonded +\n/// totalCredit` and never user payments. Parameters are bounded and not retroactive: the\n/// window is snapshotted into each ruling, and the bond is fixed when posted.\ncontract PredgeRefundArbiterV2 is Operated, PullCredit {\n enum Direction {\n None,\n Refund,\n Release\n }\n\n enum Status {\n None,\n Ruled, // window running, or elapsed and not yet finalized\n Disputed, // frozen, waiting for the owner\n Finalized, // upheld; bond returned; refund executed if Refund (final)\n Overturned, // owner disagreed; bond paid to the harmed party (final)\n Voided // expired without a decision; nothing executed; bond returned (final)\n }\n\n struct Ruling {\n address staker; // the operator key that ruled; the only address its bond is ever returned to\n Status status;\n Direction direction;\n uint64 ruledAt;\n uint64 window; // snapshot of `challengeWindow`\n uint64 expiresAt; // after this, `expire` voids the ruling\n address evidenceFrom;\n address payer;\n address payee;\n uint256 bond;\n bytes32 evidenceHash;\n bytes32 counterEvidence;\n }\n\n uint64 public constant MIN_WINDOW = 1 hours;\n uint64 public constant MAX_WINDOW = 30 days;\n uint64 public constant RESOLUTION_TIMEOUT = 14 days;\n uint16 public constant MAX_BPS = 10_000;\n\n IRefundProtocolV2 public immutable refundProtocol;\n\n uint64 public challengeWindow;\n uint256 public minBond;\n uint16 public bondBps;\n\n uint256 public totalBonded;\n uint64 public rulingCount;\n uint64 public overturnCount;\n\n mapping(uint256 => Ruling) private _rulings;\n /// @notice paymentID => party => hash of the evidence that party filed. Write-once.\n mapping(uint256 => mapping(address => bytes32)) public filedEvidence;\n\n event EvidenceFiled(uint256 indexed paymentID, address indexed party, bytes32 evidenceHash);\n event Ruled(\n uint256 indexed paymentID,\n address indexed staker,\n address indexed evidenceFrom,\n bytes32 evidenceHash,\n Direction direction,\n uint256 bond,\n uint64 windowEnd\n );\n event Disputed(uint256 indexed paymentID, address indexed disputer, bytes32 counterEvidence, uint64 expiresAt);\n event Finalized(uint256 indexed paymentID, address indexed staker, Direction direction, uint256 bond);\n event Overturned(uint256 indexed paymentID, address indexed harmed, Direction finalDirection, uint256 bond);\n event Voided(uint256 indexed paymentID, address indexed staker, uint256 bond);\n event ParamsUpdated(uint64 challengeWindow, uint256 minBond, uint16 bondBps);\n\n error ZeroHash();\n error BadParams();\n error BadDirection();\n error UnknownPayment();\n error NotAParty();\n error OperatorIsParty();\n error EvidenceClosed();\n error EvidenceAlreadyFiled(uint256 paymentID, address party);\n error NoEvidenceFiled(uint256 paymentID, address party);\n error AlreadyRuled(uint256 paymentID);\n error BondTooSmall(uint256 required);\n error LockupTooShort();\n error BadStatus();\n error WindowOpen();\n error WindowClosed();\n error NotHarmedParty();\n error NotExpired();\n error ResolutionExpired();\n\n /// @param owner_ the Safe: resolves disputes, sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that rules\n /// @param refundProtocol_ the Refund Protocol (or adapter) this arbiter drives; immutable\n /// @param challengeWindow_ seconds a ruling stays disputable, within [MIN_WINDOW, MAX_WINDOW]\n /// @param minBond_ absolute bond floor in native wei, non-zero\n /// @param bondBps_ bond as basis points of the payment amount, at most 10000\n constructor(address owner_, address operator_, address refundProtocol_, uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (refundProtocol_ == address(0)) revert ZeroAddress();\n refundProtocol = IRefundProtocolV2(refundProtocol_);\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Owner-only, bounded, not retroactive: existing rulings keep their window and bond.\n function setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Pass a recipient lockup change through to the Refund Protocol (owner only).\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyOwner {\n refundProtocol.setLockupSeconds(recipient, recipientLockupSeconds);\n }\n\n /// @notice The bond a ruling on a payment of `amount` must post right now.\n function requiredBond(uint256 amount) public view returns (uint256) {\n uint256 scaled = (amount * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice A party to the payment files the hash of its own evidence. Write-once, and only\n /// before the ruling. The caller must be the payer or the payee per the Refund Protocol.\n function fileEvidence(uint256 paymentID, bytes32 evidenceHash) external {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (_rulings[paymentID].status != Status.None) revert EvidenceClosed();\n (address payer, address payee,,) = refundProtocol.paymentInfo(paymentID);\n if (msg.sender != payer && msg.sender != payee) revert NotAParty();\n if (filedEvidence[paymentID][msg.sender] != bytes32(0)) revert EvidenceAlreadyFiled(paymentID, msg.sender);\n filedEvidence[paymentID][msg.sender] = evidenceHash;\n emit EvidenceFiled(paymentID, msg.sender, evidenceHash);\n }\n\n /// @notice Rule on a payment: cite a party's filed evidence, choose a direction, post the bond.\n /// Nothing executes until `finalize` or `resolve`.\n /// @param evidenceFrom the payer or payee whose filed evidence the ruling rests on\n function rule(uint256 paymentID, address evidenceFrom, bytes32 evidenceHash, Direction direction)\n external\n payable\n onlyOperator\n {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (direction == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.None) revert AlreadyRuled(paymentID);\n\n (address payer, address payee, uint256 amount, uint256 releaseTimestamp) = refundProtocol.paymentInfo(paymentID);\n if (payee == address(0)) revert UnknownPayment();\n if (msg.sender == payer || msg.sender == payee) revert OperatorIsParty();\n if (evidenceFrom != payer && evidenceFrom != payee) revert NotAParty();\n if (filedEvidence[paymentID][evidenceFrom] != evidenceHash) revert NoEvidenceFiled(paymentID, evidenceFrom);\n uint256 required = requiredBond(amount);\n if (msg.value < required) revert BondTooSmall(required);\n uint64 window = challengeWindow;\n // A refund must still be possible when the window closes. `finalize` must then run before the\n // payment's `releaseTimestamp`; if it does not, the Refund Protocol rejects the refund and `expire` voids the ruling.\n if (direction == Direction.Refund && releaseTimestamp <= block.timestamp + window) revert LockupTooShort();\n\n r.staker = msg.sender;\n r.status = Status.Ruled;\n r.direction = direction;\n r.ruledAt = uint64(block.timestamp);\n r.window = window;\n r.expiresAt = uint64(block.timestamp) + window + RESOLUTION_TIMEOUT;\n r.evidenceFrom = evidenceFrom;\n r.payer = payer;\n r.payee = payee;\n r.bond = msg.value;\n r.evidenceHash = evidenceHash;\n totalBonded += msg.value;\n rulingCount += 1;\n emit Ruled(paymentID, msg.sender, evidenceFrom, evidenceHash, direction, msg.value, uint64(block.timestamp) + window);\n }\n\n /// @notice The harmed party (payee on a Refund, payer on a Release) files counter-evidence\n /// inside the window. This freezes the ruling and hands it to the owner.\n function dispute(uint256 paymentID, bytes32 counterEvidence) external {\n if (counterEvidence == bytes32(0)) revert ZeroHash();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp >= uint256(r.ruledAt) + r.window) revert WindowClosed();\n if (msg.sender != _harmed(r)) revert NotHarmedParty();\n r.status = Status.Disputed;\n r.counterEvidence = counterEvidence;\n r.expiresAt = uint64(block.timestamp) + RESOLUTION_TIMEOUT;\n emit Disputed(paymentID, msg.sender, counterEvidence, r.expiresAt);\n }\n\n /// @notice After an undisputed window, return the bond to the staker and execute a Refund.\n /// Anyone may call. If the Refund Protocol reverts, this reverts too and the ruling\n /// can only be voided via `expire`.\n function finalize(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp < uint256(r.ruledAt) + r.window) revert WindowOpen();\n _uphold(paymentID, r);\n }\n\n /// @notice Owner decides a disputed ruling. `finalDirection` equal to the ruling upholds it;\n /// any other direction overturns it and slashes the bond to the harmed party. The\n /// final direction is executed either way (a Refund calls the Refund Protocol).\n function resolve(uint256 paymentID, Direction finalDirection) external onlyOwner {\n if (finalDirection == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp >= r.expiresAt) revert ResolutionExpired();\n if (finalDirection == r.direction) {\n _uphold(paymentID, r);\n return;\n }\n address harmed = _harmed(r);\n uint256 bond = r.bond;\n r.status = Status.Overturned;\n r.bond = 0;\n totalBonded -= bond;\n overturnCount += 1;\n _addCredit(harmed, bond);\n emit Overturned(paymentID, harmed, finalDirection, bond);\n if (finalDirection == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n /// @notice Void a ruling nobody decided in time. Nothing is executed; the bond returns to the\n /// staker. Anyone may call once `expiresAt` has passed.\n function expire(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled && r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp < r.expiresAt) revert NotExpired();\n uint256 bond = r.bond;\n r.status = Status.Voided;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Voided(paymentID, r.staker, bond);\n }\n\n /// @notice The full ruling record for a payment (all-zero if none).\n function rulings(uint256 paymentID) external view returns (Ruling memory) {\n return _rulings[paymentID];\n }\n\n /// @notice The party the ruling in `paymentID` would harm, or zero if there is no ruling.\n function harmedParty(uint256 paymentID) external view returns (address) {\n Ruling storage r = _rulings[paymentID];\n return r.status == Status.None ? address(0) : _harmed(r);\n }\n\n function _uphold(uint256 paymentID, Ruling storage r) private {\n uint256 bond = r.bond;\n r.status = Status.Finalized;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Finalized(paymentID, r.staker, r.direction, bond);\n if (r.direction == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n function _harmed(Ruling storage r) private view returns (address) {\n return r.direction == Direction.Refund ? r.payee : r.payer;\n }\n\n function _setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) private {\n if (challengeWindow_ < MIN_WINDOW || challengeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS)\n revert BadParams();\n challengeWindow = challengeWindow_;\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(challengeWindow_, minBond_, bondBps_);\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + }, + "v2/access/PullCredit.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeSettlementV2.json b/deployments/v2/verify/PredgeSettlementV2.json new file mode 100644 index 0000000..17e226a --- /dev/null +++ b/deployments/v2/verify/PredgeSettlementV2.json @@ -0,0 +1,29 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeSettlementV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Owned2Step} from \"./access/PredgeAccess.sol\";\n\n/// @title PredgeSettlementV2\n/// @notice Receipt layer for Predge's pay-per-call data API: an agent pays for a route in the\n/// chain's native token and this contract emits a `Paid` receipt.\n/// @dev Amounts are in native wei of the chain (on Arc the native token is USDC with 18\n/// decimals at the protocol level; the ERC-20 interface of Arc's USDC uses 6, so never\n/// mix the two units). The owner (a Safe) withdraws accumulated payments to a non-zero\n/// address; there is no other admin power and no upgrade path. Forced value (selfdestruct\n/// or coinbase) is included in `withdraw`, so it cannot be stranded.\ncontract PredgeSettlementV2 is Owned2Step {\n /// @param payer who paid\n /// @param route keccak256 of the route id\n /// @param amount native wei paid\n /// @param timestamp block time\n /// @param meta optional request reference\n event Paid(address indexed payer, bytes32 indexed route, uint256 amount, uint256 timestamp, string meta);\n event Withdrawn(address indexed to, uint256 amount);\n\n error ZeroPayment();\n error WithdrawFailed();\n\n /// @param owner_ the Safe that may withdraw\n constructor(address owner_) Owned2Step(owner_) {}\n\n /// @notice Pay for a route; emits a `Paid` receipt.\n function payForRoute(bytes32 route, string calldata meta) external payable {\n if (msg.value == 0) revert ZeroPayment();\n emit Paid(msg.sender, route, msg.value, block.timestamp, meta);\n }\n\n /// @notice Owner withdraws the whole balance to `to`. Reverts for the zero address.\n function withdraw(address payable to) external onlyOwner {\n if (to == address(0)) revert ZeroAddress();\n uint256 bal = address(this).balance;\n emit Withdrawn(to, bal);\n (bool ok,) = to.call{value: bal}(\"\");\n if (!ok) revert WithdrawFailed();\n }\n\n /// @notice A bare transfer still produces a receipt, under the route `bytes32(\"direct\")`.\n receive() external payable {\n if (msg.value == 0) revert ZeroPayment();\n emit Paid(msg.sender, bytes32(\"direct\"), msg.value, block.timestamp, \"\");\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeValidatorBondV2.json b/deployments/v2/verify/PredgeValidatorBondV2.json new file mode 100644 index 0000000..87704f3 --- /dev/null +++ b/deployments/v2/verify/PredgeValidatorBondV2.json @@ -0,0 +1,35 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeValidatorBondV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\n\n/// @title PredgeValidatorBondV2\n/// @notice Capital behind a verdict, and the EVALUATOR of an `AgentJobV2`. The job names this\n/// contract as its evaluator, so the verdict recorded here is the thing that moves the\n/// escrow, and the same transaction puts the operator's bond at risk.\n///\n/// The acceptance test is deterministic: the 32-byte deliverable hash stored in the job (sha256 of the\n/// output when the provider submitted the bytes, otherwise the hash the provider claimed) is compared by\n/// RAW EQUALITY with the `expected` 32-byte value the operator committed to. Nothing is hashed again here. That makes a false verdict provable\n/// on-chain with no judge, by comparing two hashes written by two different parties.\n///\n/// Flow:\n/// 1. `stakeAndCommit(requestHash, commitment, jobId)` (operator). Allowed ONLY while\n/// the job is Open, i.e. before any deliverable exists. `commitment` is\n/// `keccak256(abi.encode(expected, salt))`: `expected` stays hidden, so a provider\n/// cannot read it and submit it, and the operator cannot choose it after seeing the\n/// deliverable. The bond must be at least `max(minBond, bondBps * escrow / 10000)`.\n/// 2. The provider submits to the job.\n/// 3. `recordScore(requestHash, score, expected, salt, reason)` (operator). Requires the\n/// job to be Submitted and the reveal to match the commitment. It then calls\n/// `complete` (score 100) or `reject` (score 0) on the job, so the verdict and the\n/// money move together. The dispute window starts at `scoredAt`, which is always at\n/// or after the job's `submittedAt` because scoring requires a submission.\n/// 4. `challenge(requestHash)` (anyone) strictly BEFORE `scoredAt + window`; from that instant\n/// `reclaim` is possible and `challenge` reverts (no overlap, and no open-ended challenge). Slashes iff the\n/// verdict contradicts the deliverable: 100 but deliverable != expected, or 0 but\n/// deliverable == expected. The whole bond is CREDITED TO THE HARMED PARTY (the\n/// job's client on a false pass, its provider on a false fail), never to the caller,\n/// so a self-challenge pays the operator nothing and a front-runner earns nothing.\n/// 5. `reclaim(requestHash)` (anyone) once the window has closed unchallenged. The bond is\n/// credited to the staker recorded at stake time.\n/// 6. `release(requestHash)` (anyone) if the job expired with no verdict. No verdict was\n/// given, so nothing was lied about, and the bond goes back to the staker.\n///\n/// Honest limits, stated so they are not mistaken for guarantees:\n/// * The bond only covers `bondBps` of the escrow. At `bondBps = 10000` a colluding\n/// operator and provider cannot profit from a false pass; below that they can,\n/// up to the uncovered fraction.\n/// * A false verdict is only slashable if the harmed party (or someone for them) calls\n/// `challenge` inside the window. Nobody is paid to watch except the harmed party.\n/// * The operator's capital is locked until the job ends. A client picks the job's\n/// `expiredAt`, so an operator should read it before staking.\n/// * Only the deterministic `sha256(deliverable) == expected` test is covered. Subjective\n/// or \"void\" outcomes are not bonded.\n///\n/// @dev Native value only, in the chain's native wei. Roles: `owner` (Safe) sets bounded\n/// parameters and rotates `operator`; `operator` (hot key) stakes and scores. Parameters\n/// are not retroactive: the dispute window is snapshotted into each stake and the bond\n/// amount is fixed when staked. Payouts are pull-based. The contract holds, at all times,\n/// at least `totalBonded + totalCredit`.\ncontract PredgeValidatorBondV2 is Operated, PullCredit {\n enum Status {\n None,\n Committed, // bond posted, no verdict yet\n Scored, // verdict recorded, window running or elapsed, bond still held\n Slashed, // bond credited to the harmed party (final)\n Reclaimed, // bond credited back to the staker after the window (final)\n Released // job expired with no verdict, bond credited back to the staker (final)\n }\n\n struct Stake {\n address staker; // the operator key that posted the bond; the only party it is ever paid back to\n Status status;\n uint8 score; // 100 delivered / 0 failed, set when scored\n uint64 stakedAt;\n uint64 scoredAt; // set when scored; always >= the job's submittedAt\n uint64 window; // snapshot of `disputeWindow` at stake time\n uint256 jobId;\n uint256 bond;\n bytes32 commitment; // keccak256(abi.encode(expected, salt))\n bytes32 expected; // zero until revealed at recordScore\n }\n\n uint64 public constant MIN_WINDOW = 1 hours;\n uint64 public constant MAX_WINDOW = 30 days;\n uint16 public constant MAX_BPS = 10_000;\n\n IAgentJobV2 public immutable job;\n\n /// @notice Seconds a verdict stays challengeable. Applies to stakes made after a change.\n uint64 public disputeWindow;\n /// @notice Absolute floor for a bond, in native wei. Applies to stakes made after a change.\n uint256 public minBond;\n /// @notice Bond as basis points of the job escrow (10000 = 100%). Applies to future stakes.\n uint16 public bondBps;\n\n /// @notice Sum of bonds currently held (Committed or Scored).\n uint256 public totalBonded;\n uint64 public slashCount;\n\n mapping(bytes32 => Stake) public stakes;\n\n event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond, uint64 window);\n event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, uint64 scoredAt, uint64 windowEnd);\n event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered);\n event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond);\n event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond);\n event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps);\n\n error ZeroHash();\n error BadParams();\n error AlreadyCommitted();\n error NotCommitted();\n error NotScored();\n error BadStatus();\n error NotOpen();\n error NotSubmitted();\n error JobNotBound();\n error ProviderIsOperator();\n error BondTooSmall(uint256 required);\n error BadScore();\n error BadReveal();\n error VerdictHonest();\n error WindowOpen();\n error WindowClosed();\n error JobNotExpired();\n\n /// @param owner_ the Safe: sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that stakes and scores\n /// @param job_ the `AgentJobV2` this bond evaluates; immutable\n /// @param disputeWindow_ seconds a verdict stays challengeable, within [MIN_WINDOW, MAX_WINDOW]\n /// @param minBond_ absolute floor for a bond, non-zero\n /// @param bondBps_ bond as basis points of escrow, at most 10000\n constructor(address owner_, address operator_, address job_, uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (job_ == address(0)) revert ZeroAddress();\n job = IAgentJobV2(job_);\n _setParams(disputeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Owner-only parameter change, bounded and NOT retroactive: existing stakes keep\n /// the window and bond they were made with.\n function setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(disputeWindow_, minBond_, bondBps_);\n }\n\n /// @notice The bond a stake against `escrow` must post right now: `max(minBond, ceil(escrow * bondBps / 10000))`.\n function requiredBond(uint256 escrow) public view returns (uint256) {\n uint256 scaled = (escrow * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice Post the bond and commit the hidden acceptance test, while the job is still Open.\n /// @param requestHash the job's `specHash` (the domain-separated Predge request hash)\n /// @param commitment `keccak256(abi.encode(expected, salt))`; use a fresh random 32-byte salt\n /// @param jobId the job this verdict will settle; this contract must be its evaluator\n function stakeAndCommit(bytes32 requestHash, bytes32 commitment, uint256 jobId) external payable onlyOperator {\n if (requestHash == bytes32(0) || commitment == bytes32(0)) revert ZeroHash();\n Stake storage s = stakes[requestHash];\n if (s.status != Status.None) revert AlreadyCommitted();\n\n IAgentJobV2.Job memory j = job.getJob(jobId);\n // Only while Open and unexpired: once a deliverable exists, a commitment could be made to match it.\n if (j.state != IAgentJobV2.State.Open || block.timestamp >= j.expiredAt) revert NotOpen();\n if (j.specHash != requestHash || j.evaluator != address(this)) revert JobNotBound();\n // A provider that is also the operator writes both sides of the comparison.\n if (j.provider == msg.sender) revert ProviderIsOperator();\n uint256 required = requiredBond(j.escrow);\n if (msg.value < required) revert BondTooSmall(required);\n\n s.staker = msg.sender;\n s.status = Status.Committed;\n s.stakedAt = uint64(block.timestamp);\n s.window = disputeWindow;\n s.jobId = jobId;\n s.bond = msg.value;\n s.commitment = commitment;\n totalBonded += msg.value;\n emit Committed(requestHash, jobId, msg.sender, commitment, msg.value, s.window);\n }\n\n /// @notice Reveal the committed test, record the verdict, and settle the job with it.\n /// @param score 100 = delivered (job `complete`s, provider is credited), 0 = failed\n /// (job `reject`s, client is credited)\n /// @param expected,salt the preimage of the commitment\n /// @param reason the attestation commitment passed to the job (Predge: keccak256 of the signed verdict)\n function recordScore(bytes32 requestHash, uint8 score, bytes32 expected, bytes32 salt, bytes32 reason)\n external\n onlyOperator\n {\n if (score != 0 && score != 100) revert BadScore();\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n if (keccak256(abi.encode(expected, salt)) != s.commitment) revert BadReveal();\n // Re-checked here, not only at stake time: after an operator rotation the NEW operator must not be able\n // to score a job it is the provider of, while inheriting the old operator's stake.\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (j.provider == msg.sender) revert ProviderIsOperator();\n // Scoring needs a submission, so `scoredAt >= submittedAt` and the dispute window can never\n // start before the deliverable it is judged against exists.\n if (j.state != IAgentJobV2.State.Submitted) revert NotSubmitted();\n\n s.expected = expected;\n s.score = score;\n s.scoredAt = uint64(block.timestamp);\n s.status = Status.Scored;\n emit Scored(requestHash, s.jobId, score, expected, s.scoredAt, s.scoredAt + s.window);\n\n if (score == 100) job.complete(s.jobId, reason, \"\");\n else job.reject(s.jobId, reason, \"\");\n }\n\n /// @notice Prove the recorded verdict contradicts the deliverable and slash the bond to the\n /// harmed party. Anyone may call; the caller receives nothing. Takes no evidence.\n function challenge(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored) revert NotScored();\n if (block.timestamp >= uint256(s.scoredAt) + s.window) revert WindowClosed();\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (!_lied(s, j.deliverable)) revert VerdictHonest();\n\n address harmed = s.score == 100 ? j.client : j.provider;\n uint256 bond = s.bond;\n s.status = Status.Slashed;\n s.bond = 0;\n totalBonded -= bond;\n slashCount += 1;\n _addCredit(harmed, bond);\n emit Slashed(requestHash, msg.sender, harmed, bond, s.score, j.deliverable);\n }\n\n /// @notice Credit the bond back to its staker once the dispute window has closed unchallenged.\n /// Anyone may trigger it; the credit always goes to the staker.\n function reclaim(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored) revert NotScored();\n if (block.timestamp < uint256(s.scoredAt) + s.window) revert WindowOpen();\n uint256 bond = _close(s, Status.Reclaimed);\n emit Reclaimed(requestHash, s.staker, bond);\n }\n\n /// @notice Credit the bond back to its staker if the job expired without a verdict.\n function release(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n if (job.getJob(s.jobId).state != IAgentJobV2.State.Expired) revert JobNotExpired();\n uint256 bond = _close(s, Status.Released);\n emit Released(requestHash, s.staker, bond);\n }\n\n /// @notice Is this verdict slashable right now? Free check before spending gas.\n function wouldSlash(bytes32 requestHash) external view returns (bool) {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored || block.timestamp >= uint256(s.scoredAt) + s.window) return false;\n return _lied(s, job.getJob(s.jobId).deliverable);\n }\n\n function _lied(Stake storage s, bytes32 delivered) private view returns (bool) {\n bool matches = delivered == s.expected;\n return (s.score == 100 && !matches) || (s.score == 0 && matches);\n }\n\n function _close(Stake storage s, Status to) private returns (uint256 bond) {\n bond = s.bond;\n s.status = to;\n s.bond = 0;\n totalBonded -= bond;\n _addCredit(s.staker, bond);\n }\n\n function _setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) private {\n if (disputeWindow_ < MIN_WINDOW || disputeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS)\n revert BadParams();\n disputeWindow = disputeWindow_;\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(disputeWindow_, minBond_, bondBps_);\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + }, + "v2/access/PullCredit.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" + }, + "v2/interfaces/IAgentJobV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (the Predge requestHash)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} From c47ed4276bef3fbe225d7f00b6a6428ced6952c1 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:53:41 +0300 Subject: [PATCH 11/19] docs: README deployments, v2 live on Arc and Base, v1 marked legacy --- README.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6552fbf..159edb0 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,9 @@ node deploy.mjs --chain base --fork # full deploy + smoke + end-to-end on a CONFIRM_MAINNET=base node deploy.mjs --chain base --broadcast # the real deploy ``` -## v2 (self-audit fixes, not deployed) +## v2 (self-audit fixes, live on Arc and Base) -`contracts/v2/` is the redesigned set that fixes the 20.10.2026 self-audit findings (H-01..H-03, M-01..M-05, L-01..L-07). **Nothing here is deployed.** -The live 16-chain set above is v1 and is untouched. Design, finding-by-finding mapping and owner actions: `SELF-AUDIT-2026-10-20/v2-design.md` (Downloads). +`contracts/v2/` is the redesigned set that fixes the 20.10.2026 self-audit findings (H-01..H-03, M-01..M-05, L-01..L-07). v2 is deployed on Arc and Base only (see [Deployments](#deployments)). The 16-chain set is v1, legacy, and is untouched. Design, finding-by-finding mapping and owner actions: `SELF-AUDIT-2026-10-20/v2-design.md` (Downloads). ``` FOUNDRY_CONFIG=foundry-v2.toml forge test # unit + fuzz + invariant + PoC tests (v1 attacks run against v1 and v2) @@ -77,7 +76,7 @@ The tool enforces this: **Never use the deployer key for anything else.** Not even a plain transfer out until every chain has the whole set. -Canonical addresses for deployer `0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9`: +Canonical v1 addresses for deployer `0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9`: | Contract | Address (every chain) | |---|---| @@ -90,7 +89,29 @@ Canonical addresses for deployer `0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9`: ## Deployments -Live on **16 mainnets** since 18 Oct 2026, all six contracts at the canonical addresses above. Checked the same day with `eth_getCode` on every chain, and every contract is an exact match on [Sourcify](https://sourcify.dev). Full receipts (block, gas, constructor args, source hash) are in `deployments/.json`. +### v2 (current): Arc (5042) and Base (8453) + +Deployed 20-21 Oct 2026. The same six addresses on both chains. All six are verified on Sourcify (exact match). Receipts: [`deployments/v2/arc.json`](deployments/v2/arc.json), [`deployments/v2/base.json`](deployments/v2/base.json). + +| Contract | Address (Arc and Base) | Sourcify | Explorer | +|---|---|---|---| +| PredgeAgentValidatorV2 | `0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495` | [Sourcify](https://sourcify.dev/#/lookup/0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495) | [Arc](https://explorer.arc.io/address/0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495), [Base](https://basescan.org/address/0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495) | +| AgentJobV2 | `0x550b360840C6614aD70E91CC6673FF3AB2dFd98C` | [Sourcify](https://sourcify.dev/#/lookup/0x550b360840C6614aD70E91CC6673FF3AB2dFd98C) | [Arc](https://explorer.arc.io/address/0x550b360840C6614aD70E91CC6673FF3AB2dFd98C), [Base](https://basescan.org/address/0x550b360840C6614aD70E91CC6673FF3AB2dFd98C) | +| PredgeValidatorBondV2 | `0xa4BB68E55398c4EDD958DD6B32460D2018F78881` | [Sourcify](https://sourcify.dev/#/lookup/0xa4BB68E55398c4EDD958DD6B32460D2018F78881) | [Arc](https://explorer.arc.io/address/0xa4BB68E55398c4EDD958DD6B32460D2018F78881), [Base](https://basescan.org/address/0xa4BB68E55398c4EDD958DD6B32460D2018F78881) | +| PredgeSettlementV2 | `0x60991fE6a7afDcC79bDc43b754acE5c16158edb3` | [Sourcify](https://sourcify.dev/#/lookup/0x60991fE6a7afDcC79bDc43b754acE5c16158edb3) | [Arc](https://explorer.arc.io/address/0x60991fE6a7afDcC79bDc43b754acE5c16158edb3), [Base](https://basescan.org/address/0x60991fE6a7afDcC79bDc43b754acE5c16158edb3) | +| MockRefundProtocolV2 (demo refund protocol) | `0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7` | [Sourcify](https://sourcify.dev/#/lookup/0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7) | [Arc](https://explorer.arc.io/address/0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7), [Base](https://basescan.org/address/0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7) | +| PredgeRefundArbiterV2 | `0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3` | [Sourcify](https://sourcify.dev/#/lookup/0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3) | [Arc](https://explorer.arc.io/address/0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3), [Base](https://basescan.org/address/0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3) | + +- **Owner:** `0x9084f5000E07C7133D6dA5eE4f271AB6D1821144`, a **temporary single EOA**. A transfer to a Safe 2-of-3 multisig via `Ownable2Step` (`transferOwnership`, then `acceptOwnership`) is planned, target this week. Until then, treat the owner as a single point of control. +- **Operator** (hot ops key, rotatable by the owner): `0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292`. +- **Bond floor:** minimal, about $5 (5 USDC on Arc, 0.002 ETH on Base). No percentage of job value (bps = 0). It is a floor, not coverage of job value. +- **Status:** an external Olympix scan of v2 (commit `edd1c46`) was requested through Circle's Arc programme. Results are pending. The on-chain bonds are not claimed as a guarantee. The signed verdict API (`api.predge.io`) is unchanged and remains the product. + +### v1 (legacy): 16 mainnets + +**Legacy, known issues, holds no funds.** Kept deployed as a reference only. The v2 set above fixes the self-review findings in it. + +The v1 set went live on **16 mainnets** on 18 Oct 2026, all six contracts at the canonical addresses above. Checked the same day with `eth_getCode` on every chain, and every contract is an exact match on [Sourcify](https://sourcify.dev). Full receipts (block, gas, constructor args, source hash) are in `deployments/.json`. Creation transactions, in nonce order: From 78c614250ae73bcc281c2b45f80749f389354590 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:06:18 +0300 Subject: [PATCH 12/19] fix(v2): Olympix BugPoCer findings on c47ed42 Contracts: - 5.1.1 (High): the client fixes `expected` in the job specHash (bond.specHashFor(keccak256(abi.encode(expected, salt)))); stakeAndCommit reverts CommitmentNotBound otherwise. "Delivered" now requires bytes hashed on-chain (hashedOnChain) equal to expected; a bare claimed hash never counts. - 5.2.2 / 5.3.8 (Medium/Low): release forfeits the bond to the provider when a submitted job expired with no verdict (new terminal status Forfeited). - 5.2.3 (Medium): ClientIsOperator in stakeAndCommit and recordScore. - 5.2.1 (Medium): rule requires releaseTimestamp > now + window + RESOLUTION_TIMEOUT for both directions. - 5.3.12: setLockupSeconds bounded to (window + RESOLUTION_TIMEOUT, 365 days]. - 5.3.3: registry requestHash includes agentId. - 5.3.4: createJob rejects evaluator == client or provider. - 5.3.10: the responding operator is recorded as validator. - NatSpec corrected (Operated rotation, bond honest limits, job evaluator). Tests: existing suites moved to the client-bound spec and byte delivery; OlympixRegressions.t.sol replays each Olympix PoC path against the fixes; new invariants for client-bound commitments and forfeiture. --- contracts/v2/AgentJobV2.sol | 12 +- contracts/v2/PredgeAgentValidatorV2.sol | 27 ++- contracts/v2/PredgeRefundArbiterV2.sol | 31 ++- contracts/v2/PredgeValidatorBondV2.sol | 95 +++++--- contracts/v2/access/PredgeAccess.sol | 6 +- contracts/v2/interfaces/IAgentJobV2.sol | 2 +- test/foundry-v2/AgentJobV2.t.sol | 10 + test/foundry-v2/Base.sol | 30 ++- test/foundry-v2/Invariants.t.sol | 59 +++-- test/foundry-v2/Misc.t.sol | 45 +++- test/foundry-v2/OlympixRegressions.t.sol | 232 ++++++++++++++++++++ test/foundry-v2/Pocs.t.sol | 30 ++- test/foundry-v2/PredgeRefundArbiterV2.t.sol | 55 ++++- test/foundry-v2/PredgeValidatorBondV2.t.sol | 129 +++++++---- test/foundry-v2/V1Findings.t.sol | 49 +++-- 15 files changed, 659 insertions(+), 153 deletions(-) create mode 100644 test/foundry-v2/OlympixRegressions.t.sol diff --git a/contracts/v2/AgentJobV2.sol b/contracts/v2/AgentJobV2.sol index 45b5ae4..96451c1 100644 --- a/contracts/v2/AgentJobV2.sol +++ b/contracts/v2/AgentJobV2.sol @@ -5,8 +5,9 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; import {PullCredit} from "./access/PullCredit.sol"; /// @title AgentJobV2 -/// @notice A minimal ERC-8183-style agent job with escrowed native value and an INDEPENDENT -/// evaluator named at creation. In Predge's deployment the evaluator is the +/// @notice A minimal ERC-8183-style agent job with escrowed native value and an evaluator named at +/// creation. The evaluator may be neither the client nor the provider (a trivial check: it +/// cannot detect two addresses controlled by one party). In Predge's deployment the evaluator is the /// `PredgeValidatorBondV2` contract, so the evaluator's verdict is backed by a bond and /// moves the money in the same transaction. /// @@ -70,6 +71,7 @@ contract AgentJobV2 is IAgentJobV2, PullCredit { error NotExpired(); error HashMismatch(); error ZeroDeliverable(); + error EvaluatorNotIndependent(); /// @param evaluationWindow_ seconds the evaluator has after a submission; must be non-zero. constructor(uint64 evaluationWindow_) { @@ -81,7 +83,9 @@ contract AgentJobV2 is IAgentJobV2, PullCredit { /// evaluator, the acceptance-test commitment and the expiry. /// @param provider the worker; paid on `complete` /// @param evaluator the only address that can complete or reject - /// @param specHash the acceptance test commitment (the Predge `requestHash`) + /// @param specHash the acceptance test commitment. For a job evaluated by `PredgeValidatorBondV2` it + /// must be `bond.specHashFor(keccak256(abi.encode(expected, salt)))`: the client + /// fixes the expected result here, hidden behind the salt. /// @param expiredAt unix time; the provider must submit strictly before it, after it the /// client can reclaim the escrow of a job that was never submitted function createJob(address provider, address evaluator, bytes32 specHash, uint64 expiredAt) @@ -90,6 +94,8 @@ contract AgentJobV2 is IAgentJobV2, PullCredit { returns (uint256 jobId) { if (provider == address(0) || evaluator == address(0)) revert ZeroAddress(); + // Olympix 5.3.4: a client (or provider) that names itself evaluator would judge its own job. + if (evaluator == msg.sender || evaluator == provider) revert EvaluatorNotIndependent(); if (msg.value == 0) revert ZeroEscrow(); if (expiredAt <= block.timestamp) revert BadExpiry(); jobId = ++jobCount; diff --git a/contracts/v2/PredgeAgentValidatorV2.sol b/contracts/v2/PredgeAgentValidatorV2.sol index d2a5aff..edcb51d 100644 --- a/contracts/v2/PredgeAgentValidatorV2.sol +++ b/contracts/v2/PredgeAgentValidatorV2.sol @@ -26,18 +26,20 @@ interface IIdentityOwner { /// request. /// /// Domain separation: the caller supplies an acceptance-test DIGEST and the registry -/// derives `requestHash = keccak256(abi.encode(block.chainid, address(this), digest))`. +/// derives `requestHash = keccak256(abi.encode(block.chainid, address(this), agentId, digest))`. /// The same digest therefore yields a different request on every chain and registry, so a -/// request or verdict cannot be replayed across deployments. This is the one deviation from -/// the ERC-8004 reference signature: the fourth argument is the digest, and the derived -/// hash is returned and emitted in the canonical event position. Use `requestHashFor` to -/// compute it off-chain, and use that value as the `AgentJobV2.specHash`. +/// request or verdict cannot be replayed across deployments, and a request is keyed to its +/// agent, so one agent cannot pre-claim another agent's digest (Olympix 5.3.3). This is the +/// one deviation from the ERC-8004 reference signature: the fourth argument is the digest, +/// and the derived hash is returned and emitted in the canonical event position. Use +/// `requestHashFor` to compute it off-chain. The registry is not linked to the bond: a bonded +/// job's specHash is `PredgeValidatorBondV2.specHashFor`, not this hash. /// /// @dev Roles: `owner` (Safe) rotates the operator; `operator` (hot key) answers requests. /// Rotating the operator never rewrites a past verdict. contract PredgeAgentValidatorV2 is Operated { struct Validation { - address validator; // the operator that was current when the request was made + address validator; // the operator at request time; replaced by the operator that writes the response uint256 agentId; bytes32 responseHash; uint64 requestedAt; @@ -91,9 +93,9 @@ contract PredgeAgentValidatorV2 is Operated { identityRegistry = identityRegistry_; } - /// @notice The request hash this registry derives for an acceptance-test digest. - function requestHashFor(bytes32 digest) public view returns (bytes32) { - return keccak256(abi.encode(block.chainid, address(this), digest)); + /// @notice The request hash this registry derives for an agent and an acceptance-test digest. + function requestHashFor(uint256 agentId, bytes32 digest) public view returns (bytes32) { + return keccak256(abi.encode(block.chainid, address(this), agentId, digest)); } /// @notice Record a validation request before the outcome is knowable. @@ -109,7 +111,7 @@ contract PredgeAgentValidatorV2 is Operated { if (digest == bytes32(0)) revert ZeroHash(); if (msg.sender != operator && !_isAgentOwner(agentId)) revert NotAuthorized(); if (validatorAddress != operator) revert WrongValidator(); - requestHash = requestHashFor(digest); + requestHash = requestHashFor(agentId, digest); Validation storage v = _v[requestHash]; if (v.requestedAt != 0) revert AlreadyRequested(); @@ -123,7 +125,9 @@ contract PredgeAgentValidatorV2 is Operated { emit RequestRecorded(requestHash, digest, msg.sender); } - /// @notice Record the verdict, once, for a request that exists. Operator only. + /// @notice Record the verdict, once, for a request that exists. Operator only. The responding + /// operator is recorded as the validator (Olympix 5.3.10), so after a rotation the canonical + /// event and `getValidationStatus` name the key that actually answered. /// @param response 0-100 (Predge: 100 delivered, 0 failed, 50 void) function validationResponse( bytes32 requestHash, @@ -138,6 +142,7 @@ contract PredgeAgentValidatorV2 is Operated { if (v.requestedAt == 0) revert NotRequested(); if (v.hasResponse) revert AlreadyResponded(); + v.validator = msg.sender; v.response = response; v.responseHash = responseHash; v.respondedAt = uint64(block.timestamp); diff --git a/contracts/v2/PredgeRefundArbiterV2.sol b/contracts/v2/PredgeRefundArbiterV2.sol index 67dfb8d..d18028a 100644 --- a/contracts/v2/PredgeRefundArbiterV2.sol +++ b/contracts/v2/PredgeRefundArbiterV2.sol @@ -49,11 +49,14 @@ interface IRefundProtocolV2 { /// /// Trust, stated plainly: for disputed rulings the owner Safe is the court of last /// resort. An operator that rules wrongly is only punished if the harmed party notices and -/// disputes inside the window, and the owner then agrees. The window must close before the -/// Refund Protocol's lockup ends, which `rule` enforces for a Refund. `finalize` should be -/// called promptly (the operator, who wants its bond back, and the payer, who wants the refund, -/// both have a reason to): once `expiresAt` has passed anyone can `expire` the ruling instead, -/// which executes nothing and returns the bond. +/// disputes inside the window, and the owner then agrees. The whole life of a ruling must fit +/// before the Refund Protocol's lockup ends (Olympix 5.2.1): `rule` requires, for EITHER +/// direction, `releaseTimestamp > now + window + RESOLUTION_TIMEOUT`, so a dispute filed at the +/// last second still leaves the owner its full resolution period, and a refund chosen by +/// `finalize` or `resolve` can still execute. `finalize` should be called promptly (the operator, +/// who wants its bond back, and the payer, who wants the refund, both have a reason to): once +/// `expiresAt` has passed anyone can `expire` the ruling instead, which executes nothing and +/// returns the bond. /// /// @dev Native value only, in the chain's native wei. The contract holds `totalBonded + /// totalCredit` and never user payments. Parameters are bounded and not retroactive: the @@ -93,6 +96,8 @@ contract PredgeRefundArbiterV2 is Operated, PullCredit { uint64 public constant MAX_WINDOW = 30 days; uint64 public constant RESOLUTION_TIMEOUT = 14 days; uint16 public constant MAX_BPS = 10_000; + /// @notice Upper bound for a recipient lockup forwarded by `setLockupSeconds`. + uint256 public constant MAX_LOCKUP = 365 days; IRefundProtocolV2 public immutable refundProtocol; @@ -123,6 +128,7 @@ contract PredgeRefundArbiterV2 is Operated, PullCredit { event Overturned(uint256 indexed paymentID, address indexed harmed, Direction finalDirection, uint256 bond); event Voided(uint256 indexed paymentID, address indexed staker, uint256 bond); event ParamsUpdated(uint64 challengeWindow, uint256 minBond, uint16 bondBps); + event LockupForwarded(address indexed recipient, uint256 lockupSeconds); error ZeroHash(); error BadParams(); @@ -162,8 +168,14 @@ contract PredgeRefundArbiterV2 is Operated, PullCredit { _setParams(challengeWindow_, minBond_, bondBps_); } - /// @notice Pass a recipient lockup change through to the Refund Protocol (owner only). + /// @notice Pass a recipient lockup change through to the Refund Protocol (owner only). Bounded + /// (Olympix 5.3.12): longer than `challengeWindow + RESOLUTION_TIMEOUT`, so a ruling on a + /// payment made under it can complete before release, and at most `MAX_LOCKUP`. function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyOwner { + if (recipientLockupSeconds <= uint256(challengeWindow) + RESOLUTION_TIMEOUT || recipientLockupSeconds > MAX_LOCKUP) { + revert BadParams(); + } + emit LockupForwarded(recipient, recipientLockupSeconds); refundProtocol.setLockupSeconds(recipient, recipientLockupSeconds); } @@ -206,9 +218,10 @@ contract PredgeRefundArbiterV2 is Operated, PullCredit { uint256 required = requiredBond(amount); if (msg.value < required) revert BondTooSmall(required); uint64 window = challengeWindow; - // A refund must still be possible when the window closes. `finalize` must then run before the - // payment's `releaseTimestamp`; if it does not, the Refund Protocol rejects the refund and `expire` voids the ruling. - if (direction == Direction.Refund && releaseTimestamp <= block.timestamp + window) revert LockupTooShort(); + // The whole ruling (window, then a dispute's full resolution period) must end before the payment is + // released, for BOTH directions: a Refund executes at finalize/resolve, and a Release can be overturned + // into a Refund by the owner. Otherwise a free last-second dispute could run the clock out. + if (releaseTimestamp <= block.timestamp + window + RESOLUTION_TIMEOUT) revert LockupTooShort(); r.staker = msg.sender; r.status = Status.Ruled; diff --git a/contracts/v2/PredgeValidatorBondV2.sol b/contracts/v2/PredgeValidatorBondV2.sol index c19348a..ce3927b 100644 --- a/contracts/v2/PredgeValidatorBondV2.sol +++ b/contracts/v2/PredgeValidatorBondV2.sol @@ -10,17 +10,26 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; /// contract as its evaluator, so the verdict recorded here is the thing that moves the /// escrow, and the same transaction puts the operator's bond at risk. /// -/// The acceptance test is deterministic: the 32-byte deliverable hash stored in the job (sha256 of the -/// output when the provider submitted the bytes, otherwise the hash the provider claimed) is compared by -/// RAW EQUALITY with the `expected` 32-byte value the operator committed to. Nothing is hashed again here. That makes a false verdict provable -/// on-chain with no judge, by comparing two hashes written by two different parties. +/// The acceptance test is deterministic: the job's deliverable counts as DELIVERED only if the +/// provider submitted the output bytes (so the job computed `sha256(output)` on-chain, +/// `hashedOnChain == true`) and that hash equals, by RAW EQUALITY, the `expected` value the +/// CLIENT fixed when it created the job. A hash the provider merely claimed, with no bytes, never +/// counts as delivered. Nothing is hashed again here. That makes a false verdict provable on-chain +/// with no judge, by comparing a hash the chain computed with a value the client committed to. +/// +/// Who fixes `expected` (Olympix 5.1.1): the client. A job evaluated by this contract must have +/// `specHash == specHashFor(keccak256(abi.encode(expected, salt)))`, which the client chooses at +/// `createJob`. The operator learns `(expected, salt)` from the client off-chain, checks it against +/// the job before staking, and can only commit to that value: it cannot pick an `expected` of its +/// own that a colluding provider then matches. /// /// Flow: /// 1. `stakeAndCommit(requestHash, commitment, jobId)` (operator). Allowed ONLY while /// the job is Open, i.e. before any deliverable exists. `commitment` is -/// `keccak256(abi.encode(expected, salt))`: `expected` stays hidden, so a provider -/// cannot read it and submit it, and the operator cannot choose it after seeing the -/// deliverable. The bond must be at least `max(minBond, bondBps * escrow / 10000)`. +/// `keccak256(abi.encode(expected, salt))` and must be the one the client bound into the +/// job's specHash (`requestHash == specHashFor(commitment)`): `expected` stays hidden, the +/// operator cannot choose it, and cannot change it after seeing the deliverable. The bond +/// must be at least `max(minBond, bondBps * escrow / 10000)`. /// 2. The provider submits to the job. /// 3. `recordScore(requestHash, score, expected, salt, reason)` (operator). Requires the /// job to be Submitted and the reveal to match the commitment. It then calls @@ -29,19 +38,26 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; /// or after the job's `submittedAt` because scoring requires a submission. /// 4. `challenge(requestHash)` (anyone) strictly BEFORE `scoredAt + window`; from that instant /// `reclaim` is possible and `challenge` reverts (no overlap, and no open-ended challenge). Slashes iff the -/// verdict contradicts the deliverable: 100 but deliverable != expected, or 0 but -/// deliverable == expected. The whole bond is CREDITED TO THE HARMED PARTY (the +/// verdict contradicts the deliverable: 100 but not delivered (no on-chain hash, or a hash that +/// is not `expected`), or 0 although delivered (on-chain hash equal to `expected`). The whole bond is CREDITED TO THE HARMED PARTY (the /// job's client on a false pass, its provider on a false fail), never to the caller, /// so a self-challenge pays the operator nothing and a front-runner earns nothing. /// 5. `reclaim(requestHash)` (anyone) once the window has closed unchallenged. The bond is /// credited to the staker recorded at stake time. -/// 6. `release(requestHash)` (anyone) if the job expired with no verdict. No verdict was -/// given, so nothing was lied about, and the bond goes back to the staker. +/// 6. `release(requestHash)` (anyone) if the job expired with no verdict. If the provider never +/// submitted, nothing was owed and the bond goes back to the staker. If the provider DID submit +/// and the operator stayed silent until the evaluation window closed (Olympix 5.2.2), the +/// bond is FORFEITED to the provider: silence on delivered work is not free. /// /// Honest limits, stated so they are not mistaken for guarantees: /// * The bond only covers `bondBps` of the escrow. At `bondBps = 10000` a colluding /// operator and provider cannot profit from a false pass; below that they can, -/// up to the uncovered fraction. +/// up to the uncovered fraction. With `bondBps = 0` the bond is a flat floor. +/// * The client defines `expected`. A provider should obtain `(expected, salt)` from the client +/// and check it against the job's specHash before working; knowing `expected` does not +/// let anyone pass without the real bytes, because only an on-chain hash counts. +/// * On silence the provider receives the bond, not the escrow: the job's timeout policy +/// still refunds the escrow to the client. /// * A false verdict is only slashable if the harmed party (or someone for them) calls /// `challenge` inside the window. Nobody is paid to watch except the harmed party. /// * The operator's capital is locked until the job ends. A client picks the job's @@ -61,7 +77,8 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { Scored, // verdict recorded, window running or elapsed, bond still held Slashed, // bond credited to the harmed party (final) Reclaimed, // bond credited back to the staker after the window (final) - Released // job expired with no verdict, bond credited back to the staker (final) + Released, // job expired before any submission, bond credited back to the staker (final) + Forfeited // job expired after a submission with no verdict, bond credited to the provider (final) } struct Stake { @@ -101,6 +118,7 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered); event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond); event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event Forfeited(bytes32 indexed requestHash, address indexed provider, uint256 bond); event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps); error ZeroHash(); @@ -113,6 +131,8 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { error NotSubmitted(); error JobNotBound(); error ProviderIsOperator(); + error ClientIsOperator(); + error CommitmentNotBound(); error BondTooSmall(uint256 required); error BadScore(); error BadReveal(); @@ -147,14 +167,22 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { return scaled > minBond ? scaled : minBond; } + /// @notice The specHash a client must give a job evaluated by this contract, for a commitment + /// `keccak256(abi.encode(expected, salt))`. Domain separated by chain and by this contract. + function specHashFor(bytes32 commitment) public view returns (bytes32) { + return keccak256(abi.encode(block.chainid, address(this), commitment)); + } + /// @notice Post the bond and commit the hidden acceptance test, while the job is still Open. - /// @param requestHash the job's `specHash` (the domain-separated Predge request hash) - /// @param commitment `keccak256(abi.encode(expected, salt))`; use a fresh random 32-byte salt + /// @param requestHash the job's `specHash`; must equal `specHashFor(commitment)` + /// @param commitment `keccak256(abi.encode(expected, salt))` as fixed by the client in the specHash /// @param jobId the job this verdict will settle; this contract must be its evaluator function stakeAndCommit(bytes32 requestHash, bytes32 commitment, uint256 jobId) external payable onlyOperator { if (requestHash == bytes32(0) || commitment == bytes32(0)) revert ZeroHash(); Stake storage s = stakes[requestHash]; if (s.status != Status.None) revert AlreadyCommitted(); + // The client fixed `expected` (hidden behind the salt) in the specHash: the operator cannot pick its own. + if (requestHash != specHashFor(commitment)) revert CommitmentNotBound(); IAgentJobV2.Job memory j = job.getJob(jobId); // Only while Open and unexpired: once a deliverable exists, a commitment could be made to match it. @@ -162,6 +190,8 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { if (j.specHash != requestHash || j.evaluator != address(this)) revert JobNotBound(); // A provider that is also the operator writes both sides of the comparison. if (j.provider == msg.sender) revert ProviderIsOperator(); + // A client that is also the operator would judge its own job (Olympix 5.2.3). + if (j.client == msg.sender) revert ClientIsOperator(); uint256 required = requiredBond(j.escrow); if (msg.value < required) revert BondTooSmall(required); @@ -194,6 +224,7 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { // to score a job it is the provider of, while inheriting the old operator's stake. IAgentJobV2.Job memory j = job.getJob(s.jobId); if (j.provider == msg.sender) revert ProviderIsOperator(); + if (j.client == msg.sender) revert ClientIsOperator(); // Scoring needs a submission, so `scoredAt >= submittedAt` and the dispute window can never // start before the deliverable it is judged against exists. if (j.state != IAgentJobV2.State.Submitted) revert NotSubmitted(); @@ -215,7 +246,7 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { if (s.status != Status.Scored) revert NotScored(); if (block.timestamp >= uint256(s.scoredAt) + s.window) revert WindowClosed(); IAgentJobV2.Job memory j = job.getJob(s.jobId); - if (!_lied(s, j.deliverable)) revert VerdictHonest(); + if (!_lied(s, j)) revert VerdictHonest(); address harmed = s.score == 100 ? j.client : j.provider; uint256 bond = s.bond; @@ -233,38 +264,48 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { Stake storage s = stakes[requestHash]; if (s.status != Status.Scored) revert NotScored(); if (block.timestamp < uint256(s.scoredAt) + s.window) revert WindowOpen(); - uint256 bond = _close(s, Status.Reclaimed); + uint256 bond = _close(s, Status.Reclaimed, s.staker); emit Reclaimed(requestHash, s.staker, bond); } - /// @notice Credit the bond back to its staker if the job expired without a verdict. + /// @notice Close a stake whose job expired without a verdict. Anyone may trigger it. If the provider + /// never submitted, the bond is credited back to the staker. If the provider submitted and the + /// operator let the evaluation window close without scoring, the bond is credited to the + /// provider instead. function release(bytes32 requestHash) external { Stake storage s = stakes[requestHash]; if (s.status == Status.None) revert NotCommitted(); if (s.status != Status.Committed) revert BadStatus(); - if (job.getJob(s.jobId).state != IAgentJobV2.State.Expired) revert JobNotExpired(); - uint256 bond = _close(s, Status.Released); - emit Released(requestHash, s.staker, bond); + IAgentJobV2.Job memory j = job.getJob(s.jobId); + if (j.state != IAgentJobV2.State.Expired) revert JobNotExpired(); + if (j.submittedAt == 0) { + uint256 bond = _close(s, Status.Released, s.staker); + emit Released(requestHash, s.staker, bond); + } else { + uint256 bond = _close(s, Status.Forfeited, j.provider); + emit Forfeited(requestHash, j.provider, bond); + } } /// @notice Is this verdict slashable right now? Free check before spending gas. function wouldSlash(bytes32 requestHash) external view returns (bool) { Stake storage s = stakes[requestHash]; if (s.status != Status.Scored || block.timestamp >= uint256(s.scoredAt) + s.window) return false; - return _lied(s, job.getJob(s.jobId).deliverable); + return _lied(s, job.getJob(s.jobId)); } - function _lied(Stake storage s, bytes32 delivered) private view returns (bool) { - bool matches = delivered == s.expected; - return (s.score == 100 && !matches) || (s.score == 0 && matches); + /// @dev Delivered means: the bytes were hashed on-chain AND the hash equals the client's `expected`. + function _lied(Stake storage s, IAgentJobV2.Job memory j) private view returns (bool) { + bool delivered = j.hashedOnChain && j.deliverable == s.expected; + return (s.score == 100 && !delivered) || (s.score == 0 && delivered); } - function _close(Stake storage s, Status to) private returns (uint256 bond) { + function _close(Stake storage s, Status to, address payee) private returns (uint256 bond) { bond = s.bond; s.status = to; s.bond = 0; totalBonded -= bond; - _addCredit(s.staker, bond); + _addCredit(payee, bond); } function _setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) private { diff --git a/contracts/v2/access/PredgeAccess.sol b/contracts/v2/access/PredgeAccess.sol index 6ca3b7c..2bad8ec 100644 --- a/contracts/v2/access/PredgeAccess.sol +++ b/contracts/v2/access/PredgeAccess.sol @@ -55,7 +55,11 @@ abstract contract Owned2Step { /// ownership or configuration. /// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker /// address on each stake or ruling, so rotating this key never lets a new key take an old -/// key's funds, and never strands them either. +/// key's bond, and never strands the bond either. It can strand an in-flight VERDICT +/// (Olympix 5.3.8): a Committed stake in `PredgeValidatorBondV2` can only be scored by the new +/// key if it is given the old commitment's `(expected, salt)`. If it is not, the job times out, +/// the client is refunded and the bond is forfeited to the provider. Rotate with no stake in +/// flight, or hand the preimages over, unless the old key is compromised. abstract contract Operated is Owned2Step { /// @notice The hot operations key. Rotatable by the owner. address public operator; diff --git a/contracts/v2/interfaces/IAgentJobV2.sol b/contracts/v2/interfaces/IAgentJobV2.sol index e83c3fe..2ce25ae 100644 --- a/contracts/v2/interfaces/IAgentJobV2.sol +++ b/contracts/v2/interfaces/IAgentJobV2.sol @@ -22,7 +22,7 @@ interface IAgentJobV2 { uint64 submittedAt; // 0 until submitted State state; bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes - bytes32 specHash; // the committed acceptance test (the Predge requestHash) + bytes32 specHash; // the committed acceptance test (bond.specHashFor(commitment) for bonded jobs) bytes32 deliverable; // sha256 of the provider's output (zero until submitted) bytes32 reason; // the evaluator's attestation commitment (zero until decided) } diff --git a/test/foundry-v2/AgentJobV2.t.sol b/test/foundry-v2/AgentJobV2.t.sol index 44cac2c..9bb65a5 100644 --- a/test/foundry-v2/AgentJobV2.t.sol +++ b/test/foundry-v2/AgentJobV2.t.sol @@ -67,6 +67,16 @@ contract AgentJobV2Test is Base { vm.stopPrank(); } + /// @dev Olympix 5.3.4: neither the client nor the provider can be the evaluator. + function test_createJob_evaluatorNotClientOrProvider() public { + vm.startPrank(client); + vm.expectRevert(AgentJobV2.EvaluatorNotIndependent.selector); + job.createJob{value: 1}(provider, client, SPEC, uint64(block.timestamp + 1)); + vm.expectRevert(AgentJobV2.EvaluatorNotIndependent.selector); + job.createJob{value: 1}(provider, provider, SPEC, uint64(block.timestamp + 1)); + vm.stopPrank(); + } + function test_submit_hashOnly() public { uint256 id = _create(1 ether); vm.expectEmit(true, true, false, true); diff --git a/test/foundry-v2/Base.sol b/test/foundry-v2/Base.sol index f8a1336..c47bf22 100644 --- a/test/foundry-v2/Base.sol +++ b/test/foundry-v2/Base.sol @@ -66,23 +66,51 @@ abstract contract Base is Test { return keccak256(abi.encode(expected, salt)); } - /// @dev client opens a job whose evaluator is the bond contract. + /// @dev The output bytes an honest provider delivers in most tests, and their sha256. + bytes internal constant GOOD = "the correct output"; + + function _good() internal pure returns (bytes32) { + return sha256(GOOD); + } + + /// @dev The specHash a client gives a bonded job: it fixes `expected` (hidden by `salt`). + function _bspec(bytes32 expected, bytes32 salt) internal view returns (bytes32) { + return bond.specHashFor(_commitment(expected, salt)); + } + + /// @dev client opens a job whose evaluator is the bond contract, with a specHash NOT bound to any + /// commitment (no stake can be made on it). For tests that never stake. function _newJob(uint256 n, uint256 escrow) internal returns (uint256 id, bytes32 spec) { spec = _spec(n); vm.prank(client); id = job.createJob{value: escrow}(provider, address(bond), spec, uint64(block.timestamp + 7 days)); } + /// @dev client opens a bonded job and fixes `expected` in its specHash. + function _newJob(uint256 escrow, bytes32 expected, bytes32 salt) internal returns (uint256 id, bytes32 spec) { + spec = _bspec(expected, salt); + vm.prank(client); + id = job.createJob{value: escrow}(provider, address(bond), spec, uint64(block.timestamp + 7 days)); + } + function _stake(bytes32 spec, bytes32 expected, bytes32 salt, uint256 id, uint256 value) internal { vm.prank(op); bond.stakeAndCommit{value: value}(spec, _commitment(expected, salt), id); } + /// @dev provider submits a CLAIMED hash only (no bytes): never counts as delivered for the bond. function _submit(uint256 id, bytes32 deliverable) internal { vm.prank(provider); job.submit(id, deliverable, ""); } + /// @dev provider submits the output bytes; the job hashes them on-chain. + function _deliver(uint256 id, bytes memory out) internal { + bytes32 h = sha256(out); // computed before the prank: a precompile call would consume it + vm.prank(provider); + job.submit(id, h, out); + } + function _score(bytes32 spec, uint8 score, bytes32 expected, bytes32 salt) internal { vm.prank(op); bond.recordScore(spec, score, expected, salt, keccak256("reason")); diff --git a/test/foundry-v2/Invariants.t.sol b/test/foundry-v2/Invariants.t.sol index ed5901b..52a904b 100644 --- a/test/foundry-v2/Invariants.t.sol +++ b/test/foundry-v2/Invariants.t.sol @@ -20,8 +20,9 @@ contract JobBondHandler is Test { bytes32[] public specs; // request hashes of every job created uint256[] public jobIds; - mapping(bytes32 => bytes32) public expectedOf; // preimage the operator committed to + mapping(bytes32 => bytes32) public expectedOf; // the value the client fixed in the specHash mapping(bytes32 => bytes32) public saltOf; + mapping(bytes32 => bytes) public outputOf; // the bytes whose sha256 is expectedOf mapping(bytes32 => uint8) public prevStatus; // last status seen, to prove monotonicity mapping(uint256 => uint8) public prevJobState; mapping(bytes32 => bytes32) public firstCommitment; @@ -46,30 +47,36 @@ contract JobBondHandler is Test { function createAndStake(uint96 escrow, uint8 salt, bool stakeIt) external { escrow = uint96(bound(escrow, 1, 1_000 ether)); - bytes32 spec = keccak256(abi.encode("s", specs.length)); + // The client fixes the expected output in the specHash (Olympix 5.1.1). + bytes memory out = abi.encode("e", salt); + bytes32 e = sha256(out); + bytes32 s = keccak256(abi.encode("salt", salt, specs.length)); + bytes32 spec = bond.specHashFor(keccak256(abi.encode(e, s))); vm.prank(client); uint256 id = job.createJob{value: escrow}(provider, address(bond), spec, uint64(block.timestamp + 5 days)); ghostEscrowedTotal += escrow; specs.push(spec); jobIds.push(id); + expectedOf[spec] = e; + saltOf[spec] = s; + outputOf[spec] = out; if (stakeIt) { - bytes32 e = keccak256(abi.encode("e", salt)); - bytes32 s = keccak256(abi.encode("salt", salt, specs.length)); - expectedOf[spec] = e; - saltOf[spec] = s; uint256 need = bond.requiredBond(escrow); vm.prank(op); try bond.stakeAndCommit{value: need}(spec, keccak256(abi.encode(e, s)), id) {} catch {} } } - function submit(uint256 i, bool correct, uint8 junk) external { + /// @dev mode 0: the real bytes; 1: the right hash CLAIMED with no bytes; 2: junk bytes; 3: a junk claimed hash. + function submit(uint256 i, uint8 mode, uint8 junk) external { if (specs.length == 0) return; i = bound(i, 0, specs.length - 1); bytes32 spec = specs[i]; - bytes32 d = correct && expectedOf[spec] != 0 ? expectedOf[spec] : keccak256(abi.encode("junk", junk)); + mode = mode % 4; + bytes memory out = mode == 0 ? outputOf[spec] : mode == 2 ? abi.encode("junk", junk) : bytes(""); + bytes32 d = mode == 1 ? expectedOf[spec] : mode == 3 ? keccak256(abi.encode("junk", junk)) : sha256(out); vm.prank(provider); - try job.submit(jobIds[i], d, "") {} catch {} + try job.submit(jobIds[i], d, out) {} catch {} } function score(uint256 i, bool pass) external { @@ -148,7 +155,7 @@ contract JobBondHandler is Test { if (firstCommitment[spec] == 0) { firstCommitment[spec] = c; firstJobId[spec] = jid; } if (c != firstCommitment[spec] || jid != firstJobId[spec]) violation = true; } - if (s8 >= 2 && s8 != 5) { + if (s8 >= 2 && s8 != 5 && s8 != 6) { // Released / Forfeited carry no verdict if (firstScoredAt[spec] == 0) { firstScoredAt[spec] = scoredAt; firstExpected[spec] = e; firstScore[spec] = sc; } if (scoredAt != firstScoredAt[spec] || e != firstExpected[spec] || sc != firstScore[spec]) violation = true; } @@ -209,15 +216,39 @@ contract JobBondInvariantTest is StdInvariant, Test { (, PredgeValidatorBondV2.Status st, uint8 score,,,,,,,) = bond.stakes(spec); if (st != PredgeValidatorBondV2.Status.Slashed) continue; IAgentJobV2.Job memory j = job.getJob(h.jobIds(i)); - bool matches = j.deliverable == h.expectedOf(spec); - assertTrue((score == 100 && !matches) || (score == 0 && matches), "slashed => the verdict contradicted the deliverable"); + bool delivered = j.hashedOnChain && j.deliverable == h.expectedOf(spec); + assertTrue((score == 100 && !delivered) || (score == 0 && delivered), "slashed => the verdict contradicted the deliverable"); + } + } + + /// @dev Olympix 5.2.2: a stake closed without a verdict pays the provider iff the provider had submitted. + function invariant_silenceAfterSubmissionForfeits() public view { + for (uint256 i; i < h.specCount(); ++i) { + (, PredgeValidatorBondV2.Status st,,,,,,,,) = bond.stakes(h.specs(i)); + uint64 submittedAt = job.getJob(h.jobIds(i)).submittedAt; + if (st == PredgeValidatorBondV2.Status.Forfeited) assertGt(submittedAt, 0, "forfeit only after a submission"); + if (st == PredgeValidatorBondV2.Status.Released) assertEq(submittedAt, 0, "returned only if nothing was submitted"); + } + } + + /// @dev Olympix 5.1.1: every stake commits to the value the client bound into the job's specHash. + function invariant_stakeCommitsToClientValue() public view { + for (uint256 i; i < h.specCount(); ++i) { + bytes32 spec = h.specs(i); + (, PredgeValidatorBondV2.Status st,,,,,,, bytes32 c,) = bond.stakes(spec); + if (st == PredgeValidatorBondV2.Status.None) continue; + assertEq(c, keccak256(abi.encode(h.expectedOf(spec), h.saltOf(spec)))); + assertEq(bond.specHashFor(c), spec); } } function invariant_scoredNeverBeforeSubmission() public view { for (uint256 i; i < h.specCount(); ++i) { (, PredgeValidatorBondV2.Status st,,, uint64 scoredAt,,,,,) = bond.stakes(h.specs(i)); - if (st == PredgeValidatorBondV2.Status.Committed || st == PredgeValidatorBondV2.Status.None || st == PredgeValidatorBondV2.Status.Released) continue; + if ( + st == PredgeValidatorBondV2.Status.Committed || st == PredgeValidatorBondV2.Status.None + || st == PredgeValidatorBondV2.Status.Released || st == PredgeValidatorBondV2.Status.Forfeited + ) continue; assertGe(scoredAt, job.getJob(h.jobIds(i)).submittedAt, "H-01: window starts at or after the submission"); assertGt(job.getJob(h.jobIds(i)).submittedAt, 0); } @@ -276,7 +307,7 @@ contract ArbiterHandler is Test { function newPayment(uint96 amount) external { amount = uint96(bound(amount, 1, 500 ether)); - pids.push(rp.recordPayment(payer, payee, amount, block.timestamp + 20 days)); + pids.push(rp.recordPayment(payer, payee, amount, block.timestamp + 40 days)); } function file(uint256 i, bool asPayer, bytes32 ev) external { diff --git a/test/foundry-v2/Misc.t.sol b/test/foundry-v2/Misc.t.sol index 8c2f988..207eb0d 100644 --- a/test/foundry-v2/Misc.t.sol +++ b/test/foundry-v2/Misc.t.sol @@ -47,16 +47,49 @@ contract PredgeAgentValidatorV2Test is Test { function test_requestHash_isDomainSeparated() public { bytes32 d = keccak256("d"); - assertEq(reg.requestHashFor(d), keccak256(abi.encode(block.chainid, address(reg), d))); - assertTrue(reg.requestHashFor(d) != regId.requestHashFor(d), "differs per registry"); - bytes32 onThisChain = reg.requestHashFor(d); + assertEq(reg.requestHashFor(7, d), keccak256(abi.encode(block.chainid, address(reg), uint256(7), d))); + assertTrue(reg.requestHashFor(7, d) != regId.requestHashFor(7, d), "differs per registry"); + assertTrue(reg.requestHashFor(7, d) != reg.requestHashFor(8, d), "differs per agent"); + bytes32 onThisChain = reg.requestHashFor(7, d); vm.chainId(999); - assertTrue(reg.requestHashFor(d) != onThisChain, "differs per chain"); + assertTrue(reg.requestHashFor(7, d) != onThisChain, "differs per chain"); + } + + /// @dev Olympix 5.3.3: an agent-NFT holder cannot pre-claim another agent's digest; the verdict lands on the right agent. + function test_request_digestCannotBeSquattedAcrossAgents() public { + bytes32 d = keccak256("victim digest"); + vm.prank(agentOwner); // owns agent 7, front-runs the digest under its own agent + bytes32 squat = regId.validationRequest(op, 7, "squat", d); + vm.prank(op); // the legitimate request for agent 1 still goes through, under its own key + bytes32 legit = regId.validationRequest(op, 1, "legit", d); + assertTrue(squat != legit); + vm.prank(op); + regId.validationResponse(legit, 100, "", keccak256("v"), ""); + bytes32[] memory hs = new bytes32[](2); + hs[0] = legit; hs[1] = squat; + (uint64 n1,) = regId.getSummary(1, hs); + (uint64 n7,) = regId.getSummary(7, hs); + assertEq(n1, 1, "verdict counted for the requesting agent"); + assertEq(n7, 0, "nothing credited to the squatter"); + } + + /// @dev Olympix 5.3.10: a pending request answered after a rotation records the key that answered. + function test_response_afterRotationNamesTheResponder() public { + bytes32 h = _req(reg, 1, keccak256("pending")); + address newOp = makeAddr("newOp"); + vm.prank(safe); + reg.setOperator(newOp); + vm.expectEmit(true, true, true, true); + emit ValidationResponse(newOp, 1, h, 100, "", keccak256("a"), ""); + vm.prank(newOp); + reg.validationResponse(h, 100, "", keccak256("a"), ""); + (address v,,,,,) = reg.getValidationStatus(h); + assertEq(v, newOp); } function test_request_response_flow() public { bytes32 d = keccak256("d"); - bytes32 h = reg.requestHashFor(d); + bytes32 h = reg.requestHashFor(7, d); vm.expectEmit(true, true, true, true); emit ValidationRequest(op, 7, "ipfs://req", h); vm.expectEmit(true, true, false, true); @@ -99,7 +132,7 @@ contract PredgeAgentValidatorV2Test is Test { function test_request_agentOwnerViaIdentityRegistry() public { vm.prank(agentOwner); bytes32 h = regId.validationRequest(op, 7, "x", keccak256("d")); - assertEq(h, regId.requestHashFor(keccak256("d"))); + assertEq(h, regId.requestHashFor(7, keccak256("d"))); vm.prank(agentOwner); // owner of agent 7 cannot request for agent 8 (unknown token -> ownerOf reverts) vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); regId.validationRequest(op, 8, "x", keccak256("e")); diff --git a/test/foundry-v2/OlympixRegressions.t.sol b/test/foundry-v2/OlympixRegressions.t.sol new file mode 100644 index 0000000..372a940 --- /dev/null +++ b/test/foundry-v2/OlympixRegressions.t.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Base} from "./Base.sol"; +import {ArbiterBase} from "./PredgeRefundArbiterV2.t.sol"; +import {PredgeValidatorBondV2} from "../../contracts/v2/PredgeValidatorBondV2.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {IAgentJobV2} from "../../contracts/v2/interfaces/IAgentJobV2.sol"; + +/// @notice Regression tests for the Olympix BugPoCer scan of c47ed42 (23 Oct 2026). Each test replays +/// the attack sequence of the corresponding Olympix PoC against the fixed contracts and asserts +/// the harm assertions of that PoC now hold (or, where the attack's first step is now refused, +/// that it reverts and the closest still-possible variant is harmless). +contract OlympixBondRegressionTest is Base { + bytes32 internal constant SALT = keccak256("client salt"); + bytes32 internal constant AGREED = keccak256("operator-and-provider-agreed hash"); + uint256 internal constant ESCROW = 10 ether; + bytes32 internal EXPECTED; // sha256(GOOD): fixed by the client in the specHash + + function setUp() public { + _deployJobAndBond(); + EXPECTED = _good(); + } + + // ------------------------------------------------------------ 5.1.1 (a, b, c) + + /// @dev 5.1.1-a/b step 1: the operator can no longer commit to a value of its own choosing. + function test_5_1_1_operatorCannotChooseExpected() public { + (uint256 id, bytes32 spec) = _newJob(ESCROW, EXPECTED, SALT); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.CommitmentNotBound.selector); + bond.stakeAndCommit{value: ESCROW}(spec, _commitment(AGREED, SALT), id); + // Not even with a spec derived for its own value: that spec belongs to no job the client created. + bytes32 own = _bspec(AGREED, SALT); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.JobNotBound.selector); + bond.stakeAndCommit{value: ESCROW}(own, _commitment(AGREED, SALT), id); + } + + /// @dev 5.1.1-a/c, strongest remaining variant: the operator commits to the client's value (it must) and + /// leaks it to a colluding provider, who submits the right hash with NO bytes. The PoC's harm + /// assertions now hold: the pass is slashable, the client is made whole at full bond, the + /// colluders do not profit and the operator cannot reclaim. + function test_5_1_1_leakedExpectedWithoutBytesIsSlashable() public { + uint256 providerBefore = provider.balance; + uint256 opBefore = op.balance; + (uint256 id, bytes32 spec) = _newJob(ESCROW, EXPECTED, SALT); + assertEq(bond.requiredBond(ESCROW), ESCROW, "bond fully covers the escrow"); + _stake(spec, EXPECTED, SALT, id, ESCROW); + _submit(id, EXPECTED); // claimed hash only: no work, no bytes + assertFalse(job.getJob(id).hashedOnChain); + _score(spec, 100, EXPECTED, SALT); + assertEq(job.credit(provider), ESCROW, "the false pass still moves the escrow..."); + + assertTrue(bond.wouldSlash(spec), "...but it is now slashable"); + vm.prank(client); + bond.challenge(spec); + vm.warp(block.timestamp + WINDOW); + vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); + bond.reclaim(spec); + + vm.prank(provider); + job.withdraw(); + int256 colludersProfit = + (int256(provider.balance) - int256(providerBefore)) + (int256(op.balance) - int256(opBefore)); + assertLe(colludersProfit, 0, "colluders do not profit at full bond"); + assertGe(bond.credit(client) + job.credit(client), ESCROW, "client is made whole"); + } + + /// @dev 5.1.1-b false fail: the operator cannot commit an unmatchable value; with the client's value, a 0 + /// on real bytes is slashable and pays the provider. + function test_5_1_1_falseFailOnRealWorkIsSlashable() public { + (uint256 id, bytes32 spec) = _newJob(ESCROW, EXPECTED, SALT); + bytes32 unmatchable = keccak256("garbage the operator picked"); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.CommitmentNotBound.selector); + bond.stakeAndCommit{value: ESCROW}(spec, _commitment(unmatchable, SALT), id); + + _stake(spec, EXPECTED, SALT, id, ESCROW); + _deliver(id, GOOD); + assertTrue(job.getJob(id).hashedOnChain); + _score(spec, 0, EXPECTED, SALT); // false fail: client credited the escrow + assertTrue(bond.wouldSlash(spec)); + vm.prank(provider); + bond.challenge(spec); + assertGe(job.credit(provider) + bond.credit(provider), ESCROW, "provider made whole at full bond"); + assertEq(bond.credit(op), 0, "operator loses its bond"); + } + + // ------------------------------------------------------------ 5.2.2 (a, b, c, d, e) + + /// @dev 5.2.2-a, shipped parameters (bondBps = 0): silence on a delivered job now costs the operator its bond, + /// and the provider receives it. + function test_5_2_2_silenceForfeitsFloorBondToProvider() public { + vm.prank(safe); + bond.setParams(WINDOW, MIN_BOND, 0); + (uint256 id, bytes32 spec) = _newJob(100 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, MIN_BOND); + _deliver(id, GOOD); + vm.warp(block.timestamp + EVAL_WINDOW); + job.claimRefund(id); + bond.release(spec); + assertEq(bond.credit(op), 0, "silence is not free"); + assertEq(bond.credit(provider), MIN_BOND, "the provider is compensated with the bond"); + assertEq(job.credit(client), 100 ether, "documented timeout policy: escrow to the client"); + } + + /// @dev 5.2.2-a second test / 5.2.2-b: at full bond the provider receives a bond equal to the escrow. + function test_5_2_2_silenceForfeitsFullBondToProvider() public { + (uint256 id, bytes32 spec) = _newJob(5 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 5 ether); + _deliver(id, GOOD); + vm.warp(block.timestamp + EVAL_WINDOW); + vm.prank(op); + vm.expectRevert(); // a late verdict is impossible (job: PastDeadline) + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + job.claimRefund(id); + bond.release(spec); + assertLt(bond.credit(op), 5 ether); + assertEq(bond.credit(provider), 5 ether); + uint256 before_ = provider.balance; + vm.prank(provider); + bond.withdraw(); + assertEq(provider.balance - before_, 5 ether); + } + + /// @dev Unchanged and intended: an operator that never staked, or a job never submitted, returns the bond. + function test_5_2_2_noSubmissionStillReturnsBond() public { + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 1 ether); + vm.warp(block.timestamp + 7 days); + job.claimRefund(id); + bond.release(spec); + assertEq(bond.credit(op), 1 ether); + assertEq(bond.credit(provider), 0); + } + + // ------------------------------------------------------------ 5.2.3 / 5.1.2-a + + /// @dev 5.2.3: the operator acting as the job's client cannot stake on (or later score) that job. + function test_5_2_3_operatorAsClientRefused() public { + bytes32 spec = _bspec(EXPECTED, SALT); + vm.prank(op); + uint256 id = job.createJob{value: 1 ether}(provider, address(bond), spec, uint64(block.timestamp + 1 days)); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.ClientIsOperator.selector); + bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); + } + + // ------------------------------------------------------------ 5.3.8 + + /// @dev 5.3.8: rotating the operator mid-flight without handing over the preimage no longer leaves the provider + /// with nothing: the stake is forfeited to it. + function test_5_3_8_rotationWithoutPreimageForfeitsToProvider() public { + (uint256 id, bytes32 spec) = _newJob(2 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 2 ether); + _deliver(id, GOOD); + address newOp = makeAddr("newOp"); + vm.prank(safe); + bond.setOperator(newOp); + vm.prank(newOp); // does not know the salt + vm.expectRevert(PredgeValidatorBondV2.BadReveal.selector); + bond.recordScore(spec, 100, EXPECTED, keccak256("guess"), 0); + vm.warp(block.timestamp + EVAL_WINDOW); + job.claimRefund(id); + bond.release(spec); + assertEq(bond.credit(provider), 2 ether); + assertEq(bond.credit(op), 0); + } +} + +contract OlympixArbiterRegressionTest is ArbiterBase { + PredgeRefundArbiterV2.Direction internal constant REFUND = PredgeRefundArbiterV2.Direction.Refund; + PredgeRefundArbiterV2.Direction internal constant RELEASE = PredgeRefundArbiterV2.Direction.Release; + + function setUp() public { + _deployArbiter(); + } + + /// @dev 5.2.1-a/b/d: the PoC's payment (lockup ends 1 hour after the window) can no longer be ruled on in + /// either direction, so a free dispute can never run a ruling past the release. + function test_5_2_1_shortLockupCannotBeRuled() public { + uint256 pid = rp.recordPayment(payer, payee, 1 ether, block.timestamp + WINDOW + 1 hours); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + vm.startPrank(op); + vm.expectRevert(PredgeRefundArbiterV2.LockupTooShort.selector); + arb.rule{value: need}(pid, payer, EV_PAYER, REFUND); + vm.expectRevert(PredgeRefundArbiterV2.LockupTooShort.selector); + arb.rule{value: need}(pid, payee, EV_PAYEE, RELEASE); + vm.stopPrank(); + } + + /// @dev 5.2.1-a replayed at the tightest lockup that is now allowed: the payee disputes one second before the + /// window closes and the owner resolves at the very last second of its resolution period. The refund + /// still executes, because the whole timeline ends before releaseTimestamp. + function test_5_2_1_lastSecondDisputeStillLeavesOwnerFullPeriod() public { + uint256 pid = rp.recordPayment(payer, payee, 1 ether, block.timestamp + WINDOW + arb.RESOLUTION_TIMEOUT() + 1); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + _rule(pid, payer, EV_PAYER, REFUND, need); + uint256 ruledAt = block.timestamp; + vm.warp(ruledAt + WINDOW - 1); + vm.prank(payee); + arb.dispute(pid, EV_COUNTER); + uint64 expiresAt = arb.rulings(pid).expiresAt; + (,,, uint256 releaseTs) = rp.paymentInfo(pid); + assertLt(expiresAt, releaseTs, "the owner's whole resolution period ends before release"); + vm.warp(uint256(expiresAt) - 1); + vm.prank(safe); + arb.resolve(pid, REFUND); + (,,,, bool refunded) = rp.payments(pid); + assertTrue(refunded, "the correct refund executes"); + assertEq(arb.credit(op), need); + } + + /// @dev 5.2.1-b: a Release ruling overturned into a Refund at the last moment still executes and slashes. + function test_5_2_1_releaseOverturnStillExecutes() public { + uint256 pid = rp.recordPayment(payer, payee, 1 ether, block.timestamp + WINDOW + arb.RESOLUTION_TIMEOUT() + 1); + _file(pid); + uint256 need = arb.requiredBond(1 ether); + _rule(pid, payee, EV_PAYEE, RELEASE, need); + vm.warp(block.timestamp + WINDOW - 1); + vm.prank(payer); + arb.dispute(pid, EV_COUNTER); + vm.warp(uint256(arb.rulings(pid).expiresAt) - 1); + vm.prank(safe); + arb.resolve(pid, REFUND); + assertEq(arb.credit(payer), need, "operator's bond goes to the harmed payer"); + assertEq(rp.refundCount(), 1); + } +} diff --git a/test/foundry-v2/Pocs.t.sol b/test/foundry-v2/Pocs.t.sol index ecc7cba..03c6f04 100644 --- a/test/foundry-v2/Pocs.t.sol +++ b/test/foundry-v2/Pocs.t.sol @@ -15,12 +15,13 @@ import {MockRefundProtocol as MockRPV1} from "../../contracts/MockRefundProtocol /// v1 attack sequence run against the v1 contracts (it SUCCEEDS there, which proves the PoC /// is a real exploit) and the same sequence against v2 (it reverts or is harmless). contract PocsBondTest is Base { - bytes32 constant E = keccak256("expected"); + bytes32 internal E; // sha256(GOOD): the client's expected output hash bytes32 constant SALT = keccak256("salt"); bytes32 constant X = keccak256("not the expected thing"); function setUp() public { _deployJobAndBond(); + E = _good(); } // ------------------------------------------------------------------ H-01 @@ -48,7 +49,7 @@ contract PocsBondTest is Base { /// @dev v2: the same sequence cannot even start. Scoring needs a submission, so nothing can be /// scored or reclaimed before the deliverable exists, and the window starts at the score. function test_v2_H01_verdictBeforeSubmissionReverts() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); vm.prank(op); vm.expectRevert(PredgeValidatorBondV2.NotSubmitted.selector); @@ -65,7 +66,7 @@ contract PocsBondTest is Base { /// @dev v2: a submission that lands after the score attempt is judged inside a full window. function test_v2_H01_windowStartsAtOrAfterSubmission() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); vm.warp(block.timestamp + 2 days); _submit(id, X); @@ -103,7 +104,7 @@ contract PocsBondTest is Base { /// @dev v2: the committed value is a hash, so there is nothing to copy. function test_v2_H02_expectedIsHiddenSoItCannotBeCopied() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); (,,,,,,,, bytes32 commitment, bytes32 expected) = bond.stakes(spec); assertEq(expected, bytes32(0)); @@ -118,16 +119,21 @@ contract PocsBondTest is Base { /// @dev v2: the operator cannot choose `expected` after seeing the deliverable. function test_v2_H02_cannotCommitAfterSubmission() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _submit(id, X); - vm.prank(op); - vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); + vm.startPrank(op); + // committing to the deliverable it has just seen is impossible twice over: the client fixed `expected`... + vm.expectRevert(PredgeValidatorBondV2.CommitmentNotBound.selector); bond.stakeAndCommit{value: 1 ether}(spec, keccak256(abi.encode(X, SALT)), id); + // ...and even the client's own commitment can no longer be staked once a deliverable exists + vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); + bond.stakeAndCommit{value: 1 ether}(spec, keccak256(abi.encode(E, SALT)), id); + vm.stopPrank(); } /// @dev v2: a reveal that does not match the commitment is rejected, so `expected` cannot be swapped. function test_v2_H02_cannotSwapExpectedAtReveal() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); _submit(id, X); vm.prank(op); @@ -139,7 +145,7 @@ contract PocsBondTest is Base { /// @dev M-02: the bond is paid to the harmed party; a self-challenge by the operator gains nothing. function test_v2_M02_selfChallengeIsNotProfitable() public { - (uint256 id, bytes32 spec) = _newJob(1, 5 ether); + (uint256 id, bytes32 spec) = _newJob(5 ether, E, SALT); _stake(spec, E, SALT, id, 5 ether); _submit(id, X); _score(spec, 100, E, SALT); // false pass, provider gets 5 @@ -153,9 +159,9 @@ contract PocsBondTest is Base { /// @dev M-04: the verdict IS the settlement; there is no way to record a verdict that does not move the job. function test_v2_M04_verdictMovesTheMoney() public { - (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + (uint256 id, bytes32 spec) = _newJob(2 ether, E, SALT); _stake(spec, E, SALT, id, 2 ether); - _submit(id, E); + _deliver(id, GOOD); _score(spec, 100, E, SALT); assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Completed)); // No other party can settle this job: the bond contract is the only evaluator. @@ -167,7 +173,7 @@ contract PocsBondTest is Base { /// @dev A colluding operator and provider cannot profit from a false pass at the default 100% bond ratio. function testFuzz_collusionNeverProfitsAtFullBond(uint96 escrow) public { escrow = uint96(bound(escrow, 1, 100 ether)); - (uint256 id, bytes32 spec) = _newJob(9, escrow); + (uint256 id, bytes32 spec) = _newJob(escrow, E, SALT); uint256 b = bond.requiredBond(escrow); _stake(spec, E, SALT, id, b); _submit(id, X); diff --git a/test/foundry-v2/PredgeRefundArbiterV2.t.sol b/test/foundry-v2/PredgeRefundArbiterV2.t.sol index 5f9a363..3940c03 100644 --- a/test/foundry-v2/PredgeRefundArbiterV2.t.sol +++ b/test/foundry-v2/PredgeRefundArbiterV2.t.sol @@ -113,13 +113,42 @@ contract PredgeRefundArbiterV2Test is ArbiterBase { assertEq(arb.requiredBond(1), 5); } + event LockupForwarded(address indexed recipient, uint256 lockupSeconds); + function test_setLockupSeconds_isOwnerOnlyPassThrough() public { + uint256 ok = WINDOW + arb.RESOLUTION_TIMEOUT() + 1; vm.prank(op); vm.expectRevert(Owned2Step.NotOwner.selector); - arb.setLockupSeconds(payee, 5); + arb.setLockupSeconds(payee, ok); + vm.expectEmit(true, false, false, true); + emit LockupForwarded(payee, ok); vm.prank(safe); - arb.setLockupSeconds(payee, 5); - assertEq(rp.lockupSeconds(payee), 5); + arb.setLockupSeconds(payee, ok); + assertEq(rp.lockupSeconds(payee), ok); + } + + /// @dev Olympix 5.3.12: the forwarded lockup is bounded below by window + resolution timeout and above by MAX_LOCKUP. + function test_setLockupSeconds_bounded() public { + uint256 floor_ = uint256(WINDOW) + 14 days; // literal: also pins RESOLUTION_TIMEOUT + vm.startPrank(safe); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setLockupSeconds(payee, 0); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setLockupSeconds(payee, floor_); + arb.setLockupSeconds(payee, floor_ + 1); + arb.setLockupSeconds(payee, 365 days); + assertEq(arb.MAX_LOCKUP(), 365 days); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setLockupSeconds(payee, 365 days + 1); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setLockupSeconds(payee, type(uint256).max); + // the floor follows the current challenge window + arb.setParams(2 days, MIN_BOND, BPS); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + arb.setLockupSeconds(payee, floor_ + 1); + arb.setLockupSeconds(payee, floor_ + 1 days + 1); + vm.stopPrank(); + assertEq(rp.lockupSeconds(payee), floor_ + 1 days + 1); } // ---- evidence ---- @@ -267,13 +296,25 @@ contract PredgeRefundArbiterV2Test is ArbiterBase { arb.rule{value: 1 ether}(pid2, payer, EV_PAYER, REFUND); } - function test_rule_refundNeedsLockupBeyondWindow() public { - uint256 pid = rp.recordPayment(payer, payee, 1 ether, block.timestamp + WINDOW); + /// @dev Olympix 5.2.1: the whole ruling (window + full resolution period) must end before release, for + /// BOTH directions (a Release can be overturned into a Refund). Boundary is exclusive. + function test_rule_needsLockupBeyondWindowAndResolution() public { + uint256 edge = block.timestamp + WINDOW + 14 days; // literal 14 days pins RESOLUTION_TIMEOUT + uint256 pid = rp.recordPayment(payer, payee, 1 ether, edge); _file(pid); - vm.prank(op); + vm.startPrank(op); vm.expectRevert(PredgeRefundArbiterV2.LockupTooShort.selector); arb.rule{value: 1 ether}(pid, payer, EV_PAYER, REFUND); - _rule(pid, payer, EV_PAYER, RELEASE, 1 ether); // a Release moves nothing, so the lockup does not matter + vm.expectRevert(PredgeRefundArbiterV2.LockupTooShort.selector); + arb.rule{value: 1 ether}(pid, payee, EV_PAYEE, RELEASE); + vm.stopPrank(); + uint256 pid2 = rp.recordPayment(payer, payee, 1 ether, edge + 1); + _file(pid2); + _rule(pid2, payer, EV_PAYER, REFUND, 1 ether); + uint256 pid3 = rp.recordPayment(payer, payee, 1 ether, edge + 1); + _file(pid3); + _rule(pid3, payee, EV_PAYEE, RELEASE, 1 ether); + assertEq(arb.RESOLUTION_TIMEOUT(), 14 days); } function test_bondScalesWithPayment() public { diff --git a/test/foundry-v2/PredgeValidatorBondV2.t.sol b/test/foundry-v2/PredgeValidatorBondV2.t.sol index d0a642a..abbe9a2 100644 --- a/test/foundry-v2/PredgeValidatorBondV2.t.sol +++ b/test/foundry-v2/PredgeValidatorBondV2.t.sol @@ -8,7 +8,7 @@ import {PullCredit} from "../../contracts/v2/access/PullCredit.sol"; import {Owned2Step, Operated} from "../../contracts/v2/access/PredgeAccess.sol"; contract PredgeValidatorBondV2Test is Base { - bytes32 internal constant EXPECTED = keccak256("expected output hash"); + bytes32 internal EXPECTED; // sha256(GOOD), set in setUp (sha256 is not a compile-time constant) bytes32 internal constant SALT = keccak256("salt"); bytes32 internal constant OTHER = keccak256("something else"); @@ -17,10 +17,12 @@ contract PredgeValidatorBondV2Test is Base { event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered); event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond); event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event Forfeited(bytes32 indexed requestHash, address indexed provider, uint256 bond); event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps); function setUp() public { _deployJobAndBond(); + EXPECTED = _good(); } // ---- construction / params (M-01, L-01) ---- @@ -73,11 +75,11 @@ contract PredgeValidatorBondV2Test is Base { } function test_paramChangeIsNotRetroactive() public { - (uint256 id, bytes32 spec) = _newJob(1, 10 ether); + (uint256 id, bytes32 spec) = _newJob(10 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 10 ether); vm.prank(safe); bond.setParams(30 days, MIN_BOND, BPS); // owner stretches the window afterwards - _submit(id, EXPECTED); + _deliver(id, GOOD); _score(spec, 100, EXPECTED, SALT); (,,,, , uint64 snap,,,,) = bond.stakes(spec); assertEq(snap, WINDOW, "stake keeps the window it was made with"); @@ -100,7 +102,7 @@ contract PredgeValidatorBondV2Test is Base { // ---- stakeAndCommit ---- function test_stake_ok() public { - (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + (uint256 id, bytes32 spec) = _newJob(2 ether, EXPECTED, SALT); vm.expectEmit(true, true, true, true); emit Committed(spec, id, op, _commitment(EXPECTED, SALT), 2 ether, WINDOW); _stake(spec, EXPECTED, SALT, id, 2 ether); @@ -117,7 +119,7 @@ contract PredgeValidatorBondV2Test is Base { } function test_stake_reverts() public { - (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + (uint256 id, bytes32 spec) = _newJob(2 ether, EXPECTED, SALT); bytes32 c = _commitment(EXPECTED, SALT); vm.prank(stranger); vm.expectRevert(Operated.NotOperator.selector); @@ -132,8 +134,15 @@ contract PredgeValidatorBondV2Test is Base { bond.stakeAndCommit{value: 2 ether - 1}(spec, c, id); vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); bond.stakeAndCommit{value: 2 ether}(spec, c, 999); // unknown job - vm.expectRevert(PredgeValidatorBondV2.JobNotBound.selector); + // Olympix 5.1.1: the operator cannot commit to a value the client did not fix in the specHash + vm.expectRevert(PredgeValidatorBondV2.CommitmentNotBound.selector); + bond.stakeAndCommit{value: 2 ether}(spec, _commitment(OTHER, SALT), id); + vm.expectRevert(PredgeValidatorBondV2.CommitmentNotBound.selector); bond.stakeAndCommit{value: 2 ether}(keccak256("other spec"), c, id); + // a correctly derived hash for ANOTHER commitment is still not this job's spec + bytes32 otherSpec = _bspec(OTHER, SALT); + vm.expectRevert(PredgeValidatorBondV2.JobNotBound.selector); + bond.stakeAndCommit{value: 2 ether}(otherSpec, _commitment(OTHER, SALT), id); bond.stakeAndCommit{value: 2 ether}(spec, c, id); vm.expectRevert(PredgeValidatorBondV2.AlreadyCommitted.selector); bond.stakeAndCommit{value: 2 ether}(spec, c, id); @@ -141,7 +150,7 @@ contract PredgeValidatorBondV2Test is Base { } function test_stake_wrongEvaluatorNotBound() public { - bytes32 spec = _spec(5); + bytes32 spec = _bspec(EXPECTED, SALT); vm.prank(client); uint256 id = job.createJob{value: 1 ether}(provider, stranger, spec, uint64(block.timestamp + 1 days)); vm.prank(op); @@ -150,7 +159,7 @@ contract PredgeValidatorBondV2Test is Base { } function test_stake_operatorCannotBeProvider() public { - bytes32 spec = _spec(5); + bytes32 spec = _bspec(EXPECTED, SALT); vm.prank(client); uint256 id = job.createJob{value: 1 ether}(op, address(bond), spec, uint64(block.timestamp + 1 days)); vm.prank(op); @@ -158,26 +167,52 @@ contract PredgeValidatorBondV2Test is Base { bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); } + /// @dev Olympix 5.2.3: the operator cannot be the client of a job it stakes on or scores. + function test_stake_operatorCannotBeClient() public { + bytes32 spec = _bspec(EXPECTED, SALT); + vm.prank(op); + uint256 id = job.createJob{value: 1 ether}(provider, address(bond), spec, uint64(block.timestamp + 1 days)); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.ClientIsOperator.selector); + bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); + } + + function test_score_rotatedOperatorCannotBeClient() public { + address newOp = makeAddr("newOp"); + vm.deal(newOp, 10 ether); + bytes32 spec = _bspec(EXPECTED, SALT); + vm.prank(newOp); + uint256 id = job.createJob{value: 1 ether}(provider, address(bond), spec, uint64(block.timestamp + 1 days)); + _stake(spec, EXPECTED, SALT, id, 1 ether); // old operator stakes: fine, it is not the client + _deliver(id, GOOD); + vm.prank(safe); + bond.setOperator(newOp); // the owner rotates to the job's client + vm.prank(newOp); + vm.expectRevert(PredgeValidatorBondV2.ClientIsOperator.selector); + bond.recordScore(spec, 0, EXPECTED, SALT, 0); + } + function test_stake_notOpenOnceSubmittedOrLapsed() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); _submit(id, OTHER); vm.prank(op); vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); - bond.stakeAndCommit{value: 1 ether}(spec, _commitment(OTHER, SALT), id); + bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); - (uint256 id2, bytes32 spec2) = _newJob(2, 1 ether); + bytes32 salt2 = keccak256("salt2"); + (uint256 id2, bytes32 spec2) = _newJob(1 ether, EXPECTED, salt2); vm.warp(block.timestamp + 7 days); vm.prank(op); vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); - bond.stakeAndCommit{value: 1 ether}(spec2, _commitment(OTHER, SALT), id2); + bond.stakeAndCommit{value: 1 ether}(spec2, _commitment(EXPECTED, salt2), id2); } // ---- recordScore ---- function test_score_pass_movesTheMoney() public { - (uint256 id, bytes32 spec) = _newJob(1, 4 ether); + (uint256 id, bytes32 spec) = _newJob(4 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 4 ether); - _submit(id, EXPECTED); + _deliver(id, GOOD); vm.expectEmit(true, true, false, true); emit Scored(spec, id, 100, EXPECTED, uint64(block.timestamp), uint64(block.timestamp) + WINDOW); _score(spec, 100, EXPECTED, SALT); @@ -191,7 +226,7 @@ contract PredgeValidatorBondV2Test is Base { } function test_score_fail_refundsClient() public { - (uint256 id, bytes32 spec) = _newJob(1, 4 ether); + (uint256 id, bytes32 spec) = _newJob(4 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 4 ether); _submit(id, OTHER); _score(spec, 0, EXPECTED, SALT); @@ -200,7 +235,7 @@ contract PredgeValidatorBondV2Test is Base { } function test_score_reverts() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); vm.startPrank(op); vm.expectRevert(PredgeValidatorBondV2.BadScore.selector); bond.recordScore(spec, 50, EXPECTED, SALT, 0); @@ -214,7 +249,7 @@ contract PredgeValidatorBondV2Test is Base { vm.expectRevert(PredgeValidatorBondV2.NotSubmitted.selector); bond.recordScore(spec, 100, EXPECTED, SALT, 0); vm.stopPrank(); - _submit(id, EXPECTED); + _deliver(id, GOOD); vm.prank(stranger); vm.expectRevert(Operated.NotOperator.selector); bond.recordScore(spec, 100, EXPECTED, SALT, 0); @@ -225,9 +260,9 @@ contract PredgeValidatorBondV2Test is Base { } function test_score_afterEvaluationWindowRevertsAtTheJob() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 1 ether); - _submit(id, EXPECTED); + _deliver(id, GOOD); vm.warp(block.timestamp + EVAL_WINDOW); vm.prank(op); vm.expectRevert(); // job.complete -> PastDeadline: a verdict after the deadline is impossible @@ -236,11 +271,14 @@ contract PredgeValidatorBondV2Test is Base { // ---- challenge ---- + /// @dev `delivered == EXPECTED` means the provider delivers the real bytes; anything else is a claimed hash. function _scored(uint256 n, bytes32 delivered, uint8 score, uint256 escrow) internal returns (uint256 id, bytes32 spec) { - (id, spec) = _newJob(n, escrow); - _stake(spec, EXPECTED, SALT, id, bond.requiredBond(escrow)); - _submit(id, delivered); - _score(spec, score, EXPECTED, SALT); + bytes32 salt = keccak256(abi.encode(SALT, n)); + (id, spec) = _newJob(escrow, EXPECTED, salt); + _stake(spec, EXPECTED, salt, id, bond.requiredBond(escrow)); + if (delivered == EXPECTED) _deliver(id, GOOD); + else _submit(id, delivered); + _score(spec, score, EXPECTED, salt); } function test_challenge_falsePass_paysClientNotCaller() public { @@ -319,7 +357,7 @@ contract PredgeValidatorBondV2Test is Base { } function test_release_whenJobExpiredWithoutVerdict() public { - (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + (uint256 id, bytes32 spec) = _newJob(2 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 2 ether); vm.expectRevert(PredgeValidatorBondV2.JobNotExpired.selector); bond.release(spec); @@ -336,14 +374,24 @@ contract PredgeValidatorBondV2Test is Base { } function test_release_afterSilentEvaluationWindow() public { - (uint256 id, bytes32 spec) = _newJob(1, 2 ether); + (uint256 id, bytes32 spec) = _newJob(2 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 2 ether); - _submit(id, EXPECTED); + _deliver(id, GOOD); vm.warp(block.timestamp + EVAL_WINDOW); job.claimRefund(id); + vm.expectEmit(true, true, false, true); + emit Forfeited(spec, provider, 2 ether); + bond.release(spec); + // Olympix 5.2.2: silence on delivered work forfeits the bond to the provider + assertEq(bond.credit(op), 0); + assertEq(bond.credit(provider), 2 ether); + assertEq(bond.totalBonded(), 0); + (, PredgeValidatorBondV2.Status st,,,,,, uint256 left,,) = bond.stakes(spec); + assertEq(uint8(st), uint8(PredgeValidatorBondV2.Status.Forfeited)); + assertEq(left, 0); + assertEq(job.credit(client), 2 ether, "documented timeout policy: the escrow still goes to the client"); + vm.expectRevert(PredgeValidatorBondV2.BadStatus.selector); bond.release(spec); - assertEq(bond.credit(op), 2 ether); - assertEq(job.credit(client), 2 ether); } function test_release_reverts() public { @@ -359,7 +407,7 @@ contract PredgeValidatorBondV2Test is Base { function test_payoutToRevertingStakerDoesNotBlock() public { // The harmed party cannot receive value: the slash still lands as credit and can be redirected. NoReceive bad = new NoReceive(); - bytes32 spec = _spec(9); + bytes32 spec = _bspec(EXPECTED, SALT); vm.deal(address(bad), 1 ether); vm.prank(address(bad)); uint256 id = job.createJob{value: 1 ether}(provider, address(bond), spec, uint64(block.timestamp + 1 days)); @@ -377,14 +425,16 @@ contract PredgeValidatorBondV2Test is Base { // ---- fuzz ---- - /// @dev Whatever the provider submits, the verdict is slashable iff it contradicts the hidden preimage. - function testFuzz_slashableIffContradicted(bytes32 expected, bytes32 delivered, bytes32 salt, bool claimPass) public { - vm.assume(expected != bytes32(0) && delivered != bytes32(0)); - (uint256 id, bytes32 spec) = _newJob(77, 1 ether); - _stake(spec, expected, salt, id, 1 ether); - _submit(id, delivered); - _score(spec, claimPass ? 100 : 0, expected, salt); - bool truth = delivered == expected; + /// @dev Whatever the provider submits, the verdict is slashable iff it contradicts the truth, where + /// "delivered" means bytes hashed on-chain whose hash equals the client's expected value. + function testFuzz_slashableIffContradicted(bytes32 salt, bool viaBytes, bool matching, bool claimPass, bytes32 junk) public { + vm.assume(junk != bytes32(0) && junk != EXPECTED); + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, salt); + _stake(spec, EXPECTED, salt, id, 1 ether); + if (viaBytes) _deliver(id, matching ? GOOD : abi.encode(junk)); + else _submit(id, matching ? EXPECTED : junk); // a claimed hash, even the right one, is not a delivery + _score(spec, claimPass ? 100 : 0, EXPECTED, salt); + bool truth = viaBytes && matching; bool lied = claimPass != truth; assertEq(bond.wouldSlash(spec), lied); if (lied) { @@ -399,10 +449,11 @@ contract PredgeValidatorBondV2Test is Base { /// @dev Self-challenge is never profitable: whoever calls challenge, the operator's net is <= 0 on a lie. function testFuzz_operatorNeverProfitsFromChallenging(uint96 escrow, bool claimPass) public { escrow = uint96(bound(escrow, 1, 100 ether)); - (uint256 id, bytes32 spec) = _newJob(88, escrow); + (uint256 id, bytes32 spec) = _newJob(escrow, EXPECTED, SALT); uint256 b = bond.requiredBond(escrow); _stake(spec, EXPECTED, SALT, id, b); - _submit(id, claimPass ? OTHER : EXPECTED); // the verdict below is the opposite of the truth + if (claimPass) _submit(id, OTHER); // the verdict below is the opposite of the truth + else _deliver(id, GOOD); _score(spec, claimPass ? 100 : 0, EXPECTED, SALT); vm.prank(op); bond.challenge(spec); diff --git a/test/foundry-v2/V1Findings.t.sol b/test/foundry-v2/V1Findings.t.sol index 5f82683..58ea619 100644 --- a/test/foundry-v2/V1Findings.t.sol +++ b/test/foundry-v2/V1Findings.t.sol @@ -14,24 +14,27 @@ import {PullCredit} from "../../contracts/v2/access/PullCredit.sol"; /// @notice v2 versions of the v1 test_BUG_ / test_finding_ tests (PR #2): each v1 attack must revert or be harmless. contract V1FindingsBondTest is Base { - bytes32 constant E = keccak256("expected"); + bytes32 internal E; // sha256(GOOD) bytes32 constant SALT = keccak256("salt"); bytes32 constant X = keccak256("other"); - function setUp() public { _deployJobAndBond(); } + function setUp() public { + _deployJobAndBond(); + E = _good(); + } // VB-01 / test_BUG_stake_allowedAfterDeliverableIsPublic, test_finding_postHocCommitMakesFalsePassUnslashable function test_fixed_VB01_stakeAfterDeliverableReverts() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _submit(id, X); vm.prank(op); vm.expectRevert(PredgeValidatorBondV2.NotOpen.selector); - bond.stakeAndCommit{value: 1 ether}(spec, keccak256(abi.encode(X, SALT)), id); + bond.stakeAndCommit{value: 1 ether}(spec, keccak256(abi.encode(E, SALT)), id); } // test_finding_scoreBeforeDeliveryThenReclaim function test_fixed_scoreBeforeDeliveryThenReclaim() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); vm.prank(op); vm.expectRevert(PredgeValidatorBondV2.NotSubmitted.selector); @@ -42,20 +45,21 @@ contract V1FindingsBondTest is Base { function test_fixed_noUint96Truncation() public { uint256 big = uint256(type(uint96).max) + 1 ether; vm.deal(client, big); + bytes32 spec = _bspec(E, SALT); vm.prank(client); - uint256 id = job.createJob{value: big}(provider, address(bond), _spec(1), uint64(block.timestamp + 1 days)); + uint256 id = job.createJob{value: big}(provider, address(bond), spec, uint64(block.timestamp + 1 days)); assertEq(job.getJob(id).escrow, big); assertEq(address(job).balance, big); vm.deal(op, big); - _stake(_spec(1), E, SALT, id, big); - (,,,,,,, uint256 b,,) = bond.stakes(_spec(1)); + _stake(spec, E, SALT, id, big); + (,,,,,,, uint256 b,,) = bond.stakes(spec); assertEq(b, big); assertEq(bond.totalBonded(), big); } // test_finding_rotationInheritsStakesAndBypassesProviderCheck (VB-04) function test_fixed_VB04_rotatedOperatorCannotScoreItsOwnJob() public { - bytes32 spec = _spec(1); + bytes32 spec = _bspec(E, SALT); address newOp = makeAddr("newOp"); vm.prank(client); uint256 id = job.createJob{value: 1 ether}(newOp, address(bond), spec, uint64(block.timestamp + 1 days)); @@ -71,9 +75,9 @@ contract V1FindingsBondTest is Base { // test_finding_operatorRotationRedirectsBond (L-04): the bond always goes back to the staker. function test_fixed_rotationDoesNotRedirectBond() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); - _submit(id, E); + _deliver(id, GOOD); _score(spec, 100, E, SALT); address newOp = makeAddr("newOp"); vm.prank(safe); @@ -85,27 +89,28 @@ contract V1FindingsBondTest is Base { assertEq(bond.credit(op), 1 ether); } - // test_finding_comparisonIsRawEquality (VB-05): documented and tested: raw equality of the stored 32 bytes. + // test_finding_comparisonIsRawEquality (VB-05): raw equality of the stored 32 bytes, and only for a hash + // the chain computed. After Olympix 5.1.1 a claimed hash with no bytes never counts as delivered. function test_fixed_VB05_rawEqualityOfStoredHash() public { bytes memory out = "output"; bytes32 h = sha256(out); - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, h, SALT); _stake(spec, h, SALT, id, 1 ether); - vm.prank(provider); - job.submit(id, h, out); + _deliver(id, out); _score(spec, 100, h, SALT); assertFalse(bond.wouldSlash(spec)); - // and a claimed hash that is not the sha256 of anything is just compared as-is - (uint256 id2, bytes32 spec2) = _newJob(2, 1 ether); - _stake(spec2, E, SALT, id2, 1 ether); + // a claimed hash equal to `expected`, with no bytes, is NOT a delivery: passing it is slashable + bytes32 salt2 = keccak256("salt2"); + (uint256 id2, bytes32 spec2) = _newJob(1 ether, E, salt2); + _stake(spec2, E, salt2, id2, 1 ether); _submit(id2, E); - _score(spec2, 100, E, SALT); - assertFalse(bond.wouldSlash(spec2)); + _score(spec2, 100, E, salt2); + assertTrue(bond.wouldSlash(spec2)); } // Intended v2 challenge semantics, explicit: allowed while now < scoredAt+window, impossible from then on. function test_challengeDeadlineBoundary() public { - (uint256 id, bytes32 spec) = _newJob(1, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); _submit(id, X); _score(spec, 100, E, SALT); @@ -213,7 +218,7 @@ contract V1FindingsMiscTest is Base { vm.expectRevert(PredgeAgentValidatorV2.NotAuthorized.selector); reg.validationRequest(op, 1, "squat", keccak256("spec")); // and the hash is domain separated, so a squat on another chain/registry cannot collide anyway - assertTrue(reg.requestHashFor(keccak256("spec")) != keccak256("spec")); + assertTrue(reg.requestHashFor(1, keccak256("spec")) != keccak256("spec")); } // AV-02 / test_BUG_commitLeadTime_zeroWhenSameBlock: documented, not a bug: zero is possible in the same block. From fd785517751f504382b1d653d798d2da50fe21da Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:06:18 +0300 Subject: [PATCH 13/19] test(v2): kill the surviving Olympix mutants Constructor guards and RESOLUTION_TIMEOUT are now asserted in named test functions with no setUp, so a mutant that makes every deployment revert fails a named test rather than only setUp(). The three storage->memory mutants in PredgeAgentValidatorV2 view functions are equivalent (read-only, gas only). --- test/foundry-v2/ConstructorGuards.t.sol | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 test/foundry-v2/ConstructorGuards.t.sol diff --git a/test/foundry-v2/ConstructorGuards.t.sol b/test/foundry-v2/ConstructorGuards.t.sol new file mode 100644 index 0000000..7ee3675 --- /dev/null +++ b/test/foundry-v2/ConstructorGuards.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {AgentJobV2} from "../../contracts/v2/AgentJobV2.sol"; +import {PredgeValidatorBondV2} from "../../contracts/v2/PredgeValidatorBondV2.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {MockRefundProtocolV2} from "../../contracts/v2/mocks/MockRefundProtocolV2.sol"; +import {Owned2Step} from "../../contracts/v2/access/PredgeAccess.sol"; + +/// @notice Constructor guards and constants, tested in named test functions with NO setUp. +/// @dev Olympix mutation run on c47ed42: four constructor-guard mutants (`== address(0)` -> `!=`, +/// `bondBps_ > MAX_BPS` -> `<`, `evaluationWindow_ == 0` -> `!=`) were reported as surviving although +/// they make every deployment revert. In our suites they only broke `setUp()`, which a runner that +/// attributes kills to named test functions does not count. Here every deployment happens inside a +/// test function, so such a mutant fails a named test. +contract ConstructorGuardsTest is Test { + address internal safe = makeAddr("safe"); + address internal op = makeAddr("operator"); + + function mkJob(uint64 w) external returns (address) { + return address(new AgentJobV2(w)); + } + + function mkBond(address j, uint16 bps) external returns (address) { + return address(new PredgeValidatorBondV2(safe, op, j, 1 hours, 1, bps)); + } + + function mkArb(address rp, uint16 bps) external returns (address) { + return address(new PredgeRefundArbiterV2(safe, op, rp, 1 hours, 1, bps)); + } + + function test_job_constructorGuard() public { + vm.expectRevert(AgentJobV2.BadWindow.selector); + this.mkJob(0); + AgentJobV2 j = AgentJobV2(this.mkJob(1)); + assertEq(j.evaluationWindow(), 1, "a non-zero window deploys"); + } + + function test_bond_constructorGuards() public { + address j = this.mkJob(1 days); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mkBond(address(0), 0); + PredgeValidatorBondV2 b = PredgeValidatorBondV2(this.mkBond(j, 0)); + assertEq(address(b.job()), j, "a non-zero job deploys"); + assertEq(PredgeValidatorBondV2(this.mkBond(j, 10_000)).bondBps(), 10_000); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + this.mkBond(j, 10_001); + } + + function test_arbiter_constructorGuards() public { + address rp = address(new MockRefundProtocolV2(address(this))); + vm.expectRevert(Owned2Step.ZeroAddress.selector); + this.mkArb(address(0), 0); + PredgeRefundArbiterV2 a = PredgeRefundArbiterV2(this.mkArb(rp, 0)); + assertEq(address(a.refundProtocol()), rp, "a non-zero refund protocol deploys"); + assertEq(PredgeRefundArbiterV2(this.mkArb(rp, 500)).bondBps(), 500, "bps below the cap deploys"); + assertEq(PredgeRefundArbiterV2(this.mkArb(rp, 10_000)).bondBps(), 10_000, "bps at the cap deploys"); + vm.expectRevert(PredgeRefundArbiterV2.BadParams.selector); + this.mkArb(rp, 10_001); + } + + function test_arbiter_constants() public { + address rp = address(new MockRefundProtocolV2(address(this))); + PredgeRefundArbiterV2 a = PredgeRefundArbiterV2(this.mkArb(rp, 0)); + assertEq(a.RESOLUTION_TIMEOUT(), 14 days); + assertEq(a.MIN_WINDOW(), 1 hours); + assertEq(a.MAX_WINDOW(), 30 days); + assertEq(a.MAX_LOCKUP(), 365 days); + } +} From c27b836a874412c3ce8df3c2e866cf4fc9f4037c Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:06:18 +0300 Subject: [PATCH 14/19] chore(v2): fork e2e and README for the Olympix fixes smoke-v2 uses the client-bound specHash, delivers bytes, checks specHashFor/requestHashFor(agentId, digest), and adds the 5.1.1 and 5.2.2 attack sequences. README states that the deployed Arc/Base v2 instances are the scanned code and do not include these fixes. --- README.md | 2 +- lib/smoke-v2.mjs | 60 +++++++++++++++++++++++++++++------------------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 159edb0..e906150 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Deployed 20-21 Oct 2026. The same six addresses on both chains. All six are veri - **Owner:** `0x9084f5000E07C7133D6dA5eE4f271AB6D1821144`, a **temporary single EOA**. A transfer to a Safe 2-of-3 multisig via `Ownable2Step` (`transferOwnership`, then `acceptOwnership`) is planned, target this week. Until then, treat the owner as a single point of control. - **Operator** (hot ops key, rotatable by the owner): `0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292`. - **Bond floor:** minimal, about $5 (5 USDC on Arc, 0.002 ETH on Base). No percentage of job value (bps = 0). It is a floor, not coverage of job value. -- **Status:** an external Olympix scan of v2 (commit `edd1c46`) was requested through Circle's Arc programme. Results are pending. The on-chain bonds are not claimed as a guarantee. The signed verdict API (`api.predge.io`) is unchanged and remains the product. +- **Status:** Olympix (BugPoCer, via Circle's Arc programme) scanned v2 at commit `c47ed42` on 23 Oct 2026: 2 High, 3 Medium, 12 Low. Fixes are on branch `fix/olympix-2026-10-23` and are **not deployed**: the Arc and Base instances above are the scanned code and are affected by the confirmed findings (the contracts are not upgradeable, so a fix means a redeployment). The on-chain bonds are not claimed as a guarantee. The signed verdict API (`api.predge.io`) is unchanged and remains the product. ### v1 (legacy): 16 mainnets diff --git a/lib/smoke-v2.mjs b/lib/smoke-v2.mjs index 52f15d0..c7c8094 100644 --- a/lib/smoke-v2.mjs +++ b/lib/smoke-v2.mjs @@ -42,7 +42,8 @@ export async function readOnlyV2(provider, plan, roles) { ok("arbiter.refundProtocol == mock", (await arb.refundProtocol()) === mock.target, await arb.refundProtocol()); ok("mock.arbiter == arbiter", (await mock.arbiter()) === arb.target, await mock.arbiter()); ok("arbiter params", (await arb.challengeWindow()) === BigInt(arbArgs[3]) && (await arb.minBond()) === BigInt(arbArgs[4]) && (await arb.bondBps()) === BigInt(arbArgs[5]), ""); - ok("registry.requestHashFor is domain separated", (await reg.requestHashFor(h("d"))) === keccak256(coder.encode(["uint256", "address", "bytes32"], [(await provider.getNetwork()).chainId, reg.target, h("d")])), ""); + ok("registry.requestHashFor is domain separated", (await reg.requestHashFor(1n, h("d"))) === keccak256(coder.encode(["uint256", "address", "uint256", "bytes32"], [(await provider.getNetwork()).chainId, reg.target, 1n, h("d")])), ""); + ok("bond.specHashFor is domain separated", (await bond.specHashFor(h("c"))) === keccak256(coder.encode(["uint256", "address", "bytes32"], [(await provider.getNetwork()).chainId, bond.target, h("c")])), ""); return checks; } @@ -76,31 +77,33 @@ export async function e2eV2(provider, plan, s, increaseTime) { const arb = at(plan, "PredgeRefundArbiterV2", op), mock = at(plan, "MockRefundProtocolV2", client), settle = at(plan, "PredgeSettlementV2", client); const now = async () => (await provider.getBlock("latest")).timestamp; const ESCROW = 10n ** 15n; - const mkSpec = async (n) => { - const digest = h(`predge/v2-fork/${n}/${Date.now()}`); - await send(`registry.validationRequest #${n}`, reg.validationRequest(op.address, 1n, "ipfs://v2-fork", digest)); - return reg.requestHashFor(digest); - }; + const output = Buffer.from("predge v2 fork e2e output"); + const OUT = "0x" + output.toString("hex"); + const E = sha256(output), X = h("not the expected output"); + const commit = (expected, salt) => keccak256(coder.encode(["bytes32", "bytes32"], [expected, salt])); + // The CLIENT fixes the expected output in the job's specHash (Olympix 5.1.1): spec = bond.specHashFor(commit(E, salt)). const newJob = async (n, escrow = ESCROW) => { - const spec = await mkSpec(n); + const salt = h(`predge/v2-fork/salt/${n}/${Date.now()}`); + const spec = await bond.specHashFor(commit(E, salt)); await send(`job.createJob #${n}`, job.createJob(await addr(worker), bond.target, spec, (await now()) + 7 * 86400, { value: escrow })); - return { id: await job.jobCount(), spec }; + return { id: await job.jobCount(), spec, salt }; }; - const stake = async (spec, id, expected, salt, escrow = ESCROW, label = "bond.stakeAndCommit") => { + const stake = async (j, escrow = ESCROW, label = "bond.stakeAndCommit") => { const need = await bond.requiredBond(escrow); - return send(label, bond.stakeAndCommit(spec, keccak256(coder.encode(["bytes32", "bytes32"], [expected, salt])), id, { value: need })); + return send(label, bond.stakeAndCommit(j.spec, commit(E, j.salt), j.id, { value: need })); }; - const output = Buffer.from("predge v2 fork e2e output"); - const E = sha256(output), SALT = h("v2-salt"), X = h("not the expected output"); // ---------------- honest path ---------------- + const digest = h(`predge/v2-fork/1/${Date.now()}`); + await send("registry.validationRequest", reg.validationRequest(op.address, 1n, "ipfs://v2-fork", digest)); + const req1 = await reg.requestHashFor(1n, digest); const j1 = await newJob(1); - await stake(j1.spec, j1.id, E, SALT); - await send("job.submit (bytes, sha256 on-chain)", job.connect(worker).submit(j1.id, E, "0x" + output.toString("hex"))); + await stake(j1); + await send("job.submit (bytes, sha256 on-chain)", job.connect(worker).submit(j1.id, E, OUT)); if (!(await job.getJob(j1.id)).hashedOnChain) throw new Error("deliverable was not hashed on-chain"); - await send("bond.recordScore(100) completes the job", bond.recordScore(j1.spec, 100, E, SALT, h("verdict"))); + await send("bond.recordScore(100) completes the job", bond.recordScore(j1.spec, 100, E, j1.salt, h("verdict"))); if (Number((await job.getJob(j1.id)).state) !== 3) throw new Error("job not Completed by the bond"); - await send("registry.validationResponse", reg.validationResponse(j1.spec, 100, "ipfs://v2-fork/verdict", h("verdict"), "predge/v2-fork")); + await send("registry.validationResponse", reg.validationResponse(req1, 100, "ipfs://v2-fork/verdict", h("verdict"), "predge/v2-fork")); if (await bond.wouldSlash(j1.spec)) throw new Error("honest verdict is slashable"); await mustRevert("challenge of an honest verdict", "VerdictHonest", async () => bond.connect(party).challenge.staticCall(j1.spec)); if ((await job.credit(await addr(worker))) !== ESCROW) throw new Error("provider is not credited the escrow"); @@ -112,37 +115,48 @@ export async function e2eV2(provider, plan, s, increaseTime) { // ---------------- attacks that worked on v1 ---------------- // H-01: verdict before submission, then wait the window out and reclaim. const j2 = await newJob(2); - await stake(j2.spec, j2.id, E, SALT); - await mustRevert("H-01 recordScore before the provider submits", "NotSubmitted", async () => bond.recordScore.staticCall(j2.spec, 100, E, SALT, ZeroHash)); + await mustRevert("Olympix 5.1.1 operator commits a value the client did not fix", "CommitmentNotBound", async () => bond.stakeAndCommit.staticCall(j2.spec, commit(X, j2.salt), j2.id, { value: ESCROW })); + await stake(j2); + await mustRevert("H-01 recordScore before the provider submits", "NotSubmitted", async () => bond.recordScore.staticCall(j2.spec, 100, E, j2.salt, ZeroHash)); await mustRevert("H-01 reclaim without a verdict", "NotScored", async () => bond.reclaim.staticCall(j2.spec)); // H-02: commit after the deliverable exists / swap the revealed value / provider copies public data. const j3 = await newJob(3); await send("job.submit (j3, wrong output)", job.connect(worker).submit(j3.id, X, "0x")); - await mustRevert("H-02 commit after submission", "NotOpen", async () => bond.stakeAndCommit.staticCall(j3.spec, keccak256(coder.encode(["bytes32", "bytes32"], [X, SALT])), j3.id, { value: ESCROW * 2n })); + await mustRevert("H-02 commit after submission", "NotOpen", async () => bond.stakeAndCommit.staticCall(j3.spec, commit(E, j3.salt), j3.id, { value: ESCROW * 2n })); const j4 = await newJob(4); - await stake(j4.spec, j4.id, E, SALT); + await stake(j4); const staked = await bond.stakes(j4.spec); if (staked.expected !== ZeroHash) throw new Error("expected is visible on-chain before the reveal"); await send("job.submit (j4, provider submits the public commitment)", job.connect(worker).submit(j4.id, staked.commitment, "0x")); - await send("bond.recordScore(0) honest fail", bond.recordScore(j4.spec, 0, E, SALT, h("fail"))); + await send("bond.recordScore(0) honest fail", bond.recordScore(j4.spec, 0, E, j4.salt, h("fail"))); await mustRevert("H-02 honest fail is not slashable", "VerdictHonest", async () => bond.connect(party).challenge.staticCall(j4.spec)); // M-02: a false pass is slashed to the harmed client; the challenger gets nothing. const j5 = await newJob(5); - await stake(j5.spec, j5.id, E, SALT); + await stake(j5); await send("job.submit (j5, wrong output)", job.connect(worker).submit(j5.id, X, "0x")); - await send("bond.recordScore(100) FALSE pass", bond.recordScore(j5.spec, 100, E, SALT, h("lie"))); + await send("bond.recordScore(100) FALSE pass", bond.recordScore(j5.spec, 100, E, j5.salt, h("lie"))); if (!(await bond.wouldSlash(j5.spec))) throw new Error("false pass not slashable"); await send("bond.challenge by a stranger", bond.connect(party).challenge(j5.spec)); const clientCredit = await bond.credit(await addr(client)), partyCredit = await bond.credit(await addr(party)); if (clientCredit < ESCROW || partyCredit !== 0n) throw new Error(`slash routing wrong: client ${clientCredit} challenger ${partyCredit}`); attacks.push({ attack: "M-02 slash pays the harmed client, challenger earns 0", blockedBy: "routing" }); await send("bond.withdraw (client)", bond.connect(client).withdraw()); + // Olympix 5.2.2: an operator that stays silent on delivered work forfeits its bond to the provider. + const j6 = await newJob(6); + await stake(j6); + await send("job.submit (j6, bytes, operator stays silent)", job.connect(worker).submit(j6.id, E, OUT)); // M-03: expiry. An unsubmitted job can be refunded after expiry; the bond is released with no verdict. await increaseTime(7 * 86400 + 1); await mustRevert("late submit after expiry", "PastDeadline", async () => job.connect(worker).submit.staticCall(j2.id, E, "0x")); await send("job.claimRefund (expired, never submitted)", job.connect(party).claimRefund(j2.id)); await send("bond.release (no verdict was given)", bond.connect(party).release(j2.spec)); + await send("job.claimRefund (j6, silent evaluator)", job.connect(party).claimRefund(j6.id)); + const workerBondBefore = await bond.credit(await addr(worker)); + await send("bond.release (j6, forfeited to the provider)", bond.connect(party).release(j6.spec)); + if ((await bond.credit(await addr(worker))) - workerBondBefore !== (await bond.requiredBond(ESCROW))) throw new Error("silent operator's bond not forfeited to the provider"); + attacks.push({ attack: "Olympix 5.2.2 silence on delivered work is free", blockedBy: "bond forfeited to provider" }); + await send("bond.withdraw (provider gets the forfeited bond)", bond.connect(worker).withdraw()); // reclaim of the honest stake after its window await send("bond.reclaim (j1, anyone, after window)", bond.connect(party).reclaim(j1.spec)); From f38c4b67a05fbe3e66cfa579083da089db57692f Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:04:18 +0300 Subject: [PATCH 15/19] feat(v2.1): recordScore refuses a verdict that contradicts on-chain evidence (option a) PredgeValidatorBondV2.recordScore now reverts VerdictContradictsEvidence unless score 100 <=> (hashedOnChain && deliverable == client-committed expected). A false verdict cannot be recorded at any bond size, including bondBps = 0 (closes Olympix 5.1.2 by construction). - remove the unreachable challenge path: challenge, wouldSlash, reclaim, Slashed/Reclaimed, slashCount, disputeWindow and its bounds - bond is credited back to the staker with the verdict; it backs liveness only (silence after a submission still forfeits it to the provider, 5.2.2) - add verdictFor(jobId, expected); constructor (owner, operator, job, minBond, bondBps) - tests: unit + fuzz proving no false verdict can be recorded, invariant everyRecordedVerdictIsTrue; 100% coverage on contracts/v2 - set 2026-10-23-v2.1 with nonceBase 6: the 20.10 deployer creates v2.1 at nonces 6..11, same addresses on Arc and Base - fork e2e updated (false pass / false fail / leaked hash must revert) - scripts/v2-post-deploy.mjs: archive v2.0 records as *.v2-0.json, retry verification, mark v2.0 addresses deprecated in README --- README.md | 13 +- config/set.v2.json | 8 +- contracts/v2/AgentJobV2.sol | 4 +- contracts/v2/PredgeAgentValidatorV2.sol | 3 +- contracts/v2/PredgeValidatorBondV2.sol | 191 +++++--------- deploy.mjs | 7 +- deployments/fork-v2/arc.json | 261 ++++++++++-------- deployments/fork-v2/base.json | 271 +++++++++++-------- lib/plan.mjs | 9 +- lib/smoke-v2.mjs | 43 +-- scripts/v2-post-deploy.mjs | 121 +++++++++ test/foundry-v2/Base.sol | 2 +- test/foundry-v2/ConstructorGuards.t.sol | 2 +- test/foundry-v2/Invariants.t.sol | 57 ++-- test/foundry-v2/OlympixRegressions.t.sol | 70 ++--- test/foundry-v2/Pocs.t.sol | 62 ++--- test/foundry-v2/PredgeValidatorBondV2.t.sol | 279 +++++++++----------- test/foundry-v2/V1Findings.t.sol | 41 +-- test/plan-v2.test.mjs | 18 +- 19 files changed, 805 insertions(+), 657 deletions(-) create mode 100644 scripts/v2-post-deploy.mjs diff --git a/README.md b/README.md index e906150..e6ddc8f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ CONFIRM_MAINNET=base node deploy.mjs --chain base --broadcast # the real deplo `contracts/v2/` is the redesigned set that fixes the 20.10.2026 self-audit findings (H-01..H-03, M-01..M-05, L-01..L-07). v2 is deployed on Arc and Base only (see [Deployments](#deployments)). The 16-chain set is v1, legacy, and is untouched. Design, finding-by-finding mapping and owner actions: `SELF-AUDIT-2026-10-20/v2-design.md` (Downloads). +**v2.1 (23.10.2026, set `2026-10-23-v2.1`)** adds the fixes for the Olympix BugPoCer scan of `c47ed42` and the owner's option (a) +for the validator bond: `PredgeValidatorBondV2.recordScore` refuses any verdict that contradicts the on-chain evidence and reverts +`VerdictContradictsEvidence`. A pass (100) needs the provider to have submitted the output bytes (hashed on-chain by the job) with a +hash equal to the `expected` the client committed in the job's specHash; a fail (0) needs the opposite. A false verdict therefore +cannot be recorded at any bond size, including `bondBps = 0`. `challenge`, `wouldSlash`, `reclaim` and the dispute window are gone +(nothing is left to challenge); the bond is credited back to the staker with the verdict and only backs liveness: an operator that +stays silent on a submitted job forfeits it to the provider (`release`). `verdictFor(jobId, expected)` returns the only score +`recordScore` will accept. Constructor: `(owner, operator, job, minBond, bondBps)`; `setParams(minBond, bondBps)`. +The same deployer creates v2.1 at nonces 6..11 (`nonceBase` in `config/set.v2.json`), again the same addresses on Arc and Base. + ``` FOUNDRY_CONFIG=foundry-v2.toml forge test # unit + fuzz + invariant + PoC tests (v1 attacks run against v1 and v2) FOUNDRY_CONFIG=foundry-v2.toml forge coverage --no-match-coverage "(test/|foundry-lib|contracts/[A-Za-z]+\.sol)" @@ -22,6 +32,7 @@ node deploy.mjs --chain base --set v2 --fork # anvil fork of Base: node scripts/fork-all.mjs --set v2 --no-e2e # gas / cost on every chain node scripts/mutate-v2.mjs # mutation smoke run node scripts/v2-params.mjs 5 # regenerate per-chain bond floors (about $5 each) from live prices +node scripts/v2-post-deploy.mjs --chains arc,base # after a v2.1 broadcast: archive v2.0 records (*.v2-0.json), verify leftovers, README ``` Real v2 runs (`--set v2` without `--fork`) refuse to start until the owner fills `config/v2.config.json` (new deployer address, the Safe, the ops key) @@ -105,7 +116,7 @@ Deployed 20-21 Oct 2026. The same six addresses on both chains. All six are veri - **Owner:** `0x9084f5000E07C7133D6dA5eE4f271AB6D1821144`, a **temporary single EOA**. A transfer to a Safe 2-of-3 multisig via `Ownable2Step` (`transferOwnership`, then `acceptOwnership`) is planned, target this week. Until then, treat the owner as a single point of control. - **Operator** (hot ops key, rotatable by the owner): `0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292`. - **Bond floor:** minimal, about $5 (5 USDC on Arc, 0.002 ETH on Base). No percentage of job value (bps = 0). It is a floor, not coverage of job value. -- **Status:** Olympix (BugPoCer, via Circle's Arc programme) scanned v2 at commit `c47ed42` on 23 Oct 2026: 2 High, 3 Medium, 12 Low. Fixes are on branch `fix/olympix-2026-10-23` and are **not deployed**: the Arc and Base instances above are the scanned code and are affected by the confirmed findings (the contracts are not upgradeable, so a fix means a redeployment). The on-chain bonds are not claimed as a guarantee. The signed verdict API (`api.predge.io`) is unchanged and remains the product. +- **Status:** Olympix (BugPoCer, via Circle's Arc programme) scanned v2 at commit `c47ed42` on 23 Oct 2026: 2 High, 3 Medium, 12 Low. The fixes and the option (a) verdict check are v2.1 (see above) and are **not deployed yet**: the Arc and Base instances above are the scanned code and are affected by the confirmed findings (the contracts are not upgradeable, so a fix means a redeployment). v2.1 will land at nonces 6..11 of the same deployer; `scripts/v2-post-deploy.mjs` then marks the addresses above deprecated. The on-chain bonds are not claimed as a guarantee. The signed verdict API (`api.predge.io`) is unchanged and remains the product. ### v1 (legacy): 16 mainnets diff --git a/config/set.v2.json b/config/set.v2.json index 98ce973..cabe371 100644 --- a/config/set.v2.json +++ b/config/set.v2.json @@ -1,8 +1,8 @@ { - "_comment": "The Predge v2 set (contracts/v2). Same rules as set.json: contract i is created at deployer nonce i, so a fresh v2 deployer + this order gives the same addresses on every chain. Never reorder; append only. $owner is the Safe, $operator the hot ops key, $deployer holds NO role (it only pays gas). Chain-specific values (minBond, arbiterMinBond) come from config/v2.chains.json and do not change the addresses. Owner decision 20.10.2026: bonds MINIMAL, bondBps = arbiterBondBps = 0, so every bond is exactly the per-chain floor (~$5); the bps mechanism stays in the contracts and the Safe can raise it later with setParams (a paid bonded-verdict tier).", - "version": "2026-10-20-v2", + "_comment": "The Predge v2 set (contracts/v2). Same rules as set.json: contract i is created at deployer nonce nonceBase + i, so a fresh v2 deployer + this order gives the same addresses on every chain. Never reorder; append only. $owner is the Safe, $operator the hot ops key, $deployer holds NO role (it only pays gas). Chain-specific values (minBond, arbiterMinBond) come from config/v2.chains.json and do not change the addresses. Owner decision 20.10.2026: bonds MINIMAL, bondBps = arbiterBondBps = 0, so every bond is exactly the per-chain floor (~$5); the bps mechanism stays in the contracts and the Safe can raise it later with setParams (a paid bonded-verdict tier). 23.10.2026 (v2.1, Olympix fixes + option a): the v2 deployer already used nonces 0..5 on Arc and Base for the 20.10 set, so v2.1 is created at nonces 6..11 (nonceBase 6), again the same addresses on both chains; the validator bond has no dispute window any more (recordScore refuses a false verdict, so nothing is left to challenge).", + "version": "2026-10-23-v2.1", + "nonceBase": 6, "params": { - "disputeWindow": "86400", "challengeWindow": "86400", "evaluationWindow": "259200", "bondBps": "0", @@ -12,7 +12,7 @@ "plan": [ { "name": "PredgeAgentValidatorV2", "file": "v2/PredgeAgentValidatorV2", "args": ["$owner", "$operator", "$identityRegistry"], "role": "ERC-8004 validation registry, request-before-response, domain-separated request hashes" }, { "name": "AgentJobV2", "file": "v2/AgentJobV2", "args": ["$evaluationWindow"], "role": "ERC-8183-style job escrow with expiry and pull payments; no owner" }, - { "name": "PredgeValidatorBondV2", "file": "v2/PredgeValidatorBondV2", "args": ["$owner", "$operator", "$AgentJobV2", "$disputeWindow", "$minBond", "$bondBps"], "role": "commit-reveal validator bond; the evaluator of AgentJobV2" }, + { "name": "PredgeValidatorBondV2", "file": "v2/PredgeValidatorBondV2", "args": ["$owner", "$operator", "$AgentJobV2", "$minBond", "$bondBps"], "role": "commit-reveal validator bond; the evaluator of AgentJobV2; records only verdicts that match the on-chain evidence" }, { "name": "PredgeSettlementV2", "file": "v2/PredgeSettlementV2", "args": ["$owner"], "role": "pay-per-route receipts in the native token" }, { "name": "MockRefundProtocolV2", "file": "v2/mocks/MockRefundProtocolV2", "args": ["$PredgeRefundArbiterV2"], "role": "DEMO ONLY stand-in Refund Protocol (moves no money); out of audit scope" }, { "name": "PredgeRefundArbiterV2", "file": "v2/PredgeRefundArbiterV2", "args": ["$owner", "$operator", "$MockRefundProtocolV2", "$challengeWindow", "$arbiterMinBond", "$arbiterBondBps"], "role": "optimistic bonded refund arbiter; Safe is the court of last resort" } diff --git a/contracts/v2/AgentJobV2.sol b/contracts/v2/AgentJobV2.sol index 96451c1..9f65f18 100644 --- a/contracts/v2/AgentJobV2.sol +++ b/contracts/v2/AgentJobV2.sol @@ -8,8 +8,8 @@ import {PullCredit} from "./access/PullCredit.sol"; /// @notice A minimal ERC-8183-style agent job with escrowed native value and an evaluator named at /// creation. The evaluator may be neither the client nor the provider (a trivial check: it /// cannot detect two addresses controlled by one party). In Predge's deployment the evaluator is the -/// `PredgeValidatorBondV2` contract, so the evaluator's verdict is backed by a bond and -/// moves the money in the same transaction. +/// `PredgeValidatorBondV2` contract, which only accepts a verdict that agrees with the on-chain +/// deliverable hash and moves the money in the same transaction. /// /// Lifecycle: /// createJob (client escrows value, names provider, evaluator, spec and an expiry) diff --git a/contracts/v2/PredgeAgentValidatorV2.sol b/contracts/v2/PredgeAgentValidatorV2.sol index edcb51d..beb590f 100644 --- a/contracts/v2/PredgeAgentValidatorV2.sol +++ b/contracts/v2/PredgeAgentValidatorV2.sol @@ -17,7 +17,8 @@ interface IIdentityOwner { /// * It does not verify the ed25519 attestation. The operator verifies it off-chain and /// commits `responseHash`, the keccak256 of the signed bytes. /// * It is not linked to `PredgeValidatorBondV2`. A registry response is a statement; the -/// bonded, slashable verdict is the bond's `Scored` event and the job it settled. +/// bonded verdict, checked against the on-chain evidence, is the bond's `Scored` event and the +/// job it settled. /// Consumers that want the bonded one must read the bond. /// /// Authorisation (fixes the v1 permissionless `validationRequest`): a request may be made diff --git a/contracts/v2/PredgeValidatorBondV2.sol b/contracts/v2/PredgeValidatorBondV2.sol index ce3927b..dd799aa 100644 --- a/contracts/v2/PredgeValidatorBondV2.sol +++ b/contracts/v2/PredgeValidatorBondV2.sol @@ -8,58 +8,54 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; /// @title PredgeValidatorBondV2 /// @notice Capital behind a verdict, and the EVALUATOR of an `AgentJobV2`. The job names this /// contract as its evaluator, so the verdict recorded here is the thing that moves the -/// escrow, and the same transaction puts the operator's bond at risk. +/// escrow. /// /// The acceptance test is deterministic: the job's deliverable counts as DELIVERED only if the /// provider submitted the output bytes (so the job computed `sha256(output)` on-chain, /// `hashedOnChain == true`) and that hash equals, by RAW EQUALITY, the `expected` value the /// CLIENT fixed when it created the job. A hash the provider merely claimed, with no bytes, never -/// counts as delivered. Nothing is hashed again here. That makes a false verdict provable on-chain -/// with no judge, by comparing a hash the chain computed with a value the client committed to. +/// counts as delivered. Nothing is hashed again here. +/// +/// A FALSE VERDICT CANNOT BE RECORDED (owner decision 23.10.2026, option a). Both sides of the +/// comparison are on-chain at `recordScore`: the hash the chain computed and the `expected` the +/// client committed to. So `recordScore` checks the score against them and reverts +/// `VerdictContradictsEvidence` unless score 100 <=> delivered and score 0 <=> not delivered. This +/// holds for any bond size, including `bondBps = 0`, and needs no challenger, no watcher and no +/// dispute window: there is nothing to challenge, so there is no `challenge` and the bond is +/// credited back to the staker in the same transaction as the verdict. /// /// Who fixes `expected` (Olympix 5.1.1): the client. A job evaluated by this contract must have /// `specHash == specHashFor(keccak256(abi.encode(expected, salt)))`, which the client chooses at /// `createJob`. The operator learns `(expected, salt)` from the client off-chain, checks it against -/// the job before staking, and can only commit to that value: it cannot pick an `expected` of its -/// own that a colluding provider then matches. +/// the job before staking, and can only commit to that value. /// /// Flow: /// 1. `stakeAndCommit(requestHash, commitment, jobId)` (operator). Allowed ONLY while /// the job is Open, i.e. before any deliverable exists. `commitment` is /// `keccak256(abi.encode(expected, salt))` and must be the one the client bound into the -/// job's specHash (`requestHash == specHashFor(commitment)`): `expected` stays hidden, the -/// operator cannot choose it, and cannot change it after seeing the deliverable. The bond -/// must be at least `max(minBond, bondBps * escrow / 10000)`. -/// 2. The provider submits to the job. +/// job's specHash (`requestHash == specHashFor(commitment)`). The bond must be at least +/// `max(minBond, bondBps * escrow / 10000)`. +/// 2. The provider submits to the job (with the output bytes, or the job can only fail). /// 3. `recordScore(requestHash, score, expected, salt, reason)` (operator). Requires the -/// job to be Submitted and the reveal to match the commitment. It then calls -/// `complete` (score 100) or `reject` (score 0) on the job, so the verdict and the -/// money move together. The dispute window starts at `scoredAt`, which is always at -/// or after the job's `submittedAt` because scoring requires a submission. -/// 4. `challenge(requestHash)` (anyone) strictly BEFORE `scoredAt + window`; from that instant -/// `reclaim` is possible and `challenge` reverts (no overlap, and no open-ended challenge). Slashes iff the -/// verdict contradicts the deliverable: 100 but not delivered (no on-chain hash, or a hash that -/// is not `expected`), or 0 although delivered (on-chain hash equal to `expected`). The whole bond is CREDITED TO THE HARMED PARTY (the -/// job's client on a false pass, its provider on a false fail), never to the caller, -/// so a self-challenge pays the operator nothing and a front-runner earns nothing. -/// 5. `reclaim(requestHash)` (anyone) once the window has closed unchallenged. The bond is -/// credited to the staker recorded at stake time. -/// 6. `release(requestHash)` (anyone) if the job expired with no verdict. If the provider never +/// job to be Submitted, the reveal to match the commitment, and the score to agree with +/// the on-chain evidence. It then calls `complete` (score 100) or `reject` (score 0) on +/// the job, so the verdict and the money move together, and credits the bond back to the +/// staker. +/// 4. `release(requestHash)` (anyone) if the job expired with no verdict. If the provider never /// submitted, nothing was owed and the bond goes back to the staker. If the provider DID submit /// and the operator stayed silent until the evaluation window closed (Olympix 5.2.2), the -/// bond is FORFEITED to the provider: silence on delivered work is not free. +/// bond is FORFEITED to the provider: silence on submitted work is not free. +/// +/// What the bond is for, after option (a): LIVENESS, not correctness. Correctness is enforced by +/// `recordScore`. The bond is what the operator loses if it stays silent on a submitted job. /// /// Honest limits, stated so they are not mistaken for guarantees: -/// * The bond only covers `bondBps` of the escrow. At `bondBps = 10000` a colluding -/// operator and provider cannot profit from a false pass; below that they can, -/// up to the uncovered fraction. With `bondBps = 0` the bond is a flat floor. /// * The client defines `expected`. A provider should obtain `(expected, salt)` from the client /// and check it against the job's specHash before working; knowing `expected` does not /// let anyone pass without the real bytes, because only an on-chain hash counts. +/// * A pass needs the output bytes in calldata; large outputs cost the provider gas. /// * On silence the provider receives the bond, not the escrow: the job's timeout policy -/// still refunds the escrow to the client. -/// * A false verdict is only slashable if the harmed party (or someone for them) calls -/// `challenge` inside the window. Nobody is paid to watch except the harmed party. +/// still refunds the escrow to the client. With `bondBps = 0` the bond is a flat floor. /// * The operator's capital is locked until the job ends. A client picks the job's /// `expiredAt`, so an operator should read it before staking. /// * Only the deterministic `sha256(deliverable) == expected` test is covered. Subjective @@ -67,16 +63,13 @@ import {IAgentJobV2} from "./interfaces/IAgentJobV2.sol"; /// /// @dev Native value only, in the chain's native wei. Roles: `owner` (Safe) sets bounded /// parameters and rotates `operator`; `operator` (hot key) stakes and scores. Parameters -/// are not retroactive: the dispute window is snapshotted into each stake and the bond -/// amount is fixed when staked. Payouts are pull-based. The contract holds, at all times, -/// at least `totalBonded + totalCredit`. +/// are not retroactive: the bond amount is fixed when staked. Payouts are pull-based. The +/// contract holds, at all times, at least `totalBonded + totalCredit`. contract PredgeValidatorBondV2 is Operated, PullCredit { enum Status { None, Committed, // bond posted, no verdict yet - Scored, // verdict recorded, window running or elapsed, bond still held - Slashed, // bond credited to the harmed party (final) - Reclaimed, // bond credited back to the staker after the window (final) + Scored, // verdict recorded (it matches the evidence), bond credited back to the staker (final) Released, // job expired before any submission, bond credited back to the staker (final) Forfeited // job expired after a submission with no verdict, bond credited to the provider (final) } @@ -84,48 +77,39 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { struct Stake { address staker; // the operator key that posted the bond; the only party it is ever paid back to Status status; - uint8 score; // 100 delivered / 0 failed, set when scored + uint8 score; // 100 delivered / 0 not delivered, set when scored uint64 stakedAt; uint64 scoredAt; // set when scored; always >= the job's submittedAt - uint64 window; // snapshot of `disputeWindow` at stake time uint256 jobId; - uint256 bond; + uint256 bond; // the posted bond while Committed; zero once closed bytes32 commitment; // keccak256(abi.encode(expected, salt)) bytes32 expected; // zero until revealed at recordScore } - uint64 public constant MIN_WINDOW = 1 hours; - uint64 public constant MAX_WINDOW = 30 days; uint16 public constant MAX_BPS = 10_000; IAgentJobV2 public immutable job; - /// @notice Seconds a verdict stays challengeable. Applies to stakes made after a change. - uint64 public disputeWindow; /// @notice Absolute floor for a bond, in native wei. Applies to stakes made after a change. uint256 public minBond; /// @notice Bond as basis points of the job escrow (10000 = 100%). Applies to future stakes. uint16 public bondBps; - /// @notice Sum of bonds currently held (Committed or Scored). + /// @notice Sum of bonds currently held (Committed). uint256 public totalBonded; - uint64 public slashCount; mapping(bytes32 => Stake) public stakes; - event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond, uint64 window); - event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, uint64 scoredAt, uint64 windowEnd); - event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered); - event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond); + event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, address staker, uint256 bond); event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond); event Forfeited(bytes32 indexed requestHash, address indexed provider, uint256 bond); - event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps); + event ParamsUpdated(uint256 minBond, uint16 bondBps); error ZeroHash(); error BadParams(); error AlreadyCommitted(); error NotCommitted(); - error NotScored(); error BadStatus(); error NotOpen(); error NotSubmitted(); @@ -136,29 +120,26 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { error BondTooSmall(uint256 required); error BadScore(); error BadReveal(); - error VerdictHonest(); - error WindowOpen(); - error WindowClosed(); + error VerdictContradictsEvidence(); error JobNotExpired(); - /// @param owner_ the Safe: sets bounded parameters, rotates the operator - /// @param operator_ the hot key that stakes and scores - /// @param job_ the `AgentJobV2` this bond evaluates; immutable - /// @param disputeWindow_ seconds a verdict stays challengeable, within [MIN_WINDOW, MAX_WINDOW] - /// @param minBond_ absolute floor for a bond, non-zero - /// @param bondBps_ bond as basis points of escrow, at most 10000 - constructor(address owner_, address operator_, address job_, uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) + /// @param owner_ the Safe: sets bounded parameters, rotates the operator + /// @param operator_ the hot key that stakes and scores + /// @param job_ the `AgentJobV2` this bond evaluates; immutable + /// @param minBond_ absolute floor for a bond, non-zero + /// @param bondBps_ bond as basis points of escrow, at most 10000 + constructor(address owner_, address operator_, address job_, uint256 minBond_, uint16 bondBps_) Operated(owner_, operator_) { if (job_ == address(0)) revert ZeroAddress(); job = IAgentJobV2(job_); - _setParams(disputeWindow_, minBond_, bondBps_); + _setParams(minBond_, bondBps_); } /// @notice Owner-only parameter change, bounded and NOT retroactive: existing stakes keep - /// the window and bond they were made with. - function setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner { - _setParams(disputeWindow_, minBond_, bondBps_); + /// the bond they were made with. + function setParams(uint256 minBond_, uint16 bondBps_) external onlyOwner { + _setParams(minBond_, bondBps_); } /// @notice The bond a stake against `escrow` must post right now: `max(minBond, ceil(escrow * bondBps / 10000))`. @@ -173,6 +154,14 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { return keccak256(abi.encode(block.chainid, address(this), commitment)); } + /// @notice The only verdict `recordScore` accepts for job `jobId` against `expected`: 100 if the + /// provider submitted the bytes and their on-chain sha256 equals `expected`, otherwise 0. + /// Meaningful once the job is Submitted. + function verdictFor(uint256 jobId, bytes32 expected) public view returns (uint8) { + IAgentJobV2.Job memory j = job.getJob(jobId); + return _delivered(j, expected) ? 100 : 0; + } + /// @notice Post the bond and commit the hidden acceptance test, while the job is still Open. /// @param requestHash the job's `specHash`; must equal `specHashFor(commitment)` /// @param commitment `keccak256(abi.encode(expected, salt))` as fixed by the client in the specHash @@ -185,10 +174,10 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { if (requestHash != specHashFor(commitment)) revert CommitmentNotBound(); IAgentJobV2.Job memory j = job.getJob(jobId); - // Only while Open and unexpired: once a deliverable exists, a commitment could be made to match it. + // Only while Open and unexpired: the stake must exist before the work does. if (j.state != IAgentJobV2.State.Open || block.timestamp >= j.expiredAt) revert NotOpen(); if (j.specHash != requestHash || j.evaluator != address(this)) revert JobNotBound(); - // A provider that is also the operator writes both sides of the comparison. + // A provider that is also the operator would decide whether to deliver and when to stay silent. if (j.provider == msg.sender) revert ProviderIsOperator(); // A client that is also the operator would judge its own job (Olympix 5.2.3). if (j.client == msg.sender) revert ClientIsOperator(); @@ -198,17 +187,17 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { s.staker = msg.sender; s.status = Status.Committed; s.stakedAt = uint64(block.timestamp); - s.window = disputeWindow; s.jobId = jobId; s.bond = msg.value; s.commitment = commitment; totalBonded += msg.value; - emit Committed(requestHash, jobId, msg.sender, commitment, msg.value, s.window); + emit Committed(requestHash, jobId, msg.sender, commitment, msg.value); } - /// @notice Reveal the committed test, record the verdict, and settle the job with it. - /// @param score 100 = delivered (job `complete`s, provider is credited), 0 = failed - /// (job `reject`s, client is credited) + /// @notice Reveal the committed test, record the verdict, settle the job with it, and credit the + /// bond back to the staker. Reverts `VerdictContradictsEvidence` on a false verdict. + /// @param score 100 = delivered (job `complete`s, provider is credited), 0 = not delivered + /// (job `reject`s, client is credited). Must equal `verdictFor(jobId, expected)`. /// @param expected,salt the preimage of the commitment /// @param reason the attestation commitment passed to the job (Predge: keccak256 of the signed verdict) function recordScore(bytes32 requestHash, uint8 score, bytes32 expected, bytes32 salt, bytes32 reason) @@ -221,53 +210,25 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { if (s.status != Status.Committed) revert BadStatus(); if (keccak256(abi.encode(expected, salt)) != s.commitment) revert BadReveal(); // Re-checked here, not only at stake time: after an operator rotation the NEW operator must not be able - // to score a job it is the provider of, while inheriting the old operator's stake. + // to score a job it is a party to, while inheriting the old operator's stake. IAgentJobV2.Job memory j = job.getJob(s.jobId); if (j.provider == msg.sender) revert ProviderIsOperator(); if (j.client == msg.sender) revert ClientIsOperator(); - // Scoring needs a submission, so `scoredAt >= submittedAt` and the dispute window can never - // start before the deliverable it is judged against exists. + // Scoring needs a submission: the verdict is judged against a deliverable that exists. if (j.state != IAgentJobV2.State.Submitted) revert NotSubmitted(); + // Option (a): the evidence decides. A pass needs an on-chain hash equal to `expected`; a fail needs the opposite. + if ((score == 100) != _delivered(j, expected)) revert VerdictContradictsEvidence(); s.expected = expected; s.score = score; s.scoredAt = uint64(block.timestamp); - s.status = Status.Scored; - emit Scored(requestHash, s.jobId, score, expected, s.scoredAt, s.scoredAt + s.window); + uint256 bond = _close(s, Status.Scored, s.staker); + emit Scored(requestHash, s.jobId, score, expected, s.staker, bond); if (score == 100) job.complete(s.jobId, reason, ""); else job.reject(s.jobId, reason, ""); } - /// @notice Prove the recorded verdict contradicts the deliverable and slash the bond to the - /// harmed party. Anyone may call; the caller receives nothing. Takes no evidence. - function challenge(bytes32 requestHash) external { - Stake storage s = stakes[requestHash]; - if (s.status != Status.Scored) revert NotScored(); - if (block.timestamp >= uint256(s.scoredAt) + s.window) revert WindowClosed(); - IAgentJobV2.Job memory j = job.getJob(s.jobId); - if (!_lied(s, j)) revert VerdictHonest(); - - address harmed = s.score == 100 ? j.client : j.provider; - uint256 bond = s.bond; - s.status = Status.Slashed; - s.bond = 0; - totalBonded -= bond; - slashCount += 1; - _addCredit(harmed, bond); - emit Slashed(requestHash, msg.sender, harmed, bond, s.score, j.deliverable); - } - - /// @notice Credit the bond back to its staker once the dispute window has closed unchallenged. - /// Anyone may trigger it; the credit always goes to the staker. - function reclaim(bytes32 requestHash) external { - Stake storage s = stakes[requestHash]; - if (s.status != Status.Scored) revert NotScored(); - if (block.timestamp < uint256(s.scoredAt) + s.window) revert WindowOpen(); - uint256 bond = _close(s, Status.Reclaimed, s.staker); - emit Reclaimed(requestHash, s.staker, bond); - } - /// @notice Close a stake whose job expired without a verdict. Anyone may trigger it. If the provider /// never submitted, the bond is credited back to the staker. If the provider submitted and the /// operator let the evaluation window close without scoring, the bond is credited to the @@ -287,17 +248,9 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { } } - /// @notice Is this verdict slashable right now? Free check before spending gas. - function wouldSlash(bytes32 requestHash) external view returns (bool) { - Stake storage s = stakes[requestHash]; - if (s.status != Status.Scored || block.timestamp >= uint256(s.scoredAt) + s.window) return false; - return _lied(s, job.getJob(s.jobId)); - } - /// @dev Delivered means: the bytes were hashed on-chain AND the hash equals the client's `expected`. - function _lied(Stake storage s, IAgentJobV2.Job memory j) private view returns (bool) { - bool delivered = j.hashedOnChain && j.deliverable == s.expected; - return (s.score == 100 && !delivered) || (s.score == 0 && delivered); + function _delivered(IAgentJobV2.Job memory j, bytes32 expected) private pure returns (bool) { + return j.hashedOnChain && j.deliverable == expected; } function _close(Stake storage s, Status to, address payee) private returns (uint256 bond) { @@ -308,12 +261,10 @@ contract PredgeValidatorBondV2 is Operated, PullCredit { _addCredit(payee, bond); } - function _setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) private { - if (disputeWindow_ < MIN_WINDOW || disputeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS) - revert BadParams(); - disputeWindow = disputeWindow_; + function _setParams(uint256 minBond_, uint16 bondBps_) private { + if (minBond_ == 0 || bondBps_ > MAX_BPS) revert BadParams(); minBond = minBond_; bondBps = bondBps_; - emit ParamsUpdated(disputeWindow_, minBond_, bondBps_); + emit ParamsUpdated(minBond_, bondBps_); } } diff --git a/deploy.mjs b/deploy.mjs index 04da439..e6b5887 100755 --- a/deploy.mjs +++ b/deploy.mjs @@ -154,7 +154,9 @@ async function main() { } const need = (gas * price * 150n) / 100n; log(`\nnonce ${nonce}, balance ${formatEther(bal)} ${chain.native}; estimated gas ${gas} @ ${price} wei = ${formatEther(gas * price)} ${chain.native} (fund >= ${formatEther(need)} incl. 50% buffer; L2 data fees: see funding table)`); - if (nonce > 0 && (await live.getCode(plan[Math.min(nonce, plan.length) - 1].address)).length <= 2) + const base = plan[0].nonce, done = nonce - base; + if (done < 0 || done > plan.length) throw new Error(`deployer nonce ${nonce} on ${chain.name} is outside this set's nonces ${base}..${base + plan.length}: nonce desync.`); + if (done > 0 && (await live.getCode(plan[done - 1].address)).length <= 2) throw new Error(`deployer nonce ${nonce} on ${chain.name} but the contract for nonce ${nonce - 1} is missing: nonce desync.`); if (mode === "dry-run") { log("dry run only. Nothing sent."); return; } if (bal < (gas * price)) { console.error(`Balance too low. Fund ${deployer.address} with >= ${formatEther(need)} ${chain.native} on ${chain.name}.`); process.exit(4); } @@ -197,6 +199,7 @@ async function main() { const anvil = V2 ? anvilWallets(fork) : null; const forkWallet = V2 ? anvil[0] : new Wallet(deployer.privateKey, fork); if (V2) for (const w of anvil) { await fork.send("anvil_setCode", [w.address, "0x"]); await fork.send("anvil_setNonce", [w.address, "0x0"]); await fork.send("anvil_setBalance", [w.address, "0x3635C9ADC5DEA00000"]); } // clean EOAs at nonce 0 (the public anvil keys are heavily used on mainnets), 1000 native, fork only + if (V2 && plan[0].nonce > 0) await fork.send("anvil_setNonce", [anvil[0].address, "0x" + plan[0].nonce.toString(16)]); // the fork deployer starts where the real one does (set nonceBase) const startNonce = await fork.getTransactionCount(deployer.address); await fork.send("anvil_setBalance", [deployer.address, "0x56BC75E2D63100000"]); // 100 native, fork only const t0 = Date.now(); @@ -208,7 +211,7 @@ async function main() { const [, owner, op, client, worker, party] = anvil; const r = await e2eV2(fork, plan, { owner, op, client, worker, party }, async (s) => { await fork.send("evm_increaseTime", [s]); await fork.send("evm_mine", []); }); steps = r.steps; attacks = r.attacks; - log(`e2e: ${steps.length} honest-path txs passed; ${attacks.length} attack sequences blocked (H-01, H-02, H-03, M-02, M-03)`); + log(`e2e: ${steps.length} honest-path txs passed; ${attacks.length} attack sequences blocked (H-01, H-02, H-03, M-03, Olympix 5.1.1/5.2.2, false pass/fail)`); } else if (!flag("--no-e2e")) { // Fresh throwaway keys, funded on the fork only (the well-known anvil test accounts carry // EIP-7702 delegations on several mainnets, so they are not clean EOAs on a fork). diff --git a/deployments/fork-v2/arc.json b/deployments/fork-v2/arc.json index 204f6b9..ad1c363 100644 --- a/deployments/fork-v2/arc.json +++ b/deployments/fork-v2/arc.json @@ -3,122 +3,121 @@ "chainId": 5042, "mode": "anvil-fork", "forkedFrom": "https://rpc.mainnet.arc.io", - "forkBlock": 27007868, - "at": "2026-10-20T21:37:32.818Z", - "seconds": 47, + "forkBlock": 27514359, + "at": "2026-10-23T21:01:39.793Z", + "seconds": 51, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", - "startNonce": 0, + "startNonce": 6, "contracts": { "PredgeAgentValidatorV2": { "name": "PredgeAgentValidatorV2", - "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", - "nonce": 0, + "address": "0xAfF33F45707ebd84C779D2D0836513ef6706312c", + "nonce": 6, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", "0x0000000000000000000000000000000000000000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", - "txHash": "0x7dff382649311298b1297083d15d5a9728c9ed21056b5c22478e2b9decc306ad", - "block": 27007772, - "gasUsed": "1162822", - "effectiveGasPrice": "18539309000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x83de1869cda9efd2e8e906797dc727d552c0a19857e53a85136b2382bc7107c7", + "block": 27514255, + "gasUsed": "1167799", + "effectiveGasPrice": "18546384000", "l1FeeWei": "0" }, "AgentJobV2": { "name": "AgentJobV2", - "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", - "nonce": 1, + "address": "0x9e4A48A5Ebc237f6AEaF71211DcdF75628fC0507", + "nonce": 7, "args": [ "259200" ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", - "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0x4445cd5bcfcb19d369e681cbef99a02b29c79f8a0a711fe9d5c9628bc2ea2b6f", - "block": 27007773, - "gasUsed": "904910", - "effectiveGasPrice": "16516854495", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x56007fd54cfd2ecd647843c06c7bfb46aac90b7b96c6bba6e06ff9bc88e52b52", + "block": 27514256, + "gasUsed": "920066", + "effectiveGasPrice": "16523841415", "l1FeeWei": "0" }, "PredgeValidatorBondV2": { "name": "PredgeValidatorBondV2", - "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", - "nonce": 2, + "address": "0x6bfe9aE3C60C764B88D77218faeF7E4C87EEF547", + "nonce": 8, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", - "86400", + "0x9e4A48A5Ebc237f6AEaF71211DcdF75628fC0507", "5000000000000000000", "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", - "txHash": "0x6bb77a282877e03c7514896b104b8b587e97eb9e595c783522d9a2b9d4f5accf", - "block": 27007774, - "gasUsed": "1705859", - "effectiveGasPrice": "14694258990", + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000009e4a48a5ebc237f6aeaf71211dcdf75628fc05070000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x38c568a459cc259c1a55536ea4deea1106279278377ad9d43a44221e48259155", + "block": 27514257, + "gasUsed": "1453368", + "effectiveGasPrice": "14702385894", "l1FeeWei": "0" }, "PredgeSettlementV2": { "name": "PredgeSettlementV2", - "address": "0xef3450a304FD3B239ea90254370adD900b40C710", - "nonce": 3, + "address": "0xe7de9717cB95634639eF114Cda060dB1Fdf60F16", + "nonce": 9, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0x423ad3db6d7084cb675d6aa5272f674b131158ea7d7cd38c75f1a6680592a523", - "block": 27007775, - "gasUsed": "369083", - "effectiveGasPrice": "13177147241", + "txHash": "0xa9bee3dab4e3656dfc8f2092537cb8194b179d5626ab7d49f4e8fc089dc6c66a", + "block": 27514258, + "gasUsed": "369071", + "effectiveGasPrice": "13155542734", "l1FeeWei": "0" }, "MockRefundProtocolV2": { "name": "MockRefundProtocolV2", - "address": "0xd01f1026c372749873EA32BDB017434a05226837", - "nonce": 4, + "address": "0x78461d287398a45a1204EfbcCa88804f9dacfA87", + "nonce": 10, "args": [ - "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + "0x37a722A40875ff6a26E1F506B96a8d1Ae08353a1" ], - "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "constructorArgs": "0x00000000000000000000000037a722a40875ff6a26e1f506b96a8d1ae08353a1", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0x4eec0b4ca00998536dc2a38a8ff945a3e72d24bb3fa5515b47e109aa39a31e30", - "block": 27007776, + "txHash": "0x6104ce5b2904386916b2c98ee4da9e2bd7a2a2933a6dd5826b5e707866a1c119", + "block": 27514259, "gasUsed": "408763", - "effectiveGasPrice": "11692456987", + "effectiveGasPrice": "11673485379", "l1FeeWei": "0" }, "PredgeRefundArbiterV2": { "name": "PredgeRefundArbiterV2", - "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", - "nonce": 5, + "address": "0x37a722A40875ff6a26E1F506B96a8d1Ae08353a1", + "nonce": 11, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - "0xd01f1026c372749873EA32BDB017434a05226837", + "0x78461d287398a45a1204EfbcCa88804f9dacfA87", "86400", "5000000000000000000", "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a0522683700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", - "txHash": "0xd9a8464349c27c4fb1dbb880dd1ad1716c084059c90a1ba311d06548cf617fac", - "block": 27007777, - "gasUsed": "1898263", - "effectiveGasPrice": "10392322204", + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000078461d287398a45a1204efbcca88804f9dacfa8700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0x99e4b4c6ff02869284724e8a5c4a9f593dd0dbf937fc3c52611ec42b97106bcd", + "block": 27514260, + "gasUsed": "1930219", + "effectiveGasPrice": "10375657423", "l1FeeWei": "0" } }, "totals": { - "gasUsed": "6449700", - "liveGasPriceWei": "23900000000", + "gasUsed": "6249286", + "liveGasPriceWei": "25000000000", "l1FeeWei": "0", "l1FeeMethod": "none (execution gas only)", - "costWei": "154147830000000000", - "cost": "0.15414783", + "costWei": "156232150000000000", + "cost": "0.15623215", "native": "USDC" }, "smoke": [ @@ -145,24 +144,26 @@ "job.jobCount == 0", "bond.job == AgentJobV2", "bond params", - "bond.wouldSlash(unknown) == false", + "bond.verdictFor(no job) == 0", + "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", "arbiter.refundProtocol == mock", "mock.arbiter == arbiter", "arbiter params", - "registry.requestHashFor is domain separated" + "registry.requestHashFor is domain separated", + "bond.specHashFor is domain separated" ], "e2e": [ { - "step": "registry.validationRequest #1", - "gasUsed": 123226 + "step": "registry.validationRequest", + "gasUsed": 123282 }, { "step": "job.createJob #1", - "gasUsed": 203490 + "gasUsed": 203597 }, { "step": "bond.stakeAndCommit", - "gasUsed": 190513 + "gasUsed": 165934 }, { "step": "job.submit (bytes, sha256 on-chain)", @@ -170,11 +171,11 @@ }, { "step": "bond.recordScore(100) completes the job", - "gasUsed": 159228 + "gasUsed": 221752 }, { "step": "registry.validationResponse", - "gasUsed": 132144 + "gasUsed": 132382 }, { "step": "job.withdraw (provider pulls payout)", @@ -188,77 +189,97 @@ "step": "settlement.withdraw (owner)", "gasUsed": 32279 }, - { - "step": "registry.validationRequest #2", - "gasUsed": 123238 - }, { "step": "job.createJob #2", - "gasUsed": 186390 + "gasUsed": 186485 }, { "step": "bond.stakeAndCommit", - "gasUsed": 173413 - }, - { - "step": "registry.validationRequest #3", - "gasUsed": 123226 + "gasUsed": 165922 }, { "step": "job.createJob #3", - "gasUsed": 169290 + "gasUsed": 169397 }, { "step": "job.submit (j3, wrong output)", "gasUsed": 54547 }, - { - "step": "registry.validationRequest #4", - "gasUsed": 123238 - }, { "step": "job.createJob #4", - "gasUsed": 169290 + "gasUsed": 169397 }, { "step": "bond.stakeAndCommit", - "gasUsed": 173413 + "gasUsed": 148822 }, { "step": "job.submit (j4, provider submits the public commitment)", - "gasUsed": 54547 + "gasUsed": 54535 }, { "step": "bond.recordScore(0) honest fail", - "gasUsed": 163942 + "gasUsed": 197054 }, { - "step": "registry.validationRequest #5", - "gasUsed": 123238 + "step": "job.createJob #5", + "gasUsed": 169397 }, { - "step": "job.createJob #5", - "gasUsed": 169290 + "step": "bond.stakeAndCommit", + "gasUsed": 148822 + }, + { + "step": "job.submit (j5, wrong output bytes)", + "gasUsed": 55205 + }, + { + "step": "bond.recordScore(0) on wrong bytes", + "gasUsed": 162866 + }, + { + "step": "job.createJob #7", + "gasUsed": 169397 }, { "step": "bond.stakeAndCommit", - "gasUsed": 173413 + "gasUsed": 148834 }, { - "step": "job.submit (j5, wrong output)", + "step": "job.submit (j7, leaked expected, no bytes)", "gasUsed": 54547 }, { - "step": "bond.recordScore(100) FALSE pass", - "gasUsed": 146928 + "step": "bond.recordScore(0) j7", + "gasUsed": 162842 }, { - "step": "bond.challenge by a stranger", - "gasUsed": 131748 + "step": "job.createJob #8", + "gasUsed": 169397 }, { - "step": "bond.withdraw (client)", - "gasUsed": 32262 + "step": "bond.stakeAndCommit", + "gasUsed": 148834 + }, + { + "step": "job.submit (j8, correct bytes)", + "gasUsed": 55445 + }, + { + "step": "bond.recordScore(100) j8", + "gasUsed": 180028 + }, + { + "step": "job.createJob #6", + "gasUsed": 169397 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 148834 + }, + { + "step": "job.submit (j6, bytes, operator stays silent)", + "gasUsed": 55445 }, { "step": "job.claimRefund (expired, never submitted)", @@ -266,15 +287,23 @@ }, { "step": "bond.release (no verdict was given)", - "gasUsed": 104039 + "gasUsed": 69800 }, { - "step": "bond.reclaim (j1, anyone, after window)", - "gasUsed": 47188 + "step": "job.claimRefund (j6, silent evaluator)", + "gasUsed": 47292 + }, + { + "step": "bond.release (j6, forfeited to the provider)", + "gasUsed": 81906 + }, + { + "step": "bond.withdraw (provider gets the forfeited bond)", + "gasUsed": 35571 }, { "step": "bond.withdraw (staker)", - "gasUsed": 32262 + "gasUsed": 32297 }, { "step": "mock.recordPayment", @@ -286,15 +315,15 @@ }, { "step": "arbiter.rule(Refund)", - "gasUsed": 250669 + "gasUsed": 250666 }, { "step": "arbiter.dispute (harmed payee)", - "gasUsed": 59538 + "gasUsed": 59516 }, { "step": "arbiter.resolve(Release) overturns", - "gasUsed": 86656 + "gasUsed": 86634 }, { "step": "arbiter.withdraw (payee gets the operator bond)", @@ -310,7 +339,7 @@ }, { "step": "arbiter.rule(Refund, p2)", - "gasUsed": 233569 + "gasUsed": 233566 }, { "step": "arbiter.finalize (undisputed, after window)", @@ -323,33 +352,45 @@ ], "attacksBlocked": [ { - "attack": "challenge of an honest verdict", - "blockedBy": "VerdictHonest" + "attack": "Olympix 5.1.1 operator commits a value the client did not fix", + "blockedBy": "CommitmentNotBound" }, { "attack": "H-01 recordScore before the provider submits", "blockedBy": "NotSubmitted" }, { - "attack": "H-01 reclaim without a verdict", - "blockedBy": "NotScored" + "attack": "H-01 bond back without a verdict", + "blockedBy": "JobNotExpired" }, { "attack": "H-02 commit after submission", "blockedBy": "NotOpen" }, { - "attack": "H-02 honest fail is not slashable", - "blockedBy": "VerdictHonest" + "attack": "H-02 pass on a copied public value", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "false pass on wrong on-chain bytes", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "Olympix 5.1.1 pass on a leaked hash without bytes", + "blockedBy": "VerdictContradictsEvidence" }, { - "attack": "M-02 slash pays the harmed client, challenger earns 0", - "blockedBy": "routing" + "attack": "false fail on correct on-chain bytes", + "blockedBy": "VerdictContradictsEvidence" }, { "attack": "late submit after expiry", "blockedBy": "PastDeadline" }, + { + "attack": "Olympix 5.2.2 silence on delivered work is free", + "blockedBy": "bond forfeited to provider" + }, { "attack": "H-03 stranger files evidence for someone else's payment", "blockedBy": "NotAParty" @@ -375,7 +416,7 @@ "blockedBy": "NotOwner" } ], - "setVersion": "2026-10-20-v2", + "setVersion": "2026-10-23-v2.1", "erc8004": { "IdentityRegistry": { "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", diff --git a/deployments/fork-v2/base.json b/deployments/fork-v2/base.json index a8e8301..3b75440 100644 --- a/deployments/fork-v2/base.json +++ b/deployments/fork-v2/base.json @@ -3,122 +3,121 @@ "chainId": 8453, "mode": "anvil-fork", "forkedFrom": "https://base-rpc.publicnode.com", - "forkBlock": 52871478, - "at": "2026-10-20T21:38:23.427Z", - "seconds": 45, + "forkBlock": 52999945, + "at": "2026-10-23T21:00:38.712Z", + "seconds": 50, "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", - "startNonce": 0, + "startNonce": 6, "contracts": { "PredgeAgentValidatorV2": { "name": "PredgeAgentValidatorV2", - "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", - "nonce": 0, + "address": "0xAfF33F45707ebd84C779D2D0836513ef6706312c", + "nonce": 6, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", "0x0000000000000000000000000000000000000000" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", - "txHash": "0xaa509fc65ae9c27fc045b4e2fb2e3085d17ec05474062aef22b9efb06424c8d6", - "block": 52871456, - "gasUsed": "1162822", - "effectiveGasPrice": "1004990607", - "l1FeeWei": "38368600787" + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x7b273f1f7a180986086e17138b8d403e3f74de3748f9e69425e5cb3424bb5918", + "block": 52999921, + "gasUsed": "1167799", + "effectiveGasPrice": "1004993460", + "l1FeeWei": "19358672360" }, "AgentJobV2": { "name": "AgentJobV2", - "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", - "nonce": 1, + "address": "0x9e4A48A5Ebc237f6AEaF71211DcdF75628fC0507", + "nonce": 7, "args": [ "259200" ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", - "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0x80214af61cb9b45ae18ced0571bfb6b7fbc4b5114735511f84f6d815a3b988c7", - "block": 52871457, - "gasUsed": "904910", - "effectiveGasPrice": "1004970993", - "l1FeeWei": "29027153846" + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x67310cf91c27370c6e0cbf5fc591552dfc5eac1c2fa69b35fef675c6de13feab", + "block": 52999922, + "gasUsed": "920066", + "effectiveGasPrice": "1004973837", + "l1FeeWei": "14742746611" }, "PredgeValidatorBondV2": { "name": "PredgeValidatorBondV2", - "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", - "nonce": 2, + "address": "0x6bfe9aE3C60C764B88D77218faeF7E4C87EEF547", + "nonce": 8, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", - "86400", + "0x9e4A48A5Ebc237f6AEaF71211DcdF75628fC0507", "2000000000000000", "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e616000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", - "txHash": "0x42f2832ba3298c566ad55ea9b0f1aa6468d120fef32a57f6d333a0644753d2d6", - "block": 52871458, - "gasUsed": "1705847", - "effectiveGasPrice": "1004951379", - "l1FeeWei": "49674600186" + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000009e4a48a5ebc237f6aeaf71211dcdf75628fc050700000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x588b3ab2208798839e21355967b0549ae7aa4b07d464da2295a02a5db590ab45", + "block": 52999923, + "gasUsed": "1453356", + "effectiveGasPrice": "1004954217", + "l1FeeWei": "21646866815" }, "PredgeSettlementV2": { "name": "PredgeSettlementV2", - "address": "0xef3450a304FD3B239ea90254370adD900b40C710", - "nonce": 3, + "address": "0xe7de9717cB95634639eF114Cda060dB1Fdf60F16", + "nonce": 9, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" ], "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0xcfebf9d01a42635fb54df1749ba6e0369b229b35025e147c1f0e55f3c2dd1142", - "block": 52871459, - "gasUsed": "369083", - "effectiveGasPrice": "1004932081", - "l1FeeWei": "12515090431" + "txHash": "0x7e94bce39db26116e1ece8bbf716d9d4eee03d8876607888409c72646ee7f4dd", + "block": 52999924, + "gasUsed": "369071", + "effectiveGasPrice": "1004934833", + "l1FeeWei": "6296689754" }, "MockRefundProtocolV2": { "name": "MockRefundProtocolV2", - "address": "0xd01f1026c372749873EA32BDB017434a05226837", - "nonce": 4, + "address": "0x78461d287398a45a1204EfbcCa88804f9dacfA87", + "nonce": 10, "args": [ - "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + "0x37a722A40875ff6a26E1F506B96a8d1Ae08353a1" ], - "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "constructorArgs": "0x00000000000000000000000037a722a40875ff6a26e1f506b96a8d1ae08353a1", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0xba45e466198a5894b060e7ae449662f405b338739b0a5fc5d72d7be185d46815", - "block": 52871460, + "txHash": "0x533c5d0dabc3f3d05234bd7918ec08693863475ac208545076512a29e9ff243f", + "block": 52999925, "gasUsed": "408763", - "effectiveGasPrice": "1004912462", - "l1FeeWei": "14813616808" + "effectiveGasPrice": "1004915203", + "l1FeeWei": "7453142244" }, "PredgeRefundArbiterV2": { "name": "PredgeRefundArbiterV2", - "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", - "nonce": 5, + "address": "0x37a722A40875ff6a26E1F506B96a8d1Ae08353a1", + "nonce": 11, "args": [ "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - "0xd01f1026c372749873EA32BDB017434a05226837", + "0x78461d287398a45a1204EfbcCa88804f9dacfA87", "86400", "2000000000000000", "0" ], - "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", - "txHash": "0x925ce7527d58a5d8c7177fe99eabbf29c496ab9c08c40b5739b9abc5f0ce3aee", - "block": 52871461, - "gasUsed": "1898251", - "effectiveGasPrice": "1004892933", - "l1FeeWei": "55813826620" + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000078461d287398a45a1204efbcca88804f9dacfa87000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0x1266241e7f57d4a39d3cdaddef4e0079383af4d2bff7b4017f9a9134902f0016", + "block": 52999926, + "gasUsed": "1930207", + "effectiveGasPrice": "1004895663", + "l1FeeWei": "28437318613" } }, "totals": { - "gasUsed": "6449676", + "gasUsed": "6249262", "liveGasPriceWei": "6000000", - "l1FeeWei": "200212888678", + "l1FeeWei": "97935436397", "l1FeeMethod": "op-stack oracle getL1Fee", - "costWei": "38898268888678", - "cost": "0.000038898268888678", + "costWei": "37593507436397", + "cost": "0.000037593507436397", "native": "ETH" }, "smoke": [ @@ -145,24 +144,26 @@ "job.jobCount == 0", "bond.job == AgentJobV2", "bond params", - "bond.wouldSlash(unknown) == false", + "bond.verdictFor(no job) == 0", + "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", "arbiter.refundProtocol == mock", "mock.arbiter == arbiter", "arbiter params", - "registry.requestHashFor is domain separated" + "registry.requestHashFor is domain separated", + "bond.specHashFor is domain separated" ], "e2e": [ { - "step": "registry.validationRequest #1", - "gasUsed": 123226 + "step": "registry.validationRequest", + "gasUsed": 123282 }, { "step": "job.createJob #1", - "gasUsed": 203478 + "gasUsed": 203597 }, { "step": "bond.stakeAndCommit", - "gasUsed": 190501 + "gasUsed": 165922 }, { "step": "job.submit (bytes, sha256 on-chain)", @@ -170,11 +171,11 @@ }, { "step": "bond.recordScore(100) completes the job", - "gasUsed": 159216 + "gasUsed": 221752 }, { "step": "registry.validationResponse", - "gasUsed": 132132 + "gasUsed": 132382 }, { "step": "job.withdraw (provider pulls payout)", @@ -188,41 +189,29 @@ "step": "settlement.withdraw (owner)", "gasUsed": 32279 }, - { - "step": "registry.validationRequest #2", - "gasUsed": 123238 - }, { "step": "job.createJob #2", - "gasUsed": 186390 + "gasUsed": 186497 }, { "step": "bond.stakeAndCommit", - "gasUsed": 173413 - }, - { - "step": "registry.validationRequest #3", - "gasUsed": 123226 + "gasUsed": 165934 }, { "step": "job.createJob #3", - "gasUsed": 169290 + "gasUsed": 169397 }, { "step": "job.submit (j3, wrong output)", "gasUsed": 54547 }, - { - "step": "registry.validationRequest #4", - "gasUsed": 123238 - }, { "step": "job.createJob #4", - "gasUsed": 169290 + "gasUsed": 169397 }, { "step": "bond.stakeAndCommit", - "gasUsed": 173413 + "gasUsed": 148834 }, { "step": "job.submit (j4, provider submits the public commitment)", @@ -230,35 +219,67 @@ }, { "step": "bond.recordScore(0) honest fail", - "gasUsed": 163942 + "gasUsed": 197054 }, { - "step": "registry.validationRequest #5", - "gasUsed": 123238 + "step": "job.createJob #5", + "gasUsed": 169397 }, { - "step": "job.createJob #5", - "gasUsed": 169290 + "step": "bond.stakeAndCommit", + "gasUsed": 148834 + }, + { + "step": "job.submit (j5, wrong output bytes)", + "gasUsed": 55205 + }, + { + "step": "bond.recordScore(0) on wrong bytes", + "gasUsed": 162854 + }, + { + "step": "job.createJob #7", + "gasUsed": 169397 }, { "step": "bond.stakeAndCommit", - "gasUsed": 173413 + "gasUsed": 148834 }, { - "step": "job.submit (j5, wrong output)", + "step": "job.submit (j7, leaked expected, no bytes)", "gasUsed": 54547 }, { - "step": "bond.recordScore(100) FALSE pass", - "gasUsed": 146928 + "step": "bond.recordScore(0) j7", + "gasUsed": 162854 + }, + { + "step": "job.createJob #8", + "gasUsed": 169385 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 148822 + }, + { + "step": "job.submit (j8, correct bytes)", + "gasUsed": 55445 + }, + { + "step": "bond.recordScore(100) j8", + "gasUsed": 180028 }, { - "step": "bond.challenge by a stranger", - "gasUsed": 131748 + "step": "job.createJob #6", + "gasUsed": 169397 }, { - "step": "bond.withdraw (client)", - "gasUsed": 32262 + "step": "bond.stakeAndCommit", + "gasUsed": 148834 + }, + { + "step": "job.submit (j6, bytes, operator stays silent)", + "gasUsed": 55445 }, { "step": "job.claimRefund (expired, never submitted)", @@ -266,15 +287,23 @@ }, { "step": "bond.release (no verdict was given)", - "gasUsed": 104039 + "gasUsed": 69812 }, { - "step": "bond.reclaim (j1, anyone, after window)", - "gasUsed": 47176 + "step": "job.claimRefund (j6, silent evaluator)", + "gasUsed": 47292 + }, + { + "step": "bond.release (j6, forfeited to the provider)", + "gasUsed": 81906 + }, + { + "step": "bond.withdraw (provider gets the forfeited bond)", + "gasUsed": 35571 }, { "step": "bond.withdraw (staker)", - "gasUsed": 32262 + "gasUsed": 32297 }, { "step": "mock.recordPayment", @@ -286,15 +315,15 @@ }, { "step": "arbiter.rule(Refund)", - "gasUsed": 250669 + "gasUsed": 250666 }, { "step": "arbiter.dispute (harmed payee)", - "gasUsed": 59538 + "gasUsed": 59516 }, { "step": "arbiter.resolve(Release) overturns", - "gasUsed": 86656 + "gasUsed": 86634 }, { "step": "arbiter.withdraw (payee gets the operator bond)", @@ -310,7 +339,7 @@ }, { "step": "arbiter.rule(Refund, p2)", - "gasUsed": 233569 + "gasUsed": 233566 }, { "step": "arbiter.finalize (undisputed, after window)", @@ -323,33 +352,45 @@ ], "attacksBlocked": [ { - "attack": "challenge of an honest verdict", - "blockedBy": "VerdictHonest" + "attack": "Olympix 5.1.1 operator commits a value the client did not fix", + "blockedBy": "CommitmentNotBound" }, { "attack": "H-01 recordScore before the provider submits", "blockedBy": "NotSubmitted" }, { - "attack": "H-01 reclaim without a verdict", - "blockedBy": "NotScored" + "attack": "H-01 bond back without a verdict", + "blockedBy": "JobNotExpired" }, { "attack": "H-02 commit after submission", "blockedBy": "NotOpen" }, { - "attack": "H-02 honest fail is not slashable", - "blockedBy": "VerdictHonest" + "attack": "H-02 pass on a copied public value", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "false pass on wrong on-chain bytes", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "Olympix 5.1.1 pass on a leaked hash without bytes", + "blockedBy": "VerdictContradictsEvidence" }, { - "attack": "M-02 slash pays the harmed client, challenger earns 0", - "blockedBy": "routing" + "attack": "false fail on correct on-chain bytes", + "blockedBy": "VerdictContradictsEvidence" }, { "attack": "late submit after expiry", "blockedBy": "PastDeadline" }, + { + "attack": "Olympix 5.2.2 silence on delivered work is free", + "blockedBy": "bond forfeited to provider" + }, { "attack": "H-03 stranger files evidence for someone else's payment", "blockedBy": "NotAParty" @@ -375,7 +416,7 @@ "blockedBy": "NotOwner" } ], - "setVersion": "2026-10-20-v2", + "setVersion": "2026-10-23-v2.1", "erc8004": { "IdentityRegistry": { "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", diff --git a/lib/plan.mjs b/lib/plan.mjs index f584930..ea7b0f0 100644 --- a/lib/plan.mjs +++ b/lib/plan.mjs @@ -1,12 +1,14 @@ // Resolves the canonical plan for one deployer: every address is known before anything is sent, -// because contract i is created by the deployer's nonce i (CREATE), on every chain. +// because contract i is created by the deployer's nonce `nonceBase + i` (CREATE), on every chain. `nonceBase` comes from +// the set file (0 if absent): a later version of a set can reuse the same deployer key after the earlier version. import { getCreateAddress, ContractFactory } from "ethers"; import { SET as SET_V1 } from "./chains.mjs"; import { compile } from "./compile.mjs"; export function resolvePlan(deployer, overrides = {}, SET = SET_V1) { const params = { ...SET.params, ...overrides }; - const addresses = Object.fromEntries(SET.plan.map((s, i) => [s.name, getCreateAddress({ from: deployer, nonce: i })])); + const base = SET.nonceBase ?? 0; + const addresses = Object.fromEntries(SET.plan.map((s, i) => [s.name, getCreateAddress({ from: deployer, nonce: base + i })])); const ctx = { deployer, ...params, ...addresses }; if (params.operator === "$deployer") ctx.operator = deployer; const resolve = (a) => { @@ -15,7 +17,8 @@ export function resolvePlan(deployer, overrides = {}, SET = SET_V1) { if (v === undefined) throw new Error(`unresolved plan arg ${a}`); return v; }; - return SET.plan.map((step, nonce) => { + return SET.plan.map((step, i) => { + const nonce = base + i; const c = compile(step.name, step.file); const args = step.args.map(resolve); const data = new ContractFactory(c.abi, c.bytecode).interface.encodeDeploy(args); diff --git a/lib/smoke-v2.mjs b/lib/smoke-v2.mjs index c7c8094..aa1a8e1 100644 --- a/lib/smoke-v2.mjs +++ b/lib/smoke-v2.mjs @@ -1,6 +1,6 @@ // v2 smoke checks. `readOnlyV2` runs on a live chain (eth_call only). `e2eV2` runs on a local fork ONLY, with the // public anvil accounts: the full honest path through every contract, then the v1 attack sequences (H-01, H-02, -// H-03 and the self-challenge) which must now revert or be harmless. +// H-03) and the Olympix ones (5.1.1, 5.2.2, false verdicts) which must now revert or be harmless. import { Contract, keccak256, toUtf8Bytes, sha256, AbiCoder, ZeroAddress, ZeroHash } from "ethers"; import { maskImmutables } from "./compile.mjs"; import { waitReceipt } from "./chains.mjs"; @@ -37,8 +37,9 @@ export async function readOnlyV2(provider, plan, roles) { ok("job.evaluationWindow", (await job.evaluationWindow()) === BigInt(step(plan, "AgentJobV2").args[0]), ""); ok("job.jobCount == 0", (await job.jobCount()) === 0n, ""); ok("bond.job == AgentJobV2", (await bond.job()) === job.target, await bond.job()); - ok("bond params", (await bond.disputeWindow()) === BigInt(bondArgs[3]) && (await bond.minBond()) === BigInt(bondArgs[4]) && (await bond.bondBps()) === BigInt(bondArgs[5]), ""); - ok("bond.wouldSlash(unknown) == false", (await bond.wouldSlash(h("predge/smoke/none"))) === false, ""); + ok("bond params", (await bond.minBond()) === BigInt(bondArgs[3]) && (await bond.bondBps()) === BigInt(bondArgs[4]), ""); + ok("bond.verdictFor(no job) == 0", (await bond.verdictFor(0n, h("predge/smoke/none"))) === 0n, ""); + ok("bond has no challenge() (v2.1: false verdicts are refused, not slashed)", (await provider.call({ to: bond.target, data: keccak256(toUtf8Bytes("challenge(bytes32)")).slice(0, 10) + "00".repeat(32) }).then(() => false, () => true)), ""); ok("arbiter.refundProtocol == mock", (await arb.refundProtocol()) === mock.target, await arb.refundProtocol()); ok("mock.arbiter == arbiter", (await mock.arbiter()) === arb.target, await mock.arbiter()); ok("arbiter params", (await arb.challengeWindow()) === BigInt(arbArgs[3]) && (await arb.minBond()) === BigInt(arbArgs[4]) && (await arb.bondBps()) === BigInt(arbArgs[5]), ""); @@ -104,8 +105,7 @@ export async function e2eV2(provider, plan, s, increaseTime) { await send("bond.recordScore(100) completes the job", bond.recordScore(j1.spec, 100, E, j1.salt, h("verdict"))); if (Number((await job.getJob(j1.id)).state) !== 3) throw new Error("job not Completed by the bond"); await send("registry.validationResponse", reg.validationResponse(req1, 100, "ipfs://v2-fork/verdict", h("verdict"), "predge/v2-fork")); - if (await bond.wouldSlash(j1.spec)) throw new Error("honest verdict is slashable"); - await mustRevert("challenge of an honest verdict", "VerdictHonest", async () => bond.connect(party).challenge.staticCall(j1.spec)); + if ((await bond.credit(op.address)) < (await bond.requiredBond(ESCROW))) throw new Error("bond not credited back to the staker with the verdict"); if ((await job.credit(await addr(worker))) !== ESCROW) throw new Error("provider is not credited the escrow"); await send("job.withdraw (provider pulls payout)", job.connect(worker).withdraw()); if ((await job.credit(await addr(worker))) !== 0n) throw new Error("provider credit not cleared"); @@ -113,12 +113,12 @@ export async function e2eV2(provider, plan, s, increaseTime) { await send("settlement.withdraw (owner)", settle.connect(owner).withdraw(await addr(owner))); // ---------------- attacks that worked on v1 ---------------- - // H-01: verdict before submission, then wait the window out and reclaim. + // H-01: verdict before submission; no way to get the bond back while the job runs. const j2 = await newJob(2); await mustRevert("Olympix 5.1.1 operator commits a value the client did not fix", "CommitmentNotBound", async () => bond.stakeAndCommit.staticCall(j2.spec, commit(X, j2.salt), j2.id, { value: ESCROW })); await stake(j2); await mustRevert("H-01 recordScore before the provider submits", "NotSubmitted", async () => bond.recordScore.staticCall(j2.spec, 100, E, j2.salt, ZeroHash)); - await mustRevert("H-01 reclaim without a verdict", "NotScored", async () => bond.reclaim.staticCall(j2.spec)); + await mustRevert("H-01 bond back without a verdict", "JobNotExpired", async () => bond.release.staticCall(j2.spec)); // H-02: commit after the deliverable exists / swap the revealed value / provider copies public data. const j3 = await newJob(3); await send("job.submit (j3, wrong output)", job.connect(worker).submit(j3.id, X, "0x")); @@ -128,19 +128,24 @@ export async function e2eV2(provider, plan, s, increaseTime) { const staked = await bond.stakes(j4.spec); if (staked.expected !== ZeroHash) throw new Error("expected is visible on-chain before the reveal"); await send("job.submit (j4, provider submits the public commitment)", job.connect(worker).submit(j4.id, staked.commitment, "0x")); + await mustRevert("H-02 pass on a copied public value", "VerdictContradictsEvidence", async () => bond.recordScore.staticCall(j4.spec, 100, E, j4.salt, h("lie"))); await send("bond.recordScore(0) honest fail", bond.recordScore(j4.spec, 0, E, j4.salt, h("fail"))); - await mustRevert("H-02 honest fail is not slashable", "VerdictHonest", async () => bond.connect(party).challenge.staticCall(j4.spec)); - // M-02: a false pass is slashed to the harmed client; the challenger gets nothing. + // Option (a): a false verdict cannot be recorded at all, whatever the bond size (Olympix 5.1.1 / 5.1.2). const j5 = await newJob(5); await stake(j5); - await send("job.submit (j5, wrong output)", job.connect(worker).submit(j5.id, X, "0x")); - await send("bond.recordScore(100) FALSE pass", bond.recordScore(j5.spec, 100, E, j5.salt, h("lie"))); - if (!(await bond.wouldSlash(j5.spec))) throw new Error("false pass not slashable"); - await send("bond.challenge by a stranger", bond.connect(party).challenge(j5.spec)); - const clientCredit = await bond.credit(await addr(client)), partyCredit = await bond.credit(await addr(party)); - if (clientCredit < ESCROW || partyCredit !== 0n) throw new Error(`slash routing wrong: client ${clientCredit} challenger ${partyCredit}`); - attacks.push({ attack: "M-02 slash pays the harmed client, challenger earns 0", blockedBy: "routing" }); - await send("bond.withdraw (client)", bond.connect(client).withdraw()); + await send("job.submit (j5, wrong output bytes)", job.connect(worker).submit(j5.id, sha256(Buffer.from("wrong")), "0x" + Buffer.from("wrong").toString("hex"))); + await mustRevert("false pass on wrong on-chain bytes", "VerdictContradictsEvidence", async () => bond.recordScore.staticCall(j5.spec, 100, E, j5.salt, h("lie"))); + await send("bond.recordScore(0) on wrong bytes", bond.recordScore(j5.spec, 0, E, j5.salt, h("fail"))); + const j7 = await newJob(7); + await stake(j7); + await send("job.submit (j7, leaked expected, no bytes)", job.connect(worker).submit(j7.id, E, "0x")); + await mustRevert("Olympix 5.1.1 pass on a leaked hash without bytes", "VerdictContradictsEvidence", async () => bond.recordScore.staticCall(j7.spec, 100, E, j7.salt, h("lie"))); + await send("bond.recordScore(0) j7", bond.recordScore(j7.spec, 0, E, j7.salt, h("fail"))); + const j8 = await newJob(8); + await stake(j8); + await send("job.submit (j8, correct bytes)", job.connect(worker).submit(j8.id, E, OUT)); + await mustRevert("false fail on correct on-chain bytes", "VerdictContradictsEvidence", async () => bond.recordScore.staticCall(j8.spec, 0, E, j8.salt, h("lie"))); + await send("bond.recordScore(100) j8", bond.recordScore(j8.spec, 100, E, j8.salt, h("pass"))); // Olympix 5.2.2: an operator that stays silent on delivered work forfeits its bond to the provider. const j6 = await newJob(6); await stake(j6); @@ -158,9 +163,9 @@ export async function e2eV2(provider, plan, s, increaseTime) { attacks.push({ attack: "Olympix 5.2.2 silence on delivered work is free", blockedBy: "bond forfeited to provider" }); await send("bond.withdraw (provider gets the forfeited bond)", bond.connect(worker).withdraw()); - // reclaim of the honest stake after its window - await send("bond.reclaim (j1, anyone, after window)", bond.connect(party).reclaim(j1.spec)); + // every scored stake already credited the bond back to the staker await send("bond.withdraw (staker)", bond.withdraw()); + if ((await bond.totalBonded()) !== 0n) throw new Error("bond still holds stakes after every job ended"); // ---------------- arbiter ---------------- const payer = await addr(party), payee = await addr(worker); diff --git a/scripts/v2-post-deploy.mjs b/scripts/v2-post-deploy.mjs new file mode 100644 index 0000000..e95d247 --- /dev/null +++ b/scripts/v2-post-deploy.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// After the founder has broadcast the v2.1 set (config/set.v2.json, nonces 6..11) on Arc and Base: +// node scripts/v2-post-deploy.mjs [--chains arc,base] [--from-ref 849d1ff] [--no-verify] [--check] +// +// 1. keeps the 20.10 records: deployments/v2/.v2-0.json and deployments/v2/verify/.v2-0.json, restored +// from git (`--from-ref`, default 849d1ff = the commit that recorded v2.0), because `deploy.mjs --broadcast` has +// overwritten deployments/v2/.json and verify/*.json in place; marks them deprecated; +// 2. links the new record to the old one (`supersedes`); +// 3. retries Sourcify (then Etherscan/Blockscout, as deploy.mjs does) for any contract the broadcast left unverified; +// 4. rewrites the README "v2" deployments section: the v2.1 table as current, the v2.0 addresses marked DEPRECATED. +// Idempotent. Sends no transaction and reads no key. `--check` only validates and prints what it would do. +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { chainConfig, SET_V2 } from "../lib/chains.mjs"; +import { resolvePlan } from "../lib/plan.mjs"; +import { chainParams } from "../lib/v2.mjs"; +import { verifyStep } from "../lib/verify.mjs"; + +const ROOT = new URL("../", import.meta.url).pathname; +const argv = process.argv.slice(2); +const opt = (f, d) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : d; }; +const flag = (f) => argv.includes(f); +const CHAINS = opt("--chains", "arc,base").split(","); +const REF = opt("--from-ref", "849d1ff"); +const OLD_VERSION = "2026-10-20-v2"; +const CHECK = flag("--check"); +const json = (o) => JSON.stringify(o, null, 2) + "\n"; +const git = (...a) => execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8" }); +const write = (rel, content) => { if (CHECK) console.log(` would write ${rel}`); else writeFileSync(`${ROOT}${rel}`, content); }; +const fail = (m) => { console.error(`v2-post-deploy: ${m}`); process.exit(1); }; + +const records = {}; +for (const c of CHAINS) { + const path = `deployments/v2/${c}.json`; + if (!existsSync(`${ROOT}${path}`)) fail(`${path} missing: broadcast ${c} first`); + const r = JSON.parse(readFileSync(`${ROOT}${path}`, "utf8")); + if (r.setVersion !== SET_V2.version) fail(`${path} has setVersion ${r.setVersion}, expected ${SET_V2.version}: was ${c} broadcast with the v2.1 set?`); + const names = SET_V2.plan.map((s) => s.name); + for (const [i, n] of names.entries()) { + const k = r.contracts[n]; + if (!k) fail(`${path}: ${n} missing`); + if (k.nonce !== (SET_V2.nonceBase ?? 0) + i) fail(`${path}: ${n} at nonce ${k.nonce}, expected ${(SET_V2.nonceBase ?? 0) + i}`); + } + records[c] = r; +} +const addrs = (r) => SET_V2.plan.map((s) => r.contracts[s.name].address); +const first = addrs(records[CHAINS[0]]); +for (const c of CHAINS) if (addrs(records[c]).join() !== first.join()) fail(`addresses differ between ${CHAINS[0]} and ${c}`); + +// 1-2. archive the v2.0 records from git and link them +const old = {}; +for (const c of CHAINS) { + const arch = `deployments/v2/${c}.v2-0.json`; + let o; + if (existsSync(`${ROOT}${arch}`)) o = JSON.parse(readFileSync(`${ROOT}${arch}`, "utf8")); + else { + o = JSON.parse(git("show", `${REF}:deployments/v2/${c}.json`)); + if (o.setVersion !== OLD_VERSION) fail(`${REF}:deployments/v2/${c}.json is ${o.setVersion}, not ${OLD_VERSION}`); + } + o.deprecated = { by: SET_V2.version, record: `${c}.json`, reason: "Olympix BugPoCer scan of c47ed42 (23.10.2026): confirmed findings, fixed in v2.1. Not upgradeable, so the v2.0 instances stay on chain; do not use them." }; + write(arch, json(o)); + old[c] = o; + records[c].supersedes = { setVersion: OLD_VERSION, record: `${c}.v2-0.json`, addresses: Object.fromEntries(Object.entries(o.contracts).map(([k, v]) => [k, v.address])) }; +} +for (const f of git("ls-tree", "--name-only", `${REF}`, "deployments/v2/verify/").split("\n").filter(Boolean)) { + const to = f.replace(/\.json$/, ".v2-0.json"); + if (!existsSync(`${ROOT}${to}`)) write(to, git("show", `${REF}:${f}`)); +} + +// 3. retry verification for anything left unverified +if (!flag("--no-verify")) { + for (const c of CHAINS) { + const r = records[c]; + const chain = chainConfig(c); + const plan = resolvePlan(r.deployer, { owner: r.owner, operator: r.operator, ...chainParams(c) }, SET_V2); + for (const s of plan) { + const k = r.contracts[s.name]; + if (k.address !== s.address) fail(`${c}: ${s.name} recorded at ${k.address}, plan says ${s.address}`); + if (k.verified?.ok) continue; + if (CHECK) { console.log(` would verify ${c} ${s.name}`); continue; } + k.verified = await verifyStep(chain, s, k.txHash); + console.log(` verify ${c} ${s.name}: ${k.verified.ok ? "OK via " + k.verified.via : "TODO " + JSON.stringify(k.verified).slice(0, 200)}`); + } + } +} +for (const c of CHAINS) write(`deployments/v2/${c}.json`, json(records[c])); + +// 4. README +const EXPL = { arc: (a) => `[Arc](https://explorer.arc.io/address/${a})`, base: (a) => `[Base](https://basescan.org/address/${a})` }; +const label = (n) => (n === "MockRefundProtocolV2" ? "MockRefundProtocolV2 (demo refund protocol)" : n); +const row = (n, a) => `| ${label(n)} | \`${a}\` | [Sourcify](https://sourcify.dev/#/lookup/${a}) | ${CHAINS.map((c) => (EXPL[c] ?? (() => c))(a)).join(", ")} |`; +const r0 = records[CHAINS[0]], o0 = old[CHAINS[0]]; +const OWNER_NOTE = (() => { try { return JSON.parse(readFileSync(`${ROOT}config/v2.config.json`, "utf8"))._owner_note ?? ""; } catch { return ""; } })().replace(/^[0-9.]+:\s*/, ""); +const allVerified = CHAINS.every((c) => Object.values(records[c].contracts).every((k) => k.verified?.ok)); +const day = (iso) => iso.slice(0, 10); +const section = `### v2 (current, v2.1): Arc (5042) and Base (8453) + +Deployed ${[...new Set(CHAINS.map((c) => day(records[c].deployedAt)))].join(" / ")} from set \`${SET_V2.version}\` (deployer nonces ${SET_V2.nonceBase}..${SET_V2.nonceBase + SET_V2.plan.length - 1}). The same six addresses on both chains. ${allVerified ? "All six are verified on Sourcify." : "**Verification incomplete: see the `verified` fields in the records.**"} Receipts: ${CHAINS.map((c) => `[\`deployments/v2/${c}.json\`](deployments/v2/${c}.json)`).join(", ")}. + +| Contract | Address (${CHAINS.map((c) => c[0].toUpperCase() + c.slice(1)).join(" and ")}) | Sourcify | Explorer | +|---|---|---|---| +${SET_V2.plan.map((s) => row(s.name, r0.contracts[s.name].address)).join("\n")} + +- **Owner:** \`${r0.owner}\`${OWNER_NOTE ? ` (${OWNER_NOTE})` : ""}. **Operator** (hot ops key, rotatable by the owner): \`${r0.operator}\`. +- **Bond floor:** minimal (bps = 0), per-chain floor from \`config/v2.chains.json\`. Since v2.1 the validator bond backs liveness only: \`recordScore\` refuses any verdict that contradicts the on-chain evidence (pass needs the output bytes hashed on-chain and equal to the client-committed \`expected\`; fail needs the opposite), so a false verdict cannot be recorded at any bond size and there is no challenge window. +- **Status:** v2.1 contains the fixes for the Olympix BugPoCer scan of \`c47ed42\` (23 Oct 2026) and the option (a) verdict check. The signed verdict API (\`api.predge.io\`) is unchanged and remains the product. + +### v2.0 (DEPRECATED, 20-21 Oct 2026): do not use + +The first v2 set (\`${OLD_VERSION}\`, commit \`c47ed42\`) is still on chain at the addresses below, because the contracts are not upgradeable. It is affected by the confirmed Olympix findings and held no funds when it was superseded. Nothing in Predge points at it. Records: ${CHAINS.map((c) => `[\`deployments/v2/${c}.v2-0.json\`](deployments/v2/${c}.v2-0.json)`).join(", ")}. + +| Contract | Deprecated address (${CHAINS.map((c) => c[0].toUpperCase() + c.slice(1)).join(" and ")}) | +|---|---| +${SET_V2.plan.map((s) => `| ${s.name} | ~~\`${o0.contracts[s.name].address}\`~~ |`).join("\n")} + +`; +const readme = readFileSync(`${ROOT}README.md`, "utf8"); +const start = readme.search(/^### v2 \(current/m), end = readme.indexOf("### v1 (legacy)"); +if (start < 0 || end < start) fail("README: could not find the v2 deployments section"); +write("README.md", readme.slice(0, start) + section + readme.slice(end)); +console.log(`${CHECK ? "check ok" : "done"}: ${CHAINS.join(", ")} -> ${first[0]}.. (v2.1); v2.0 archived from ${REF}`); diff --git a/test/foundry-v2/Base.sol b/test/foundry-v2/Base.sol index c47bf22..e7022b9 100644 --- a/test/foundry-v2/Base.sol +++ b/test/foundry-v2/Base.sol @@ -51,7 +51,7 @@ abstract contract Base is Test { function _deployJobAndBond() internal { vm.warp(1_760_000_000); // realistic timestamp: block.timestamp == 1 hides arithmetic mutants job = new AgentJobV2(EVAL_WINDOW); - bond = new PredgeValidatorBondV2(safe, op, address(job), WINDOW, MIN_BOND, BPS); + bond = new PredgeValidatorBondV2(safe, op, address(job), MIN_BOND, BPS); vm.deal(op, 1_000 ether); vm.deal(client, 1_000 ether); vm.deal(provider, 10 ether); diff --git a/test/foundry-v2/ConstructorGuards.t.sol b/test/foundry-v2/ConstructorGuards.t.sol index 7ee3675..84d0f21 100644 --- a/test/foundry-v2/ConstructorGuards.t.sol +++ b/test/foundry-v2/ConstructorGuards.t.sol @@ -23,7 +23,7 @@ contract ConstructorGuardsTest is Test { } function mkBond(address j, uint16 bps) external returns (address) { - return address(new PredgeValidatorBondV2(safe, op, j, 1 hours, 1, bps)); + return address(new PredgeValidatorBondV2(safe, op, j, 1, bps)); } function mkArb(address rp, uint16 bps) external returns (address) { diff --git a/test/foundry-v2/Invariants.t.sol b/test/foundry-v2/Invariants.t.sol index 52a904b..38ea392 100644 --- a/test/foundry-v2/Invariants.t.sol +++ b/test/foundry-v2/Invariants.t.sol @@ -79,29 +79,16 @@ contract JobBondHandler is Test { try job.submit(jobIds[i], d, out) {} catch {} } + /// @dev Option (a): a recorded verdict must equal the truth, so a successful call with a false score is a violation. function score(uint256 i, bool pass) external { if (specs.length == 0) return; i = bound(i, 0, specs.length - 1); bytes32 spec = specs[i]; - vm.prank(op); - try bond.recordScore(spec, pass ? 100 : 0, expectedOf[spec], saltOf[spec], bytes32(0)) {} catch {} - } - - function challenge(uint256 i) external { - if (specs.length == 0) return; - i = bound(i, 0, specs.length - 1); - bool should = bond.wouldSlash(specs[i]); - try bond.challenge(specs[i]) { if (!should) violation = true; } catch { if (should) violation = true; } - } - - function reclaim(uint256 i) external { - if (specs.length == 0) return; - i = bound(i, 0, specs.length - 1); - bytes32 spec = specs[i]; - (, , , , uint64 scoredAt, uint64 window, , , , ) = bond.stakes(spec); - try bond.reclaim(spec) { - // reclaim implies the window had elapsed from the score - if (block.timestamp < uint256(scoredAt) + window) violation = true; + IAgentJobV2.Job memory j = job.getJob(jobIds[i]); + bool delivered = j.hashedOnChain && j.deliverable == expectedOf[spec]; + vm.prank(bond.operator()); + try bond.recordScore(spec, pass ? 100 : 0, expectedOf[spec], saltOf[spec], bytes32(0)) { + if (pass != delivered) violation = true; } catch {} } @@ -147,15 +134,15 @@ contract JobBondHandler is Test { } function _checkStake(bytes32 spec) internal { - (, PredgeValidatorBondV2.Status st, uint8 sc, , uint64 scoredAt, , uint256 jid, , bytes32 c, bytes32 e) = bond.stakes(spec); + (, PredgeValidatorBondV2.Status st, uint8 sc, , uint64 scoredAt, uint256 jid, , bytes32 c, bytes32 e) = bond.stakes(spec); uint8 s8 = uint8(st); if (s8 < prevStatus[spec]) violation = true; // never moves backwards - if (prevStatus[spec] >= 3 && s8 != prevStatus[spec]) violation = true; // final states are final + if (prevStatus[spec] >= 2 && s8 != prevStatus[spec]) violation = true; // Scored / Released / Forfeited are final if (s8 != 0) { if (firstCommitment[spec] == 0) { firstCommitment[spec] = c; firstJobId[spec] = jid; } if (c != firstCommitment[spec] || jid != firstJobId[spec]) violation = true; } - if (s8 >= 2 && s8 != 5 && s8 != 6) { // Released / Forfeited carry no verdict + if (s8 == 2) { // only Scored carries a verdict if (firstScoredAt[spec] == 0) { firstScoredAt[spec] = scoredAt; firstExpected[spec] = e; firstScore[spec] = sc; } if (scoredAt != firstScoredAt[spec] || e != firstExpected[spec] || sc != firstScore[spec]) violation = true; } @@ -172,7 +159,7 @@ contract JobBondInvariantTest is StdInvariant, Test { function setUp() public { job = new AgentJobV2(3 days); - bond = new PredgeValidatorBondV2(address(0x5AFE), address(0x0A11), address(job), 1 days, 1e15, 10_000); + bond = new PredgeValidatorBondV2(address(0x5AFE), address(0x0A11), address(job), 1e15, 10_000); h = new JobBondHandler(job, bond); targetContract(address(h)); } @@ -188,8 +175,8 @@ contract JobBondInvariantTest is StdInvariant, Test { function invariant_totalBondedEqualsOpenBonds() public view { uint256 sum; for (uint256 i; i < h.specCount(); ++i) { - (, PredgeValidatorBondV2.Status st,,,,,, uint256 b,,) = bond.stakes(h.specs(i)); - if (st == PredgeValidatorBondV2.Status.Committed || st == PredgeValidatorBondV2.Status.Scored) sum += b; + (, PredgeValidatorBondV2.Status st,,,,, uint256 b,,) = bond.stakes(h.specs(i)); + if (st == PredgeValidatorBondV2.Status.Committed) sum += b; else assertEq(b, 0, "closed => bond == 0"); } assertEq(bond.totalBonded(), sum); @@ -210,21 +197,23 @@ contract JobBondInvariantTest is StdInvariant, Test { assertLe(job.totalCredit(), paid); } - function invariant_honestVerdictNeverSlashed() public view { + /// @dev Option (a): every verdict on record equals the on-chain truth, and the job settled the same way. + function invariant_everyRecordedVerdictIsTrue() public view { for (uint256 i; i < h.specCount(); ++i) { bytes32 spec = h.specs(i); - (, PredgeValidatorBondV2.Status st, uint8 score,,,,,,,) = bond.stakes(spec); - if (st != PredgeValidatorBondV2.Status.Slashed) continue; + (, PredgeValidatorBondV2.Status st, uint8 score,,,,,,) = bond.stakes(spec); + if (st != PredgeValidatorBondV2.Status.Scored) continue; IAgentJobV2.Job memory j = job.getJob(h.jobIds(i)); bool delivered = j.hashedOnChain && j.deliverable == h.expectedOf(spec); - assertTrue((score == 100 && !delivered) || (score == 0 && delivered), "slashed => the verdict contradicted the deliverable"); + assertEq(score, delivered ? 100 : 0, "recorded verdict == truth"); + assertEq(uint8(j.state), uint8(delivered ? IAgentJobV2.State.Completed : IAgentJobV2.State.Rejected)); } } /// @dev Olympix 5.2.2: a stake closed without a verdict pays the provider iff the provider had submitted. function invariant_silenceAfterSubmissionForfeits() public view { for (uint256 i; i < h.specCount(); ++i) { - (, PredgeValidatorBondV2.Status st,,,,,,,,) = bond.stakes(h.specs(i)); + (, PredgeValidatorBondV2.Status st,,,,,,,) = bond.stakes(h.specs(i)); uint64 submittedAt = job.getJob(h.jobIds(i)).submittedAt; if (st == PredgeValidatorBondV2.Status.Forfeited) assertGt(submittedAt, 0, "forfeit only after a submission"); if (st == PredgeValidatorBondV2.Status.Released) assertEq(submittedAt, 0, "returned only if nothing was submitted"); @@ -235,7 +224,7 @@ contract JobBondInvariantTest is StdInvariant, Test { function invariant_stakeCommitsToClientValue() public view { for (uint256 i; i < h.specCount(); ++i) { bytes32 spec = h.specs(i); - (, PredgeValidatorBondV2.Status st,,,,,,, bytes32 c,) = bond.stakes(spec); + (, PredgeValidatorBondV2.Status st,,,,,, bytes32 c,) = bond.stakes(spec); if (st == PredgeValidatorBondV2.Status.None) continue; assertEq(c, keccak256(abi.encode(h.expectedOf(spec), h.saltOf(spec)))); assertEq(bond.specHashFor(c), spec); @@ -244,12 +233,12 @@ contract JobBondInvariantTest is StdInvariant, Test { function invariant_scoredNeverBeforeSubmission() public view { for (uint256 i; i < h.specCount(); ++i) { - (, PredgeValidatorBondV2.Status st,,, uint64 scoredAt,,,,,) = bond.stakes(h.specs(i)); + (, PredgeValidatorBondV2.Status st,,, uint64 scoredAt,,,,) = bond.stakes(h.specs(i)); if ( st == PredgeValidatorBondV2.Status.Committed || st == PredgeValidatorBondV2.Status.None || st == PredgeValidatorBondV2.Status.Released || st == PredgeValidatorBondV2.Status.Forfeited ) continue; - assertGe(scoredAt, job.getJob(h.jobIds(i)).submittedAt, "H-01: window starts at or after the submission"); + assertGe(scoredAt, job.getJob(h.jobIds(i)).submittedAt, "H-01: a verdict never precedes the submission"); assertGt(job.getJob(h.jobIds(i)).submittedAt, 0); } } @@ -264,7 +253,7 @@ contract JobBondInvariantTest is StdInvariant, Test { function invariant_writeOnceAndMonotonic() public { h.snapshot(); - assertFalse(h.violation(), "write-once / monotonic / challenge-iff-lied / reclaim-after-window"); + assertFalse(h.violation(), "write-once / monotonic / only true verdicts recorded"); } function invariant_livenessNoPermanentLock() public view { diff --git a/test/foundry-v2/OlympixRegressions.t.sol b/test/foundry-v2/OlympixRegressions.t.sol index 372a940..0359d4c 100644 --- a/test/foundry-v2/OlympixRegressions.t.sol +++ b/test/foundry-v2/OlympixRegressions.t.sol @@ -38,38 +38,25 @@ contract OlympixBondRegressionTest is Base { } /// @dev 5.1.1-a/c, strongest remaining variant: the operator commits to the client's value (it must) and - /// leaks it to a colluding provider, who submits the right hash with NO bytes. The PoC's harm - /// assertions now hold: the pass is slashable, the client is made whole at full bond, the - /// colluders do not profit and the operator cannot reclaim. - function test_5_1_1_leakedExpectedWithoutBytesIsSlashable() public { - uint256 providerBefore = provider.balance; - uint256 opBefore = op.balance; + /// leaks it to a colluding provider, who submits the right hash with NO bytes. With option (a) the + /// false pass is refused outright: the escrow never moves, whatever the bond size. + function test_5_1_1_leakedExpectedWithoutBytesCannotPass() public { (uint256 id, bytes32 spec) = _newJob(ESCROW, EXPECTED, SALT); - assertEq(bond.requiredBond(ESCROW), ESCROW, "bond fully covers the escrow"); _stake(spec, EXPECTED, SALT, id, ESCROW); _submit(id, EXPECTED); // claimed hash only: no work, no bytes assertFalse(job.getJob(id).hashedOnChain); - _score(spec, 100, EXPECTED, SALT); - assertEq(job.credit(provider), ESCROW, "the false pass still moves the escrow..."); - - assertTrue(bond.wouldSlash(spec), "...but it is now slashable"); - vm.prank(client); - bond.challenge(spec); - vm.warp(block.timestamp + WINDOW); - vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); - bond.reclaim(spec); - - vm.prank(provider); - job.withdraw(); - int256 colludersProfit = - (int256(provider.balance) - int256(providerBefore)) + (int256(op.balance) - int256(opBefore)); - assertLe(colludersProfit, 0, "colluders do not profit at full bond"); - assertGe(bond.credit(client) + job.credit(client), ESCROW, "client is made whole"); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + assertEq(job.credit(provider), 0, "the escrow did not move"); + _score(spec, 0, EXPECTED, SALT); // the only verdict the operator can record + assertEq(job.credit(client), ESCROW, "client refunded"); + assertEq(job.credit(provider), 0); } - /// @dev 5.1.1-b false fail: the operator cannot commit an unmatchable value; with the client's value, a 0 - /// on real bytes is slashable and pays the provider. - function test_5_1_1_falseFailOnRealWorkIsSlashable() public { + /// @dev 5.1.1-b false fail: the operator cannot commit an unmatchable value, and with the client's value a 0 + /// on real bytes is refused; the provider is paid. + function test_5_1_1_falseFailOnRealWorkRefused() public { (uint256 id, bytes32 spec) = _newJob(ESCROW, EXPECTED, SALT); bytes32 unmatchable = keccak256("garbage the operator picked"); vm.prank(op); @@ -79,12 +66,29 @@ contract OlympixBondRegressionTest is Base { _stake(spec, EXPECTED, SALT, id, ESCROW); _deliver(id, GOOD); assertTrue(job.getJob(id).hashedOnChain); - _score(spec, 0, EXPECTED, SALT); // false fail: client credited the escrow - assertTrue(bond.wouldSlash(spec)); - vm.prank(provider); - bond.challenge(spec); - assertGe(job.credit(provider) + bond.credit(provider), ESCROW, "provider made whole at full bond"); - assertEq(bond.credit(op), 0, "operator loses its bond"); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec, 0, EXPECTED, SALT, 0); + _score(spec, 100, EXPECTED, SALT); + assertEq(job.credit(provider), ESCROW, "provider paid"); + } + + /// @dev 5.1.2 (High, bond not collateralised against escrow): with option (a) the bond size no longer matters for + /// correctness. At the shipped bondBps = 0, a colluding operator and provider cannot record a false pass on a + /// mismatching on-chain deliverable, for any escrow. + function testFuzz_5_1_2_falsePassImpossibleAtShippedParams(uint96 escrow, bytes calldata wrong) public { + vm.assume(wrong.length != 0 && sha256(wrong) != EXPECTED); + escrow = uint96(bound(escrow, 1, 1_000 ether)); + vm.prank(safe); + bond.setParams(MIN_BOND, 0); + (uint256 id, bytes32 spec) = _newJob(escrow, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, MIN_BOND); + _deliver(id, wrong); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec, 100, EXPECTED, SALT, 0); + assertEq(job.credit(provider), 0); + assertEq(job.totalEscrowed(), escrow); } // ------------------------------------------------------------ 5.2.2 (a, b, c, d, e) @@ -93,7 +97,7 @@ contract OlympixBondRegressionTest is Base { /// and the provider receives it. function test_5_2_2_silenceForfeitsFloorBondToProvider() public { vm.prank(safe); - bond.setParams(WINDOW, MIN_BOND, 0); + bond.setParams(MIN_BOND, 0); (uint256 id, bytes32 spec) = _newJob(100 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, MIN_BOND); _deliver(id, GOOD); diff --git a/test/foundry-v2/Pocs.t.sol b/test/foundry-v2/Pocs.t.sol index 03c6f04..8bc5ecf 100644 --- a/test/foundry-v2/Pocs.t.sol +++ b/test/foundry-v2/Pocs.t.sol @@ -47,7 +47,7 @@ contract PocsBondTest is Base { } /// @dev v2: the same sequence cannot even start. Scoring needs a submission, so nothing can be - /// scored or reclaimed before the deliverable exists, and the window starts at the score. + /// scored before the deliverable exists, and the bond only comes back with a verdict or on expiry. function test_v2_H01_verdictBeforeSubmissionReverts() public { (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); @@ -55,8 +55,8 @@ contract PocsBondTest is Base { vm.expectRevert(PredgeValidatorBondV2.NotSubmitted.selector); bond.recordScore(spec, 100, E, SALT, 0); vm.warp(block.timestamp + 1 days); - vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); - bond.reclaim(spec); // nothing to reclaim without a verdict + vm.expectRevert(PredgeValidatorBondV2.JobNotExpired.selector); + bond.release(spec); // no way to get the bond back without a verdict while the job runs // The only exit is the job expiring with NO verdict ever given, which lies about nothing. vm.warp(block.timestamp + 7 days); job.claimRefund(id); @@ -64,18 +64,19 @@ contract PocsBondTest is Base { assertEq(job.credit(client), 1 ether, "client got the escrow back"); } - /// @dev v2: a submission that lands after the score attempt is judged inside a full window. - function test_v2_H01_windowStartsAtOrAfterSubmission() public { + /// @dev v2: a late submission is still judged against its own bytes; the lie the v1 PoC recorded is refused. + function test_v2_H01_lateSubmissionStillJudgedOnEvidence() public { (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); vm.warp(block.timestamp + 2 days); _submit(id, X); - _score(spec, 100, E, SALT); // a lie - (,,,, uint64 scoredAt,,,,,) = bond.stakes(spec); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec, 100, E, SALT, 0); // the lie + _score(spec, 0, E, SALT); + (,,,, uint64 scoredAt,,,,) = bond.stakes(spec); assertGe(scoredAt, job.getJob(id).submittedAt); - vm.warp(block.timestamp + 1 days - 1); - bond.challenge(spec); // still inside its own full window, however long ago the stake was made - assertEq(bond.credit(client), 1 ether); + assertEq(job.credit(client), 1 ether); } // ------------------------------------------------------------------ H-02 @@ -106,15 +107,13 @@ contract PocsBondTest is Base { function test_v2_H02_expectedIsHiddenSoItCannotBeCopied() public { (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); - (,,,,,,,, bytes32 commitment, bytes32 expected) = bond.stakes(spec); + (,,,,,,, bytes32 commitment, bytes32 expected) = bond.stakes(spec); assertEq(expected, bytes32(0)); assertEq(commitment, keccak256(abi.encode(E, SALT))); // The best the provider can do with public data is submit the commitment itself. _submit(id, commitment); - _score(spec, 0, E, SALT); // honest: the deliverable is not the expected hash - assertFalse(bond.wouldSlash(spec)); - vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); - bond.challenge(spec); + _score(spec, 0, E, SALT); // honest: the deliverable is not the expected hash, and nothing can overturn it + assertEq(bond.credit(op), 1 ether, "the honest operator keeps its bond"); } /// @dev v2: the operator cannot choose `expected` after seeing the deliverable. @@ -143,18 +142,16 @@ contract PocsBondTest is Base { // ------------------------------------------------------------------ M-02 / M-04 - /// @dev M-02: the bond is paid to the harmed party; a self-challenge by the operator gains nothing. - function test_v2_M02_selfChallengeIsNotProfitable() public { + /// @dev M-02 (v1: slash paid the caller). v2 has no challenge at all: the false pass a self-challenge would have + /// "punished" cannot be recorded in the first place. + function test_v2_M02_falsePassCannotBeRecorded() public { (uint256 id, bytes32 spec) = _newJob(5 ether, E, SALT); _stake(spec, E, SALT, id, 5 ether); _submit(id, X); - _score(spec, 100, E, SALT); // false pass, provider gets 5 - uint256 opBefore = op.balance; vm.prank(op); - bond.challenge(spec); - assertEq(bond.credit(op), 0); - assertEq(op.balance, opBefore); - assertEq(bond.credit(client), 5 ether, "the cheated client is made whole"); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec, 100, E, SALT, 0); + assertEq(job.credit(provider), 0); } /// @dev M-04: the verdict IS the settlement; there is no way to record a verdict that does not move the job. @@ -170,19 +167,22 @@ contract PocsBondTest is Base { job.reject(id, 0, ""); } - /// @dev A colluding operator and provider cannot profit from a false pass at the default 100% bond ratio. - function testFuzz_collusionNeverProfitsAtFullBond(uint96 escrow) public { + /// @dev A colluding operator and provider cannot extract the escrow with a false pass, at ANY bond ratio. + function testFuzz_collusionCannotExtractEscrowAtAnyBond(uint96 escrow, uint16 bps) public { escrow = uint96(bound(escrow, 1, 100 ether)); + bps = uint16(bound(bps, 0, 10_000)); + vm.prank(safe); + bond.setParams(MIN_BOND, bps); (uint256 id, bytes32 spec) = _newJob(escrow, E, SALT); uint256 b = bond.requiredBond(escrow); _stake(spec, E, SALT, id, b); _submit(id, X); - _score(spec, 100, E, SALT); - bond.challenge(spec); - uint256 colludersGain = job.credit(provider); // the escrow they extracted - uint256 colludersLoss = b; // the bond they lost to the client - assertGe(colludersLoss, colludersGain); - assertGe(bond.credit(client), escrow, "client is made whole"); + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec, 100, E, SALT, 0); + _score(spec, 0, E, SALT); + assertEq(job.credit(provider), 0, "colluders extracted nothing"); + assertEq(job.credit(client), escrow, "client is made whole"); } } diff --git a/test/foundry-v2/PredgeValidatorBondV2.t.sol b/test/foundry-v2/PredgeValidatorBondV2.t.sol index abbe9a2..e575e90 100644 --- a/test/foundry-v2/PredgeValidatorBondV2.t.sol +++ b/test/foundry-v2/PredgeValidatorBondV2.t.sol @@ -12,13 +12,11 @@ contract PredgeValidatorBondV2Test is Base { bytes32 internal constant SALT = keccak256("salt"); bytes32 internal constant OTHER = keccak256("something else"); - event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond, uint64 window); - event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, uint64 scoredAt, uint64 windowEnd); - event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered); - event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond); + event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond); + event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, address staker, uint256 bond); event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond); event Forfeited(bytes32 indexed requestHash, address indexed provider, uint256 bond); - event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps); + event ParamsUpdated(uint256 minBond, uint16 bondBps); function setUp() public { _deployJobAndBond(); @@ -28,48 +26,45 @@ contract PredgeValidatorBondV2Test is Base { // ---- construction / params (M-01, L-01) ---- /// @dev `new` under `vm.expectRevert` ends the test early in forge; deploy through an external call instead. - function mk(address o, address p, address j, uint64 w, uint256 m, uint16 b) external returns (address) { - return address(new PredgeValidatorBondV2(o, p, j, w, m, b)); + function mk(address o, address p, address j, uint256 m, uint16 b) external returns (address) { + return address(new PredgeValidatorBondV2(o, p, j, m, b)); } function test_constructor() public view { assertEq(bond.owner(), safe); assertEq(bond.operator(), op); assertEq(address(bond.job()), address(job)); - assertEq(bond.disputeWindow(), WINDOW); assertEq(bond.minBond(), MIN_BOND); assertEq(bond.bondBps(), BPS); } function test_constructor_reverts() public { vm.expectRevert(Owned2Step.ZeroAddress.selector); - this.mk(address(0), op, address(job), WINDOW, MIN_BOND, BPS); + this.mk(address(0), op, address(job), MIN_BOND, BPS); vm.expectRevert(Owned2Step.ZeroAddress.selector); - this.mk(safe, address(0), address(job), WINDOW, MIN_BOND, BPS); + this.mk(safe, address(0), address(job), MIN_BOND, BPS); vm.expectRevert(Owned2Step.ZeroAddress.selector); - this.mk(safe, op, address(0), WINDOW, MIN_BOND, BPS); + this.mk(safe, op, address(0), MIN_BOND, BPS); vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); - this.mk(safe, op, address(job), 59 minutes, MIN_BOND, BPS); + this.mk(safe, op, address(job), 0, BPS); vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); - this.mk(safe, op, address(job), 31 days, MIN_BOND, BPS); - vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); - this.mk(safe, op, address(job), WINDOW, 0, BPS); - vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); - this.mk(safe, op, address(job), WINDOW, MIN_BOND, 10_001); + this.mk(safe, op, address(job), MIN_BOND, 10_001); + this.mk(safe, op, address(job), 1, 10_000); // both bounds inclusive } function test_setParams_ownerOnlyAndBounded() public { vm.prank(op); vm.expectRevert(Owned2Step.NotOwner.selector); - bond.setParams(2 days, 1, 5000); + bond.setParams(1, 5000); vm.startPrank(safe); vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); - bond.setParams(1 minutes, 1, 5000); + bond.setParams(0, 5000); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + bond.setParams(1, 10_001); vm.expectEmit(false, false, false, true); - emit ParamsUpdated(2 days, 7, 5000); - bond.setParams(2 days, 7, 5000); + emit ParamsUpdated(7, 5000); + bond.setParams(7, 5000); vm.stopPrank(); - assertEq(bond.disputeWindow(), 2 days); assertEq(bond.minBond(), 7); assertEq(bond.bondBps(), 5000); } @@ -78,24 +73,21 @@ contract PredgeValidatorBondV2Test is Base { (uint256 id, bytes32 spec) = _newJob(10 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 10 ether); vm.prank(safe); - bond.setParams(30 days, MIN_BOND, BPS); // owner stretches the window afterwards + bond.setParams(MIN_BOND, 0); // owner drops the bps afterwards _deliver(id, GOOD); _score(spec, 100, EXPECTED, SALT); - (,,,, , uint64 snap,,,,) = bond.stakes(spec); - assertEq(snap, WINDOW, "stake keeps the window it was made with"); - vm.warp(block.timestamp + WINDOW); - bond.reclaim(spec); // would revert WindowOpen if the new 30d window applied + assertEq(bond.credit(op), 10 ether, "the staker gets back exactly what it posted"); } function test_requiredBond_isMaxOfFloorAndScaled() public { assertEq(bond.requiredBond(1), MIN_BOND); assertEq(bond.requiredBond(10 ether), 10 ether); vm.prank(safe); - bond.setParams(WINDOW, MIN_BOND, 2500); + bond.setParams(MIN_BOND, 2500); assertEq(bond.requiredBond(10 ether), 2.5 ether); assertEq(bond.requiredBond(3), MIN_BOND); vm.prank(safe); - bond.setParams(WINDOW, 1, 3333); + bond.setParams(1, 3333); assertEq(bond.requiredBond(10), 4, "rounds up: ceil(3.333)"); } @@ -104,18 +96,17 @@ contract PredgeValidatorBondV2Test is Base { function test_stake_ok() public { (uint256 id, bytes32 spec) = _newJob(2 ether, EXPECTED, SALT); vm.expectEmit(true, true, true, true); - emit Committed(spec, id, op, _commitment(EXPECTED, SALT), 2 ether, WINDOW); + emit Committed(spec, id, op, _commitment(EXPECTED, SALT), 2 ether); _stake(spec, EXPECTED, SALT, id, 2 ether); - (address staker, PredgeValidatorBondV2.Status st,,,,, uint256 jobId, uint256 b, bytes32 c, bytes32 e) = bond.stakes(spec); + (address staker, PredgeValidatorBondV2.Status st,, uint64 stakedAt,, uint256 jobId, uint256 b, bytes32 c, bytes32 e) = bond.stakes(spec); assertEq(staker, op); assertEq(uint8(st), uint8(PredgeValidatorBondV2.Status.Committed)); + assertEq(stakedAt, block.timestamp); assertEq(jobId, id); assertEq(b, 2 ether); assertEq(c, _commitment(EXPECTED, SALT)); assertEq(e, bytes32(0), "expected is hidden until the reveal"); assertEq(bond.totalBonded(), 2 ether); - (,,, uint64 stakedAt,,,,,,) = bond.stakes(spec); - assertEq(stakedAt, block.timestamp); } function test_stake_reverts() public { @@ -189,7 +180,7 @@ contract PredgeValidatorBondV2Test is Base { bond.setOperator(newOp); // the owner rotates to the job's client vm.prank(newOp); vm.expectRevert(PredgeValidatorBondV2.ClientIsOperator.selector); - bond.recordScore(spec, 0, EXPECTED, SALT, 0); + bond.recordScore(spec, 100, EXPECTED, SALT, 0); } function test_stake_notOpenOnceSubmittedOrLapsed() public { @@ -209,29 +200,37 @@ contract PredgeValidatorBondV2Test is Base { // ---- recordScore ---- - function test_score_pass_movesTheMoney() public { + function test_score_pass_movesTheMoneyAndReturnsTheBond() public { (uint256 id, bytes32 spec) = _newJob(4 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 4 ether); _deliver(id, GOOD); + assertEq(bond.verdictFor(id, EXPECTED), 100); vm.expectEmit(true, true, false, true); - emit Scored(spec, id, 100, EXPECTED, uint64(block.timestamp), uint64(block.timestamp) + WINDOW); + emit Scored(spec, id, 100, EXPECTED, op, 4 ether); _score(spec, 100, EXPECTED, SALT); assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Completed)); assertEq(job.credit(provider), 4 ether); assertEq(job.getJob(id).reason, keccak256("reason")); - (, PredgeValidatorBondV2.Status st,, , uint64 scoredAt,,,,, bytes32 revealed) = bond.stakes(spec); + (, PredgeValidatorBondV2.Status st, uint8 sc,, uint64 scoredAt,, uint256 left,, bytes32 revealed) = bond.stakes(spec); assertEq(uint8(st), uint8(PredgeValidatorBondV2.Status.Scored)); + assertEq(sc, 100); assertEq(revealed, EXPECTED); - assertGe(scoredAt, job.getJob(id).submittedAt, "window can never start before the submission"); + assertEq(left, 0, "a scored stake holds no bond"); + assertEq(scoredAt, block.timestamp); + assertGe(scoredAt, job.getJob(id).submittedAt, "the verdict never precedes the submission"); + assertEq(bond.credit(op), 4 ether, "bond credited back to the staker with the verdict"); + assertEq(bond.totalBonded(), 0); } function test_score_fail_refundsClient() public { (uint256 id, bytes32 spec) = _newJob(4 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 4 ether); _submit(id, OTHER); + assertEq(bond.verdictFor(id, EXPECTED), 0); _score(spec, 0, EXPECTED, SALT); assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Rejected)); assertEq(job.credit(client), 4 ether); + assertEq(bond.credit(op), 4 ether); } function test_score_reverts() public { @@ -239,6 +238,8 @@ contract PredgeValidatorBondV2Test is Base { vm.startPrank(op); vm.expectRevert(PredgeValidatorBondV2.BadScore.selector); bond.recordScore(spec, 50, EXPECTED, SALT, 0); + vm.expectRevert(PredgeValidatorBondV2.BadScore.selector); + bond.recordScore(spec, 1, EXPECTED, SALT, 0); vm.expectRevert(PredgeValidatorBondV2.NotCommitted.selector); bond.recordScore(spec, 100, EXPECTED, SALT, 0); bond.stakeAndCommit{value: 1 ether}(spec, _commitment(EXPECTED, SALT), id); @@ -269,93 +270,57 @@ contract PredgeValidatorBondV2Test is Base { bond.recordScore(spec, 100, EXPECTED, SALT, 0); } - // ---- challenge ---- + // ---- option (a): a false verdict cannot be recorded ---- - /// @dev `delivered == EXPECTED` means the provider delivers the real bytes; anything else is a claimed hash. - function _scored(uint256 n, bytes32 delivered, uint8 score, uint256 escrow) internal returns (uint256 id, bytes32 spec) { - bytes32 salt = keccak256(abi.encode(SALT, n)); - (id, spec) = _newJob(escrow, EXPECTED, salt); - _stake(spec, EXPECTED, salt, id, bond.requiredBond(escrow)); - if (delivered == EXPECTED) _deliver(id, GOOD); - else _submit(id, delivered); - _score(spec, score, EXPECTED, salt); - } - - function test_challenge_falsePass_paysClientNotCaller() public { - (, bytes32 spec) = _scored(1, OTHER, 100, 5 ether); - assertTrue(bond.wouldSlash(spec)); - uint256 callerBefore = stranger.balance; - vm.expectEmit(true, true, true, true); - emit Slashed(spec, stranger, client, 5 ether, 100, OTHER); - vm.prank(stranger); - bond.challenge(spec); - assertEq(bond.credit(client), 5 ether, "harmed client is credited"); - assertEq(bond.credit(stranger), 0, "challenger earns nothing"); - assertEq(stranger.balance, callerBefore); - assertEq(bond.totalBonded(), 0); - assertEq(bond.slashCount(), 1); - (,,,,,,, uint256 left,,) = bond.stakes(spec); - assertEq(left, 0, "slashed stake holds no bond"); - assertFalse(bond.wouldSlash(spec)); - vm.prank(client); - bond.withdraw(); - assertEq(client.balance, 1_000 ether - 5 ether + 5 ether, "client lost the escrow and got the bond"); + function _expectContradiction(bytes32 spec, uint8 score, bytes32 salt) internal { + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec, score, EXPECTED, salt, 0); } - function test_challenge_falseFail_paysProvider() public { - (, bytes32 spec) = _scored(1, EXPECTED, 0, 5 ether); - vm.prank(stranger); - bond.challenge(spec); - assertEq(bond.credit(provider), 5 ether); + function test_falsePass_wrongBytes_reverts() public { + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 1 ether); + _deliver(id, "not the output"); + _expectContradiction(spec, 100, SALT); + _score(spec, 0, EXPECTED, SALT); // the true verdict goes through + assertEq(job.credit(client), 1 ether); } - function test_challenge_honestVerdictsRevert() public { - (, bytes32 s1) = _scored(1, EXPECTED, 100, 1 ether); - (, bytes32 s2) = _scored(2, OTHER, 0, 1 ether); - assertFalse(bond.wouldSlash(s1)); - assertFalse(bond.wouldSlash(s2)); - vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); - bond.challenge(s1); - vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); - bond.challenge(s2); + /// @dev The provider knows `expected` (e.g. leaked by the client) and claims it without bytes: still not a delivery. + function test_falsePass_claimedHashWithoutBytes_reverts() public { + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 1 ether); + _submit(id, EXPECTED); + assertEq(bond.verdictFor(id, EXPECTED), 0); + _expectContradiction(spec, 100, SALT); + _score(spec, 0, EXPECTED, SALT); + assertEq(job.credit(provider), 0); } - function test_challenge_stateAndWindowGuards() public { - vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); - bond.challenge(keccak256("none")); - (, bytes32 spec) = _scored(1, OTHER, 100, 1 ether); - vm.warp(block.timestamp + WINDOW); - assertFalse(bond.wouldSlash(spec)); - vm.expectRevert(PredgeValidatorBondV2.WindowClosed.selector); - bond.challenge(spec); - bond.reclaim(spec); // a lie nobody challenged in time stands, by design - vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); - bond.challenge(spec); + function test_falseFail_correctBytes_reverts() public { + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 1 ether); + _deliver(id, GOOD); + _expectContradiction(spec, 0, SALT); + _score(spec, 100, EXPECTED, SALT); + assertEq(job.credit(provider), 1 ether); } - // ---- reclaim / release ---- - - function test_reclaim_paysStakerAfterWindowEvenAfterRotation() public { - (, bytes32 spec) = _scored(1, EXPECTED, 100, 3 ether); - vm.expectRevert(PredgeValidatorBondV2.WindowOpen.selector); - bond.reclaim(spec); - address newOp = makeAddr("newOp"); + /// @dev bondBps = 0 (the shipped value) and the smallest floor: the false pass is still impossible (Olympix 5.1.2). + function test_falsePass_impossibleAtZeroBps() public { vm.prank(safe); - bond.setOperator(newOp); - vm.warp(block.timestamp + WINDOW); - vm.expectEmit(true, true, false, true); - emit Reclaimed(spec, op, 3 ether); - vm.prank(newOp); // the new key triggers it, the OLD staker is credited (L-04) - bond.reclaim(spec); - assertEq(bond.credit(op), 3 ether); - assertEq(bond.credit(newOp), 0); - assertEq(bond.totalBonded(), 0); - (,,,,,,, uint256 left,,) = bond.stakes(spec); - assertEq(left, 0, "reclaimed stake holds no bond"); - vm.expectRevert(PredgeValidatorBondV2.NotScored.selector); - bond.reclaim(spec); + bond.setParams(1, 0); + (uint256 id, bytes32 spec) = _newJob(100 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 1); + _deliver(id, "colluding provider, wrong output"); + _expectContradiction(spec, 100, SALT); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Submitted)); + assertEq(job.credit(provider), 0); } + // ---- release ---- + function test_release_whenJobExpiredWithoutVerdict() public { (uint256 id, bytes32 spec) = _newJob(2 ether, EXPECTED, SALT); _stake(spec, EXPECTED, SALT, id, 2 ether); @@ -365,10 +330,18 @@ contract PredgeValidatorBondV2Test is Base { vm.expectRevert(PredgeValidatorBondV2.JobNotExpired.selector); // lapsed but not yet claimed bond.release(spec); job.claimRefund(id); + address newOp = makeAddr("newOp"); + vm.prank(safe); + bond.setOperator(newOp); vm.expectEmit(true, true, false, true); emit Released(spec, op, 2 ether); + vm.prank(newOp); // the new key triggers it, the OLD staker is credited (L-04) bond.release(spec); assertEq(bond.credit(op), 2 ether); + assertEq(bond.credit(newOp), 0); + (, PredgeValidatorBondV2.Status st,,,,, uint256 left,,) = bond.stakes(spec); + assertEq(uint8(st), uint8(PredgeValidatorBondV2.Status.Released)); + assertEq(left, 0); vm.expectRevert(PredgeValidatorBondV2.BadStatus.selector); bond.release(spec); } @@ -386,7 +359,7 @@ contract PredgeValidatorBondV2Test is Base { assertEq(bond.credit(op), 0); assertEq(bond.credit(provider), 2 ether); assertEq(bond.totalBonded(), 0); - (, PredgeValidatorBondV2.Status st,,,,,, uint256 left,,) = bond.stakes(spec); + (, PredgeValidatorBondV2.Status st,,,,, uint256 left,,) = bond.stakes(spec); assertEq(uint8(st), uint8(PredgeValidatorBondV2.Status.Forfeited)); assertEq(left, 0); assertEq(job.credit(client), 2 ether, "documented timeout policy: the escrow still goes to the client"); @@ -397,67 +370,75 @@ contract PredgeValidatorBondV2Test is Base { function test_release_reverts() public { vm.expectRevert(PredgeValidatorBondV2.NotCommitted.selector); bond.release(keccak256("none")); - (, bytes32 spec) = _scored(1, EXPECTED, 100, 1 ether); + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 1 ether); + _deliver(id, GOOD); + _score(spec, 100, EXPECTED, SALT); vm.expectRevert(PredgeValidatorBondV2.BadStatus.selector); bond.release(spec); } // ---- pull payments ---- - function test_payoutToRevertingStakerDoesNotBlock() public { - // The harmed party cannot receive value: the slash still lands as credit and can be redirected. + function test_payoutToRevertingProviderDoesNotBlock() public { + // The forfeit beneficiary cannot receive value: the bond still lands as credit and can be redirected. NoReceive bad = new NoReceive(); bytes32 spec = _bspec(EXPECTED, SALT); - vm.deal(address(bad), 1 ether); - vm.prank(address(bad)); - uint256 id = job.createJob{value: 1 ether}(provider, address(bond), spec, uint64(block.timestamp + 1 days)); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(address(bad), address(bond), spec, uint64(block.timestamp + 1 days)); _stake(spec, EXPECTED, SALT, id, 1 ether); - _submit(id, OTHER); - _score(spec, 100, EXPECTED, SALT); - bond.challenge(spec); + vm.prank(address(bad)); + job.submit(id, OTHER, ""); + vm.warp(block.timestamp + EVAL_WINDOW); + job.claimRefund(id); + bond.release(spec); assertEq(bond.credit(address(bad)), 1 ether); vm.expectRevert(PullCredit.TransferFailed.selector); bad.callWithdraw(address(bond)); vm.prank(address(bad)); bond.withdrawTo(payable(stranger)); assertEq(bond.credit(address(bad)), 0); + assertEq(stranger.balance, 10 ether + 1 ether); } // ---- fuzz ---- - /// @dev Whatever the provider submits, the verdict is slashable iff it contradicts the truth, where - /// "delivered" means bytes hashed on-chain whose hash equals the client's expected value. - function testFuzz_slashableIffContradicted(bytes32 salt, bool viaBytes, bool matching, bool claimPass, bytes32 junk) public { + /// @dev Whatever the provider submits and whatever the operator claims, recordScore accepts the verdict + /// iff it equals the truth ("delivered" = bytes hashed on-chain whose hash equals the client's + /// expected value). A false verdict always reverts and moves nothing. + function testFuzz_noFalseVerdictCanBeRecorded(bytes32 salt, bool viaBytes, bool matching, bool claimPass, bytes32 junk, uint96 escrow) + public + { vm.assume(junk != bytes32(0) && junk != EXPECTED); - (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, salt); - _stake(spec, EXPECTED, salt, id, 1 ether); + escrow = uint96(bound(escrow, 1, 100 ether)); + (uint256 id, bytes32 spec) = _newJob(escrow, EXPECTED, salt); + uint256 b = bond.requiredBond(escrow); + _stake(spec, EXPECTED, salt, id, b); if (viaBytes) _deliver(id, matching ? GOOD : abi.encode(junk)); else _submit(id, matching ? EXPECTED : junk); // a claimed hash, even the right one, is not a delivery - _score(spec, claimPass ? 100 : 0, EXPECTED, salt); bool truth = viaBytes && matching; - bool lied = claimPass != truth; - assertEq(bond.wouldSlash(spec), lied); - if (lied) { - bond.challenge(spec); - assertEq(bond.credit(claimPass ? client : provider), 1 ether); + assertEq(bond.verdictFor(id, EXPECTED), truth ? 100 : 0); + uint8 claimed = claimPass ? 100 : 0; + if (claimPass != truth) { + _expectContradiction(spec, claimed, salt); + assertEq(uint8(job.getJob(id).state), uint8(IAgentJobV2.State.Submitted), "a false verdict moves nothing"); + assertEq(bond.totalBonded(), b); } else { - vm.expectRevert(PredgeValidatorBondV2.VerdictHonest.selector); - bond.challenge(spec); + _score(spec, claimed, EXPECTED, salt); + assertEq(uint8(job.getJob(id).state), uint8(truth ? IAgentJobV2.State.Completed : IAgentJobV2.State.Rejected)); + assertEq(job.credit(truth ? provider : client), escrow); + assertEq(bond.credit(op), b); } } - /// @dev Self-challenge is never profitable: whoever calls challenge, the operator's net is <= 0 on a lie. - function testFuzz_operatorNeverProfitsFromChallenging(uint96 escrow, bool claimPass) public { - escrow = uint96(bound(escrow, 1, 100 ether)); - (uint256 id, bytes32 spec) = _newJob(escrow, EXPECTED, SALT); - uint256 b = bond.requiredBond(escrow); - _stake(spec, EXPECTED, SALT, id, b); - if (claimPass) _submit(id, OTHER); // the verdict below is the opposite of the truth - else _deliver(id, GOOD); - _score(spec, claimPass ? 100 : 0, EXPECTED, SALT); - vm.prank(op); - bond.challenge(spec); - assertEq(bond.credit(op), 0); - assertGe(bond.credit(claimPass ? client : provider), b); + /// @dev Any output bytes at all: a pass is recorded iff sha256(bytes) == expected, a fail iff not. + function testFuzz_passIffOnChainHashMatches(bytes calldata out, bool claimPass) public { + vm.assume(out.length != 0); + (uint256 id, bytes32 spec) = _newJob(1 ether, EXPECTED, SALT); + _stake(spec, EXPECTED, SALT, id, 1 ether); + _deliver(id, out); + bool truth = sha256(out) == EXPECTED; + if (claimPass != truth) _expectContradiction(spec, claimPass ? 100 : 0, SALT); + else _score(spec, claimPass ? 100 : 0, EXPECTED, SALT); } } diff --git a/test/foundry-v2/V1Findings.t.sol b/test/foundry-v2/V1Findings.t.sol index 58ea619..319d87e 100644 --- a/test/foundry-v2/V1Findings.t.sol +++ b/test/foundry-v2/V1Findings.t.sol @@ -52,7 +52,7 @@ contract V1FindingsBondTest is Base { assertEq(address(job).balance, big); vm.deal(op, big); _stake(spec, E, SALT, id, big); - (,,,,,,, uint256 b,,) = bond.stakes(spec); + (,,,,,, uint256 b,,) = bond.stakes(spec); assertEq(b, big); assertEq(bond.totalBonded(), big); } @@ -78,15 +78,13 @@ contract V1FindingsBondTest is Base { (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); _stake(spec, E, SALT, id, 1 ether); _deliver(id, GOOD); - _score(spec, 100, E, SALT); address newOp = makeAddr("newOp"); vm.prank(safe); bond.setOperator(newOp); - vm.warp(block.timestamp + WINDOW); - vm.prank(newOp); - bond.reclaim(spec); + vm.prank(newOp); // the new key scores (it was handed the preimage); the OLD staker is credited + bond.recordScore(spec, 100, E, SALT, 0); assertEq(bond.credit(newOp), 0); - assertEq(bond.credit(op), 1 ether); + assertEq(bond.credit(op), 1 ether, "the bond came back to the staker with the verdict"); } // test_finding_comparisonIsRawEquality (VB-05): raw equality of the stored 32 bytes, and only for a hash @@ -98,38 +96,25 @@ contract V1FindingsBondTest is Base { _stake(spec, h, SALT, id, 1 ether); _deliver(id, out); _score(spec, 100, h, SALT); - assertFalse(bond.wouldSlash(spec)); - // a claimed hash equal to `expected`, with no bytes, is NOT a delivery: passing it is slashable + // a claimed hash equal to `expected`, with no bytes, is NOT a delivery: passing it is refused bytes32 salt2 = keccak256("salt2"); (uint256 id2, bytes32 spec2) = _newJob(1 ether, E, salt2); _stake(spec2, E, salt2, id2, 1 ether); _submit(id2, E); - _score(spec2, 100, E, salt2); - assertTrue(bond.wouldSlash(spec2)); - } - - // Intended v2 challenge semantics, explicit: allowed while now < scoredAt+window, impossible from then on. - function test_challengeDeadlineBoundary() public { - (uint256 id, bytes32 spec) = _newJob(1 ether, E, SALT); - _stake(spec, E, SALT, id, 1 ether); - _submit(id, X); - _score(spec, 100, E, SALT); - vm.warp(block.timestamp + WINDOW - 1); - assertTrue(bond.wouldSlash(spec)); - vm.warp(block.timestamp + 1); - assertFalse(bond.wouldSlash(spec)); - vm.expectRevert(PredgeValidatorBondV2.WindowClosed.selector); - bond.challenge(spec); - bond.reclaim(spec); // reclaim opens at exactly the instant challenge closes + vm.prank(op); + vm.expectRevert(PredgeValidatorBondV2.VerdictContradictsEvidence.selector); + bond.recordScore(spec2, 100, E, salt2, 0); } // Parameter boundaries are inclusive. function test_paramBoundaries() public { vm.startPrank(safe); - bond.setParams(1 hours, 1, 0); - bond.setParams(30 days, 1, 10_000); + bond.setParams(1, 0); + bond.setParams(1, 10_000); + vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); + bond.setParams(1, 10_001); vm.expectRevert(PredgeValidatorBondV2.BadParams.selector); - bond.setParams(30 days + 1, 1, 0); + bond.setParams(0, 0); vm.stopPrank(); } diff --git a/test/plan-v2.test.mjs b/test/plan-v2.test.mjs index e3df551..9b08887 100644 --- a/test/plan-v2.test.mjs +++ b/test/plan-v2.test.mjs @@ -15,10 +15,19 @@ const plan = (chain = "base", d = D) => resolvePlan(d, { owner: OWNER, operator: test("addresses depend only on deployer and nonce order, not on the chain or chain params", () => { const a = plan("base"), b = plan("arc"); assert.deepEqual(a.map((s) => s.address), b.map((s) => s.address)); - a.forEach((s, i) => assert.equal(s.address, getCreateAddress({ from: D, nonce: i }))); + a.forEach((s, i) => assert.equal(s.address, getCreateAddress({ from: D, nonce: (SET_V2.nonceBase ?? 0) + i }))); + a.forEach((s, i) => assert.equal(s.nonce, (SET_V2.nonceBase ?? 0) + i)); assert.notDeepEqual(a.map((s) => s.initcode), b.map((s) => s.initcode), "bond floors differ per chain, so initcode does"); }); +test("v2.1 continues the 20.10 deployer at nonce 6 (nonces 0..5 hold the v2.0 set on Arc and Base)", () => { + assert.equal(SET_V2.nonceBase, 6); + const live = JSON.parse(readFileSync(new URL("../deployments/v2/arc.json", import.meta.url), "utf8")); + const p = resolvePlan(live.deployer, { owner: OWNER, operator: OP, ...chainParams("arc") }, SET_V2); + const old = new Set(Object.values(live.contracts).map((c) => c.address)); + for (const s of p) assert.ok(!old.has(s.address), `${s.name} would collide with a v2.0 address`); +}); + test("the plan order is fixed: append-only", () => { assert.deepEqual(SET_V2.plan.map((s) => s.name), ["PredgeAgentValidatorV2", "AgentJobV2", "PredgeValidatorBondV2", "PredgeSettlementV2", "MockRefundProtocolV2", "PredgeRefundArbiterV2"]); }); @@ -36,12 +45,15 @@ test("roles are wired from the constructor args and the deployer holds none", () for (const s of Object.values(by)) assert.ok(!s.args.includes(D), `${s.name} must not take the deployer as an argument`); }); -test("every chain has a non-zero bond floor, and the live config refuses to run while unset", () => { +test("every chain has a non-zero bond floor, and the live config refuses to run while unset (or is complete)", () => { for (const name of Object.keys(CHAINS)) { const p = chainParams(name); assert.ok(BigInt(p.minBond) > 0n && BigInt(p.arbiterMinBond) > 0n, name); } - assert.throws(() => realRoles(), /is not set/); + // Since 20.10 the owner has filled config/v2.config.json; before that realRoles() had to refuse to run. + let roles = null; + try { roles = realRoles(); } catch (e) { assert.match(e.message, /is not set/); } + if (roles) assert.equal(new Set(Object.values(roles)).size, 3, "deployer, owner and operator are distinct"); }); test("v2 key handling: never the v1 key dir; fork accounts are not accepted as real roles", () => { From 8756d9504ac4635d99dc184c1049f067f7855f51 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:56:52 +0300 Subject: [PATCH 16/19] deployments: v2.1 live on Arc and Base (Olympix fixes, false verdicts revert); v2.0 archived as deprecated --- README.md | 38 ++- deployments/v2/arc.json | 139 ++++---- deployments/v2/arc.v2-0.json | 310 ++++++++++++++++++ deployments/v2/base.json | 95 +++--- deployments/v2/base.v2-0.json | 292 +++++++++++++++++ deployments/v2/verify/AgentJobV2.json | 4 +- deployments/v2/verify/AgentJobV2.v2-0.json | 32 ++ .../v2/verify/MockRefundProtocolV2.v2-0.json | 26 ++ .../v2/verify/PredgeAgentValidatorV2.json | 4 +- .../verify/PredgeAgentValidatorV2.v2-0.json | 29 ++ .../v2/verify/PredgeRefundArbiterV2.json | 4 +- .../v2/verify/PredgeRefundArbiterV2.v2-0.json | 32 ++ deployments/v2/verify/PredgeSettlementV2.json | 2 +- .../v2/verify/PredgeSettlementV2.v2-0.json | 29 ++ .../v2/verify/PredgeValidatorBondV2.json | 6 +- .../v2/verify/PredgeValidatorBondV2.v2-0.json | 35 ++ 16 files changed, 958 insertions(+), 119 deletions(-) create mode 100644 deployments/v2/arc.v2-0.json create mode 100644 deployments/v2/base.v2-0.json create mode 100644 deployments/v2/verify/AgentJobV2.v2-0.json create mode 100644 deployments/v2/verify/MockRefundProtocolV2.v2-0.json create mode 100644 deployments/v2/verify/PredgeAgentValidatorV2.v2-0.json create mode 100644 deployments/v2/verify/PredgeRefundArbiterV2.v2-0.json create mode 100644 deployments/v2/verify/PredgeSettlementV2.v2-0.json create mode 100644 deployments/v2/verify/PredgeValidatorBondV2.v2-0.json diff --git a/README.md b/README.md index e6ddc8f..012d340 100644 --- a/README.md +++ b/README.md @@ -100,23 +100,35 @@ Canonical v1 addresses for deployer `0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9` ## Deployments -### v2 (current): Arc (5042) and Base (8453) +### v2 (current, v2.1): Arc (5042) and Base (8453) -Deployed 20-21 Oct 2026. The same six addresses on both chains. All six are verified on Sourcify (exact match). Receipts: [`deployments/v2/arc.json`](deployments/v2/arc.json), [`deployments/v2/base.json`](deployments/v2/base.json). +Deployed 2026-10-23 from set `2026-10-23-v2.1` (deployer nonces 6..11). The same six addresses on both chains. All six are verified on Sourcify. Receipts: [`deployments/v2/arc.json`](deployments/v2/arc.json), [`deployments/v2/base.json`](deployments/v2/base.json). | Contract | Address (Arc and Base) | Sourcify | Explorer | |---|---|---|---| -| PredgeAgentValidatorV2 | `0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495` | [Sourcify](https://sourcify.dev/#/lookup/0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495) | [Arc](https://explorer.arc.io/address/0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495), [Base](https://basescan.org/address/0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495) | -| AgentJobV2 | `0x550b360840C6614aD70E91CC6673FF3AB2dFd98C` | [Sourcify](https://sourcify.dev/#/lookup/0x550b360840C6614aD70E91CC6673FF3AB2dFd98C) | [Arc](https://explorer.arc.io/address/0x550b360840C6614aD70E91CC6673FF3AB2dFd98C), [Base](https://basescan.org/address/0x550b360840C6614aD70E91CC6673FF3AB2dFd98C) | -| PredgeValidatorBondV2 | `0xa4BB68E55398c4EDD958DD6B32460D2018F78881` | [Sourcify](https://sourcify.dev/#/lookup/0xa4BB68E55398c4EDD958DD6B32460D2018F78881) | [Arc](https://explorer.arc.io/address/0xa4BB68E55398c4EDD958DD6B32460D2018F78881), [Base](https://basescan.org/address/0xa4BB68E55398c4EDD958DD6B32460D2018F78881) | -| PredgeSettlementV2 | `0x60991fE6a7afDcC79bDc43b754acE5c16158edb3` | [Sourcify](https://sourcify.dev/#/lookup/0x60991fE6a7afDcC79bDc43b754acE5c16158edb3) | [Arc](https://explorer.arc.io/address/0x60991fE6a7afDcC79bDc43b754acE5c16158edb3), [Base](https://basescan.org/address/0x60991fE6a7afDcC79bDc43b754acE5c16158edb3) | -| MockRefundProtocolV2 (demo refund protocol) | `0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7` | [Sourcify](https://sourcify.dev/#/lookup/0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7) | [Arc](https://explorer.arc.io/address/0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7), [Base](https://basescan.org/address/0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7) | -| PredgeRefundArbiterV2 | `0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3` | [Sourcify](https://sourcify.dev/#/lookup/0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3) | [Arc](https://explorer.arc.io/address/0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3), [Base](https://basescan.org/address/0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3) | - -- **Owner:** `0x9084f5000E07C7133D6dA5eE4f271AB6D1821144`, a **temporary single EOA**. A transfer to a Safe 2-of-3 multisig via `Ownable2Step` (`transferOwnership`, then `acceptOwnership`) is planned, target this week. Until then, treat the owner as a single point of control. -- **Operator** (hot ops key, rotatable by the owner): `0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292`. -- **Bond floor:** minimal, about $5 (5 USDC on Arc, 0.002 ETH on Base). No percentage of job value (bps = 0). It is a floor, not coverage of job value. -- **Status:** Olympix (BugPoCer, via Circle's Arc programme) scanned v2 at commit `c47ed42` on 23 Oct 2026: 2 High, 3 Medium, 12 Low. The fixes and the option (a) verdict check are v2.1 (see above) and are **not deployed yet**: the Arc and Base instances above are the scanned code and are affected by the confirmed findings (the contracts are not upgradeable, so a fix means a redeployment). v2.1 will land at nonces 6..11 of the same deployer; `scripts/v2-post-deploy.mjs` then marks the addresses above deprecated. The on-chain bonds are not claimed as a guarantee. The signed verdict API (`api.predge.io`) is unchanged and remains the product. +| PredgeAgentValidatorV2 | `0xE4CFdE5dbD9afD518b9c59136939607505bF643D` | [Sourcify](https://sourcify.dev/#/lookup/0xE4CFdE5dbD9afD518b9c59136939607505bF643D) | [Arc](https://explorer.arc.io/address/0xE4CFdE5dbD9afD518b9c59136939607505bF643D), [Base](https://basescan.org/address/0xE4CFdE5dbD9afD518b9c59136939607505bF643D) | +| AgentJobV2 | `0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F` | [Sourcify](https://sourcify.dev/#/lookup/0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F) | [Arc](https://explorer.arc.io/address/0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F), [Base](https://basescan.org/address/0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F) | +| PredgeValidatorBondV2 | `0x97E960Fe3D7759af8f51A95319859E60Df554329` | [Sourcify](https://sourcify.dev/#/lookup/0x97E960Fe3D7759af8f51A95319859E60Df554329) | [Arc](https://explorer.arc.io/address/0x97E960Fe3D7759af8f51A95319859E60Df554329), [Base](https://basescan.org/address/0x97E960Fe3D7759af8f51A95319859E60Df554329) | +| PredgeSettlementV2 | `0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24` | [Sourcify](https://sourcify.dev/#/lookup/0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24) | [Arc](https://explorer.arc.io/address/0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24), [Base](https://basescan.org/address/0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24) | +| MockRefundProtocolV2 (demo refund protocol) | `0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9` | [Sourcify](https://sourcify.dev/#/lookup/0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9) | [Arc](https://explorer.arc.io/address/0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9), [Base](https://basescan.org/address/0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9) | +| PredgeRefundArbiterV2 | `0xf404F908cb1ba47C01AB8526B725aAA940557eA9` | [Sourcify](https://sourcify.dev/#/lookup/0xf404F908cb1ba47C01AB8526B725aAA940557eA9) | [Arc](https://explorer.arc.io/address/0xf404F908cb1ba47C01AB8526B725aAA940557eA9), [Base](https://basescan.org/address/0xf404F908cb1ba47C01AB8526B725aAA940557eA9) | + +- **Owner:** `0x9084f5000E07C7133D6dA5eE4f271AB6D1821144` (TEMPORARY owner = Amir EOA 0x9084…1144 by owner decision; transferOwnership→acceptOwnership to the Safe once created.). **Operator** (hot ops key, rotatable by the owner): `0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292`. +- **Bond floor:** minimal (bps = 0), per-chain floor from `config/v2.chains.json`. Since v2.1 the validator bond backs liveness only: `recordScore` refuses any verdict that contradicts the on-chain evidence (pass needs the output bytes hashed on-chain and equal to the client-committed `expected`; fail needs the opposite), so a false verdict cannot be recorded at any bond size and there is no challenge window. +- **Status:** v2.1 contains the fixes for the Olympix BugPoCer scan of `c47ed42` (23 Oct 2026) and the option (a) verdict check. The signed verdict API (`api.predge.io`) is unchanged and remains the product. + +### v2.0 (DEPRECATED, 20-21 Oct 2026): do not use + +The first v2 set (`2026-10-20-v2`, commit `c47ed42`) is still on chain at the addresses below, because the contracts are not upgradeable. It is affected by the confirmed Olympix findings and held no funds when it was superseded. Nothing in Predge points at it. Records: [`deployments/v2/arc.v2-0.json`](deployments/v2/arc.v2-0.json), [`deployments/v2/base.v2-0.json`](deployments/v2/base.v2-0.json). + +| Contract | Deprecated address (Arc and Base) | +|---|---| +| PredgeAgentValidatorV2 | ~~`0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495`~~ | +| AgentJobV2 | ~~`0x550b360840C6614aD70E91CC6673FF3AB2dFd98C`~~ | +| PredgeValidatorBondV2 | ~~`0xa4BB68E55398c4EDD958DD6B32460D2018F78881`~~ | +| PredgeSettlementV2 | ~~`0x60991fE6a7afDcC79bDc43b754acE5c16158edb3`~~ | +| MockRefundProtocolV2 | ~~`0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7`~~ | +| PredgeRefundArbiterV2 | ~~`0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3`~~ | ### v1 (legacy): 16 mainnets diff --git a/deployments/v2/arc.json b/deployments/v2/arc.json index 2ec4d81..1fe2f01 100644 --- a/deployments/v2/arc.json +++ b/deployments/v2/arc.json @@ -4,7 +4,7 @@ "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", - "setVersion": "2026-10-20-v2", + "setVersion": "2026-10-23-v2.1", "compiler": { "solc": "v0.8.26+commit.8a97fa7a", "settings": { @@ -15,24 +15,24 @@ "evmVersion": "cancun" } }, - "deployedAt": "2026-10-20T21:42:10.287Z", + "deployedAt": "2026-10-23T21:39:59.380Z", "explorer": "https://explorer.arc.io", "contracts": { "PredgeAgentValidatorV2": { "name": "PredgeAgentValidatorV2", - "address": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", - "nonce": 0, + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", "0x0000000000000000000000000000000000000000" ], "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", - "txHash": "0x85281d12e1c9a3db70261f2b2fb8d5b82ec5a88dca4389359934d22df52e790a", - "block": 27008331, - "gasUsed": "1162822", - "effectiveGasPrice": "20001749006", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x5e255a03b2dfc5b9067ff243645740401269003f56e3b34dbc84afb52695cd99", + "block": 27518801, + "gasUsed": "1167799", + "effectiveGasPrice": "22281312500", "verified": { "ok": true, "via": "sourcify", @@ -41,17 +41,17 @@ }, "AgentJobV2": { "name": "AgentJobV2", - "address": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", - "nonce": 1, + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, "args": [ "259200" ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", - "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", - "txHash": "0x860eb93b9a1652275f02f4da2cd07aaf34abe2e4a67640b8742ac7355f5813ef", - "block": 27008334, - "gasUsed": "904910", - "effectiveGasPrice": "20001749006", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x6a8877fc737f422363f7bbcf51a086b0401fe009e2d7cb5f092f040f672b4b9a", + "block": 27518804, + "gasUsed": "920066", + "effectiveGasPrice": "22281312500", "verified": { "ok": true, "via": "sourcify", @@ -60,22 +60,21 @@ }, "PredgeValidatorBondV2": { "name": "PredgeValidatorBondV2", - "address": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", - "nonce": 2, + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", - "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", - "86400", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", "5000000000000000000", "0" ], - "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e292000000000000000000000000550b360840c6614ad70e91cc6673ff3ab2dfd98c00000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", - "txHash": "0x1c56cd30a31eeab6b9b344b9c9aa47c493eb0d25dcf17ec91a48f97b99327b69", - "block": 27008337, - "gasUsed": "1705859", - "effectiveGasPrice": "20001749006", + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f0000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x89623fb5dabfb8bd9b804d04c32d275cc6864aaaed592c081b8a5a0412a07cb9", + "block": 27518808, + "gasUsed": "1453368", + "effectiveGasPrice": "22281312500", "verified": { "ok": true, "via": "sourcify", @@ -84,17 +83,17 @@ }, "PredgeSettlementV2": { "name": "PredgeSettlementV2", - "address": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", - "nonce": 3, + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" ], "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", - "txHash": "0x7f3da7502d81decfb37c3e7498aa8bc4fc42acaca95e6e2f060e19d6b42f8640", - "block": 27008341, - "gasUsed": "369071", - "effectiveGasPrice": "20001749006", + "txHash": "0x0f8e45d1156d6480396c23c3d707fcd9be54896b7699be032ac6e44f8fc4db26", + "block": 27518810, + "gasUsed": "369059", + "effectiveGasPrice": "22623509375", "verified": { "ok": true, "via": "sourcify", @@ -103,17 +102,17 @@ }, "MockRefundProtocolV2": { "name": "MockRefundProtocolV2", - "address": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", - "nonce": 4, + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, "args": [ - "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" ], - "constructorArgs": "0x00000000000000000000000072faeb8ee70816dbf66722657cc4bea314eb5fe3", + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", - "txHash": "0xe28fe22cac1322b91746dd7341b21eb5394c096e309cb52853010d3e92d4afbb", - "block": 27008343, + "txHash": "0x6dbd184dbc3ad8692ba13928e6f411dfadf752d9925bde25cdc931ea67885c3e", + "block": 27518813, "gasUsed": "408763", - "effectiveGasPrice": "20001749006", + "effectiveGasPrice": "22623509375", "verified": { "ok": true, "via": "sourcify", @@ -122,22 +121,22 @@ }, "PredgeRefundArbiterV2": { "name": "PredgeRefundArbiterV2", - "address": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3", - "nonce": 5, + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", - "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", "86400", "5000000000000000000", "0" ], - "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000002768dbc17728403bae6d8838e4aec63be21a9da700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", - "txHash": "0x111d5cb1b0045dee19d114d44de12565c5336104735d62bb55f28a8bef12ab14", - "block": 27008347, - "gasUsed": "1898263", - "effectiveGasPrice": "20001749006", + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e900000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0xe7889a21d05c3967c2193f1e54cb43d722607e7eeba77ec873a9b1833e486c8f", + "block": 27518817, + "gasUsed": "1930219", + "effectiveGasPrice": "22623509375", "verified": { "ok": true, "via": "sourcify", @@ -163,32 +162,32 @@ { "check": "PredgeAgentValidatorV2 runtime == compiled", "ok": true, - "detail": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495" + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" }, { "check": "AgentJobV2 runtime == compiled", "ok": true, - "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" }, { "check": "PredgeValidatorBondV2 runtime == compiled", "ok": true, - "detail": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881" + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" }, { "check": "PredgeSettlementV2 runtime == compiled", "ok": true, - "detail": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3" + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" }, { "check": "MockRefundProtocolV2 runtime == compiled", "ok": true, - "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" }, { "check": "PredgeRefundArbiterV2 runtime == compiled", "ok": true, - "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" }, { "check": "registry.owner == owner (not the deployer)", @@ -268,7 +267,7 @@ { "check": "bond.job == AgentJobV2", "ok": true, - "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" }, { "check": "bond params", @@ -276,19 +275,24 @@ "detail": "" }, { - "check": "bond.wouldSlash(unknown) == false", + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", "ok": true, "detail": "" }, { "check": "arbiter.refundProtocol == mock", "ok": true, - "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" }, { "check": "mock.arbiter == arbiter", "ok": true, - "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" }, { "check": "arbiter params", @@ -299,7 +303,24 @@ "check": "registry.requestHashFor is domain separated", "ok": true, "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" } ], - "legacy": "predge-arc/deployments/arc-mainnet (deployer 0xEc7A…6223): full set incl. bond v2.1 + arbiter v2" + "legacy": "predge-arc/deployments/arc-mainnet (deployer 0xEc7A…6223): full set incl. bond v2.1 + arbiter v2", + "supersedes": { + "setVersion": "2026-10-20-v2", + "record": "arc.v2-0.json", + "addresses": { + "PredgeAgentValidatorV2": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", + "AgentJobV2": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "PredgeValidatorBondV2": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", + "PredgeSettlementV2": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", + "MockRefundProtocolV2": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "PredgeRefundArbiterV2": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + } + } } diff --git a/deployments/v2/arc.v2-0.json b/deployments/v2/arc.v2-0.json new file mode 100644 index 0000000..c00f0dd --- /dev/null +++ b/deployments/v2/arc.v2-0.json @@ -0,0 +1,310 @@ +{ + "chain": "arc", + "chainId": 5042, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-20-v2", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-20T21:42:10.287Z", + "explorer": "https://explorer.arc.io", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", + "nonce": 0, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", + "txHash": "0x85281d12e1c9a3db70261f2b2fb8d5b82ec5a88dca4389359934d22df52e790a", + "block": 27008331, + "gasUsed": "1162822", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "txHash": "0x860eb93b9a1652275f02f4da2cd07aaf34abe2e4a67640b8742ac7355f5813ef", + "block": 27008334, + "gasUsed": "904910", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", + "nonce": 2, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "86400", + "5000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e292000000000000000000000000550b360840c6614ad70e91cc6673ff3ab2dfd98c00000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", + "txHash": "0x1c56cd30a31eeab6b9b344b9c9aa47c493eb0d25dcf17ec91a48f97b99327b69", + "block": 27008337, + "gasUsed": "1705859", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", + "nonce": 3, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x7f3da7502d81decfb37c3e7498aa8bc4fc42acaca95e6e2f060e19d6b42f8640", + "block": 27008341, + "gasUsed": "369071", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "nonce": 4, + "args": [ + "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + ], + "constructorArgs": "0x00000000000000000000000072faeb8ee70816dbf66722657cc4bea314eb5fe3", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xe28fe22cac1322b91746dd7341b21eb5394c096e309cb52853010d3e92d4afbb", + "block": 27008343, + "gasUsed": "408763", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3", + "nonce": 5, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "86400", + "5000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000002768dbc17728403bae6d8838e4aec63be21a9da700000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000004563918244f400000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", + "txHash": "0x111d5cb1b0045dee19d114d44de12565c5336104735d62bb55f28a8bef12ab14", + "block": 27008347, + "gasUsed": "1898263", + "effectiveGasPrice": "20001749006", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.wouldSlash(unknown) == false", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": "predge-arc/deployments/arc-mainnet (deployer 0xEc7A…6223): full set incl. bond v2.1 + arbiter v2", + "deprecated": { + "by": "2026-10-23-v2.1", + "record": "arc.json", + "reason": "Olympix BugPoCer scan of c47ed42 (23.10.2026): confirmed findings, fixed in v2.1. Not upgradeable, so the v2.0 instances stay on chain; do not use them." + } +} diff --git a/deployments/v2/base.json b/deployments/v2/base.json index 349b0b1..3b9f58b 100644 --- a/deployments/v2/base.json +++ b/deployments/v2/base.json @@ -4,7 +4,7 @@ "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", - "setVersion": "2026-10-20-v2", + "setVersion": "2026-10-23-v2.1", "compiler": { "solc": "v0.8.26+commit.8a97fa7a", "settings": { @@ -15,20 +15,20 @@ "evmVersion": "cancun" } }, - "deployedAt": "2026-10-20T21:52:38.533Z", + "deployedAt": "2026-10-23T21:44:53.724Z", "explorer": "https://basescan.org", "contracts": { "PredgeAgentValidatorV2": { "name": "PredgeAgentValidatorV2", - "address": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", - "nonce": 0, + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", "0x0000000000000000000000000000000000000000" ], "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", "skipped": true, "verified": { "ok": true, @@ -38,13 +38,13 @@ }, "AgentJobV2": { "name": "AgentJobV2", - "address": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", - "nonce": 1, + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, "args": [ "259200" ], "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", - "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", "skipped": true, "verified": { "ok": true, @@ -54,18 +54,17 @@ }, "PredgeValidatorBondV2": { "name": "PredgeValidatorBondV2", - "address": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", - "nonce": 2, + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", - "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", - "86400", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", "2000000000000000", "0" ], - "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e292000000000000000000000000550b360840c6614ad70e91cc6673ff3ab2dfd98c000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f00000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", "skipped": true, "verified": { "ok": true, @@ -75,8 +74,8 @@ }, "PredgeSettlementV2": { "name": "PredgeSettlementV2", - "address": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", - "nonce": 3, + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" ], @@ -86,17 +85,17 @@ "verified": { "ok": true, "via": "sourcify", - "note": "already verified" + "match": "exact_match" } }, "MockRefundProtocolV2": { "name": "MockRefundProtocolV2", - "address": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", - "nonce": 4, + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, "args": [ - "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" ], - "constructorArgs": "0x00000000000000000000000072faeb8ee70816dbf66722657cc4bea314eb5fe3", + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", "skipped": true, "verified": { @@ -107,18 +106,18 @@ }, "PredgeRefundArbiterV2": { "name": "PredgeRefundArbiterV2", - "address": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3", - "nonce": 5, + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, "args": [ "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", - "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", "86400", "2000000000000000", "0" ], - "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000002768dbc17728403bae6d8838e4aec63be21a9da7000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", - "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e9000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", "skipped": true, "verified": { "ok": true, @@ -145,32 +144,32 @@ { "check": "PredgeAgentValidatorV2 runtime == compiled", "ok": true, - "detail": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495" + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" }, { "check": "AgentJobV2 runtime == compiled", "ok": true, - "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" }, { "check": "PredgeValidatorBondV2 runtime == compiled", "ok": true, - "detail": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881" + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" }, { "check": "PredgeSettlementV2 runtime == compiled", "ok": true, - "detail": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3" + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" }, { "check": "MockRefundProtocolV2 runtime == compiled", "ok": true, - "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" }, { "check": "PredgeRefundArbiterV2 runtime == compiled", "ok": true, - "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" }, { "check": "registry.owner == owner (not the deployer)", @@ -250,7 +249,7 @@ { "check": "bond.job == AgentJobV2", "ok": true, - "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" }, { "check": "bond params", @@ -258,19 +257,24 @@ "detail": "" }, { - "check": "bond.wouldSlash(unknown) == false", + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", "ok": true, "detail": "" }, { "check": "arbiter.refundProtocol == mock", "ok": true, - "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" }, { "check": "mock.arbiter == arbiter", "ok": true, - "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" }, { "check": "arbiter params", @@ -281,7 +285,24 @@ "check": "registry.requestHashFor is domain separated", "ok": true, "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" } ], - "legacy": null + "legacy": null, + "supersedes": { + "setVersion": "2026-10-20-v2", + "record": "base.v2-0.json", + "addresses": { + "PredgeAgentValidatorV2": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", + "AgentJobV2": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "PredgeValidatorBondV2": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", + "PredgeSettlementV2": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", + "MockRefundProtocolV2": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "PredgeRefundArbiterV2": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + } + } } diff --git a/deployments/v2/base.v2-0.json b/deployments/v2/base.v2-0.json new file mode 100644 index 0000000..7d4d4c5 --- /dev/null +++ b/deployments/v2/base.v2-0.json @@ -0,0 +1,292 @@ +{ + "chain": "base", + "chainId": 8453, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-20-v2", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-20T21:52:38.533Z", + "explorer": "https://basescan.org", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495", + "nonce": 0, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "39ce8493d21512143f7c0fa37419a3cd14e1808bceb3af3e9680dfedb107ecd0", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "16050fa97d42c0982eac3bf4de36a4478c68165c7fbdfeac8e5b89960f6a8972", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881", + "nonce": 2, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C", + "86400", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e292000000000000000000000000550b360840c6614ad70e91cc6673ff3ab2dfd98c000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "887d0ae4945ea287ebddc2d9169a8f88074a75f871a49d0f7667b12f6b8f7e7d", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3", + "nonce": 3, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "nonce": 4, + "args": [ + "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + ], + "constructorArgs": "0x00000000000000000000000072faeb8ee70816dbf66722657cc4bea314eb5fe3", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3", + "nonce": 5, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7", + "86400", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000002768dbc17728403bae6d8838e4aec63be21a9da7000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "4942e21f42a00e7034c5af74884dea2e0e1d4b7605d770d0b2ca9411df6e2a5e", + "skipped": true, + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xACC29BF9Ae35e51d36792C3B20D0Ac65f86Dd495" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0xa4BB68E55398c4EDD958DD6B32460D2018F78881" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x60991fE6a7afDcC79bDc43b754acE5c16158edb3" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x550b360840C6614aD70E91CC6673FF3AB2dFd98C" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.wouldSlash(unknown) == false", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x2768dBC17728403BAE6D8838e4AeC63bE21a9dA7" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0x72FAEb8eE70816Dbf66722657cc4Bea314EB5FE3" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null, + "deprecated": { + "by": "2026-10-23-v2.1", + "record": "base.json", + "reason": "Olympix BugPoCer scan of c47ed42 (23.10.2026): confirmed findings, fixed in v2.1. Not upgradeable, so the v2.0 instances stay on chain; do not use them." + } +} diff --git a/deployments/v2/verify/AgentJobV2.json b/deployments/v2/verify/AgentJobV2.json index b330112..3ebb803 100644 --- a/deployments/v2/verify/AgentJobV2.json +++ b/deployments/v2/verify/AgentJobV2.json @@ -2,10 +2,10 @@ "language": "Solidity", "sources": { "v2/AgentJobV2.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @title AgentJobV2\n/// @notice A minimal ERC-8183-style agent job with escrowed native value and an INDEPENDENT\n/// evaluator named at creation. In Predge's deployment the evaluator is the\n/// `PredgeValidatorBondV2` contract, so the evaluator's verdict is backed by a bond and\n/// moves the money in the same transaction.\n///\n/// Lifecycle:\n/// createJob (client escrows value, names provider, evaluator, spec and an expiry)\n/// submit (provider, strictly before `expiredAt`)\n/// complete / reject (evaluator, strictly before `submittedAt + evaluationWindow`)\n/// claimRefund (anyone may trigger; the CLIENT is credited) when\n/// - the job is still Open at or after `expiredAt`, or\n/// - the job is Submitted and the evaluator stayed silent until\n/// `submittedAt + evaluationWindow`.\n///\n/// Timeout policy (documented choice): if the evaluator never decides, the CLIENT is\n/// refunded, as ERC-8183 does on expiry. The alternative (pay the provider by default)\n/// would let a silent or bribed evaluator pay out unverified work. The cost of this\n/// choice is borne by the provider, who did the work; providers should only work for\n/// evaluators they trust to answer inside the window.\n///\n/// Money never leaves by push. Every payout, whether to provider or client, is credited\n/// to `credit[account]` and pulled with `withdraw` / `withdrawTo`, so a reverting or\n/// blocklisted recipient can neither force an outcome nor lock another party's funds.\n///\n/// @dev Native value only; amounts are in the chain's native wei. There is no owner, no\n/// admin, no upgrade path and no mutable parameter: `evaluationWindow` is immutable.\n/// Deliverable hashing: `sha256`. When the provider supplies the output bytes, the hash is\n/// computed on-chain with the sha256 precompile (so the stored hash provably belongs to\n/// those bytes); otherwise the provider's claimed hash is stored as given.\ncontract AgentJobV2 is IAgentJobV2, PullCredit {\n /// @notice Seconds the evaluator has to decide after a submission. Immutable, so it cannot\n /// be changed under a job that is already running.\n uint64 public immutable evaluationWindow;\n\n uint256 public jobCount;\n /// @notice Sum of every job's escrow that has not been credited yet (Open or Submitted).\n uint256 public totalEscrowed;\n\n mapping(uint256 => Job) private _jobs;\n\n event JobCreated(\n uint256 indexed jobId,\n address indexed client,\n address indexed evaluator,\n address provider,\n uint256 escrow,\n uint64 expiredAt,\n bytes32 specHash\n );\n event Submitted(uint256 indexed jobId, address indexed provider, bytes32 deliverable, bool hashedOnChain);\n event Completed(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Rejected(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Refunded(uint256 indexed jobId, address indexed caller, address indexed recipient, uint256 amount);\n\n error ZeroAddress();\n error ZeroEscrow();\n error BadExpiry();\n error BadWindow();\n error NotProvider();\n error NotEvaluator();\n error BadState();\n error PastDeadline();\n error NotExpired();\n error HashMismatch();\n error ZeroDeliverable();\n\n /// @param evaluationWindow_ seconds the evaluator has after a submission; must be non-zero.\n constructor(uint64 evaluationWindow_) {\n if (evaluationWindow_ == 0) revert BadWindow();\n evaluationWindow = evaluationWindow_;\n }\n\n /// @notice Client opens a job, escrows `msg.value`, and names the provider, the independent\n /// evaluator, the acceptance-test commitment and the expiry.\n /// @param provider the worker; paid on `complete`\n /// @param evaluator the only address that can complete or reject\n /// @param specHash the acceptance test commitment (the Predge `requestHash`)\n /// @param expiredAt unix time; the provider must submit strictly before it, after it the\n /// client can reclaim the escrow of a job that was never submitted\n function createJob(address provider, address evaluator, bytes32 specHash, uint64 expiredAt)\n external\n payable\n returns (uint256 jobId)\n {\n if (provider == address(0) || evaluator == address(0)) revert ZeroAddress();\n if (msg.value == 0) revert ZeroEscrow();\n if (expiredAt <= block.timestamp) revert BadExpiry();\n jobId = ++jobCount;\n totalEscrowed += msg.value;\n Job storage j = _jobs[jobId];\n j.client = msg.sender;\n j.provider = provider;\n j.evaluator = evaluator;\n j.escrow = msg.value;\n j.expiredAt = expiredAt;\n j.state = State.Open;\n j.specHash = specHash;\n emit JobCreated(jobId, msg.sender, evaluator, provider, msg.value, expiredAt, specHash);\n }\n\n /// @notice Provider submits the deliverable, strictly before `expiredAt`.\n /// @param deliverable sha256 of the output. Must be non-zero.\n /// @param output optional output bytes. If non-empty, `sha256(output)` must equal\n /// `deliverable` (reverts `HashMismatch` otherwise) and the job records that\n /// the hash was verified on-chain. If empty, the claimed hash is stored.\n function submit(uint256 jobId, bytes32 deliverable, bytes calldata output) external {\n Job storage j = _jobs[jobId];\n if (j.state != State.Open) revert BadState();\n if (msg.sender != j.provider) revert NotProvider();\n if (block.timestamp >= j.expiredAt) revert PastDeadline();\n if (deliverable == bytes32(0)) revert ZeroDeliverable();\n bool onChain = output.length != 0;\n if (onChain && sha256(output) != deliverable) revert HashMismatch();\n j.deliverable = deliverable;\n j.hashedOnChain = onChain;\n j.submittedAt = uint64(block.timestamp);\n j.state = State.Submitted;\n emit Submitted(jobId, msg.sender, deliverable, onChain);\n }\n\n /// @notice ERC-8183 `complete`. Evaluator only, while the job is Submitted and the\n /// evaluation window is still running. Credits the provider.\n /// @param reason the evaluator's attestation commitment (Predge: keccak256 of the signed verdict)\n function complete(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Completed;\n uint256 amt = _release(j, j.provider);\n emit Completed(jobId, msg.sender, j.provider, amt, reason);\n }\n\n /// @notice ERC-8183 `reject`. Evaluator only, same timing as `complete`. Credits the client.\n function reject(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Rejected;\n uint256 amt = _release(j, j.client);\n emit Rejected(jobId, msg.sender, j.client, amt, reason);\n }\n\n /// @notice Refund the client of a job that ran out of time. Callable by anyone (the credit\n /// always goes to the client): see the contract notes for the two cases.\n function claimRefund(uint256 jobId) external {\n Job storage j = _jobs[jobId];\n State s = j.state;\n if (s == State.Open) {\n if (block.timestamp < j.expiredAt) revert NotExpired();\n } else if (s == State.Submitted) {\n if (block.timestamp < uint256(j.submittedAt) + evaluationWindow) revert NotExpired();\n } else {\n revert BadState();\n }\n j.state = State.Expired;\n uint256 amt = _release(j, j.client);\n emit Refunded(jobId, msg.sender, j.client, amt);\n }\n\n /// @inheritdoc IAgentJobV2\n function getJob(uint256 jobId) external view returns (Job memory) {\n return _jobs[jobId];\n }\n\n function _decide(uint256 jobId, bytes32 reason) private returns (Job storage j) {\n j = _jobs[jobId];\n if (msg.sender != j.evaluator) revert NotEvaluator();\n if (j.state != State.Submitted) revert BadState();\n if (block.timestamp >= uint256(j.submittedAt) + evaluationWindow) revert PastDeadline();\n j.reason = reason;\n }\n\n function _release(Job storage j, address to) private returns (uint256 amt) {\n amt = j.escrow;\n j.escrow = 0;\n totalEscrowed -= amt;\n _addCredit(to, amt);\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @title AgentJobV2\n/// @notice A minimal ERC-8183-style agent job with escrowed native value and an evaluator named at\n/// creation. The evaluator may be neither the client nor the provider (a trivial check: it\n/// cannot detect two addresses controlled by one party). In Predge's deployment the evaluator is the\n/// `PredgeValidatorBondV2` contract, which only accepts a verdict that agrees with the on-chain\n/// deliverable hash and moves the money in the same transaction.\n///\n/// Lifecycle:\n/// createJob (client escrows value, names provider, evaluator, spec and an expiry)\n/// submit (provider, strictly before `expiredAt`)\n/// complete / reject (evaluator, strictly before `submittedAt + evaluationWindow`)\n/// claimRefund (anyone may trigger; the CLIENT is credited) when\n/// - the job is still Open at or after `expiredAt`, or\n/// - the job is Submitted and the evaluator stayed silent until\n/// `submittedAt + evaluationWindow`.\n///\n/// Timeout policy (documented choice): if the evaluator never decides, the CLIENT is\n/// refunded, as ERC-8183 does on expiry. The alternative (pay the provider by default)\n/// would let a silent or bribed evaluator pay out unverified work. The cost of this\n/// choice is borne by the provider, who did the work; providers should only work for\n/// evaluators they trust to answer inside the window.\n///\n/// Money never leaves by push. Every payout, whether to provider or client, is credited\n/// to `credit[account]` and pulled with `withdraw` / `withdrawTo`, so a reverting or\n/// blocklisted recipient can neither force an outcome nor lock another party's funds.\n///\n/// @dev Native value only; amounts are in the chain's native wei. There is no owner, no\n/// admin, no upgrade path and no mutable parameter: `evaluationWindow` is immutable.\n/// Deliverable hashing: `sha256`. When the provider supplies the output bytes, the hash is\n/// computed on-chain with the sha256 precompile (so the stored hash provably belongs to\n/// those bytes); otherwise the provider's claimed hash is stored as given.\ncontract AgentJobV2 is IAgentJobV2, PullCredit {\n /// @notice Seconds the evaluator has to decide after a submission. Immutable, so it cannot\n /// be changed under a job that is already running.\n uint64 public immutable evaluationWindow;\n\n uint256 public jobCount;\n /// @notice Sum of every job's escrow that has not been credited yet (Open or Submitted).\n uint256 public totalEscrowed;\n\n mapping(uint256 => Job) private _jobs;\n\n event JobCreated(\n uint256 indexed jobId,\n address indexed client,\n address indexed evaluator,\n address provider,\n uint256 escrow,\n uint64 expiredAt,\n bytes32 specHash\n );\n event Submitted(uint256 indexed jobId, address indexed provider, bytes32 deliverable, bool hashedOnChain);\n event Completed(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Rejected(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Refunded(uint256 indexed jobId, address indexed caller, address indexed recipient, uint256 amount);\n\n error ZeroAddress();\n error ZeroEscrow();\n error BadExpiry();\n error BadWindow();\n error NotProvider();\n error NotEvaluator();\n error BadState();\n error PastDeadline();\n error NotExpired();\n error HashMismatch();\n error ZeroDeliverable();\n error EvaluatorNotIndependent();\n\n /// @param evaluationWindow_ seconds the evaluator has after a submission; must be non-zero.\n constructor(uint64 evaluationWindow_) {\n if (evaluationWindow_ == 0) revert BadWindow();\n evaluationWindow = evaluationWindow_;\n }\n\n /// @notice Client opens a job, escrows `msg.value`, and names the provider, the independent\n /// evaluator, the acceptance-test commitment and the expiry.\n /// @param provider the worker; paid on `complete`\n /// @param evaluator the only address that can complete or reject\n /// @param specHash the acceptance test commitment. For a job evaluated by `PredgeValidatorBondV2` it\n /// must be `bond.specHashFor(keccak256(abi.encode(expected, salt)))`: the client\n /// fixes the expected result here, hidden behind the salt.\n /// @param expiredAt unix time; the provider must submit strictly before it, after it the\n /// client can reclaim the escrow of a job that was never submitted\n function createJob(address provider, address evaluator, bytes32 specHash, uint64 expiredAt)\n external\n payable\n returns (uint256 jobId)\n {\n if (provider == address(0) || evaluator == address(0)) revert ZeroAddress();\n // Olympix 5.3.4: a client (or provider) that names itself evaluator would judge its own job.\n if (evaluator == msg.sender || evaluator == provider) revert EvaluatorNotIndependent();\n if (msg.value == 0) revert ZeroEscrow();\n if (expiredAt <= block.timestamp) revert BadExpiry();\n jobId = ++jobCount;\n totalEscrowed += msg.value;\n Job storage j = _jobs[jobId];\n j.client = msg.sender;\n j.provider = provider;\n j.evaluator = evaluator;\n j.escrow = msg.value;\n j.expiredAt = expiredAt;\n j.state = State.Open;\n j.specHash = specHash;\n emit JobCreated(jobId, msg.sender, evaluator, provider, msg.value, expiredAt, specHash);\n }\n\n /// @notice Provider submits the deliverable, strictly before `expiredAt`.\n /// @param deliverable sha256 of the output. Must be non-zero.\n /// @param output optional output bytes. If non-empty, `sha256(output)` must equal\n /// `deliverable` (reverts `HashMismatch` otherwise) and the job records that\n /// the hash was verified on-chain. If empty, the claimed hash is stored.\n function submit(uint256 jobId, bytes32 deliverable, bytes calldata output) external {\n Job storage j = _jobs[jobId];\n if (j.state != State.Open) revert BadState();\n if (msg.sender != j.provider) revert NotProvider();\n if (block.timestamp >= j.expiredAt) revert PastDeadline();\n if (deliverable == bytes32(0)) revert ZeroDeliverable();\n bool onChain = output.length != 0;\n if (onChain && sha256(output) != deliverable) revert HashMismatch();\n j.deliverable = deliverable;\n j.hashedOnChain = onChain;\n j.submittedAt = uint64(block.timestamp);\n j.state = State.Submitted;\n emit Submitted(jobId, msg.sender, deliverable, onChain);\n }\n\n /// @notice ERC-8183 `complete`. Evaluator only, while the job is Submitted and the\n /// evaluation window is still running. Credits the provider.\n /// @param reason the evaluator's attestation commitment (Predge: keccak256 of the signed verdict)\n function complete(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Completed;\n uint256 amt = _release(j, j.provider);\n emit Completed(jobId, msg.sender, j.provider, amt, reason);\n }\n\n /// @notice ERC-8183 `reject`. Evaluator only, same timing as `complete`. Credits the client.\n function reject(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Rejected;\n uint256 amt = _release(j, j.client);\n emit Rejected(jobId, msg.sender, j.client, amt, reason);\n }\n\n /// @notice Refund the client of a job that ran out of time. Callable by anyone (the credit\n /// always goes to the client): see the contract notes for the two cases.\n function claimRefund(uint256 jobId) external {\n Job storage j = _jobs[jobId];\n State s = j.state;\n if (s == State.Open) {\n if (block.timestamp < j.expiredAt) revert NotExpired();\n } else if (s == State.Submitted) {\n if (block.timestamp < uint256(j.submittedAt) + evaluationWindow) revert NotExpired();\n } else {\n revert BadState();\n }\n j.state = State.Expired;\n uint256 amt = _release(j, j.client);\n emit Refunded(jobId, msg.sender, j.client, amt);\n }\n\n /// @inheritdoc IAgentJobV2\n function getJob(uint256 jobId) external view returns (Job memory) {\n return _jobs[jobId];\n }\n\n function _decide(uint256 jobId, bytes32 reason) private returns (Job storage j) {\n j = _jobs[jobId];\n if (msg.sender != j.evaluator) revert NotEvaluator();\n if (j.state != State.Submitted) revert BadState();\n if (block.timestamp >= uint256(j.submittedAt) + evaluationWindow) revert PastDeadline();\n j.reason = reason;\n }\n\n function _release(Job storage j, address to) private returns (uint256 amt) {\n amt = j.escrow;\n j.escrow = 0;\n totalEscrowed -= amt;\n _addCredit(to, amt);\n }\n}\n" }, "v2/interfaces/IAgentJobV2.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (the Predge requestHash)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (bond.specHashFor(commitment) for bonded jobs)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" }, "v2/access/PullCredit.sol": { "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" diff --git a/deployments/v2/verify/AgentJobV2.v2-0.json b/deployments/v2/verify/AgentJobV2.v2-0.json new file mode 100644 index 0000000..b330112 --- /dev/null +++ b/deployments/v2/verify/AgentJobV2.v2-0.json @@ -0,0 +1,32 @@ +{ + "language": "Solidity", + "sources": { + "v2/AgentJobV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @title AgentJobV2\n/// @notice A minimal ERC-8183-style agent job with escrowed native value and an INDEPENDENT\n/// evaluator named at creation. In Predge's deployment the evaluator is the\n/// `PredgeValidatorBondV2` contract, so the evaluator's verdict is backed by a bond and\n/// moves the money in the same transaction.\n///\n/// Lifecycle:\n/// createJob (client escrows value, names provider, evaluator, spec and an expiry)\n/// submit (provider, strictly before `expiredAt`)\n/// complete / reject (evaluator, strictly before `submittedAt + evaluationWindow`)\n/// claimRefund (anyone may trigger; the CLIENT is credited) when\n/// - the job is still Open at or after `expiredAt`, or\n/// - the job is Submitted and the evaluator stayed silent until\n/// `submittedAt + evaluationWindow`.\n///\n/// Timeout policy (documented choice): if the evaluator never decides, the CLIENT is\n/// refunded, as ERC-8183 does on expiry. The alternative (pay the provider by default)\n/// would let a silent or bribed evaluator pay out unverified work. The cost of this\n/// choice is borne by the provider, who did the work; providers should only work for\n/// evaluators they trust to answer inside the window.\n///\n/// Money never leaves by push. Every payout, whether to provider or client, is credited\n/// to `credit[account]` and pulled with `withdraw` / `withdrawTo`, so a reverting or\n/// blocklisted recipient can neither force an outcome nor lock another party's funds.\n///\n/// @dev Native value only; amounts are in the chain's native wei. There is no owner, no\n/// admin, no upgrade path and no mutable parameter: `evaluationWindow` is immutable.\n/// Deliverable hashing: `sha256`. When the provider supplies the output bytes, the hash is\n/// computed on-chain with the sha256 precompile (so the stored hash provably belongs to\n/// those bytes); otherwise the provider's claimed hash is stored as given.\ncontract AgentJobV2 is IAgentJobV2, PullCredit {\n /// @notice Seconds the evaluator has to decide after a submission. Immutable, so it cannot\n /// be changed under a job that is already running.\n uint64 public immutable evaluationWindow;\n\n uint256 public jobCount;\n /// @notice Sum of every job's escrow that has not been credited yet (Open or Submitted).\n uint256 public totalEscrowed;\n\n mapping(uint256 => Job) private _jobs;\n\n event JobCreated(\n uint256 indexed jobId,\n address indexed client,\n address indexed evaluator,\n address provider,\n uint256 escrow,\n uint64 expiredAt,\n bytes32 specHash\n );\n event Submitted(uint256 indexed jobId, address indexed provider, bytes32 deliverable, bool hashedOnChain);\n event Completed(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Rejected(uint256 indexed jobId, address indexed evaluator, address indexed recipient, uint256 amount, bytes32 reason);\n event Refunded(uint256 indexed jobId, address indexed caller, address indexed recipient, uint256 amount);\n\n error ZeroAddress();\n error ZeroEscrow();\n error BadExpiry();\n error BadWindow();\n error NotProvider();\n error NotEvaluator();\n error BadState();\n error PastDeadline();\n error NotExpired();\n error HashMismatch();\n error ZeroDeliverable();\n\n /// @param evaluationWindow_ seconds the evaluator has after a submission; must be non-zero.\n constructor(uint64 evaluationWindow_) {\n if (evaluationWindow_ == 0) revert BadWindow();\n evaluationWindow = evaluationWindow_;\n }\n\n /// @notice Client opens a job, escrows `msg.value`, and names the provider, the independent\n /// evaluator, the acceptance-test commitment and the expiry.\n /// @param provider the worker; paid on `complete`\n /// @param evaluator the only address that can complete or reject\n /// @param specHash the acceptance test commitment (the Predge `requestHash`)\n /// @param expiredAt unix time; the provider must submit strictly before it, after it the\n /// client can reclaim the escrow of a job that was never submitted\n function createJob(address provider, address evaluator, bytes32 specHash, uint64 expiredAt)\n external\n payable\n returns (uint256 jobId)\n {\n if (provider == address(0) || evaluator == address(0)) revert ZeroAddress();\n if (msg.value == 0) revert ZeroEscrow();\n if (expiredAt <= block.timestamp) revert BadExpiry();\n jobId = ++jobCount;\n totalEscrowed += msg.value;\n Job storage j = _jobs[jobId];\n j.client = msg.sender;\n j.provider = provider;\n j.evaluator = evaluator;\n j.escrow = msg.value;\n j.expiredAt = expiredAt;\n j.state = State.Open;\n j.specHash = specHash;\n emit JobCreated(jobId, msg.sender, evaluator, provider, msg.value, expiredAt, specHash);\n }\n\n /// @notice Provider submits the deliverable, strictly before `expiredAt`.\n /// @param deliverable sha256 of the output. Must be non-zero.\n /// @param output optional output bytes. If non-empty, `sha256(output)` must equal\n /// `deliverable` (reverts `HashMismatch` otherwise) and the job records that\n /// the hash was verified on-chain. If empty, the claimed hash is stored.\n function submit(uint256 jobId, bytes32 deliverable, bytes calldata output) external {\n Job storage j = _jobs[jobId];\n if (j.state != State.Open) revert BadState();\n if (msg.sender != j.provider) revert NotProvider();\n if (block.timestamp >= j.expiredAt) revert PastDeadline();\n if (deliverable == bytes32(0)) revert ZeroDeliverable();\n bool onChain = output.length != 0;\n if (onChain && sha256(output) != deliverable) revert HashMismatch();\n j.deliverable = deliverable;\n j.hashedOnChain = onChain;\n j.submittedAt = uint64(block.timestamp);\n j.state = State.Submitted;\n emit Submitted(jobId, msg.sender, deliverable, onChain);\n }\n\n /// @notice ERC-8183 `complete`. Evaluator only, while the job is Submitted and the\n /// evaluation window is still running. Credits the provider.\n /// @param reason the evaluator's attestation commitment (Predge: keccak256 of the signed verdict)\n function complete(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Completed;\n uint256 amt = _release(j, j.provider);\n emit Completed(jobId, msg.sender, j.provider, amt, reason);\n }\n\n /// @notice ERC-8183 `reject`. Evaluator only, same timing as `complete`. Credits the client.\n function reject(uint256 jobId, bytes32 reason, bytes calldata) external {\n Job storage j = _decide(jobId, reason);\n j.state = State.Rejected;\n uint256 amt = _release(j, j.client);\n emit Rejected(jobId, msg.sender, j.client, amt, reason);\n }\n\n /// @notice Refund the client of a job that ran out of time. Callable by anyone (the credit\n /// always goes to the client): see the contract notes for the two cases.\n function claimRefund(uint256 jobId) external {\n Job storage j = _jobs[jobId];\n State s = j.state;\n if (s == State.Open) {\n if (block.timestamp < j.expiredAt) revert NotExpired();\n } else if (s == State.Submitted) {\n if (block.timestamp < uint256(j.submittedAt) + evaluationWindow) revert NotExpired();\n } else {\n revert BadState();\n }\n j.state = State.Expired;\n uint256 amt = _release(j, j.client);\n emit Refunded(jobId, msg.sender, j.client, amt);\n }\n\n /// @inheritdoc IAgentJobV2\n function getJob(uint256 jobId) external view returns (Job memory) {\n return _jobs[jobId];\n }\n\n function _decide(uint256 jobId, bytes32 reason) private returns (Job storage j) {\n j = _jobs[jobId];\n if (msg.sender != j.evaluator) revert NotEvaluator();\n if (j.state != State.Submitted) revert BadState();\n if (block.timestamp >= uint256(j.submittedAt) + evaluationWindow) revert PastDeadline();\n j.reason = reason;\n }\n\n function _release(Job storage j, address to) private returns (uint256 amt) {\n amt = j.escrow;\n j.escrow = 0;\n totalEscrowed -= amt;\n _addCredit(to, amt);\n }\n}\n" + }, + "v2/interfaces/IAgentJobV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (the Predge requestHash)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" + }, + "v2/access/PullCredit.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/MockRefundProtocolV2.v2-0.json b/deployments/v2/verify/MockRefundProtocolV2.v2-0.json new file mode 100644 index 0000000..9121898 --- /dev/null +++ b/deployments/v2/verify/MockRefundProtocolV2.v2-0.json @@ -0,0 +1,26 @@ +{ + "language": "Solidity", + "sources": { + "v2/mocks/MockRefundProtocolV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice DEMO / TEST ONLY stand-in for a Refund Protocol. It holds NO funds and moves NO\n/// money: `refundByArbiter` only flips a flag and emits an event, so a \"refund\" here\n/// proves the arbiter made the call and nothing more. It exposes the payment parties the\n/// v2 arbiter binds evidence to. Anyone can create a payment record; that is intended for\n/// demos and is the reason this contract must never be treated as a payment rail.\ncontract MockRefundProtocolV2 {\n struct Payment {\n address payer;\n address payee;\n uint256 amount;\n uint256 releaseTimestamp;\n bool refunded;\n }\n\n address public immutable arbiter;\n uint256 public paymentCount;\n uint256 public refundCount;\n mapping(uint256 => Payment) public payments;\n mapping(address => uint256) public lockupSeconds;\n\n event PaymentRecorded(uint256 indexed paymentID, address indexed payer, address indexed payee, uint256 amount, uint256 releaseTimestamp);\n event RefundByArbiter(uint256 indexed paymentID, address indexed caller);\n event LockupSet(address indexed recipient, uint256 lockupSeconds);\n\n error CallerNotArbiter();\n error UnknownPayment();\n error AlreadyRefunded();\n error AlreadyReleased();\n error ZeroAddress();\n\n constructor(address arbiter_) {\n if (arbiter_ == address(0)) revert ZeroAddress();\n arbiter = arbiter_;\n }\n\n /// @notice Record a payment's parties (no value moves). Demo only.\n function recordPayment(address payer, address payee, uint256 amount, uint256 releaseTimestamp)\n external\n returns (uint256 paymentID)\n {\n if (payer == address(0) || payee == address(0)) revert ZeroAddress();\n paymentID = ++paymentCount;\n payments[paymentID] = Payment(payer, payee, amount, releaseTimestamp, false);\n emit PaymentRecorded(paymentID, payer, payee, amount, releaseTimestamp);\n }\n\n function paymentInfo(uint256 paymentID)\n external\n view\n returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp)\n {\n Payment storage p = payments[paymentID];\n return (p.payer, p.payee, p.amount, p.releaseTimestamp);\n }\n\n function refundByArbiter(uint256 paymentID) external {\n if (msg.sender != arbiter) revert CallerNotArbiter();\n Payment storage p = payments[paymentID];\n if (p.payee == address(0)) revert UnknownPayment();\n if (p.refunded) revert AlreadyRefunded();\n if (block.timestamp >= p.releaseTimestamp) revert AlreadyReleased();\n p.refunded = true;\n refundCount += 1;\n emit RefundByArbiter(paymentID, msg.sender);\n }\n\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external {\n if (msg.sender != arbiter) revert CallerNotArbiter();\n lockupSeconds[recipient] = recipientLockupSeconds;\n emit LockupSet(recipient, recipientLockupSeconds);\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeAgentValidatorV2.json b/deployments/v2/verify/PredgeAgentValidatorV2.json index 6fc2c04..a03acde 100644 --- a/deployments/v2/verify/PredgeAgentValidatorV2.json +++ b/deployments/v2/verify/PredgeAgentValidatorV2.json @@ -2,10 +2,10 @@ "language": "Solidity", "sources": { "v2/PredgeAgentValidatorV2.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\n\n/// @notice The one ERC-721 call the registry needs from an ERC-8004 Identity Registry.\ninterface IIdentityOwner {\n function ownerOf(uint256 agentId) external view returns (address);\n}\n\n/// @title PredgeAgentValidatorV2\n/// @notice An ERC-8004 Validation Registry shaped log of validation requests and verdicts, with\n/// one property enforced by the chain: a request is timestamped BEFORE its response, and a\n/// response is written once and never changes.\n///\n/// What this contract does NOT do, so nobody reads more into it than is there:\n/// * It does not verify the ed25519 attestation. The operator verifies it off-chain and\n/// commits `responseHash`, the keccak256 of the signed bytes.\n/// * It is not linked to `PredgeValidatorBondV2`. A registry response is a statement; the\n/// bonded, slashable verdict is the bond's `Scored` event and the job it settled.\n/// Consumers that want the bonded one must read the bond.\n///\n/// Authorisation (fixes the v1 permissionless `validationRequest`): a request may be made\n/// by the operator, or, when an Identity Registry address was set at construction, by the\n/// current owner of the agent NFT. With no identity registry only the operator may\n/// request.\n///\n/// Domain separation: the caller supplies an acceptance-test DIGEST and the registry\n/// derives `requestHash = keccak256(abi.encode(block.chainid, address(this), digest))`.\n/// The same digest therefore yields a different request on every chain and registry, so a\n/// request or verdict cannot be replayed across deployments. This is the one deviation from\n/// the ERC-8004 reference signature: the fourth argument is the digest, and the derived\n/// hash is returned and emitted in the canonical event position. Use `requestHashFor` to\n/// compute it off-chain, and use that value as the `AgentJobV2.specHash`.\n///\n/// @dev Roles: `owner` (Safe) rotates the operator; `operator` (hot key) answers requests.\n/// Rotating the operator never rewrites a past verdict.\ncontract PredgeAgentValidatorV2 is Operated {\n struct Validation {\n address validator; // the operator that was current when the request was made\n uint256 agentId;\n bytes32 responseHash;\n uint64 requestedAt;\n uint64 respondedAt; // 0 while pending\n uint8 response; // 0-100\n bool hasResponse;\n string tag;\n }\n\n /// @notice Optional ERC-8004 Identity Registry (the agent NFT). Zero = only the operator requests.\n address public immutable identityRegistry;\n\n uint64 public requestCount;\n uint64 public responseCount;\n\n /// @dev `getSummary` is O(n^2) in the number of hashes (it dedupes); cap the input.\n uint256 public constant MAX_SUMMARY = 100;\n\n mapping(bytes32 => Validation) private _v;\n mapping(bytes32 => string) public requestURIOf;\n mapping(bytes32 => string) public responseURIOf;\n\n event ValidationRequest(address indexed validatorAddress, uint256 indexed agentId, string requestURI, bytes32 indexed requestHash);\n event ValidationResponse(\n address indexed validatorAddress,\n uint256 indexed agentId,\n bytes32 indexed requestHash,\n uint8 response,\n string responseURI,\n bytes32 responseHash,\n string tag\n );\n /// @notice Extra context the canonical events lack: who asked, and the digest behind the hash.\n event RequestRecorded(bytes32 indexed requestHash, bytes32 digest, address indexed requester);\n /// @notice Extra context the canonical event lacks: who answered, and when.\n event ResponseRecorded(bytes32 indexed requestHash, address indexed responder, uint64 respondedAt);\n\n error NotAuthorized();\n error WrongValidator();\n error ZeroHash();\n error AlreadyRequested();\n error NotRequested();\n error AlreadyResponded();\n error BadScore();\n error TooManyHashes();\n\n /// @param owner_ the Safe\n /// @param operator_ the hot key that answers requests\n /// @param identityRegistry_ optional ERC-8004 Identity Registry; zero disables agent-owner requests\n constructor(address owner_, address operator_, address identityRegistry_) Operated(owner_, operator_) {\n identityRegistry = identityRegistry_;\n }\n\n /// @notice The request hash this registry derives for an acceptance-test digest.\n function requestHashFor(bytes32 digest) public view returns (bytes32) {\n return keccak256(abi.encode(block.chainid, address(this), digest));\n }\n\n /// @notice Record a validation request before the outcome is knowable.\n /// @param validatorAddress must equal the current `operator` (kept for ERC-8004 compatibility)\n /// @param agentId the agent under validation (the NFT id when an identity registry is set)\n /// @param requestURI pointer to the signed acceptance test\n /// @param digest keccak256 of the signed acceptance-test bytes; must be non-zero\n /// @return requestHash the domain-separated hash under which the request is stored\n function validationRequest(address validatorAddress, uint256 agentId, string calldata requestURI, bytes32 digest)\n external\n returns (bytes32 requestHash)\n {\n if (digest == bytes32(0)) revert ZeroHash();\n if (msg.sender != operator && !_isAgentOwner(agentId)) revert NotAuthorized();\n if (validatorAddress != operator) revert WrongValidator();\n requestHash = requestHashFor(digest);\n Validation storage v = _v[requestHash];\n if (v.requestedAt != 0) revert AlreadyRequested();\n\n v.validator = validatorAddress;\n v.agentId = agentId;\n v.requestedAt = uint64(block.timestamp);\n requestURIOf[requestHash] = requestURI;\n requestCount += 1;\n\n emit ValidationRequest(validatorAddress, agentId, requestURI, requestHash);\n emit RequestRecorded(requestHash, digest, msg.sender);\n }\n\n /// @notice Record the verdict, once, for a request that exists. Operator only.\n /// @param response 0-100 (Predge: 100 delivered, 0 failed, 50 void)\n function validationResponse(\n bytes32 requestHash,\n uint8 response,\n string calldata responseURI,\n bytes32 responseHash,\n string calldata tag\n ) external onlyOperator {\n if (response > 100) revert BadScore();\n if (responseHash == bytes32(0)) revert ZeroHash();\n Validation storage v = _v[requestHash];\n if (v.requestedAt == 0) revert NotRequested();\n if (v.hasResponse) revert AlreadyResponded();\n\n v.response = response;\n v.responseHash = responseHash;\n v.respondedAt = uint64(block.timestamp);\n v.hasResponse = true;\n v.tag = tag;\n responseURIOf[requestHash] = responseURI;\n responseCount += 1;\n\n emit ValidationResponse(v.validator, v.agentId, requestHash, response, responseURI, responseHash, tag);\n emit ResponseRecorded(requestHash, msg.sender, uint64(block.timestamp));\n }\n\n /// @notice ERC-8004 `getValidationStatus`.\n function getValidationStatus(bytes32 requestHash)\n external\n view\n returns (address validatorAddress, uint256 agentId, uint8 response, bytes32 responseHash, string memory tag, uint256 lastUpdate)\n {\n Validation storage v = _v[requestHash];\n return (v.validator, v.agentId, v.response, v.responseHash, v.tag, v.respondedAt != 0 ? v.respondedAt : v.requestedAt);\n }\n\n /// @notice True once a verdict is permanently recorded.\n function isValidated(bytes32 requestHash) external view returns (bool) {\n return _v[requestHash].hasResponse;\n }\n\n /// @notice Seconds the request stayed committed before its verdict. 0 while pending, and also 0 if the\n /// verdict landed in the same block as the request (chain timestamps have block granularity), so\n /// a non-zero value proves the lead, but zero does not prove its absence.\n function commitLeadTime(bytes32 requestHash) external view returns (uint64) {\n Validation storage v = _v[requestHash];\n if (!v.hasResponse) return 0;\n return v.respondedAt - v.requestedAt;\n }\n\n /// @notice ERC-8004 `getSummary` for one agent over the supplied hashes. A hash that appears\n /// more than once is counted once.\n function getSummary(uint256 agentId, bytes32[] calldata requestHashes)\n external\n view\n returns (uint64 count, uint8 averageResponse)\n {\n uint256 n = requestHashes.length;\n if (n > MAX_SUMMARY) revert TooManyHashes();\n uint256 sum = 0;\n for (uint256 i = 0; i < n; i++) {\n bool seen = false;\n for (uint256 k = 0; k < i; k++) {\n if (requestHashes[k] == requestHashes[i]) {\n seen = true;\n break;\n }\n }\n if (seen) continue;\n Validation storage v = _v[requestHashes[i]];\n if (v.hasResponse && v.agentId == agentId) {\n sum += v.response;\n count += 1;\n }\n }\n averageResponse = count == 0 ? 0 : uint8(sum / count);\n }\n\n function _isAgentOwner(uint256 agentId) private view returns (bool) {\n if (identityRegistry == address(0)) return false;\n try IIdentityOwner(identityRegistry).ownerOf(agentId) returns (address o) {\n return o == msg.sender;\n } catch {\n return false;\n }\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\n\n/// @notice The one ERC-721 call the registry needs from an ERC-8004 Identity Registry.\ninterface IIdentityOwner {\n function ownerOf(uint256 agentId) external view returns (address);\n}\n\n/// @title PredgeAgentValidatorV2\n/// @notice An ERC-8004 Validation Registry shaped log of validation requests and verdicts, with\n/// one property enforced by the chain: a request is timestamped BEFORE its response, and a\n/// response is written once and never changes.\n///\n/// What this contract does NOT do, so nobody reads more into it than is there:\n/// * It does not verify the ed25519 attestation. The operator verifies it off-chain and\n/// commits `responseHash`, the keccak256 of the signed bytes.\n/// * It is not linked to `PredgeValidatorBondV2`. A registry response is a statement; the\n/// bonded verdict, checked against the on-chain evidence, is the bond's `Scored` event and the\n/// job it settled.\n/// Consumers that want the bonded one must read the bond.\n///\n/// Authorisation (fixes the v1 permissionless `validationRequest`): a request may be made\n/// by the operator, or, when an Identity Registry address was set at construction, by the\n/// current owner of the agent NFT. With no identity registry only the operator may\n/// request.\n///\n/// Domain separation: the caller supplies an acceptance-test DIGEST and the registry\n/// derives `requestHash = keccak256(abi.encode(block.chainid, address(this), agentId, digest))`.\n/// The same digest therefore yields a different request on every chain and registry, so a\n/// request or verdict cannot be replayed across deployments, and a request is keyed to its\n/// agent, so one agent cannot pre-claim another agent's digest (Olympix 5.3.3). This is the\n/// one deviation from the ERC-8004 reference signature: the fourth argument is the digest,\n/// and the derived hash is returned and emitted in the canonical event position. Use\n/// `requestHashFor` to compute it off-chain. The registry is not linked to the bond: a bonded\n/// job's specHash is `PredgeValidatorBondV2.specHashFor`, not this hash.\n///\n/// @dev Roles: `owner` (Safe) rotates the operator; `operator` (hot key) answers requests.\n/// Rotating the operator never rewrites a past verdict.\ncontract PredgeAgentValidatorV2 is Operated {\n struct Validation {\n address validator; // the operator at request time; replaced by the operator that writes the response\n uint256 agentId;\n bytes32 responseHash;\n uint64 requestedAt;\n uint64 respondedAt; // 0 while pending\n uint8 response; // 0-100\n bool hasResponse;\n string tag;\n }\n\n /// @notice Optional ERC-8004 Identity Registry (the agent NFT). Zero = only the operator requests.\n address public immutable identityRegistry;\n\n uint64 public requestCount;\n uint64 public responseCount;\n\n /// @dev `getSummary` is O(n^2) in the number of hashes (it dedupes); cap the input.\n uint256 public constant MAX_SUMMARY = 100;\n\n mapping(bytes32 => Validation) private _v;\n mapping(bytes32 => string) public requestURIOf;\n mapping(bytes32 => string) public responseURIOf;\n\n event ValidationRequest(address indexed validatorAddress, uint256 indexed agentId, string requestURI, bytes32 indexed requestHash);\n event ValidationResponse(\n address indexed validatorAddress,\n uint256 indexed agentId,\n bytes32 indexed requestHash,\n uint8 response,\n string responseURI,\n bytes32 responseHash,\n string tag\n );\n /// @notice Extra context the canonical events lack: who asked, and the digest behind the hash.\n event RequestRecorded(bytes32 indexed requestHash, bytes32 digest, address indexed requester);\n /// @notice Extra context the canonical event lacks: who answered, and when.\n event ResponseRecorded(bytes32 indexed requestHash, address indexed responder, uint64 respondedAt);\n\n error NotAuthorized();\n error WrongValidator();\n error ZeroHash();\n error AlreadyRequested();\n error NotRequested();\n error AlreadyResponded();\n error BadScore();\n error TooManyHashes();\n\n /// @param owner_ the Safe\n /// @param operator_ the hot key that answers requests\n /// @param identityRegistry_ optional ERC-8004 Identity Registry; zero disables agent-owner requests\n constructor(address owner_, address operator_, address identityRegistry_) Operated(owner_, operator_) {\n identityRegistry = identityRegistry_;\n }\n\n /// @notice The request hash this registry derives for an agent and an acceptance-test digest.\n function requestHashFor(uint256 agentId, bytes32 digest) public view returns (bytes32) {\n return keccak256(abi.encode(block.chainid, address(this), agentId, digest));\n }\n\n /// @notice Record a validation request before the outcome is knowable.\n /// @param validatorAddress must equal the current `operator` (kept for ERC-8004 compatibility)\n /// @param agentId the agent under validation (the NFT id when an identity registry is set)\n /// @param requestURI pointer to the signed acceptance test\n /// @param digest keccak256 of the signed acceptance-test bytes; must be non-zero\n /// @return requestHash the domain-separated hash under which the request is stored\n function validationRequest(address validatorAddress, uint256 agentId, string calldata requestURI, bytes32 digest)\n external\n returns (bytes32 requestHash)\n {\n if (digest == bytes32(0)) revert ZeroHash();\n if (msg.sender != operator && !_isAgentOwner(agentId)) revert NotAuthorized();\n if (validatorAddress != operator) revert WrongValidator();\n requestHash = requestHashFor(agentId, digest);\n Validation storage v = _v[requestHash];\n if (v.requestedAt != 0) revert AlreadyRequested();\n\n v.validator = validatorAddress;\n v.agentId = agentId;\n v.requestedAt = uint64(block.timestamp);\n requestURIOf[requestHash] = requestURI;\n requestCount += 1;\n\n emit ValidationRequest(validatorAddress, agentId, requestURI, requestHash);\n emit RequestRecorded(requestHash, digest, msg.sender);\n }\n\n /// @notice Record the verdict, once, for a request that exists. Operator only. The responding\n /// operator is recorded as the validator (Olympix 5.3.10), so after a rotation the canonical\n /// event and `getValidationStatus` name the key that actually answered.\n /// @param response 0-100 (Predge: 100 delivered, 0 failed, 50 void)\n function validationResponse(\n bytes32 requestHash,\n uint8 response,\n string calldata responseURI,\n bytes32 responseHash,\n string calldata tag\n ) external onlyOperator {\n if (response > 100) revert BadScore();\n if (responseHash == bytes32(0)) revert ZeroHash();\n Validation storage v = _v[requestHash];\n if (v.requestedAt == 0) revert NotRequested();\n if (v.hasResponse) revert AlreadyResponded();\n\n v.validator = msg.sender;\n v.response = response;\n v.responseHash = responseHash;\n v.respondedAt = uint64(block.timestamp);\n v.hasResponse = true;\n v.tag = tag;\n responseURIOf[requestHash] = responseURI;\n responseCount += 1;\n\n emit ValidationResponse(v.validator, v.agentId, requestHash, response, responseURI, responseHash, tag);\n emit ResponseRecorded(requestHash, msg.sender, uint64(block.timestamp));\n }\n\n /// @notice ERC-8004 `getValidationStatus`.\n function getValidationStatus(bytes32 requestHash)\n external\n view\n returns (address validatorAddress, uint256 agentId, uint8 response, bytes32 responseHash, string memory tag, uint256 lastUpdate)\n {\n Validation storage v = _v[requestHash];\n return (v.validator, v.agentId, v.response, v.responseHash, v.tag, v.respondedAt != 0 ? v.respondedAt : v.requestedAt);\n }\n\n /// @notice True once a verdict is permanently recorded.\n function isValidated(bytes32 requestHash) external view returns (bool) {\n return _v[requestHash].hasResponse;\n }\n\n /// @notice Seconds the request stayed committed before its verdict. 0 while pending, and also 0 if the\n /// verdict landed in the same block as the request (chain timestamps have block granularity), so\n /// a non-zero value proves the lead, but zero does not prove its absence.\n function commitLeadTime(bytes32 requestHash) external view returns (uint64) {\n Validation storage v = _v[requestHash];\n if (!v.hasResponse) return 0;\n return v.respondedAt - v.requestedAt;\n }\n\n /// @notice ERC-8004 `getSummary` for one agent over the supplied hashes. A hash that appears\n /// more than once is counted once.\n function getSummary(uint256 agentId, bytes32[] calldata requestHashes)\n external\n view\n returns (uint64 count, uint8 averageResponse)\n {\n uint256 n = requestHashes.length;\n if (n > MAX_SUMMARY) revert TooManyHashes();\n uint256 sum = 0;\n for (uint256 i = 0; i < n; i++) {\n bool seen = false;\n for (uint256 k = 0; k < i; k++) {\n if (requestHashes[k] == requestHashes[i]) {\n seen = true;\n break;\n }\n }\n if (seen) continue;\n Validation storage v = _v[requestHashes[i]];\n if (v.hasResponse && v.agentId == agentId) {\n sum += v.response;\n count += 1;\n }\n }\n averageResponse = count == 0 ? 0 : uint8(sum / count);\n }\n\n function _isAgentOwner(uint256 agentId) private view returns (bool) {\n if (identityRegistry == address(0)) return false;\n try IIdentityOwner(identityRegistry).ownerOf(agentId) returns (address o) {\n return o == msg.sender;\n } catch {\n return false;\n }\n }\n}\n" }, "v2/access/PredgeAccess.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's bond, and never strands the bond either. It can strand an in-flight VERDICT\n/// (Olympix 5.3.8): a Committed stake in `PredgeValidatorBondV2` can only be scored by the new\n/// key if it is given the old commitment's `(expected, salt)`. If it is not, the job times out,\n/// the client is refunded and the bond is forfeited to the provider. Rotate with no stake in\n/// flight, or hand the preimages over, unless the old key is compromised.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" } }, "settings": { diff --git a/deployments/v2/verify/PredgeAgentValidatorV2.v2-0.json b/deployments/v2/verify/PredgeAgentValidatorV2.v2-0.json new file mode 100644 index 0000000..6fc2c04 --- /dev/null +++ b/deployments/v2/verify/PredgeAgentValidatorV2.v2-0.json @@ -0,0 +1,29 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeAgentValidatorV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\n\n/// @notice The one ERC-721 call the registry needs from an ERC-8004 Identity Registry.\ninterface IIdentityOwner {\n function ownerOf(uint256 agentId) external view returns (address);\n}\n\n/// @title PredgeAgentValidatorV2\n/// @notice An ERC-8004 Validation Registry shaped log of validation requests and verdicts, with\n/// one property enforced by the chain: a request is timestamped BEFORE its response, and a\n/// response is written once and never changes.\n///\n/// What this contract does NOT do, so nobody reads more into it than is there:\n/// * It does not verify the ed25519 attestation. The operator verifies it off-chain and\n/// commits `responseHash`, the keccak256 of the signed bytes.\n/// * It is not linked to `PredgeValidatorBondV2`. A registry response is a statement; the\n/// bonded, slashable verdict is the bond's `Scored` event and the job it settled.\n/// Consumers that want the bonded one must read the bond.\n///\n/// Authorisation (fixes the v1 permissionless `validationRequest`): a request may be made\n/// by the operator, or, when an Identity Registry address was set at construction, by the\n/// current owner of the agent NFT. With no identity registry only the operator may\n/// request.\n///\n/// Domain separation: the caller supplies an acceptance-test DIGEST and the registry\n/// derives `requestHash = keccak256(abi.encode(block.chainid, address(this), digest))`.\n/// The same digest therefore yields a different request on every chain and registry, so a\n/// request or verdict cannot be replayed across deployments. This is the one deviation from\n/// the ERC-8004 reference signature: the fourth argument is the digest, and the derived\n/// hash is returned and emitted in the canonical event position. Use `requestHashFor` to\n/// compute it off-chain, and use that value as the `AgentJobV2.specHash`.\n///\n/// @dev Roles: `owner` (Safe) rotates the operator; `operator` (hot key) answers requests.\n/// Rotating the operator never rewrites a past verdict.\ncontract PredgeAgentValidatorV2 is Operated {\n struct Validation {\n address validator; // the operator that was current when the request was made\n uint256 agentId;\n bytes32 responseHash;\n uint64 requestedAt;\n uint64 respondedAt; // 0 while pending\n uint8 response; // 0-100\n bool hasResponse;\n string tag;\n }\n\n /// @notice Optional ERC-8004 Identity Registry (the agent NFT). Zero = only the operator requests.\n address public immutable identityRegistry;\n\n uint64 public requestCount;\n uint64 public responseCount;\n\n /// @dev `getSummary` is O(n^2) in the number of hashes (it dedupes); cap the input.\n uint256 public constant MAX_SUMMARY = 100;\n\n mapping(bytes32 => Validation) private _v;\n mapping(bytes32 => string) public requestURIOf;\n mapping(bytes32 => string) public responseURIOf;\n\n event ValidationRequest(address indexed validatorAddress, uint256 indexed agentId, string requestURI, bytes32 indexed requestHash);\n event ValidationResponse(\n address indexed validatorAddress,\n uint256 indexed agentId,\n bytes32 indexed requestHash,\n uint8 response,\n string responseURI,\n bytes32 responseHash,\n string tag\n );\n /// @notice Extra context the canonical events lack: who asked, and the digest behind the hash.\n event RequestRecorded(bytes32 indexed requestHash, bytes32 digest, address indexed requester);\n /// @notice Extra context the canonical event lacks: who answered, and when.\n event ResponseRecorded(bytes32 indexed requestHash, address indexed responder, uint64 respondedAt);\n\n error NotAuthorized();\n error WrongValidator();\n error ZeroHash();\n error AlreadyRequested();\n error NotRequested();\n error AlreadyResponded();\n error BadScore();\n error TooManyHashes();\n\n /// @param owner_ the Safe\n /// @param operator_ the hot key that answers requests\n /// @param identityRegistry_ optional ERC-8004 Identity Registry; zero disables agent-owner requests\n constructor(address owner_, address operator_, address identityRegistry_) Operated(owner_, operator_) {\n identityRegistry = identityRegistry_;\n }\n\n /// @notice The request hash this registry derives for an acceptance-test digest.\n function requestHashFor(bytes32 digest) public view returns (bytes32) {\n return keccak256(abi.encode(block.chainid, address(this), digest));\n }\n\n /// @notice Record a validation request before the outcome is knowable.\n /// @param validatorAddress must equal the current `operator` (kept for ERC-8004 compatibility)\n /// @param agentId the agent under validation (the NFT id when an identity registry is set)\n /// @param requestURI pointer to the signed acceptance test\n /// @param digest keccak256 of the signed acceptance-test bytes; must be non-zero\n /// @return requestHash the domain-separated hash under which the request is stored\n function validationRequest(address validatorAddress, uint256 agentId, string calldata requestURI, bytes32 digest)\n external\n returns (bytes32 requestHash)\n {\n if (digest == bytes32(0)) revert ZeroHash();\n if (msg.sender != operator && !_isAgentOwner(agentId)) revert NotAuthorized();\n if (validatorAddress != operator) revert WrongValidator();\n requestHash = requestHashFor(digest);\n Validation storage v = _v[requestHash];\n if (v.requestedAt != 0) revert AlreadyRequested();\n\n v.validator = validatorAddress;\n v.agentId = agentId;\n v.requestedAt = uint64(block.timestamp);\n requestURIOf[requestHash] = requestURI;\n requestCount += 1;\n\n emit ValidationRequest(validatorAddress, agentId, requestURI, requestHash);\n emit RequestRecorded(requestHash, digest, msg.sender);\n }\n\n /// @notice Record the verdict, once, for a request that exists. Operator only.\n /// @param response 0-100 (Predge: 100 delivered, 0 failed, 50 void)\n function validationResponse(\n bytes32 requestHash,\n uint8 response,\n string calldata responseURI,\n bytes32 responseHash,\n string calldata tag\n ) external onlyOperator {\n if (response > 100) revert BadScore();\n if (responseHash == bytes32(0)) revert ZeroHash();\n Validation storage v = _v[requestHash];\n if (v.requestedAt == 0) revert NotRequested();\n if (v.hasResponse) revert AlreadyResponded();\n\n v.response = response;\n v.responseHash = responseHash;\n v.respondedAt = uint64(block.timestamp);\n v.hasResponse = true;\n v.tag = tag;\n responseURIOf[requestHash] = responseURI;\n responseCount += 1;\n\n emit ValidationResponse(v.validator, v.agentId, requestHash, response, responseURI, responseHash, tag);\n emit ResponseRecorded(requestHash, msg.sender, uint64(block.timestamp));\n }\n\n /// @notice ERC-8004 `getValidationStatus`.\n function getValidationStatus(bytes32 requestHash)\n external\n view\n returns (address validatorAddress, uint256 agentId, uint8 response, bytes32 responseHash, string memory tag, uint256 lastUpdate)\n {\n Validation storage v = _v[requestHash];\n return (v.validator, v.agentId, v.response, v.responseHash, v.tag, v.respondedAt != 0 ? v.respondedAt : v.requestedAt);\n }\n\n /// @notice True once a verdict is permanently recorded.\n function isValidated(bytes32 requestHash) external view returns (bool) {\n return _v[requestHash].hasResponse;\n }\n\n /// @notice Seconds the request stayed committed before its verdict. 0 while pending, and also 0 if the\n /// verdict landed in the same block as the request (chain timestamps have block granularity), so\n /// a non-zero value proves the lead, but zero does not prove its absence.\n function commitLeadTime(bytes32 requestHash) external view returns (uint64) {\n Validation storage v = _v[requestHash];\n if (!v.hasResponse) return 0;\n return v.respondedAt - v.requestedAt;\n }\n\n /// @notice ERC-8004 `getSummary` for one agent over the supplied hashes. A hash that appears\n /// more than once is counted once.\n function getSummary(uint256 agentId, bytes32[] calldata requestHashes)\n external\n view\n returns (uint64 count, uint8 averageResponse)\n {\n uint256 n = requestHashes.length;\n if (n > MAX_SUMMARY) revert TooManyHashes();\n uint256 sum = 0;\n for (uint256 i = 0; i < n; i++) {\n bool seen = false;\n for (uint256 k = 0; k < i; k++) {\n if (requestHashes[k] == requestHashes[i]) {\n seen = true;\n break;\n }\n }\n if (seen) continue;\n Validation storage v = _v[requestHashes[i]];\n if (v.hasResponse && v.agentId == agentId) {\n sum += v.response;\n count += 1;\n }\n }\n averageResponse = count == 0 ? 0 : uint8(sum / count);\n }\n\n function _isAgentOwner(uint256 agentId) private view returns (bool) {\n if (identityRegistry == address(0)) return false;\n try IIdentityOwner(identityRegistry).ownerOf(agentId) returns (address o) {\n return o == msg.sender;\n } catch {\n return false;\n }\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeRefundArbiterV2.json b/deployments/v2/verify/PredgeRefundArbiterV2.json index afdf615..d385dd8 100644 --- a/deployments/v2/verify/PredgeRefundArbiterV2.json +++ b/deployments/v2/verify/PredgeRefundArbiterV2.json @@ -2,10 +2,10 @@ "language": "Solidity", "sources": { "v2/PredgeRefundArbiterV2.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @notice What the arbiter needs from a Refund Protocol. `paymentInfo` is the parties-and-amount\n/// view the arbiter binds evidence and bond size to. Circle's production contract is not\n/// known to expose exactly this function: against it, a thin read-only adapter that\n/// implements `paymentInfo` and forwards the two write calls is required, and must be\n/// reviewed before use. On our chains only `MockRefundProtocolV2` exists.\ninterface IRefundProtocolV2 {\n /// @return payer the party refunded by `refundByArbiter`\n /// @return payee the recipient of the payment\n /// @return amount the payment amount, in the same native wei the bond is sized in\n /// @return releaseTimestamp when the payee may withdraw; a refund is impossible after it\n function paymentInfo(uint256 paymentID)\n external\n view\n returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp);\n function refundByArbiter(uint256 paymentID) external;\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external;\n}\n\n/// @title PredgeRefundArbiterV2\n/// @notice A bonded, OPTIMISTIC arbiter for a Refund Protocol. The operator rules; nothing moves\n/// until a challenge window has passed; the harmed party can freeze the ruling and escalate\n/// it to the owner (a Safe); and a ruling the owner overturns costs the operator its bond.\n///\n/// Flow, per payment:\n/// 1. `fileEvidence`: the PAYER or the PAYEE (read from the Refund Protocol, not\n/// chosen by the caller) files the hash of its own evidence. Write-once per party, and\n/// only before a ruling exists.\n/// 2. `rule` (operator): cites one party's filed evidence, picks Refund or Release, and\n/// posts a bond of at least `max(minBond, ceil(amount * bondBps / 10000))`. The\n/// operator may not itself be a party. Nothing executes yet.\n/// 3. Within `window` seconds of the ruling, the party the ruling HARMS (the payee on a\n/// Refund, the payer on a Release) may call `dispute` with counter-evidence. That\n/// freezes the ruling and starts the owner's `RESOLUTION_TIMEOUT`.\n/// 4a. Undisputed: after the window anyone calls `finalize`. The bond is credited back to\n/// the staker and, for a Refund, `refundByArbiter` is called on the Refund Protocol.\n/// 4b. Disputed: the owner calls `resolve(paymentID, finalDirection)`. If it equals the\n/// ruling, the ruling is upheld exactly as in 4a. If not, the ruling is overturned:\n/// the bond is credited to the harmed party (the disputer), and the owner's\n/// direction is executed (a Refund is sent to the Refund Protocol).\n/// 4c. If neither happens before `expiresAt` (owner silent, or the Refund Protocol keeps\n/// reverting), anyone calls `expire`: the ruling is voided, NOTHING is executed, and\n/// the bond returns to the staker.\n///\n/// Trust, stated plainly: for disputed rulings the owner Safe is the court of last\n/// resort. An operator that rules wrongly is only punished if the harmed party notices and\n/// disputes inside the window, and the owner then agrees. The window must close before the\n/// Refund Protocol's lockup ends, which `rule` enforces for a Refund. `finalize` should be\n/// called promptly (the operator, who wants its bond back, and the payer, who wants the refund,\n/// both have a reason to): once `expiresAt` has passed anyone can `expire` the ruling instead,\n/// which executes nothing and returns the bond.\n///\n/// @dev Native value only, in the chain's native wei. The contract holds `totalBonded +\n/// totalCredit` and never user payments. Parameters are bounded and not retroactive: the\n/// window is snapshotted into each ruling, and the bond is fixed when posted.\ncontract PredgeRefundArbiterV2 is Operated, PullCredit {\n enum Direction {\n None,\n Refund,\n Release\n }\n\n enum Status {\n None,\n Ruled, // window running, or elapsed and not yet finalized\n Disputed, // frozen, waiting for the owner\n Finalized, // upheld; bond returned; refund executed if Refund (final)\n Overturned, // owner disagreed; bond paid to the harmed party (final)\n Voided // expired without a decision; nothing executed; bond returned (final)\n }\n\n struct Ruling {\n address staker; // the operator key that ruled; the only address its bond is ever returned to\n Status status;\n Direction direction;\n uint64 ruledAt;\n uint64 window; // snapshot of `challengeWindow`\n uint64 expiresAt; // after this, `expire` voids the ruling\n address evidenceFrom;\n address payer;\n address payee;\n uint256 bond;\n bytes32 evidenceHash;\n bytes32 counterEvidence;\n }\n\n uint64 public constant MIN_WINDOW = 1 hours;\n uint64 public constant MAX_WINDOW = 30 days;\n uint64 public constant RESOLUTION_TIMEOUT = 14 days;\n uint16 public constant MAX_BPS = 10_000;\n\n IRefundProtocolV2 public immutable refundProtocol;\n\n uint64 public challengeWindow;\n uint256 public minBond;\n uint16 public bondBps;\n\n uint256 public totalBonded;\n uint64 public rulingCount;\n uint64 public overturnCount;\n\n mapping(uint256 => Ruling) private _rulings;\n /// @notice paymentID => party => hash of the evidence that party filed. Write-once.\n mapping(uint256 => mapping(address => bytes32)) public filedEvidence;\n\n event EvidenceFiled(uint256 indexed paymentID, address indexed party, bytes32 evidenceHash);\n event Ruled(\n uint256 indexed paymentID,\n address indexed staker,\n address indexed evidenceFrom,\n bytes32 evidenceHash,\n Direction direction,\n uint256 bond,\n uint64 windowEnd\n );\n event Disputed(uint256 indexed paymentID, address indexed disputer, bytes32 counterEvidence, uint64 expiresAt);\n event Finalized(uint256 indexed paymentID, address indexed staker, Direction direction, uint256 bond);\n event Overturned(uint256 indexed paymentID, address indexed harmed, Direction finalDirection, uint256 bond);\n event Voided(uint256 indexed paymentID, address indexed staker, uint256 bond);\n event ParamsUpdated(uint64 challengeWindow, uint256 minBond, uint16 bondBps);\n\n error ZeroHash();\n error BadParams();\n error BadDirection();\n error UnknownPayment();\n error NotAParty();\n error OperatorIsParty();\n error EvidenceClosed();\n error EvidenceAlreadyFiled(uint256 paymentID, address party);\n error NoEvidenceFiled(uint256 paymentID, address party);\n error AlreadyRuled(uint256 paymentID);\n error BondTooSmall(uint256 required);\n error LockupTooShort();\n error BadStatus();\n error WindowOpen();\n error WindowClosed();\n error NotHarmedParty();\n error NotExpired();\n error ResolutionExpired();\n\n /// @param owner_ the Safe: resolves disputes, sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that rules\n /// @param refundProtocol_ the Refund Protocol (or adapter) this arbiter drives; immutable\n /// @param challengeWindow_ seconds a ruling stays disputable, within [MIN_WINDOW, MAX_WINDOW]\n /// @param minBond_ absolute bond floor in native wei, non-zero\n /// @param bondBps_ bond as basis points of the payment amount, at most 10000\n constructor(address owner_, address operator_, address refundProtocol_, uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (refundProtocol_ == address(0)) revert ZeroAddress();\n refundProtocol = IRefundProtocolV2(refundProtocol_);\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Owner-only, bounded, not retroactive: existing rulings keep their window and bond.\n function setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Pass a recipient lockup change through to the Refund Protocol (owner only).\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyOwner {\n refundProtocol.setLockupSeconds(recipient, recipientLockupSeconds);\n }\n\n /// @notice The bond a ruling on a payment of `amount` must post right now.\n function requiredBond(uint256 amount) public view returns (uint256) {\n uint256 scaled = (amount * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice A party to the payment files the hash of its own evidence. Write-once, and only\n /// before the ruling. The caller must be the payer or the payee per the Refund Protocol.\n function fileEvidence(uint256 paymentID, bytes32 evidenceHash) external {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (_rulings[paymentID].status != Status.None) revert EvidenceClosed();\n (address payer, address payee,,) = refundProtocol.paymentInfo(paymentID);\n if (msg.sender != payer && msg.sender != payee) revert NotAParty();\n if (filedEvidence[paymentID][msg.sender] != bytes32(0)) revert EvidenceAlreadyFiled(paymentID, msg.sender);\n filedEvidence[paymentID][msg.sender] = evidenceHash;\n emit EvidenceFiled(paymentID, msg.sender, evidenceHash);\n }\n\n /// @notice Rule on a payment: cite a party's filed evidence, choose a direction, post the bond.\n /// Nothing executes until `finalize` or `resolve`.\n /// @param evidenceFrom the payer or payee whose filed evidence the ruling rests on\n function rule(uint256 paymentID, address evidenceFrom, bytes32 evidenceHash, Direction direction)\n external\n payable\n onlyOperator\n {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (direction == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.None) revert AlreadyRuled(paymentID);\n\n (address payer, address payee, uint256 amount, uint256 releaseTimestamp) = refundProtocol.paymentInfo(paymentID);\n if (payee == address(0)) revert UnknownPayment();\n if (msg.sender == payer || msg.sender == payee) revert OperatorIsParty();\n if (evidenceFrom != payer && evidenceFrom != payee) revert NotAParty();\n if (filedEvidence[paymentID][evidenceFrom] != evidenceHash) revert NoEvidenceFiled(paymentID, evidenceFrom);\n uint256 required = requiredBond(amount);\n if (msg.value < required) revert BondTooSmall(required);\n uint64 window = challengeWindow;\n // A refund must still be possible when the window closes. `finalize` must then run before the\n // payment's `releaseTimestamp`; if it does not, the Refund Protocol rejects the refund and `expire` voids the ruling.\n if (direction == Direction.Refund && releaseTimestamp <= block.timestamp + window) revert LockupTooShort();\n\n r.staker = msg.sender;\n r.status = Status.Ruled;\n r.direction = direction;\n r.ruledAt = uint64(block.timestamp);\n r.window = window;\n r.expiresAt = uint64(block.timestamp) + window + RESOLUTION_TIMEOUT;\n r.evidenceFrom = evidenceFrom;\n r.payer = payer;\n r.payee = payee;\n r.bond = msg.value;\n r.evidenceHash = evidenceHash;\n totalBonded += msg.value;\n rulingCount += 1;\n emit Ruled(paymentID, msg.sender, evidenceFrom, evidenceHash, direction, msg.value, uint64(block.timestamp) + window);\n }\n\n /// @notice The harmed party (payee on a Refund, payer on a Release) files counter-evidence\n /// inside the window. This freezes the ruling and hands it to the owner.\n function dispute(uint256 paymentID, bytes32 counterEvidence) external {\n if (counterEvidence == bytes32(0)) revert ZeroHash();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp >= uint256(r.ruledAt) + r.window) revert WindowClosed();\n if (msg.sender != _harmed(r)) revert NotHarmedParty();\n r.status = Status.Disputed;\n r.counterEvidence = counterEvidence;\n r.expiresAt = uint64(block.timestamp) + RESOLUTION_TIMEOUT;\n emit Disputed(paymentID, msg.sender, counterEvidence, r.expiresAt);\n }\n\n /// @notice After an undisputed window, return the bond to the staker and execute a Refund.\n /// Anyone may call. If the Refund Protocol reverts, this reverts too and the ruling\n /// can only be voided via `expire`.\n function finalize(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp < uint256(r.ruledAt) + r.window) revert WindowOpen();\n _uphold(paymentID, r);\n }\n\n /// @notice Owner decides a disputed ruling. `finalDirection` equal to the ruling upholds it;\n /// any other direction overturns it and slashes the bond to the harmed party. The\n /// final direction is executed either way (a Refund calls the Refund Protocol).\n function resolve(uint256 paymentID, Direction finalDirection) external onlyOwner {\n if (finalDirection == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp >= r.expiresAt) revert ResolutionExpired();\n if (finalDirection == r.direction) {\n _uphold(paymentID, r);\n return;\n }\n address harmed = _harmed(r);\n uint256 bond = r.bond;\n r.status = Status.Overturned;\n r.bond = 0;\n totalBonded -= bond;\n overturnCount += 1;\n _addCredit(harmed, bond);\n emit Overturned(paymentID, harmed, finalDirection, bond);\n if (finalDirection == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n /// @notice Void a ruling nobody decided in time. Nothing is executed; the bond returns to the\n /// staker. Anyone may call once `expiresAt` has passed.\n function expire(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled && r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp < r.expiresAt) revert NotExpired();\n uint256 bond = r.bond;\n r.status = Status.Voided;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Voided(paymentID, r.staker, bond);\n }\n\n /// @notice The full ruling record for a payment (all-zero if none).\n function rulings(uint256 paymentID) external view returns (Ruling memory) {\n return _rulings[paymentID];\n }\n\n /// @notice The party the ruling in `paymentID` would harm, or zero if there is no ruling.\n function harmedParty(uint256 paymentID) external view returns (address) {\n Ruling storage r = _rulings[paymentID];\n return r.status == Status.None ? address(0) : _harmed(r);\n }\n\n function _uphold(uint256 paymentID, Ruling storage r) private {\n uint256 bond = r.bond;\n r.status = Status.Finalized;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Finalized(paymentID, r.staker, r.direction, bond);\n if (r.direction == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n function _harmed(Ruling storage r) private view returns (address) {\n return r.direction == Direction.Refund ? r.payee : r.payer;\n }\n\n function _setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) private {\n if (challengeWindow_ < MIN_WINDOW || challengeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS)\n revert BadParams();\n challengeWindow = challengeWindow_;\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(challengeWindow_, minBond_, bondBps_);\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @notice What the arbiter needs from a Refund Protocol. `paymentInfo` is the parties-and-amount\n/// view the arbiter binds evidence and bond size to. Circle's production contract is not\n/// known to expose exactly this function: against it, a thin read-only adapter that\n/// implements `paymentInfo` and forwards the two write calls is required, and must be\n/// reviewed before use. On our chains only `MockRefundProtocolV2` exists.\ninterface IRefundProtocolV2 {\n /// @return payer the party refunded by `refundByArbiter`\n /// @return payee the recipient of the payment\n /// @return amount the payment amount, in the same native wei the bond is sized in\n /// @return releaseTimestamp when the payee may withdraw; a refund is impossible after it\n function paymentInfo(uint256 paymentID)\n external\n view\n returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp);\n function refundByArbiter(uint256 paymentID) external;\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external;\n}\n\n/// @title PredgeRefundArbiterV2\n/// @notice A bonded, OPTIMISTIC arbiter for a Refund Protocol. The operator rules; nothing moves\n/// until a challenge window has passed; the harmed party can freeze the ruling and escalate\n/// it to the owner (a Safe); and a ruling the owner overturns costs the operator its bond.\n///\n/// Flow, per payment:\n/// 1. `fileEvidence`: the PAYER or the PAYEE (read from the Refund Protocol, not\n/// chosen by the caller) files the hash of its own evidence. Write-once per party, and\n/// only before a ruling exists.\n/// 2. `rule` (operator): cites one party's filed evidence, picks Refund or Release, and\n/// posts a bond of at least `max(minBond, ceil(amount * bondBps / 10000))`. The\n/// operator may not itself be a party. Nothing executes yet.\n/// 3. Within `window` seconds of the ruling, the party the ruling HARMS (the payee on a\n/// Refund, the payer on a Release) may call `dispute` with counter-evidence. That\n/// freezes the ruling and starts the owner's `RESOLUTION_TIMEOUT`.\n/// 4a. Undisputed: after the window anyone calls `finalize`. The bond is credited back to\n/// the staker and, for a Refund, `refundByArbiter` is called on the Refund Protocol.\n/// 4b. Disputed: the owner calls `resolve(paymentID, finalDirection)`. If it equals the\n/// ruling, the ruling is upheld exactly as in 4a. If not, the ruling is overturned:\n/// the bond is credited to the harmed party (the disputer), and the owner's\n/// direction is executed (a Refund is sent to the Refund Protocol).\n/// 4c. If neither happens before `expiresAt` (owner silent, or the Refund Protocol keeps\n/// reverting), anyone calls `expire`: the ruling is voided, NOTHING is executed, and\n/// the bond returns to the staker.\n///\n/// Trust, stated plainly: for disputed rulings the owner Safe is the court of last\n/// resort. An operator that rules wrongly is only punished if the harmed party notices and\n/// disputes inside the window, and the owner then agrees. The whole life of a ruling must fit\n/// before the Refund Protocol's lockup ends (Olympix 5.2.1): `rule` requires, for EITHER\n/// direction, `releaseTimestamp > now + window + RESOLUTION_TIMEOUT`, so a dispute filed at the\n/// last second still leaves the owner its full resolution period, and a refund chosen by\n/// `finalize` or `resolve` can still execute. `finalize` should be called promptly (the operator,\n/// who wants its bond back, and the payer, who wants the refund, both have a reason to): once\n/// `expiresAt` has passed anyone can `expire` the ruling instead, which executes nothing and\n/// returns the bond.\n///\n/// @dev Native value only, in the chain's native wei. The contract holds `totalBonded +\n/// totalCredit` and never user payments. Parameters are bounded and not retroactive: the\n/// window is snapshotted into each ruling, and the bond is fixed when posted.\ncontract PredgeRefundArbiterV2 is Operated, PullCredit {\n enum Direction {\n None,\n Refund,\n Release\n }\n\n enum Status {\n None,\n Ruled, // window running, or elapsed and not yet finalized\n Disputed, // frozen, waiting for the owner\n Finalized, // upheld; bond returned; refund executed if Refund (final)\n Overturned, // owner disagreed; bond paid to the harmed party (final)\n Voided // expired without a decision; nothing executed; bond returned (final)\n }\n\n struct Ruling {\n address staker; // the operator key that ruled; the only address its bond is ever returned to\n Status status;\n Direction direction;\n uint64 ruledAt;\n uint64 window; // snapshot of `challengeWindow`\n uint64 expiresAt; // after this, `expire` voids the ruling\n address evidenceFrom;\n address payer;\n address payee;\n uint256 bond;\n bytes32 evidenceHash;\n bytes32 counterEvidence;\n }\n\n uint64 public constant MIN_WINDOW = 1 hours;\n uint64 public constant MAX_WINDOW = 30 days;\n uint64 public constant RESOLUTION_TIMEOUT = 14 days;\n uint16 public constant MAX_BPS = 10_000;\n /// @notice Upper bound for a recipient lockup forwarded by `setLockupSeconds`.\n uint256 public constant MAX_LOCKUP = 365 days;\n\n IRefundProtocolV2 public immutable refundProtocol;\n\n uint64 public challengeWindow;\n uint256 public minBond;\n uint16 public bondBps;\n\n uint256 public totalBonded;\n uint64 public rulingCount;\n uint64 public overturnCount;\n\n mapping(uint256 => Ruling) private _rulings;\n /// @notice paymentID => party => hash of the evidence that party filed. Write-once.\n mapping(uint256 => mapping(address => bytes32)) public filedEvidence;\n\n event EvidenceFiled(uint256 indexed paymentID, address indexed party, bytes32 evidenceHash);\n event Ruled(\n uint256 indexed paymentID,\n address indexed staker,\n address indexed evidenceFrom,\n bytes32 evidenceHash,\n Direction direction,\n uint256 bond,\n uint64 windowEnd\n );\n event Disputed(uint256 indexed paymentID, address indexed disputer, bytes32 counterEvidence, uint64 expiresAt);\n event Finalized(uint256 indexed paymentID, address indexed staker, Direction direction, uint256 bond);\n event Overturned(uint256 indexed paymentID, address indexed harmed, Direction finalDirection, uint256 bond);\n event Voided(uint256 indexed paymentID, address indexed staker, uint256 bond);\n event ParamsUpdated(uint64 challengeWindow, uint256 minBond, uint16 bondBps);\n event LockupForwarded(address indexed recipient, uint256 lockupSeconds);\n\n error ZeroHash();\n error BadParams();\n error BadDirection();\n error UnknownPayment();\n error NotAParty();\n error OperatorIsParty();\n error EvidenceClosed();\n error EvidenceAlreadyFiled(uint256 paymentID, address party);\n error NoEvidenceFiled(uint256 paymentID, address party);\n error AlreadyRuled(uint256 paymentID);\n error BondTooSmall(uint256 required);\n error LockupTooShort();\n error BadStatus();\n error WindowOpen();\n error WindowClosed();\n error NotHarmedParty();\n error NotExpired();\n error ResolutionExpired();\n\n /// @param owner_ the Safe: resolves disputes, sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that rules\n /// @param refundProtocol_ the Refund Protocol (or adapter) this arbiter drives; immutable\n /// @param challengeWindow_ seconds a ruling stays disputable, within [MIN_WINDOW, MAX_WINDOW]\n /// @param minBond_ absolute bond floor in native wei, non-zero\n /// @param bondBps_ bond as basis points of the payment amount, at most 10000\n constructor(address owner_, address operator_, address refundProtocol_, uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (refundProtocol_ == address(0)) revert ZeroAddress();\n refundProtocol = IRefundProtocolV2(refundProtocol_);\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Owner-only, bounded, not retroactive: existing rulings keep their window and bond.\n function setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Pass a recipient lockup change through to the Refund Protocol (owner only). Bounded\n /// (Olympix 5.3.12): longer than `challengeWindow + RESOLUTION_TIMEOUT`, so a ruling on a\n /// payment made under it can complete before release, and at most `MAX_LOCKUP`.\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyOwner {\n if (recipientLockupSeconds <= uint256(challengeWindow) + RESOLUTION_TIMEOUT || recipientLockupSeconds > MAX_LOCKUP) {\n revert BadParams();\n }\n emit LockupForwarded(recipient, recipientLockupSeconds);\n refundProtocol.setLockupSeconds(recipient, recipientLockupSeconds);\n }\n\n /// @notice The bond a ruling on a payment of `amount` must post right now.\n function requiredBond(uint256 amount) public view returns (uint256) {\n uint256 scaled = (amount * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice A party to the payment files the hash of its own evidence. Write-once, and only\n /// before the ruling. The caller must be the payer or the payee per the Refund Protocol.\n function fileEvidence(uint256 paymentID, bytes32 evidenceHash) external {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (_rulings[paymentID].status != Status.None) revert EvidenceClosed();\n (address payer, address payee,,) = refundProtocol.paymentInfo(paymentID);\n if (msg.sender != payer && msg.sender != payee) revert NotAParty();\n if (filedEvidence[paymentID][msg.sender] != bytes32(0)) revert EvidenceAlreadyFiled(paymentID, msg.sender);\n filedEvidence[paymentID][msg.sender] = evidenceHash;\n emit EvidenceFiled(paymentID, msg.sender, evidenceHash);\n }\n\n /// @notice Rule on a payment: cite a party's filed evidence, choose a direction, post the bond.\n /// Nothing executes until `finalize` or `resolve`.\n /// @param evidenceFrom the payer or payee whose filed evidence the ruling rests on\n function rule(uint256 paymentID, address evidenceFrom, bytes32 evidenceHash, Direction direction)\n external\n payable\n onlyOperator\n {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (direction == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.None) revert AlreadyRuled(paymentID);\n\n (address payer, address payee, uint256 amount, uint256 releaseTimestamp) = refundProtocol.paymentInfo(paymentID);\n if (payee == address(0)) revert UnknownPayment();\n if (msg.sender == payer || msg.sender == payee) revert OperatorIsParty();\n if (evidenceFrom != payer && evidenceFrom != payee) revert NotAParty();\n if (filedEvidence[paymentID][evidenceFrom] != evidenceHash) revert NoEvidenceFiled(paymentID, evidenceFrom);\n uint256 required = requiredBond(amount);\n if (msg.value < required) revert BondTooSmall(required);\n uint64 window = challengeWindow;\n // The whole ruling (window, then a dispute's full resolution period) must end before the payment is\n // released, for BOTH directions: a Refund executes at finalize/resolve, and a Release can be overturned\n // into a Refund by the owner. Otherwise a free last-second dispute could run the clock out.\n if (releaseTimestamp <= block.timestamp + window + RESOLUTION_TIMEOUT) revert LockupTooShort();\n\n r.staker = msg.sender;\n r.status = Status.Ruled;\n r.direction = direction;\n r.ruledAt = uint64(block.timestamp);\n r.window = window;\n r.expiresAt = uint64(block.timestamp) + window + RESOLUTION_TIMEOUT;\n r.evidenceFrom = evidenceFrom;\n r.payer = payer;\n r.payee = payee;\n r.bond = msg.value;\n r.evidenceHash = evidenceHash;\n totalBonded += msg.value;\n rulingCount += 1;\n emit Ruled(paymentID, msg.sender, evidenceFrom, evidenceHash, direction, msg.value, uint64(block.timestamp) + window);\n }\n\n /// @notice The harmed party (payee on a Refund, payer on a Release) files counter-evidence\n /// inside the window. This freezes the ruling and hands it to the owner.\n function dispute(uint256 paymentID, bytes32 counterEvidence) external {\n if (counterEvidence == bytes32(0)) revert ZeroHash();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp >= uint256(r.ruledAt) + r.window) revert WindowClosed();\n if (msg.sender != _harmed(r)) revert NotHarmedParty();\n r.status = Status.Disputed;\n r.counterEvidence = counterEvidence;\n r.expiresAt = uint64(block.timestamp) + RESOLUTION_TIMEOUT;\n emit Disputed(paymentID, msg.sender, counterEvidence, r.expiresAt);\n }\n\n /// @notice After an undisputed window, return the bond to the staker and execute a Refund.\n /// Anyone may call. If the Refund Protocol reverts, this reverts too and the ruling\n /// can only be voided via `expire`.\n function finalize(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp < uint256(r.ruledAt) + r.window) revert WindowOpen();\n _uphold(paymentID, r);\n }\n\n /// @notice Owner decides a disputed ruling. `finalDirection` equal to the ruling upholds it;\n /// any other direction overturns it and slashes the bond to the harmed party. The\n /// final direction is executed either way (a Refund calls the Refund Protocol).\n function resolve(uint256 paymentID, Direction finalDirection) external onlyOwner {\n if (finalDirection == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp >= r.expiresAt) revert ResolutionExpired();\n if (finalDirection == r.direction) {\n _uphold(paymentID, r);\n return;\n }\n address harmed = _harmed(r);\n uint256 bond = r.bond;\n r.status = Status.Overturned;\n r.bond = 0;\n totalBonded -= bond;\n overturnCount += 1;\n _addCredit(harmed, bond);\n emit Overturned(paymentID, harmed, finalDirection, bond);\n if (finalDirection == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n /// @notice Void a ruling nobody decided in time. Nothing is executed; the bond returns to the\n /// staker. Anyone may call once `expiresAt` has passed.\n function expire(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled && r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp < r.expiresAt) revert NotExpired();\n uint256 bond = r.bond;\n r.status = Status.Voided;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Voided(paymentID, r.staker, bond);\n }\n\n /// @notice The full ruling record for a payment (all-zero if none).\n function rulings(uint256 paymentID) external view returns (Ruling memory) {\n return _rulings[paymentID];\n }\n\n /// @notice The party the ruling in `paymentID` would harm, or zero if there is no ruling.\n function harmedParty(uint256 paymentID) external view returns (address) {\n Ruling storage r = _rulings[paymentID];\n return r.status == Status.None ? address(0) : _harmed(r);\n }\n\n function _uphold(uint256 paymentID, Ruling storage r) private {\n uint256 bond = r.bond;\n r.status = Status.Finalized;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Finalized(paymentID, r.staker, r.direction, bond);\n if (r.direction == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n function _harmed(Ruling storage r) private view returns (address) {\n return r.direction == Direction.Refund ? r.payee : r.payer;\n }\n\n function _setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) private {\n if (challengeWindow_ < MIN_WINDOW || challengeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS)\n revert BadParams();\n challengeWindow = challengeWindow_;\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(challengeWindow_, minBond_, bondBps_);\n }\n}\n" }, "v2/access/PredgeAccess.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's bond, and never strands the bond either. It can strand an in-flight VERDICT\n/// (Olympix 5.3.8): a Committed stake in `PredgeValidatorBondV2` can only be scored by the new\n/// key if it is given the old commitment's `(expected, salt)`. If it is not, the job times out,\n/// the client is refunded and the bond is forfeited to the provider. Rotate with no stake in\n/// flight, or hand the preimages over, unless the old key is compromised.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" }, "v2/access/PullCredit.sol": { "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" diff --git a/deployments/v2/verify/PredgeRefundArbiterV2.v2-0.json b/deployments/v2/verify/PredgeRefundArbiterV2.v2-0.json new file mode 100644 index 0000000..afdf615 --- /dev/null +++ b/deployments/v2/verify/PredgeRefundArbiterV2.v2-0.json @@ -0,0 +1,32 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeRefundArbiterV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\n\n/// @notice What the arbiter needs from a Refund Protocol. `paymentInfo` is the parties-and-amount\n/// view the arbiter binds evidence and bond size to. Circle's production contract is not\n/// known to expose exactly this function: against it, a thin read-only adapter that\n/// implements `paymentInfo` and forwards the two write calls is required, and must be\n/// reviewed before use. On our chains only `MockRefundProtocolV2` exists.\ninterface IRefundProtocolV2 {\n /// @return payer the party refunded by `refundByArbiter`\n /// @return payee the recipient of the payment\n /// @return amount the payment amount, in the same native wei the bond is sized in\n /// @return releaseTimestamp when the payee may withdraw; a refund is impossible after it\n function paymentInfo(uint256 paymentID)\n external\n view\n returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp);\n function refundByArbiter(uint256 paymentID) external;\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external;\n}\n\n/// @title PredgeRefundArbiterV2\n/// @notice A bonded, OPTIMISTIC arbiter for a Refund Protocol. The operator rules; nothing moves\n/// until a challenge window has passed; the harmed party can freeze the ruling and escalate\n/// it to the owner (a Safe); and a ruling the owner overturns costs the operator its bond.\n///\n/// Flow, per payment:\n/// 1. `fileEvidence`: the PAYER or the PAYEE (read from the Refund Protocol, not\n/// chosen by the caller) files the hash of its own evidence. Write-once per party, and\n/// only before a ruling exists.\n/// 2. `rule` (operator): cites one party's filed evidence, picks Refund or Release, and\n/// posts a bond of at least `max(minBond, ceil(amount * bondBps / 10000))`. The\n/// operator may not itself be a party. Nothing executes yet.\n/// 3. Within `window` seconds of the ruling, the party the ruling HARMS (the payee on a\n/// Refund, the payer on a Release) may call `dispute` with counter-evidence. That\n/// freezes the ruling and starts the owner's `RESOLUTION_TIMEOUT`.\n/// 4a. Undisputed: after the window anyone calls `finalize`. The bond is credited back to\n/// the staker and, for a Refund, `refundByArbiter` is called on the Refund Protocol.\n/// 4b. Disputed: the owner calls `resolve(paymentID, finalDirection)`. If it equals the\n/// ruling, the ruling is upheld exactly as in 4a. If not, the ruling is overturned:\n/// the bond is credited to the harmed party (the disputer), and the owner's\n/// direction is executed (a Refund is sent to the Refund Protocol).\n/// 4c. If neither happens before `expiresAt` (owner silent, or the Refund Protocol keeps\n/// reverting), anyone calls `expire`: the ruling is voided, NOTHING is executed, and\n/// the bond returns to the staker.\n///\n/// Trust, stated plainly: for disputed rulings the owner Safe is the court of last\n/// resort. An operator that rules wrongly is only punished if the harmed party notices and\n/// disputes inside the window, and the owner then agrees. The window must close before the\n/// Refund Protocol's lockup ends, which `rule` enforces for a Refund. `finalize` should be\n/// called promptly (the operator, who wants its bond back, and the payer, who wants the refund,\n/// both have a reason to): once `expiresAt` has passed anyone can `expire` the ruling instead,\n/// which executes nothing and returns the bond.\n///\n/// @dev Native value only, in the chain's native wei. The contract holds `totalBonded +\n/// totalCredit` and never user payments. Parameters are bounded and not retroactive: the\n/// window is snapshotted into each ruling, and the bond is fixed when posted.\ncontract PredgeRefundArbiterV2 is Operated, PullCredit {\n enum Direction {\n None,\n Refund,\n Release\n }\n\n enum Status {\n None,\n Ruled, // window running, or elapsed and not yet finalized\n Disputed, // frozen, waiting for the owner\n Finalized, // upheld; bond returned; refund executed if Refund (final)\n Overturned, // owner disagreed; bond paid to the harmed party (final)\n Voided // expired without a decision; nothing executed; bond returned (final)\n }\n\n struct Ruling {\n address staker; // the operator key that ruled; the only address its bond is ever returned to\n Status status;\n Direction direction;\n uint64 ruledAt;\n uint64 window; // snapshot of `challengeWindow`\n uint64 expiresAt; // after this, `expire` voids the ruling\n address evidenceFrom;\n address payer;\n address payee;\n uint256 bond;\n bytes32 evidenceHash;\n bytes32 counterEvidence;\n }\n\n uint64 public constant MIN_WINDOW = 1 hours;\n uint64 public constant MAX_WINDOW = 30 days;\n uint64 public constant RESOLUTION_TIMEOUT = 14 days;\n uint16 public constant MAX_BPS = 10_000;\n\n IRefundProtocolV2 public immutable refundProtocol;\n\n uint64 public challengeWindow;\n uint256 public minBond;\n uint16 public bondBps;\n\n uint256 public totalBonded;\n uint64 public rulingCount;\n uint64 public overturnCount;\n\n mapping(uint256 => Ruling) private _rulings;\n /// @notice paymentID => party => hash of the evidence that party filed. Write-once.\n mapping(uint256 => mapping(address => bytes32)) public filedEvidence;\n\n event EvidenceFiled(uint256 indexed paymentID, address indexed party, bytes32 evidenceHash);\n event Ruled(\n uint256 indexed paymentID,\n address indexed staker,\n address indexed evidenceFrom,\n bytes32 evidenceHash,\n Direction direction,\n uint256 bond,\n uint64 windowEnd\n );\n event Disputed(uint256 indexed paymentID, address indexed disputer, bytes32 counterEvidence, uint64 expiresAt);\n event Finalized(uint256 indexed paymentID, address indexed staker, Direction direction, uint256 bond);\n event Overturned(uint256 indexed paymentID, address indexed harmed, Direction finalDirection, uint256 bond);\n event Voided(uint256 indexed paymentID, address indexed staker, uint256 bond);\n event ParamsUpdated(uint64 challengeWindow, uint256 minBond, uint16 bondBps);\n\n error ZeroHash();\n error BadParams();\n error BadDirection();\n error UnknownPayment();\n error NotAParty();\n error OperatorIsParty();\n error EvidenceClosed();\n error EvidenceAlreadyFiled(uint256 paymentID, address party);\n error NoEvidenceFiled(uint256 paymentID, address party);\n error AlreadyRuled(uint256 paymentID);\n error BondTooSmall(uint256 required);\n error LockupTooShort();\n error BadStatus();\n error WindowOpen();\n error WindowClosed();\n error NotHarmedParty();\n error NotExpired();\n error ResolutionExpired();\n\n /// @param owner_ the Safe: resolves disputes, sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that rules\n /// @param refundProtocol_ the Refund Protocol (or adapter) this arbiter drives; immutable\n /// @param challengeWindow_ seconds a ruling stays disputable, within [MIN_WINDOW, MAX_WINDOW]\n /// @param minBond_ absolute bond floor in native wei, non-zero\n /// @param bondBps_ bond as basis points of the payment amount, at most 10000\n constructor(address owner_, address operator_, address refundProtocol_, uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (refundProtocol_ == address(0)) revert ZeroAddress();\n refundProtocol = IRefundProtocolV2(refundProtocol_);\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Owner-only, bounded, not retroactive: existing rulings keep their window and bond.\n function setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(challengeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Pass a recipient lockup change through to the Refund Protocol (owner only).\n function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyOwner {\n refundProtocol.setLockupSeconds(recipient, recipientLockupSeconds);\n }\n\n /// @notice The bond a ruling on a payment of `amount` must post right now.\n function requiredBond(uint256 amount) public view returns (uint256) {\n uint256 scaled = (amount * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice A party to the payment files the hash of its own evidence. Write-once, and only\n /// before the ruling. The caller must be the payer or the payee per the Refund Protocol.\n function fileEvidence(uint256 paymentID, bytes32 evidenceHash) external {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (_rulings[paymentID].status != Status.None) revert EvidenceClosed();\n (address payer, address payee,,) = refundProtocol.paymentInfo(paymentID);\n if (msg.sender != payer && msg.sender != payee) revert NotAParty();\n if (filedEvidence[paymentID][msg.sender] != bytes32(0)) revert EvidenceAlreadyFiled(paymentID, msg.sender);\n filedEvidence[paymentID][msg.sender] = evidenceHash;\n emit EvidenceFiled(paymentID, msg.sender, evidenceHash);\n }\n\n /// @notice Rule on a payment: cite a party's filed evidence, choose a direction, post the bond.\n /// Nothing executes until `finalize` or `resolve`.\n /// @param evidenceFrom the payer or payee whose filed evidence the ruling rests on\n function rule(uint256 paymentID, address evidenceFrom, bytes32 evidenceHash, Direction direction)\n external\n payable\n onlyOperator\n {\n if (evidenceHash == bytes32(0)) revert ZeroHash();\n if (direction == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.None) revert AlreadyRuled(paymentID);\n\n (address payer, address payee, uint256 amount, uint256 releaseTimestamp) = refundProtocol.paymentInfo(paymentID);\n if (payee == address(0)) revert UnknownPayment();\n if (msg.sender == payer || msg.sender == payee) revert OperatorIsParty();\n if (evidenceFrom != payer && evidenceFrom != payee) revert NotAParty();\n if (filedEvidence[paymentID][evidenceFrom] != evidenceHash) revert NoEvidenceFiled(paymentID, evidenceFrom);\n uint256 required = requiredBond(amount);\n if (msg.value < required) revert BondTooSmall(required);\n uint64 window = challengeWindow;\n // A refund must still be possible when the window closes. `finalize` must then run before the\n // payment's `releaseTimestamp`; if it does not, the Refund Protocol rejects the refund and `expire` voids the ruling.\n if (direction == Direction.Refund && releaseTimestamp <= block.timestamp + window) revert LockupTooShort();\n\n r.staker = msg.sender;\n r.status = Status.Ruled;\n r.direction = direction;\n r.ruledAt = uint64(block.timestamp);\n r.window = window;\n r.expiresAt = uint64(block.timestamp) + window + RESOLUTION_TIMEOUT;\n r.evidenceFrom = evidenceFrom;\n r.payer = payer;\n r.payee = payee;\n r.bond = msg.value;\n r.evidenceHash = evidenceHash;\n totalBonded += msg.value;\n rulingCount += 1;\n emit Ruled(paymentID, msg.sender, evidenceFrom, evidenceHash, direction, msg.value, uint64(block.timestamp) + window);\n }\n\n /// @notice The harmed party (payee on a Refund, payer on a Release) files counter-evidence\n /// inside the window. This freezes the ruling and hands it to the owner.\n function dispute(uint256 paymentID, bytes32 counterEvidence) external {\n if (counterEvidence == bytes32(0)) revert ZeroHash();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp >= uint256(r.ruledAt) + r.window) revert WindowClosed();\n if (msg.sender != _harmed(r)) revert NotHarmedParty();\n r.status = Status.Disputed;\n r.counterEvidence = counterEvidence;\n r.expiresAt = uint64(block.timestamp) + RESOLUTION_TIMEOUT;\n emit Disputed(paymentID, msg.sender, counterEvidence, r.expiresAt);\n }\n\n /// @notice After an undisputed window, return the bond to the staker and execute a Refund.\n /// Anyone may call. If the Refund Protocol reverts, this reverts too and the ruling\n /// can only be voided via `expire`.\n function finalize(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled) revert BadStatus();\n if (block.timestamp < uint256(r.ruledAt) + r.window) revert WindowOpen();\n _uphold(paymentID, r);\n }\n\n /// @notice Owner decides a disputed ruling. `finalDirection` equal to the ruling upholds it;\n /// any other direction overturns it and slashes the bond to the harmed party. The\n /// final direction is executed either way (a Refund calls the Refund Protocol).\n function resolve(uint256 paymentID, Direction finalDirection) external onlyOwner {\n if (finalDirection == Direction.None) revert BadDirection();\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp >= r.expiresAt) revert ResolutionExpired();\n if (finalDirection == r.direction) {\n _uphold(paymentID, r);\n return;\n }\n address harmed = _harmed(r);\n uint256 bond = r.bond;\n r.status = Status.Overturned;\n r.bond = 0;\n totalBonded -= bond;\n overturnCount += 1;\n _addCredit(harmed, bond);\n emit Overturned(paymentID, harmed, finalDirection, bond);\n if (finalDirection == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n /// @notice Void a ruling nobody decided in time. Nothing is executed; the bond returns to the\n /// staker. Anyone may call once `expiresAt` has passed.\n function expire(uint256 paymentID) external {\n Ruling storage r = _rulings[paymentID];\n if (r.status != Status.Ruled && r.status != Status.Disputed) revert BadStatus();\n if (block.timestamp < r.expiresAt) revert NotExpired();\n uint256 bond = r.bond;\n r.status = Status.Voided;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Voided(paymentID, r.staker, bond);\n }\n\n /// @notice The full ruling record for a payment (all-zero if none).\n function rulings(uint256 paymentID) external view returns (Ruling memory) {\n return _rulings[paymentID];\n }\n\n /// @notice The party the ruling in `paymentID` would harm, or zero if there is no ruling.\n function harmedParty(uint256 paymentID) external view returns (address) {\n Ruling storage r = _rulings[paymentID];\n return r.status == Status.None ? address(0) : _harmed(r);\n }\n\n function _uphold(uint256 paymentID, Ruling storage r) private {\n uint256 bond = r.bond;\n r.status = Status.Finalized;\n r.bond = 0;\n totalBonded -= bond;\n _addCredit(r.staker, bond);\n emit Finalized(paymentID, r.staker, r.direction, bond);\n if (r.direction == Direction.Refund) refundProtocol.refundByArbiter(paymentID);\n }\n\n function _harmed(Ruling storage r) private view returns (address) {\n return r.direction == Direction.Refund ? r.payee : r.payer;\n }\n\n function _setParams(uint64 challengeWindow_, uint256 minBond_, uint16 bondBps_) private {\n if (challengeWindow_ < MIN_WINDOW || challengeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS)\n revert BadParams();\n challengeWindow = challengeWindow_;\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(challengeWindow_, minBond_, bondBps_);\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + }, + "v2/access/PullCredit.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeSettlementV2.json b/deployments/v2/verify/PredgeSettlementV2.json index 17e226a..c5b804a 100644 --- a/deployments/v2/verify/PredgeSettlementV2.json +++ b/deployments/v2/verify/PredgeSettlementV2.json @@ -5,7 +5,7 @@ "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Owned2Step} from \"./access/PredgeAccess.sol\";\n\n/// @title PredgeSettlementV2\n/// @notice Receipt layer for Predge's pay-per-call data API: an agent pays for a route in the\n/// chain's native token and this contract emits a `Paid` receipt.\n/// @dev Amounts are in native wei of the chain (on Arc the native token is USDC with 18\n/// decimals at the protocol level; the ERC-20 interface of Arc's USDC uses 6, so never\n/// mix the two units). The owner (a Safe) withdraws accumulated payments to a non-zero\n/// address; there is no other admin power and no upgrade path. Forced value (selfdestruct\n/// or coinbase) is included in `withdraw`, so it cannot be stranded.\ncontract PredgeSettlementV2 is Owned2Step {\n /// @param payer who paid\n /// @param route keccak256 of the route id\n /// @param amount native wei paid\n /// @param timestamp block time\n /// @param meta optional request reference\n event Paid(address indexed payer, bytes32 indexed route, uint256 amount, uint256 timestamp, string meta);\n event Withdrawn(address indexed to, uint256 amount);\n\n error ZeroPayment();\n error WithdrawFailed();\n\n /// @param owner_ the Safe that may withdraw\n constructor(address owner_) Owned2Step(owner_) {}\n\n /// @notice Pay for a route; emits a `Paid` receipt.\n function payForRoute(bytes32 route, string calldata meta) external payable {\n if (msg.value == 0) revert ZeroPayment();\n emit Paid(msg.sender, route, msg.value, block.timestamp, meta);\n }\n\n /// @notice Owner withdraws the whole balance to `to`. Reverts for the zero address.\n function withdraw(address payable to) external onlyOwner {\n if (to == address(0)) revert ZeroAddress();\n uint256 bal = address(this).balance;\n emit Withdrawn(to, bal);\n (bool ok,) = to.call{value: bal}(\"\");\n if (!ok) revert WithdrawFailed();\n }\n\n /// @notice A bare transfer still produces a receipt, under the route `bytes32(\"direct\")`.\n receive() external payable {\n if (msg.value == 0) revert ZeroPayment();\n emit Paid(msg.sender, bytes32(\"direct\"), msg.value, block.timestamp, \"\");\n }\n}\n" }, "v2/access/PredgeAccess.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's bond, and never strands the bond either. It can strand an in-flight VERDICT\n/// (Olympix 5.3.8): a Committed stake in `PredgeValidatorBondV2` can only be scored by the new\n/// key if it is given the old commitment's `(expected, salt)`. If it is not, the job times out,\n/// the client is refunded and the bond is forfeited to the provider. Rotate with no stake in\n/// flight, or hand the preimages over, unless the old key is compromised.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" } }, "settings": { diff --git a/deployments/v2/verify/PredgeSettlementV2.v2-0.json b/deployments/v2/verify/PredgeSettlementV2.v2-0.json new file mode 100644 index 0000000..17e226a --- /dev/null +++ b/deployments/v2/verify/PredgeSettlementV2.v2-0.json @@ -0,0 +1,29 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeSettlementV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Owned2Step} from \"./access/PredgeAccess.sol\";\n\n/// @title PredgeSettlementV2\n/// @notice Receipt layer for Predge's pay-per-call data API: an agent pays for a route in the\n/// chain's native token and this contract emits a `Paid` receipt.\n/// @dev Amounts are in native wei of the chain (on Arc the native token is USDC with 18\n/// decimals at the protocol level; the ERC-20 interface of Arc's USDC uses 6, so never\n/// mix the two units). The owner (a Safe) withdraws accumulated payments to a non-zero\n/// address; there is no other admin power and no upgrade path. Forced value (selfdestruct\n/// or coinbase) is included in `withdraw`, so it cannot be stranded.\ncontract PredgeSettlementV2 is Owned2Step {\n /// @param payer who paid\n /// @param route keccak256 of the route id\n /// @param amount native wei paid\n /// @param timestamp block time\n /// @param meta optional request reference\n event Paid(address indexed payer, bytes32 indexed route, uint256 amount, uint256 timestamp, string meta);\n event Withdrawn(address indexed to, uint256 amount);\n\n error ZeroPayment();\n error WithdrawFailed();\n\n /// @param owner_ the Safe that may withdraw\n constructor(address owner_) Owned2Step(owner_) {}\n\n /// @notice Pay for a route; emits a `Paid` receipt.\n function payForRoute(bytes32 route, string calldata meta) external payable {\n if (msg.value == 0) revert ZeroPayment();\n emit Paid(msg.sender, route, msg.value, block.timestamp, meta);\n }\n\n /// @notice Owner withdraws the whole balance to `to`. Reverts for the zero address.\n function withdraw(address payable to) external onlyOwner {\n if (to == address(0)) revert ZeroAddress();\n uint256 bal = address(this).balance;\n emit Withdrawn(to, bal);\n (bool ok,) = to.call{value: bal}(\"\");\n if (!ok) revert WithdrawFailed();\n }\n\n /// @notice A bare transfer still produces a receipt, under the route `bytes32(\"direct\")`.\n receive() external payable {\n if (msg.value == 0) revert ZeroPayment();\n emit Paid(msg.sender, bytes32(\"direct\"), msg.value, block.timestamp, \"\");\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} diff --git a/deployments/v2/verify/PredgeValidatorBondV2.json b/deployments/v2/verify/PredgeValidatorBondV2.json index 87704f3..c4d17a9 100644 --- a/deployments/v2/verify/PredgeValidatorBondV2.json +++ b/deployments/v2/verify/PredgeValidatorBondV2.json @@ -2,16 +2,16 @@ "language": "Solidity", "sources": { "v2/PredgeValidatorBondV2.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\n\n/// @title PredgeValidatorBondV2\n/// @notice Capital behind a verdict, and the EVALUATOR of an `AgentJobV2`. The job names this\n/// contract as its evaluator, so the verdict recorded here is the thing that moves the\n/// escrow, and the same transaction puts the operator's bond at risk.\n///\n/// The acceptance test is deterministic: the 32-byte deliverable hash stored in the job (sha256 of the\n/// output when the provider submitted the bytes, otherwise the hash the provider claimed) is compared by\n/// RAW EQUALITY with the `expected` 32-byte value the operator committed to. Nothing is hashed again here. That makes a false verdict provable\n/// on-chain with no judge, by comparing two hashes written by two different parties.\n///\n/// Flow:\n/// 1. `stakeAndCommit(requestHash, commitment, jobId)` (operator). Allowed ONLY while\n/// the job is Open, i.e. before any deliverable exists. `commitment` is\n/// `keccak256(abi.encode(expected, salt))`: `expected` stays hidden, so a provider\n/// cannot read it and submit it, and the operator cannot choose it after seeing the\n/// deliverable. The bond must be at least `max(minBond, bondBps * escrow / 10000)`.\n/// 2. The provider submits to the job.\n/// 3. `recordScore(requestHash, score, expected, salt, reason)` (operator). Requires the\n/// job to be Submitted and the reveal to match the commitment. It then calls\n/// `complete` (score 100) or `reject` (score 0) on the job, so the verdict and the\n/// money move together. The dispute window starts at `scoredAt`, which is always at\n/// or after the job's `submittedAt` because scoring requires a submission.\n/// 4. `challenge(requestHash)` (anyone) strictly BEFORE `scoredAt + window`; from that instant\n/// `reclaim` is possible and `challenge` reverts (no overlap, and no open-ended challenge). Slashes iff the\n/// verdict contradicts the deliverable: 100 but deliverable != expected, or 0 but\n/// deliverable == expected. The whole bond is CREDITED TO THE HARMED PARTY (the\n/// job's client on a false pass, its provider on a false fail), never to the caller,\n/// so a self-challenge pays the operator nothing and a front-runner earns nothing.\n/// 5. `reclaim(requestHash)` (anyone) once the window has closed unchallenged. The bond is\n/// credited to the staker recorded at stake time.\n/// 6. `release(requestHash)` (anyone) if the job expired with no verdict. No verdict was\n/// given, so nothing was lied about, and the bond goes back to the staker.\n///\n/// Honest limits, stated so they are not mistaken for guarantees:\n/// * The bond only covers `bondBps` of the escrow. At `bondBps = 10000` a colluding\n/// operator and provider cannot profit from a false pass; below that they can,\n/// up to the uncovered fraction.\n/// * A false verdict is only slashable if the harmed party (or someone for them) calls\n/// `challenge` inside the window. Nobody is paid to watch except the harmed party.\n/// * The operator's capital is locked until the job ends. A client picks the job's\n/// `expiredAt`, so an operator should read it before staking.\n/// * Only the deterministic `sha256(deliverable) == expected` test is covered. Subjective\n/// or \"void\" outcomes are not bonded.\n///\n/// @dev Native value only, in the chain's native wei. Roles: `owner` (Safe) sets bounded\n/// parameters and rotates `operator`; `operator` (hot key) stakes and scores. Parameters\n/// are not retroactive: the dispute window is snapshotted into each stake and the bond\n/// amount is fixed when staked. Payouts are pull-based. The contract holds, at all times,\n/// at least `totalBonded + totalCredit`.\ncontract PredgeValidatorBondV2 is Operated, PullCredit {\n enum Status {\n None,\n Committed, // bond posted, no verdict yet\n Scored, // verdict recorded, window running or elapsed, bond still held\n Slashed, // bond credited to the harmed party (final)\n Reclaimed, // bond credited back to the staker after the window (final)\n Released // job expired with no verdict, bond credited back to the staker (final)\n }\n\n struct Stake {\n address staker; // the operator key that posted the bond; the only party it is ever paid back to\n Status status;\n uint8 score; // 100 delivered / 0 failed, set when scored\n uint64 stakedAt;\n uint64 scoredAt; // set when scored; always >= the job's submittedAt\n uint64 window; // snapshot of `disputeWindow` at stake time\n uint256 jobId;\n uint256 bond;\n bytes32 commitment; // keccak256(abi.encode(expected, salt))\n bytes32 expected; // zero until revealed at recordScore\n }\n\n uint64 public constant MIN_WINDOW = 1 hours;\n uint64 public constant MAX_WINDOW = 30 days;\n uint16 public constant MAX_BPS = 10_000;\n\n IAgentJobV2 public immutable job;\n\n /// @notice Seconds a verdict stays challengeable. Applies to stakes made after a change.\n uint64 public disputeWindow;\n /// @notice Absolute floor for a bond, in native wei. Applies to stakes made after a change.\n uint256 public minBond;\n /// @notice Bond as basis points of the job escrow (10000 = 100%). Applies to future stakes.\n uint16 public bondBps;\n\n /// @notice Sum of bonds currently held (Committed or Scored).\n uint256 public totalBonded;\n uint64 public slashCount;\n\n mapping(bytes32 => Stake) public stakes;\n\n event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond, uint64 window);\n event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, uint64 scoredAt, uint64 windowEnd);\n event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered);\n event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond);\n event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond);\n event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps);\n\n error ZeroHash();\n error BadParams();\n error AlreadyCommitted();\n error NotCommitted();\n error NotScored();\n error BadStatus();\n error NotOpen();\n error NotSubmitted();\n error JobNotBound();\n error ProviderIsOperator();\n error BondTooSmall(uint256 required);\n error BadScore();\n error BadReveal();\n error VerdictHonest();\n error WindowOpen();\n error WindowClosed();\n error JobNotExpired();\n\n /// @param owner_ the Safe: sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that stakes and scores\n /// @param job_ the `AgentJobV2` this bond evaluates; immutable\n /// @param disputeWindow_ seconds a verdict stays challengeable, within [MIN_WINDOW, MAX_WINDOW]\n /// @param minBond_ absolute floor for a bond, non-zero\n /// @param bondBps_ bond as basis points of escrow, at most 10000\n constructor(address owner_, address operator_, address job_, uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (job_ == address(0)) revert ZeroAddress();\n job = IAgentJobV2(job_);\n _setParams(disputeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Owner-only parameter change, bounded and NOT retroactive: existing stakes keep\n /// the window and bond they were made with.\n function setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(disputeWindow_, minBond_, bondBps_);\n }\n\n /// @notice The bond a stake against `escrow` must post right now: `max(minBond, ceil(escrow * bondBps / 10000))`.\n function requiredBond(uint256 escrow) public view returns (uint256) {\n uint256 scaled = (escrow * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice Post the bond and commit the hidden acceptance test, while the job is still Open.\n /// @param requestHash the job's `specHash` (the domain-separated Predge request hash)\n /// @param commitment `keccak256(abi.encode(expected, salt))`; use a fresh random 32-byte salt\n /// @param jobId the job this verdict will settle; this contract must be its evaluator\n function stakeAndCommit(bytes32 requestHash, bytes32 commitment, uint256 jobId) external payable onlyOperator {\n if (requestHash == bytes32(0) || commitment == bytes32(0)) revert ZeroHash();\n Stake storage s = stakes[requestHash];\n if (s.status != Status.None) revert AlreadyCommitted();\n\n IAgentJobV2.Job memory j = job.getJob(jobId);\n // Only while Open and unexpired: once a deliverable exists, a commitment could be made to match it.\n if (j.state != IAgentJobV2.State.Open || block.timestamp >= j.expiredAt) revert NotOpen();\n if (j.specHash != requestHash || j.evaluator != address(this)) revert JobNotBound();\n // A provider that is also the operator writes both sides of the comparison.\n if (j.provider == msg.sender) revert ProviderIsOperator();\n uint256 required = requiredBond(j.escrow);\n if (msg.value < required) revert BondTooSmall(required);\n\n s.staker = msg.sender;\n s.status = Status.Committed;\n s.stakedAt = uint64(block.timestamp);\n s.window = disputeWindow;\n s.jobId = jobId;\n s.bond = msg.value;\n s.commitment = commitment;\n totalBonded += msg.value;\n emit Committed(requestHash, jobId, msg.sender, commitment, msg.value, s.window);\n }\n\n /// @notice Reveal the committed test, record the verdict, and settle the job with it.\n /// @param score 100 = delivered (job `complete`s, provider is credited), 0 = failed\n /// (job `reject`s, client is credited)\n /// @param expected,salt the preimage of the commitment\n /// @param reason the attestation commitment passed to the job (Predge: keccak256 of the signed verdict)\n function recordScore(bytes32 requestHash, uint8 score, bytes32 expected, bytes32 salt, bytes32 reason)\n external\n onlyOperator\n {\n if (score != 0 && score != 100) revert BadScore();\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n if (keccak256(abi.encode(expected, salt)) != s.commitment) revert BadReveal();\n // Re-checked here, not only at stake time: after an operator rotation the NEW operator must not be able\n // to score a job it is the provider of, while inheriting the old operator's stake.\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (j.provider == msg.sender) revert ProviderIsOperator();\n // Scoring needs a submission, so `scoredAt >= submittedAt` and the dispute window can never\n // start before the deliverable it is judged against exists.\n if (j.state != IAgentJobV2.State.Submitted) revert NotSubmitted();\n\n s.expected = expected;\n s.score = score;\n s.scoredAt = uint64(block.timestamp);\n s.status = Status.Scored;\n emit Scored(requestHash, s.jobId, score, expected, s.scoredAt, s.scoredAt + s.window);\n\n if (score == 100) job.complete(s.jobId, reason, \"\");\n else job.reject(s.jobId, reason, \"\");\n }\n\n /// @notice Prove the recorded verdict contradicts the deliverable and slash the bond to the\n /// harmed party. Anyone may call; the caller receives nothing. Takes no evidence.\n function challenge(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored) revert NotScored();\n if (block.timestamp >= uint256(s.scoredAt) + s.window) revert WindowClosed();\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (!_lied(s, j.deliverable)) revert VerdictHonest();\n\n address harmed = s.score == 100 ? j.client : j.provider;\n uint256 bond = s.bond;\n s.status = Status.Slashed;\n s.bond = 0;\n totalBonded -= bond;\n slashCount += 1;\n _addCredit(harmed, bond);\n emit Slashed(requestHash, msg.sender, harmed, bond, s.score, j.deliverable);\n }\n\n /// @notice Credit the bond back to its staker once the dispute window has closed unchallenged.\n /// Anyone may trigger it; the credit always goes to the staker.\n function reclaim(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored) revert NotScored();\n if (block.timestamp < uint256(s.scoredAt) + s.window) revert WindowOpen();\n uint256 bond = _close(s, Status.Reclaimed);\n emit Reclaimed(requestHash, s.staker, bond);\n }\n\n /// @notice Credit the bond back to its staker if the job expired without a verdict.\n function release(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n if (job.getJob(s.jobId).state != IAgentJobV2.State.Expired) revert JobNotExpired();\n uint256 bond = _close(s, Status.Released);\n emit Released(requestHash, s.staker, bond);\n }\n\n /// @notice Is this verdict slashable right now? Free check before spending gas.\n function wouldSlash(bytes32 requestHash) external view returns (bool) {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored || block.timestamp >= uint256(s.scoredAt) + s.window) return false;\n return _lied(s, job.getJob(s.jobId).deliverable);\n }\n\n function _lied(Stake storage s, bytes32 delivered) private view returns (bool) {\n bool matches = delivered == s.expected;\n return (s.score == 100 && !matches) || (s.score == 0 && matches);\n }\n\n function _close(Stake storage s, Status to) private returns (uint256 bond) {\n bond = s.bond;\n s.status = to;\n s.bond = 0;\n totalBonded -= bond;\n _addCredit(s.staker, bond);\n }\n\n function _setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) private {\n if (disputeWindow_ < MIN_WINDOW || disputeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS)\n revert BadParams();\n disputeWindow = disputeWindow_;\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(disputeWindow_, minBond_, bondBps_);\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\n\n/// @title PredgeValidatorBondV2\n/// @notice Capital behind a verdict, and the EVALUATOR of an `AgentJobV2`. The job names this\n/// contract as its evaluator, so the verdict recorded here is the thing that moves the\n/// escrow.\n///\n/// The acceptance test is deterministic: the job's deliverable counts as DELIVERED only if the\n/// provider submitted the output bytes (so the job computed `sha256(output)` on-chain,\n/// `hashedOnChain == true`) and that hash equals, by RAW EQUALITY, the `expected` value the\n/// CLIENT fixed when it created the job. A hash the provider merely claimed, with no bytes, never\n/// counts as delivered. Nothing is hashed again here.\n///\n/// A FALSE VERDICT CANNOT BE RECORDED (owner decision 23.10.2026, option a). Both sides of the\n/// comparison are on-chain at `recordScore`: the hash the chain computed and the `expected` the\n/// client committed to. So `recordScore` checks the score against them and reverts\n/// `VerdictContradictsEvidence` unless score 100 <=> delivered and score 0 <=> not delivered. This\n/// holds for any bond size, including `bondBps = 0`, and needs no challenger, no watcher and no\n/// dispute window: there is nothing to challenge, so there is no `challenge` and the bond is\n/// credited back to the staker in the same transaction as the verdict.\n///\n/// Who fixes `expected` (Olympix 5.1.1): the client. A job evaluated by this contract must have\n/// `specHash == specHashFor(keccak256(abi.encode(expected, salt)))`, which the client chooses at\n/// `createJob`. The operator learns `(expected, salt)` from the client off-chain, checks it against\n/// the job before staking, and can only commit to that value.\n///\n/// Flow:\n/// 1. `stakeAndCommit(requestHash, commitment, jobId)` (operator). Allowed ONLY while\n/// the job is Open, i.e. before any deliverable exists. `commitment` is\n/// `keccak256(abi.encode(expected, salt))` and must be the one the client bound into the\n/// job's specHash (`requestHash == specHashFor(commitment)`). The bond must be at least\n/// `max(minBond, bondBps * escrow / 10000)`.\n/// 2. The provider submits to the job (with the output bytes, or the job can only fail).\n/// 3. `recordScore(requestHash, score, expected, salt, reason)` (operator). Requires the\n/// job to be Submitted, the reveal to match the commitment, and the score to agree with\n/// the on-chain evidence. It then calls `complete` (score 100) or `reject` (score 0) on\n/// the job, so the verdict and the money move together, and credits the bond back to the\n/// staker.\n/// 4. `release(requestHash)` (anyone) if the job expired with no verdict. If the provider never\n/// submitted, nothing was owed and the bond goes back to the staker. If the provider DID submit\n/// and the operator stayed silent until the evaluation window closed (Olympix 5.2.2), the\n/// bond is FORFEITED to the provider: silence on submitted work is not free.\n///\n/// What the bond is for, after option (a): LIVENESS, not correctness. Correctness is enforced by\n/// `recordScore`. The bond is what the operator loses if it stays silent on a submitted job.\n///\n/// Honest limits, stated so they are not mistaken for guarantees:\n/// * The client defines `expected`. A provider should obtain `(expected, salt)` from the client\n/// and check it against the job's specHash before working; knowing `expected` does not\n/// let anyone pass without the real bytes, because only an on-chain hash counts.\n/// * A pass needs the output bytes in calldata; large outputs cost the provider gas.\n/// * On silence the provider receives the bond, not the escrow: the job's timeout policy\n/// still refunds the escrow to the client. With `bondBps = 0` the bond is a flat floor.\n/// * The operator's capital is locked until the job ends. A client picks the job's\n/// `expiredAt`, so an operator should read it before staking.\n/// * Only the deterministic `sha256(deliverable) == expected` test is covered. Subjective\n/// or \"void\" outcomes are not bonded.\n///\n/// @dev Native value only, in the chain's native wei. Roles: `owner` (Safe) sets bounded\n/// parameters and rotates `operator`; `operator` (hot key) stakes and scores. Parameters\n/// are not retroactive: the bond amount is fixed when staked. Payouts are pull-based. The\n/// contract holds, at all times, at least `totalBonded + totalCredit`.\ncontract PredgeValidatorBondV2 is Operated, PullCredit {\n enum Status {\n None,\n Committed, // bond posted, no verdict yet\n Scored, // verdict recorded (it matches the evidence), bond credited back to the staker (final)\n Released, // job expired before any submission, bond credited back to the staker (final)\n Forfeited // job expired after a submission with no verdict, bond credited to the provider (final)\n }\n\n struct Stake {\n address staker; // the operator key that posted the bond; the only party it is ever paid back to\n Status status;\n uint8 score; // 100 delivered / 0 not delivered, set when scored\n uint64 stakedAt;\n uint64 scoredAt; // set when scored; always >= the job's submittedAt\n uint256 jobId;\n uint256 bond; // the posted bond while Committed; zero once closed\n bytes32 commitment; // keccak256(abi.encode(expected, salt))\n bytes32 expected; // zero until revealed at recordScore\n }\n\n uint16 public constant MAX_BPS = 10_000;\n\n IAgentJobV2 public immutable job;\n\n /// @notice Absolute floor for a bond, in native wei. Applies to stakes made after a change.\n uint256 public minBond;\n /// @notice Bond as basis points of the job escrow (10000 = 100%). Applies to future stakes.\n uint16 public bondBps;\n\n /// @notice Sum of bonds currently held (Committed).\n uint256 public totalBonded;\n\n mapping(bytes32 => Stake) public stakes;\n\n event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond);\n event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, address staker, uint256 bond);\n event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond);\n event Forfeited(bytes32 indexed requestHash, address indexed provider, uint256 bond);\n event ParamsUpdated(uint256 minBond, uint16 bondBps);\n\n error ZeroHash();\n error BadParams();\n error AlreadyCommitted();\n error NotCommitted();\n error BadStatus();\n error NotOpen();\n error NotSubmitted();\n error JobNotBound();\n error ProviderIsOperator();\n error ClientIsOperator();\n error CommitmentNotBound();\n error BondTooSmall(uint256 required);\n error BadScore();\n error BadReveal();\n error VerdictContradictsEvidence();\n error JobNotExpired();\n\n /// @param owner_ the Safe: sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that stakes and scores\n /// @param job_ the `AgentJobV2` this bond evaluates; immutable\n /// @param minBond_ absolute floor for a bond, non-zero\n /// @param bondBps_ bond as basis points of escrow, at most 10000\n constructor(address owner_, address operator_, address job_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (job_ == address(0)) revert ZeroAddress();\n job = IAgentJobV2(job_);\n _setParams(minBond_, bondBps_);\n }\n\n /// @notice Owner-only parameter change, bounded and NOT retroactive: existing stakes keep\n /// the bond they were made with.\n function setParams(uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(minBond_, bondBps_);\n }\n\n /// @notice The bond a stake against `escrow` must post right now: `max(minBond, ceil(escrow * bondBps / 10000))`.\n function requiredBond(uint256 escrow) public view returns (uint256) {\n uint256 scaled = (escrow * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice The specHash a client must give a job evaluated by this contract, for a commitment\n /// `keccak256(abi.encode(expected, salt))`. Domain separated by chain and by this contract.\n function specHashFor(bytes32 commitment) public view returns (bytes32) {\n return keccak256(abi.encode(block.chainid, address(this), commitment));\n }\n\n /// @notice The only verdict `recordScore` accepts for job `jobId` against `expected`: 100 if the\n /// provider submitted the bytes and their on-chain sha256 equals `expected`, otherwise 0.\n /// Meaningful once the job is Submitted.\n function verdictFor(uint256 jobId, bytes32 expected) public view returns (uint8) {\n IAgentJobV2.Job memory j = job.getJob(jobId);\n return _delivered(j, expected) ? 100 : 0;\n }\n\n /// @notice Post the bond and commit the hidden acceptance test, while the job is still Open.\n /// @param requestHash the job's `specHash`; must equal `specHashFor(commitment)`\n /// @param commitment `keccak256(abi.encode(expected, salt))` as fixed by the client in the specHash\n /// @param jobId the job this verdict will settle; this contract must be its evaluator\n function stakeAndCommit(bytes32 requestHash, bytes32 commitment, uint256 jobId) external payable onlyOperator {\n if (requestHash == bytes32(0) || commitment == bytes32(0)) revert ZeroHash();\n Stake storage s = stakes[requestHash];\n if (s.status != Status.None) revert AlreadyCommitted();\n // The client fixed `expected` (hidden behind the salt) in the specHash: the operator cannot pick its own.\n if (requestHash != specHashFor(commitment)) revert CommitmentNotBound();\n\n IAgentJobV2.Job memory j = job.getJob(jobId);\n // Only while Open and unexpired: the stake must exist before the work does.\n if (j.state != IAgentJobV2.State.Open || block.timestamp >= j.expiredAt) revert NotOpen();\n if (j.specHash != requestHash || j.evaluator != address(this)) revert JobNotBound();\n // A provider that is also the operator would decide whether to deliver and when to stay silent.\n if (j.provider == msg.sender) revert ProviderIsOperator();\n // A client that is also the operator would judge its own job (Olympix 5.2.3).\n if (j.client == msg.sender) revert ClientIsOperator();\n uint256 required = requiredBond(j.escrow);\n if (msg.value < required) revert BondTooSmall(required);\n\n s.staker = msg.sender;\n s.status = Status.Committed;\n s.stakedAt = uint64(block.timestamp);\n s.jobId = jobId;\n s.bond = msg.value;\n s.commitment = commitment;\n totalBonded += msg.value;\n emit Committed(requestHash, jobId, msg.sender, commitment, msg.value);\n }\n\n /// @notice Reveal the committed test, record the verdict, settle the job with it, and credit the\n /// bond back to the staker. Reverts `VerdictContradictsEvidence` on a false verdict.\n /// @param score 100 = delivered (job `complete`s, provider is credited), 0 = not delivered\n /// (job `reject`s, client is credited). Must equal `verdictFor(jobId, expected)`.\n /// @param expected,salt the preimage of the commitment\n /// @param reason the attestation commitment passed to the job (Predge: keccak256 of the signed verdict)\n function recordScore(bytes32 requestHash, uint8 score, bytes32 expected, bytes32 salt, bytes32 reason)\n external\n onlyOperator\n {\n if (score != 0 && score != 100) revert BadScore();\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n if (keccak256(abi.encode(expected, salt)) != s.commitment) revert BadReveal();\n // Re-checked here, not only at stake time: after an operator rotation the NEW operator must not be able\n // to score a job it is a party to, while inheriting the old operator's stake.\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (j.provider == msg.sender) revert ProviderIsOperator();\n if (j.client == msg.sender) revert ClientIsOperator();\n // Scoring needs a submission: the verdict is judged against a deliverable that exists.\n if (j.state != IAgentJobV2.State.Submitted) revert NotSubmitted();\n // Option (a): the evidence decides. A pass needs an on-chain hash equal to `expected`; a fail needs the opposite.\n if ((score == 100) != _delivered(j, expected)) revert VerdictContradictsEvidence();\n\n s.expected = expected;\n s.score = score;\n s.scoredAt = uint64(block.timestamp);\n uint256 bond = _close(s, Status.Scored, s.staker);\n emit Scored(requestHash, s.jobId, score, expected, s.staker, bond);\n\n if (score == 100) job.complete(s.jobId, reason, \"\");\n else job.reject(s.jobId, reason, \"\");\n }\n\n /// @notice Close a stake whose job expired without a verdict. Anyone may trigger it. If the provider\n /// never submitted, the bond is credited back to the staker. If the provider submitted and the\n /// operator let the evaluation window close without scoring, the bond is credited to the\n /// provider instead.\n function release(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (j.state != IAgentJobV2.State.Expired) revert JobNotExpired();\n if (j.submittedAt == 0) {\n uint256 bond = _close(s, Status.Released, s.staker);\n emit Released(requestHash, s.staker, bond);\n } else {\n uint256 bond = _close(s, Status.Forfeited, j.provider);\n emit Forfeited(requestHash, j.provider, bond);\n }\n }\n\n /// @dev Delivered means: the bytes were hashed on-chain AND the hash equals the client's `expected`.\n function _delivered(IAgentJobV2.Job memory j, bytes32 expected) private pure returns (bool) {\n return j.hashedOnChain && j.deliverable == expected;\n }\n\n function _close(Stake storage s, Status to, address payee) private returns (uint256 bond) {\n bond = s.bond;\n s.status = to;\n s.bond = 0;\n totalBonded -= bond;\n _addCredit(payee, bond);\n }\n\n function _setParams(uint256 minBond_, uint16 bondBps_) private {\n if (minBond_ == 0 || bondBps_ > MAX_BPS) revert BadParams();\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(minBond_, bondBps_);\n }\n}\n" }, "v2/access/PredgeAccess.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's bond, and never strands the bond either. It can strand an in-flight VERDICT\n/// (Olympix 5.3.8): a Committed stake in `PredgeValidatorBondV2` can only be scored by the new\n/// key if it is given the old commitment's `(expected, salt)`. If it is not, the job times out,\n/// the client is refunded and the bond is forfeited to the provider. Rotate with no stake in\n/// flight, or hand the preimages over, unless the old key is compromised.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" }, "v2/access/PullCredit.sol": { "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" }, "v2/interfaces/IAgentJobV2.sol": { - "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (the Predge requestHash)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (bond.specHashFor(commitment) for bonded jobs)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" } }, "settings": { diff --git a/deployments/v2/verify/PredgeValidatorBondV2.v2-0.json b/deployments/v2/verify/PredgeValidatorBondV2.v2-0.json new file mode 100644 index 0000000..87704f3 --- /dev/null +++ b/deployments/v2/verify/PredgeValidatorBondV2.v2-0.json @@ -0,0 +1,35 @@ +{ + "language": "Solidity", + "sources": { + "v2/PredgeValidatorBondV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Operated} from \"./access/PredgeAccess.sol\";\nimport {PullCredit} from \"./access/PullCredit.sol\";\nimport {IAgentJobV2} from \"./interfaces/IAgentJobV2.sol\";\n\n/// @title PredgeValidatorBondV2\n/// @notice Capital behind a verdict, and the EVALUATOR of an `AgentJobV2`. The job names this\n/// contract as its evaluator, so the verdict recorded here is the thing that moves the\n/// escrow, and the same transaction puts the operator's bond at risk.\n///\n/// The acceptance test is deterministic: the 32-byte deliverable hash stored in the job (sha256 of the\n/// output when the provider submitted the bytes, otherwise the hash the provider claimed) is compared by\n/// RAW EQUALITY with the `expected` 32-byte value the operator committed to. Nothing is hashed again here. That makes a false verdict provable\n/// on-chain with no judge, by comparing two hashes written by two different parties.\n///\n/// Flow:\n/// 1. `stakeAndCommit(requestHash, commitment, jobId)` (operator). Allowed ONLY while\n/// the job is Open, i.e. before any deliverable exists. `commitment` is\n/// `keccak256(abi.encode(expected, salt))`: `expected` stays hidden, so a provider\n/// cannot read it and submit it, and the operator cannot choose it after seeing the\n/// deliverable. The bond must be at least `max(minBond, bondBps * escrow / 10000)`.\n/// 2. The provider submits to the job.\n/// 3. `recordScore(requestHash, score, expected, salt, reason)` (operator). Requires the\n/// job to be Submitted and the reveal to match the commitment. It then calls\n/// `complete` (score 100) or `reject` (score 0) on the job, so the verdict and the\n/// money move together. The dispute window starts at `scoredAt`, which is always at\n/// or after the job's `submittedAt` because scoring requires a submission.\n/// 4. `challenge(requestHash)` (anyone) strictly BEFORE `scoredAt + window`; from that instant\n/// `reclaim` is possible and `challenge` reverts (no overlap, and no open-ended challenge). Slashes iff the\n/// verdict contradicts the deliverable: 100 but deliverable != expected, or 0 but\n/// deliverable == expected. The whole bond is CREDITED TO THE HARMED PARTY (the\n/// job's client on a false pass, its provider on a false fail), never to the caller,\n/// so a self-challenge pays the operator nothing and a front-runner earns nothing.\n/// 5. `reclaim(requestHash)` (anyone) once the window has closed unchallenged. The bond is\n/// credited to the staker recorded at stake time.\n/// 6. `release(requestHash)` (anyone) if the job expired with no verdict. No verdict was\n/// given, so nothing was lied about, and the bond goes back to the staker.\n///\n/// Honest limits, stated so they are not mistaken for guarantees:\n/// * The bond only covers `bondBps` of the escrow. At `bondBps = 10000` a colluding\n/// operator and provider cannot profit from a false pass; below that they can,\n/// up to the uncovered fraction.\n/// * A false verdict is only slashable if the harmed party (or someone for them) calls\n/// `challenge` inside the window. Nobody is paid to watch except the harmed party.\n/// * The operator's capital is locked until the job ends. A client picks the job's\n/// `expiredAt`, so an operator should read it before staking.\n/// * Only the deterministic `sha256(deliverable) == expected` test is covered. Subjective\n/// or \"void\" outcomes are not bonded.\n///\n/// @dev Native value only, in the chain's native wei. Roles: `owner` (Safe) sets bounded\n/// parameters and rotates `operator`; `operator` (hot key) stakes and scores. Parameters\n/// are not retroactive: the dispute window is snapshotted into each stake and the bond\n/// amount is fixed when staked. Payouts are pull-based. The contract holds, at all times,\n/// at least `totalBonded + totalCredit`.\ncontract PredgeValidatorBondV2 is Operated, PullCredit {\n enum Status {\n None,\n Committed, // bond posted, no verdict yet\n Scored, // verdict recorded, window running or elapsed, bond still held\n Slashed, // bond credited to the harmed party (final)\n Reclaimed, // bond credited back to the staker after the window (final)\n Released // job expired with no verdict, bond credited back to the staker (final)\n }\n\n struct Stake {\n address staker; // the operator key that posted the bond; the only party it is ever paid back to\n Status status;\n uint8 score; // 100 delivered / 0 failed, set when scored\n uint64 stakedAt;\n uint64 scoredAt; // set when scored; always >= the job's submittedAt\n uint64 window; // snapshot of `disputeWindow` at stake time\n uint256 jobId;\n uint256 bond;\n bytes32 commitment; // keccak256(abi.encode(expected, salt))\n bytes32 expected; // zero until revealed at recordScore\n }\n\n uint64 public constant MIN_WINDOW = 1 hours;\n uint64 public constant MAX_WINDOW = 30 days;\n uint16 public constant MAX_BPS = 10_000;\n\n IAgentJobV2 public immutable job;\n\n /// @notice Seconds a verdict stays challengeable. Applies to stakes made after a change.\n uint64 public disputeWindow;\n /// @notice Absolute floor for a bond, in native wei. Applies to stakes made after a change.\n uint256 public minBond;\n /// @notice Bond as basis points of the job escrow (10000 = 100%). Applies to future stakes.\n uint16 public bondBps;\n\n /// @notice Sum of bonds currently held (Committed or Scored).\n uint256 public totalBonded;\n uint64 public slashCount;\n\n mapping(bytes32 => Stake) public stakes;\n\n event Committed(bytes32 indexed requestHash, uint256 indexed jobId, address indexed staker, bytes32 commitment, uint256 bond, uint64 window);\n event Scored(bytes32 indexed requestHash, uint256 indexed jobId, uint8 score, bytes32 expected, uint64 scoredAt, uint64 windowEnd);\n event Slashed(bytes32 indexed requestHash, address indexed challenger, address indexed harmed, uint256 bond, uint8 recordedScore, bytes32 delivered);\n event Reclaimed(bytes32 indexed requestHash, address indexed staker, uint256 bond);\n event Released(bytes32 indexed requestHash, address indexed staker, uint256 bond);\n event ParamsUpdated(uint64 disputeWindow, uint256 minBond, uint16 bondBps);\n\n error ZeroHash();\n error BadParams();\n error AlreadyCommitted();\n error NotCommitted();\n error NotScored();\n error BadStatus();\n error NotOpen();\n error NotSubmitted();\n error JobNotBound();\n error ProviderIsOperator();\n error BondTooSmall(uint256 required);\n error BadScore();\n error BadReveal();\n error VerdictHonest();\n error WindowOpen();\n error WindowClosed();\n error JobNotExpired();\n\n /// @param owner_ the Safe: sets bounded parameters, rotates the operator\n /// @param operator_ the hot key that stakes and scores\n /// @param job_ the `AgentJobV2` this bond evaluates; immutable\n /// @param disputeWindow_ seconds a verdict stays challengeable, within [MIN_WINDOW, MAX_WINDOW]\n /// @param minBond_ absolute floor for a bond, non-zero\n /// @param bondBps_ bond as basis points of escrow, at most 10000\n constructor(address owner_, address operator_, address job_, uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_)\n Operated(owner_, operator_)\n {\n if (job_ == address(0)) revert ZeroAddress();\n job = IAgentJobV2(job_);\n _setParams(disputeWindow_, minBond_, bondBps_);\n }\n\n /// @notice Owner-only parameter change, bounded and NOT retroactive: existing stakes keep\n /// the window and bond they were made with.\n function setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) external onlyOwner {\n _setParams(disputeWindow_, minBond_, bondBps_);\n }\n\n /// @notice The bond a stake against `escrow` must post right now: `max(minBond, ceil(escrow * bondBps / 10000))`.\n function requiredBond(uint256 escrow) public view returns (uint256) {\n uint256 scaled = (escrow * bondBps + MAX_BPS - 1) / MAX_BPS;\n return scaled > minBond ? scaled : minBond;\n }\n\n /// @notice Post the bond and commit the hidden acceptance test, while the job is still Open.\n /// @param requestHash the job's `specHash` (the domain-separated Predge request hash)\n /// @param commitment `keccak256(abi.encode(expected, salt))`; use a fresh random 32-byte salt\n /// @param jobId the job this verdict will settle; this contract must be its evaluator\n function stakeAndCommit(bytes32 requestHash, bytes32 commitment, uint256 jobId) external payable onlyOperator {\n if (requestHash == bytes32(0) || commitment == bytes32(0)) revert ZeroHash();\n Stake storage s = stakes[requestHash];\n if (s.status != Status.None) revert AlreadyCommitted();\n\n IAgentJobV2.Job memory j = job.getJob(jobId);\n // Only while Open and unexpired: once a deliverable exists, a commitment could be made to match it.\n if (j.state != IAgentJobV2.State.Open || block.timestamp >= j.expiredAt) revert NotOpen();\n if (j.specHash != requestHash || j.evaluator != address(this)) revert JobNotBound();\n // A provider that is also the operator writes both sides of the comparison.\n if (j.provider == msg.sender) revert ProviderIsOperator();\n uint256 required = requiredBond(j.escrow);\n if (msg.value < required) revert BondTooSmall(required);\n\n s.staker = msg.sender;\n s.status = Status.Committed;\n s.stakedAt = uint64(block.timestamp);\n s.window = disputeWindow;\n s.jobId = jobId;\n s.bond = msg.value;\n s.commitment = commitment;\n totalBonded += msg.value;\n emit Committed(requestHash, jobId, msg.sender, commitment, msg.value, s.window);\n }\n\n /// @notice Reveal the committed test, record the verdict, and settle the job with it.\n /// @param score 100 = delivered (job `complete`s, provider is credited), 0 = failed\n /// (job `reject`s, client is credited)\n /// @param expected,salt the preimage of the commitment\n /// @param reason the attestation commitment passed to the job (Predge: keccak256 of the signed verdict)\n function recordScore(bytes32 requestHash, uint8 score, bytes32 expected, bytes32 salt, bytes32 reason)\n external\n onlyOperator\n {\n if (score != 0 && score != 100) revert BadScore();\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n if (keccak256(abi.encode(expected, salt)) != s.commitment) revert BadReveal();\n // Re-checked here, not only at stake time: after an operator rotation the NEW operator must not be able\n // to score a job it is the provider of, while inheriting the old operator's stake.\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (j.provider == msg.sender) revert ProviderIsOperator();\n // Scoring needs a submission, so `scoredAt >= submittedAt` and the dispute window can never\n // start before the deliverable it is judged against exists.\n if (j.state != IAgentJobV2.State.Submitted) revert NotSubmitted();\n\n s.expected = expected;\n s.score = score;\n s.scoredAt = uint64(block.timestamp);\n s.status = Status.Scored;\n emit Scored(requestHash, s.jobId, score, expected, s.scoredAt, s.scoredAt + s.window);\n\n if (score == 100) job.complete(s.jobId, reason, \"\");\n else job.reject(s.jobId, reason, \"\");\n }\n\n /// @notice Prove the recorded verdict contradicts the deliverable and slash the bond to the\n /// harmed party. Anyone may call; the caller receives nothing. Takes no evidence.\n function challenge(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored) revert NotScored();\n if (block.timestamp >= uint256(s.scoredAt) + s.window) revert WindowClosed();\n IAgentJobV2.Job memory j = job.getJob(s.jobId);\n if (!_lied(s, j.deliverable)) revert VerdictHonest();\n\n address harmed = s.score == 100 ? j.client : j.provider;\n uint256 bond = s.bond;\n s.status = Status.Slashed;\n s.bond = 0;\n totalBonded -= bond;\n slashCount += 1;\n _addCredit(harmed, bond);\n emit Slashed(requestHash, msg.sender, harmed, bond, s.score, j.deliverable);\n }\n\n /// @notice Credit the bond back to its staker once the dispute window has closed unchallenged.\n /// Anyone may trigger it; the credit always goes to the staker.\n function reclaim(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored) revert NotScored();\n if (block.timestamp < uint256(s.scoredAt) + s.window) revert WindowOpen();\n uint256 bond = _close(s, Status.Reclaimed);\n emit Reclaimed(requestHash, s.staker, bond);\n }\n\n /// @notice Credit the bond back to its staker if the job expired without a verdict.\n function release(bytes32 requestHash) external {\n Stake storage s = stakes[requestHash];\n if (s.status == Status.None) revert NotCommitted();\n if (s.status != Status.Committed) revert BadStatus();\n if (job.getJob(s.jobId).state != IAgentJobV2.State.Expired) revert JobNotExpired();\n uint256 bond = _close(s, Status.Released);\n emit Released(requestHash, s.staker, bond);\n }\n\n /// @notice Is this verdict slashable right now? Free check before spending gas.\n function wouldSlash(bytes32 requestHash) external view returns (bool) {\n Stake storage s = stakes[requestHash];\n if (s.status != Status.Scored || block.timestamp >= uint256(s.scoredAt) + s.window) return false;\n return _lied(s, job.getJob(s.jobId).deliverable);\n }\n\n function _lied(Stake storage s, bytes32 delivered) private view returns (bool) {\n bool matches = delivered == s.expected;\n return (s.score == 100 && !matches) || (s.score == 0 && matches);\n }\n\n function _close(Stake storage s, Status to) private returns (uint256 bond) {\n bond = s.bond;\n s.status = to;\n s.bond = 0;\n totalBonded -= bond;\n _addCredit(s.staker, bond);\n }\n\n function _setParams(uint64 disputeWindow_, uint256 minBond_, uint16 bondBps_) private {\n if (disputeWindow_ < MIN_WINDOW || disputeWindow_ > MAX_WINDOW || minBond_ == 0 || bondBps_ > MAX_BPS)\n revert BadParams();\n disputeWindow = disputeWindow_;\n minBond = minBond_;\n bondBps = bondBps_;\n emit ParamsUpdated(disputeWindow_, minBond_, bondBps_);\n }\n}\n" + }, + "v2/access/PredgeAccess.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title Owned2Step\n/// @notice Minimal two-step ownership, the same hand-over model as OpenZeppelin v5 `Ownable2Step`.\n/// The owner is passed to the constructor (the intended owner is a Safe multisig), so the\n/// deployer key never holds a role. There is deliberately NO `renounceOwnership`: a\n/// renounced owner could never rotate a compromised operator or resolve a dispute.\n/// @dev Two steps: `transferOwnership(next)` nominates, `acceptOwnership()` (called by `next`)\n/// completes. Nominating `address(0)` cancels a pending nomination. A wrong address can\n/// therefore never take ownership, because it has to prove it can sign.\nabstract contract Owned2Step {\n /// @notice Current owner. Config and dispute resolution only; never moves user funds.\n address public owner;\n /// @notice Nominated owner, or zero.\n address public pendingOwner;\n\n event OwnershipTransferStarted(address indexed previousOwner, address indexed pendingOwner);\n event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);\n\n error NotOwner();\n error NotPendingOwner();\n error ZeroAddress();\n\n modifier onlyOwner() {\n if (msg.sender != owner) revert NotOwner();\n _;\n }\n\n /// @param owner_ initial owner; must not be zero.\n constructor(address owner_) {\n if (owner_ == address(0)) revert ZeroAddress();\n owner = owner_;\n emit OwnershipTransferred(address(0), owner_);\n }\n\n /// @notice Nominate `newOwner`. Passing `address(0)` cancels a pending nomination.\n function transferOwnership(address newOwner) external onlyOwner {\n pendingOwner = newOwner;\n emit OwnershipTransferStarted(owner, newOwner);\n }\n\n /// @notice Complete the hand-over. Only the nominated address can call this.\n function acceptOwnership() external {\n if (msg.sender != pendingOwner) revert NotPendingOwner();\n emit OwnershipTransferred(owner, msg.sender);\n owner = msg.sender;\n pendingOwner = address(0);\n }\n}\n\n/// @title Operated\n/// @notice Adds the hot \"operator\" role (the day-to-day validator / arbiter key) under an\n/// `Owned2Step` owner. The owner can rotate the operator; the operator can never touch\n/// ownership or configuration.\n/// @dev Rotation is not retroactive by design: the contracts that pay out snapshot the staker\n/// address on each stake or ruling, so rotating this key never lets a new key take an old\n/// key's funds, and never strands them either.\nabstract contract Operated is Owned2Step {\n /// @notice The hot operations key. Rotatable by the owner.\n address public operator;\n\n event OperatorUpdated(address indexed previousOperator, address indexed newOperator);\n\n error NotOperator();\n\n modifier onlyOperator() {\n if (msg.sender != operator) revert NotOperator();\n _;\n }\n\n /// @param owner_ initial owner (Safe); must not be zero.\n /// @param operator_ initial operator; must not be zero.\n constructor(address owner_, address operator_) Owned2Step(owner_) {\n if (operator_ == address(0)) revert ZeroAddress();\n operator = operator_;\n emit OperatorUpdated(address(0), operator_);\n }\n\n /// @notice Rotate the operator key. Owner only; the new key must be non-zero.\n function setOperator(address newOperator) external onlyOwner {\n if (newOperator == address(0)) revert ZeroAddress();\n emit OperatorUpdated(operator, newOperator);\n operator = newOperator;\n }\n}\n" + }, + "v2/access/PullCredit.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @title PullCredit\n/// @notice Pull-payment ledger shared by the Predge v2 contracts. Whoever is owed value is\n/// credited here and withdraws it themselves, so a recipient that reverts or is\n/// blocklisted can never force an outcome or lock anybody else's funds.\n/// @dev `totalCredit` is the sum of all outstanding credits; the inheriting contract's native\n/// balance is always at least `totalCredit` plus whatever it still escrows or bonds.\n/// Checks-effects-interactions: the credit is zeroed and the event emitted before the call.\nabstract contract PullCredit {\n /// @notice Withdrawable balance per account.\n mapping(address => uint256) public credit;\n /// @notice Sum of all credits not yet withdrawn.\n uint256 public totalCredit;\n\n event Withdrawn(address indexed account, address indexed to, uint256 amount);\n\n error NothingToWithdraw();\n error TransferFailed();\n error ZeroRecipient();\n\n /// @notice Withdraw the caller's whole credit to the caller.\n function withdraw() external {\n _withdraw(payable(msg.sender));\n }\n\n /// @notice Withdraw the caller's whole credit to `to`, e.g. when the caller's own address\n /// cannot receive value (blocklisted or a contract without a receive function).\n function withdrawTo(address payable to) external {\n if (to == address(0)) revert ZeroRecipient();\n _withdraw(to);\n }\n\n function _addCredit(address to, uint256 amount) internal {\n credit[to] += amount;\n totalCredit += amount;\n }\n\n function _withdraw(address payable to) private {\n uint256 amt = credit[msg.sender];\n if (amt == 0) revert NothingToWithdraw();\n credit[msg.sender] = 0;\n totalCredit -= amt;\n emit Withdrawn(msg.sender, to, amt);\n (bool ok,) = to.call{value: amt}(\"\");\n if (!ok) revert TransferFailed();\n }\n}\n" + }, + "v2/interfaces/IAgentJobV2.sol": { + "content": "// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\n/// @notice The slice of `AgentJobV2` that other Predge contracts read and drive.\ninterface IAgentJobV2 {\n /// @dev Forward-only, except that `Open` and `Submitted` may each end in `Expired`.\n enum State {\n None,\n Open,\n Submitted,\n Completed,\n Rejected,\n Expired\n }\n\n struct Job {\n address client; // pays; refunded on reject / expiry\n address provider; // does the work; paid on complete\n address evaluator; // the only address that can complete or reject\n uint256 escrow; // native value held for this job until it ends\n uint64 expiredAt; // the provider must submit strictly before this\n uint64 submittedAt; // 0 until submitted\n State state;\n bool hashedOnChain; // true if the deliverable hash was computed on-chain from submitted bytes\n bytes32 specHash; // the committed acceptance test (the Predge requestHash)\n bytes32 deliverable; // sha256 of the provider's output (zero until submitted)\n bytes32 reason; // the evaluator's attestation commitment (zero until decided)\n }\n\n function getJob(uint256 jobId) external view returns (Job memory);\n function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external;\n}\n" + } + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode.object", + "evm.deployedBytecode.object", + "evm.deployedBytecode.immutableReferences", + "metadata" + ] + } + } + } +} From cd4e2c6a1cc5ac45724c84fa2bb28559f5c5e23a Mon Sep 17 00:00:00 2001 From: PREDGE <307976007+predge-ai@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:42:25 +0300 Subject: [PATCH 17/19] deployments: v2.1 live on Arbitrum, Avalanche, BNB Chain, Celo, Optimism, Sonic, Unichain (same addresses as Arc/Base) (#7) --- README.md | 9 +- deployments/v2/arbitrum.json | 314 ++++++++++++++++++++++++++++++++++ deployments/v2/avalanche.json | 314 ++++++++++++++++++++++++++++++++++ deployments/v2/bnb.json | 314 ++++++++++++++++++++++++++++++++++ deployments/v2/celo.json | 314 ++++++++++++++++++++++++++++++++++ deployments/v2/optimism.json | 314 ++++++++++++++++++++++++++++++++++ deployments/v2/sonic.json | 305 +++++++++++++++++++++++++++++++++ deployments/v2/unichain.json | 305 +++++++++++++++++++++++++++++++++ scripts/v2-burn-nonces.mjs | 60 +++++++ 9 files changed, 2245 insertions(+), 4 deletions(-) create mode 100644 deployments/v2/arbitrum.json create mode 100644 deployments/v2/avalanche.json create mode 100644 deployments/v2/bnb.json create mode 100644 deployments/v2/celo.json create mode 100644 deployments/v2/optimism.json create mode 100644 deployments/v2/sonic.json create mode 100644 deployments/v2/unichain.json create mode 100644 scripts/v2-burn-nonces.mjs diff --git a/README.md b/README.md index 012d340..8b9f267 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ node deploy.mjs --chain base --fork # full deploy + smoke + end-to-end on a CONFIRM_MAINNET=base node deploy.mjs --chain base --broadcast # the real deploy ``` -## v2 (self-audit fixes, live on Arc and Base) +## v2 (self-audit fixes, live on 9 chains) -`contracts/v2/` is the redesigned set that fixes the 20.10.2026 self-audit findings (H-01..H-03, M-01..M-05, L-01..L-07). v2 is deployed on Arc and Base only (see [Deployments](#deployments)). The 16-chain set is v1, legacy, and is untouched. Design, finding-by-finding mapping and owner actions: `SELF-AUDIT-2026-10-20/v2-design.md` (Downloads). +`contracts/v2/` is the redesigned set that fixes the 20.10.2026 self-audit findings (H-01..H-03, M-01..M-05, L-01..L-07). v2.1 is deployed on Arc, Base, Arbitrum, Avalanche, BNB Chain, Celo, Optimism, Sonic and Unichain (see [Deployments](#deployments)). The v1 set stays deployed as legacy on its 16 chains and is untouched. Design, finding-by-finding mapping and owner actions: `SELF-AUDIT-2026-10-20/v2-design.md` (Downloads). **v2.1 (23.10.2026, set `2026-10-23-v2.1`)** adds the fixes for the Olympix BugPoCer scan of `c47ed42` and the owner's option (a) for the validator bond: `PredgeValidatorBondV2.recordScore` refuses any verdict that contradicts the on-chain evidence and reverts @@ -100,11 +100,11 @@ Canonical v1 addresses for deployer `0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9` ## Deployments -### v2 (current, v2.1): Arc (5042) and Base (8453) +### v2 (current, v2.1): Arc (5042), Base (8453), Arbitrum (42161), Avalanche (43114), BNB Chain (56), Celo (42220), Optimism (10), Sonic (146), Unichain (130) Deployed 2026-10-23 from set `2026-10-23-v2.1` (deployer nonces 6..11). The same six addresses on both chains. All six are verified on Sourcify. Receipts: [`deployments/v2/arc.json`](deployments/v2/arc.json), [`deployments/v2/base.json`](deployments/v2/base.json). -| Contract | Address (Arc and Base) | Sourcify | Explorer | +| Contract | Address (all nine chains) | Sourcify | Explorer | |---|---|---|---| | PredgeAgentValidatorV2 | `0xE4CFdE5dbD9afD518b9c59136939607505bF643D` | [Sourcify](https://sourcify.dev/#/lookup/0xE4CFdE5dbD9afD518b9c59136939607505bF643D) | [Arc](https://explorer.arc.io/address/0xE4CFdE5dbD9afD518b9c59136939607505bF643D), [Base](https://basescan.org/address/0xE4CFdE5dbD9afD518b9c59136939607505bF643D) | | AgentJobV2 | `0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F` | [Sourcify](https://sourcify.dev/#/lookup/0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F) | [Arc](https://explorer.arc.io/address/0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F), [Base](https://basescan.org/address/0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F) | @@ -113,6 +113,7 @@ Deployed 2026-10-23 from set `2026-10-23-v2.1` (deployer nonces 6..11). The same | MockRefundProtocolV2 (demo refund protocol) | `0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9` | [Sourcify](https://sourcify.dev/#/lookup/0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9) | [Arc](https://explorer.arc.io/address/0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9), [Base](https://basescan.org/address/0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9) | | PredgeRefundArbiterV2 | `0xf404F908cb1ba47C01AB8526B725aAA940557eA9` | [Sourcify](https://sourcify.dev/#/lookup/0xf404F908cb1ba47C01AB8526B725aAA940557eA9) | [Arc](https://explorer.arc.io/address/0xf404F908cb1ba47C01AB8526B725aAA940557eA9), [Base](https://basescan.org/address/0xf404F908cb1ba47C01AB8526B725aAA940557eA9) | +- **26 Oct 2026, seven more chains:** Arbitrum, Avalanche, BNB Chain, Celo, Optimism, Sonic and Unichain, the same six addresses as above. The v2 deployer was fresh on these chains, so `scripts/v2-burn-nonces.mjs` first sent six 0-value self-transactions (nonces 0..5) and the set then landed at nonces 6..11, exactly as on Arc and Base. 30 read-only smoke checks passed on every chain and all 42 contracts are exact matches on Sourcify. Receipts: [`deployments/v2/optimism.json`](deployments/v2/optimism.json), [`deployments/v2/unichain.json`](deployments/v2/unichain.json), [`deployments/v2/arbitrum.json`](deployments/v2/arbitrum.json), [`deployments/v2/bnb.json`](deployments/v2/bnb.json), [`deployments/v2/avalanche.json`](deployments/v2/avalanche.json), [`deployments/v2/sonic.json`](deployments/v2/sonic.json), [`deployments/v2/celo.json`](deployments/v2/celo.json). - **Owner:** `0x9084f5000E07C7133D6dA5eE4f271AB6D1821144` (TEMPORARY owner = Amir EOA 0x9084…1144 by owner decision; transferOwnership→acceptOwnership to the Safe once created.). **Operator** (hot ops key, rotatable by the owner): `0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292`. - **Bond floor:** minimal (bps = 0), per-chain floor from `config/v2.chains.json`. Since v2.1 the validator bond backs liveness only: `recordScore` refuses any verdict that contradicts the on-chain evidence (pass needs the output bytes hashed on-chain and equal to the client-committed `expected`; fail needs the opposite), so a false verdict cannot be recorded at any bond size and there is no challenge window. - **Status:** v2.1 contains the fixes for the Olympix BugPoCer scan of `c47ed42` (23 Oct 2026) and the option (a) verdict check. The signed verdict API (`api.predge.io`) is unchanged and remains the product. diff --git a/deployments/v2/arbitrum.json b/deployments/v2/arbitrum.json new file mode 100644 index 0000000..a744b12 --- /dev/null +++ b/deployments/v2/arbitrum.json @@ -0,0 +1,314 @@ +{ + "chain": "arbitrum", + "chainId": 42161, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-26T15:30:49.059Z", + "explorer": "https://arbiscan.io", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x9447819e5760f02a0a81a1a23af0ae8834c0fc2cc27086c5810c1b617d54fd6e", + "block": 518479783, + "gasUsed": "1186582", + "effectiveGasPrice": "20000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0xeebdbded558914e4be1817283d57a2304b2b30806b8d964a92630ad715f6fc6c", + "block": 518479795, + "gasUsed": "934768", + "effectiveGasPrice": "20000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f00000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x76e40bea03d53f037bb6789b3c0806895214941196a936ab6ea8cb77c9118648", + "block": 518479805, + "gasUsed": "1474589", + "effectiveGasPrice": "20052000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x8f34c100da04c7e8840695c5911f08695df16be21d4062402fdabb5ea0230988", + "block": 518479824, + "gasUsed": "375784", + "effectiveGasPrice": "20000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, + "args": [ + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + ], + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x80854ff2ec326cd76d3a6222386ada1b424c75c8ec2ab9b2b49da9c79abe9079", + "block": 518479838, + "gasUsed": "416467", + "effectiveGasPrice": "20114000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "86400", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e9000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0x91db814108fc0b6c07d27e950ce3e7f56987a13562013c5dc217076ee6638da8", + "block": 518479849, + "gasUsed": "1957501", + "effectiveGasPrice": "20052000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": "predge-robinhood/deploy/arbitrum-one (deployer 0x1d48…cF3c): validator, job, bond v2.1, settlement; no arbiter" +} diff --git a/deployments/v2/avalanche.json b/deployments/v2/avalanche.json new file mode 100644 index 0000000..8389a02 --- /dev/null +++ b/deployments/v2/avalanche.json @@ -0,0 +1,314 @@ +{ + "chain": "avalanche", + "chainId": 43114, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-26T15:35:48.943Z", + "explorer": "https://snowtrace.io", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x1e5a5df3475b9d6f967faaf55d4e8ef0b9e97552ca381e606f013eddec05f386", + "block": 98616150, + "gasUsed": "1167799", + "effectiveGasPrice": "2500000150", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x39a288bc83fab235632c8f0581d309980d903259d1809c5ac2d465fb721a6839", + "block": 98616151, + "gasUsed": "920066", + "effectiveGasPrice": "2500000150", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "500000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f00000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0xa6005e43e8e23435f3c742223e70258e1c96a427496c193fdb34a72382fa1cf5", + "block": 98616155, + "gasUsed": "1453368", + "effectiveGasPrice": "2500000150", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x9d9ef90f2820374c13b60fd7f8eee9001eb386cfb0aba7f2d8105e8155c61b80", + "block": 98616158, + "gasUsed": "369059", + "effectiveGasPrice": "2500000150", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, + "args": [ + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + ], + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x42e2420caa7c3d2251b7befc41b333734cec161708ec9b0cd68a4cecee3d9b74", + "block": 98616160, + "gasUsed": "408763", + "effectiveGasPrice": "2500000150", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "86400", + "500000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e9000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0xdd082023b9577a6da2616738a25092bd341c211aca60dcfbb5a63a659e7c4f28", + "block": 98616163, + "gasUsed": "1930219", + "effectiveGasPrice": "2500000150", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/deployments/v2/bnb.json b/deployments/v2/bnb.json new file mode 100644 index 0000000..323111e --- /dev/null +++ b/deployments/v2/bnb.json @@ -0,0 +1,314 @@ +{ + "chain": "bnb", + "chainId": 56, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-26T15:33:38.050Z", + "explorer": "https://bscscan.com", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x86c8e13f69b76f32fea933f6c0ae6c773a4011e2ba4d33fba4cfddb5b70b1f03", + "block": 129922016, + "gasUsed": "1167799", + "effectiveGasPrice": "50000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x0dc0bc2ec9f8b2e2572df1695ff95a38726c794d357a1b7f44a72e3be413816b", + "block": 129922028, + "gasUsed": "920066", + "effectiveGasPrice": "50000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "6000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f000000000000000000000000000000000000000000000000001550f7dca700000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x3936cf0264f114ba2321c2c8d7ffe737467150e1ad680bd09a6a4073b1a313a2", + "block": 129922036, + "gasUsed": "1453356", + "effectiveGasPrice": "50000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xdfb2be74b68ede481156d70f89ef351c73aa3a90b1b5a3bb447f6f7bfef5189b", + "block": 129922043, + "gasUsed": "369059", + "effectiveGasPrice": "50000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, + "args": [ + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + ], + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x47d7a6a687c92899e3e751078e06fe2ff8d27cadab315775997d9ebc0829c309", + "block": 129922051, + "gasUsed": "408763", + "effectiveGasPrice": "50000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "86400", + "6000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e90000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000000001550f7dca700000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0xc4d3c45794939de95393e4753245a1f1fce1bb50dc9786d673242608cb8f6917", + "block": 129922056, + "gasUsed": "1930207", + "effectiveGasPrice": "50000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/deployments/v2/celo.json b/deployments/v2/celo.json new file mode 100644 index 0000000..a7eb801 --- /dev/null +++ b/deployments/v2/celo.json @@ -0,0 +1,314 @@ +{ + "chain": "celo", + "chainId": 42220, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-26T15:40:12.601Z", + "explorer": "https://celoscan.io", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0xe0941f48d4398dcb54b85f8873c40851b01031a9b5e58bbe3b696a132d2643e7", + "block": 81128388, + "gasUsed": "1167799", + "effectiveGasPrice": "202500000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0xfce0827f1f2e9c5bde0f352368f4a26fe93633dd654f3f0ee3434ef860545ae9", + "block": 81128391, + "gasUsed": "920066", + "effectiveGasPrice": "202500000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "60000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f00000000000000000000000000000000000000000000000340aad21b3b7000000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0xf046102f5c1b4e2ad245f43a7b062a7edbcaef2c991ca84ab19152e04f7af94d", + "block": 81128394, + "gasUsed": "1453380", + "effectiveGasPrice": "202500000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xe2a49e49034169c49641b1b7a51f8780005ace679f2b601d2896c02083eaed6b", + "block": 81128396, + "gasUsed": "369059", + "effectiveGasPrice": "202500000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, + "args": [ + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + ], + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x1d45f77c7e74a30c89735de4e50b4145c622b74935b8b0f95975f44cdbb958cb", + "block": 81128399, + "gasUsed": "408763", + "effectiveGasPrice": "202500000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "86400", + "60000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e9000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000340aad21b3b7000000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0x7c939dabe2282b212f2c4bce921206404f47d91b9255ee8a35518b420d6ec727", + "block": 81128403, + "gasUsed": "1930231", + "effectiveGasPrice": "202500000000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/deployments/v2/optimism.json b/deployments/v2/optimism.json new file mode 100644 index 0000000..6eb86cd --- /dev/null +++ b/deployments/v2/optimism.json @@ -0,0 +1,314 @@ +{ + "chain": "optimism", + "chainId": 10, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-26T15:24:39.044Z", + "explorer": "https://optimistic.etherscan.io", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0xadc721af493df128734f7ea5308c92276f3d9852b5924b245c13d5342d8c8ce0", + "block": 158714685, + "gasUsed": "1167799", + "effectiveGasPrice": "1000321", + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x4f4ca06829919d21f64bd9513376e0b1d5bff18c67ac66352236fb705ec71bfd", + "block": 158714687, + "gasUsed": "920066", + "effectiveGasPrice": "1000321", + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f00000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x81b7004b596297d772c961f87cfa64acf4a73a71ed73da2b25d555709b44e49f", + "block": 158714688, + "gasUsed": "1453356", + "effectiveGasPrice": "1000321", + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xaabca6392ed67fd71e9ee228f5d5d85107f0cdb42863213710c3476dd6c35ca8", + "block": 158714697, + "gasUsed": "369059", + "effectiveGasPrice": "1000320", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, + "args": [ + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + ], + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xc42a09ea81b12ff4af92cb57100c273f3fcc8015f0d943ab991f74dc79718832", + "block": 158714699, + "gasUsed": "408763", + "effectiveGasPrice": "1000320", + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "86400", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e9000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0x76ac395225af591eb661778e27f94b80ee5e32ae9548312fc1eba19ff4588e7b", + "block": 158714716, + "gasUsed": "1930207", + "effectiveGasPrice": "1000319", + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + } + }, + "erc8004": { + "IdentityRegistry": { + "address": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "version": "2.0.0" + }, + "ReputationRegistry": { + "address": "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + "version": "2.0.0" + }, + "ValidationRegistry": { + "address": "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58", + "version": "2.0.0" + } + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/deployments/v2/sonic.json b/deployments/v2/sonic.json new file mode 100644 index 0000000..65099c8 --- /dev/null +++ b/deployments/v2/sonic.json @@ -0,0 +1,305 @@ +{ + "chain": "sonic", + "chainId": 146, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-26T15:37:59.917Z", + "explorer": "https://sonicscan.org", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x4461d9e17262f441e5770bbbe898cdda7db0d2b2c5406b8c12becb3361d3c75e", + "block": 81652981, + "gasUsed": "1196993", + "effectiveGasPrice": "50000000001", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x9561e3368b5e418eec945a391af4cd246ce6cc9dc60e9dfc18542b90efe98cbf", + "block": 81652985, + "gasUsed": "943067", + "effectiveGasPrice": "50000000001", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "80000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x234c00e7caf9f0ddb695184e567aea34d78b174139ede8aea06290f8c0831035", + "block": 81652987, + "gasUsed": "1489714", + "effectiveGasPrice": "50000000001", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x0d3fa78a9429b1c4a337374c583edd74975940595b5457edfc9afe079764ee9c", + "block": 81652989, + "gasUsed": "378285", + "effectiveGasPrice": "50000000001", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, + "args": [ + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + ], + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x87efd6c02fff03ccb4be6b2f3320b6c4379818afe946868bda3e5e06b7a82664", + "block": 81652992, + "gasUsed": "418982", + "effectiveGasPrice": "50000000001", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "86400", + "80000000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e90000000000000000000000000000000000000000000000000000000000015180000000000000000000000000000000000000000000000004563918244f4000000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0xc8e66ba3402979ae1fe904fe144438d1702a24f96eef414cf045bc69208b6f8d", + "block": 81652993, + "gasUsed": "1978486", + "effectiveGasPrice": "50000000001", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/deployments/v2/unichain.json b/deployments/v2/unichain.json new file mode 100644 index 0000000..e5f6736 --- /dev/null +++ b/deployments/v2/unichain.json @@ -0,0 +1,305 @@ +{ + "chain": "unichain", + "chainId": 130, + "deployer": "0xbB7A0c24677339045434273FFC2266B99E1e1301", + "operator": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "owner": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-26T15:28:24.836Z", + "explorer": "https://uniscan.xyz", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D", + "nonce": 6, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x38e3e6a28c46ae4339d9d929c688e0c4824bc7ebcaae017001c27b48145d1658", + "block": 62280091, + "gasUsed": "1167799", + "effectiveGasPrice": "1500000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "nonce": 7, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x35345352f913199f14af4cc7caf9e6b5c98c2684879a9ee9113bea131dabc87d", + "block": 62280093, + "gasUsed": "920066", + "effectiveGasPrice": "1500000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x97E960Fe3D7759af8f51A95319859E60Df554329", + "nonce": 8, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e2920000000000000000000000005c673c6abf3c60c3f6ce3f1509f5d99c08f8b13f00000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0xb223a1c0aea7a3afb1e5874fd387c8216ec5369c312b95ccd12e695e527dc42f", + "block": 62280096, + "gasUsed": "1453356", + "effectiveGasPrice": "1500000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24", + "nonce": 9, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d1821144", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0xffc3af35646f0dc4d6909e3e4c1f83de7200914d2190de8143b19b8a74cf1a90", + "block": 62280099, + "gasUsed": "369059", + "effectiveGasPrice": "1500000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "nonce": 10, + "args": [ + "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + ], + "constructorArgs": "0x000000000000000000000000f404f908cb1ba47c01ab8526b725aaa940557ea9", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x67509f87739a75dc0b5667845a9cda8dbc202f938a30148334082fb4993a5263", + "block": 62280101, + "gasUsed": "408763", + "effectiveGasPrice": "1500000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9", + "nonce": 11, + "args": [ + "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144", + "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292", + "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9", + "86400", + "2000000000000000", + "0" + ], + "constructorArgs": "0x0000000000000000000000009084f5000e07c7133d6da5ee4f271ab6d18211440000000000000000000000008fffb76c4329ddc8ee4a859ddf55ab48ba25e29200000000000000000000000050041fdab10f8f56e24fa9ca806f082ccaeb05e9000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0x29e263b3bf8a6595093e9a9847b7830c2c6154aee14246537073beeb5ef25055", + "block": 62280103, + "gasUsed": "1930207", + "effectiveGasPrice": "1500000", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xE4CFdE5dbD9afD518b9c59136939607505bF643D" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0x97E960Fe3D7759af8f51A95319859E60Df554329" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x6fac55c5187A9cdb519F77163C7e3fD9E87F6f24" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x9084f5000E07C7133D6dA5eE4f271AB6D1821144" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8ffFB76c4329DDC8ee4a859DDF55AB48ba25e292" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5C673c6aBF3c60C3F6Ce3f1509f5d99C08F8B13F" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x50041FdAb10f8f56E24FA9CA806f082CcAeb05E9" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xf404F908cb1ba47C01AB8526B725aAA940557eA9" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/scripts/v2-burn-nonces.mjs b/scripts/v2-burn-nonces.mjs new file mode 100644 index 0000000..74cb38d --- /dev/null +++ b/scripts/v2-burn-nonces.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Brings a FRESH v2 deployer up to the set's nonceBase on a new chain, so the v2.1 set lands at the same +// addresses as on Arc and Base (where nonces 0..5 were used by the 20.10 v2.0 set). +// +// node scripts/v2-burn-nonces.mjs --chain dry run: nonce, balance, cost. Sends nothing. +// CONFIRM_MAINNET= node scripts/v2-burn-nonces.mjs --chain --broadcast --key-file ~/.predge-deployer-v2/deployer.json +// +// Each burned nonce is a 0-value transaction from the deployer to itself (no funds leave the address; only gas is paid). +// Refuses: the v1 key, a key that is not the configured v2 deployer, a nonce already past nonceBase, or any code +// already present at a planned v2.1 address. +import { formatEther } from "ethers"; +import { chainConfig, pickRpc, providerFor, withRetry, waitReceipt, SET_V2 } from "../lib/chains.mjs"; +import { loadDeployer } from "../lib/key.mjs"; +import { resolvePlan } from "../lib/plan.mjs"; +import { realRoles, chainParams, v2KeyFile } from "../lib/v2.mjs"; + +const argv = process.argv.slice(2); +const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; }; +const name = opt("--chain"); +if (!name) { console.error("usage: node scripts/v2-burn-nonces.mjs --chain [--broadcast --key-file ]"); process.exit(2); } +const broadcast = argv.includes("--broadcast"); +const chain = chainConfig(name); +const target = SET_V2.nonceBase ?? 0; + +const url = await pickRpc(chain, opt("--rpc")); +const p = providerFor(url, chain.chainId); +const roles = realRoles(); +const plan = resolvePlan(roles.deployer, { owner: roles.owner, operator: roles.operator, ...chainParams(chain.name) }, SET_V2); +for (const s of plan) if ((await p.getCode(s.address)).length > 2) { console.error(`code already at ${s.address} (${s.name}); stop`); process.exit(1); } + +const [bal, nonce, fee] = await Promise.all([p.getBalance(roles.deployer), p.getTransactionCount(roles.deployer, "pending"), p.getFeeData()]); +const price = fee.gasPrice ?? fee.maxFeePerGas; +console.log(`${chain.name}: deployer ${roles.deployer} nonce ${nonce}, target ${target}, balance ${formatEther(bal)} ${chain.native}`); +if (nonce > target) { console.error(`nonce ${nonce} is already past ${target}: same-address deploy impossible here`); process.exit(1); } +const n = target - nonce; +console.log(`${n} self-transaction(s) needed, ~${formatEther(21000n * BigInt(n) * price)} ${chain.native} at ${price} wei`); +if (!broadcast || n === 0) { console.log(broadcast ? "nothing to do" : "dry run only. Nothing sent."); process.exit(0); } +if (process.env.CONFIRM_MAINNET !== chain.name) { console.error(`set CONFIRM_MAINNET=${chain.name}`); process.exit(3); } + +const w = loadDeployer(p, v2KeyFile(opt("--key-file"))); +if (w.address !== roles.deployer) { console.error(`key ${w.address} != config deployer ${roles.deployer}`); process.exit(1); } +for (let i = nonce; i < target; i++) { + // Load-balanced RPCs can lag a block behind our own last receipt: a LOWER nonce is waited out, a HIGHER one is fatal. + let cur = await withRetry("nonce", () => p.getTransactionCount(w.address, "pending")); + for (let k = 0; cur < i && k < 40; k++) { + await new Promise((r) => setTimeout(r, 1500)); + cur = await withRetry("nonce", () => p.getTransactionCount(w.address, "pending")); + } + if (cur !== i) { console.error(`expected nonce ${i}, chain says ${cur}; stop`); process.exit(1); } + const est = await withRetry("estimateGas", () => p.estimateGas({ from: w.address, to: w.address, value: 0n })); + const tx = await w.populateTransaction({ to: w.address, value: 0n, nonce: i, gasLimit: (est * 130n) / 100n }); + const raw = await w.signTransaction(tx); + const sent = await withRetry(`send nonce ${i}`, () => p.broadcastTransaction(raw)); + const r = await waitReceipt(p, sent.hash); + if (r.status !== 1) { console.error(`self-tx at nonce ${i} failed: ${sent.hash}`); process.exit(1); } + console.log(` nonce ${i} burned gas ${r.gasUsed} tx ${sent.hash}`); +} +const after = await p.getTransactionCount(w.address, "latest"); +console.log(`done: nonce ${after}, balance ${formatEther(await p.getBalance(w.address))} ${chain.native}`); +process.exit(after === target ? 0 : 1); From 18f75483397ea7127e91b39c6e345a131fc0d407 Mon Sep 17 00:00:00 2001 From: PREDGE <307976007+predge-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:18:29 +0300 Subject: [PATCH 18/19] feat(v2): Circle Refund Protocol adapter for PredgeRefundArbiterV2 (#8) PredgeRefundArbiterV2 cannot drive Circle's RefundProtocol directly: Circle has no paymentInfo (it exposes payments(id) with to/refundTo), amounts are in USDC 6dp while the bond is sized in native wei, and Circle's arbiter is fixed in its constructor. CircleRefundProtocolAdapter is set as Circle's arbiter, accepts writes only from the bound Predge arbiter, maps refundTo->payer, to->payee, scales the amount, and treats an already-refunded payment as done. Adds our own ICircleRefundProtocol (signatures only, read from the public source), a local MockCircleRefundProtocol mirroring its behaviour, MockUSDC, 14 Foundry tests (release, refund on verdict, dispute overturned/upheld, edge cases) and an Arc testnet deploy script (simulation only, nothing broadcast). The deployed arbiter and the v2 plan are unchanged. --- .../adapters/CircleRefundProtocolAdapter.sol | 113 +++++++ .../v2/interfaces/ICircleRefundProtocol.sol | 54 ++++ .../v2/mocks/MockCircleRefundProtocol.sol | 137 +++++++++ contracts/v2/mocks/MockUSDC.sol | 45 +++ scripts/foundry/DeployCircleRefundDemo.s.sol | 43 +++ test/foundry-v2/CircleRefundAdapter.t.sol | 291 ++++++++++++++++++ 6 files changed, 683 insertions(+) create mode 100644 contracts/v2/adapters/CircleRefundProtocolAdapter.sol create mode 100644 contracts/v2/interfaces/ICircleRefundProtocol.sol create mode 100644 contracts/v2/mocks/MockCircleRefundProtocol.sol create mode 100644 contracts/v2/mocks/MockUSDC.sol create mode 100644 scripts/foundry/DeployCircleRefundDemo.s.sol create mode 100644 test/foundry-v2/CircleRefundAdapter.t.sol diff --git a/contracts/v2/adapters/CircleRefundProtocolAdapter.sol b/contracts/v2/adapters/CircleRefundProtocolAdapter.sol new file mode 100644 index 0000000..c34ae14 --- /dev/null +++ b/contracts/v2/adapters/CircleRefundProtocolAdapter.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IRefundProtocolV2} from "../PredgeRefundArbiterV2.sol"; +import {ICircleRefundProtocol} from "../interfaces/ICircleRefundProtocol.sol"; + +interface IPredgeRefundArbiterView { + function refundProtocol() external view returns (address); +} + +/// @title CircleRefundProtocolAdapter +/// @notice Lets an unchanged `PredgeRefundArbiterV2` act as the arbiter of Circle's Refund Protocol. +/// The adapter is the address set as `arbiter` in Circle's constructor; it accepts write calls +/// only from the one Predge arbiter bound to it, and it translates the read side: +/// +/// Predge `paymentInfo` <- Circle `payments(id)` +/// payer <- refundTo (who a refund is paid to) +/// payee <- to +/// amount <- amount * amountScale (USDC 6 decimals -> native wei) +/// releaseTimestamp <- releaseTimestamp +/// +/// A payment that does not exist, is already refunded, or has been partly withdrawn reads as +/// all-zero, so no evidence can be filed and no ruling can be made on it. +/// +/// Wiring is a three-way cycle (Circle needs the adapter address, the Predge arbiter needs the +/// adapter address, the adapter needs both), so the adapter is deployed first and `bind` is +/// called once by its deployer after the other two exist. `bind` checks both directions of the +/// wiring and can never be called again. +/// +/// @dev Holds no funds and has no owner. It does not expose Circle's `earlyWithdrawByArbiter`, +/// `depositArbiterFunds` or `withdrawArbiterFunds`: a refund is paid only from the recipient's +/// balance inside Circle's contract. If that balance cannot cover it, Circle reverts, the +/// Predge `finalize`/`resolve` reverts with it, and the ruling can only be voided by `expire`. +contract CircleRefundProtocolAdapter is IRefundProtocolV2 { + address public immutable deployer; + /// @notice Multiplier from the token's units to the native wei the Predge bond is sized in + /// (1e12 on Arc: USDC ERC-20 has 6 decimals, native USDC gas has 18). + uint256 public immutable amountScale; + + ICircleRefundProtocol public circle; + address public predgeArbiter; + + event Bound(address indexed circle, address indexed predgeArbiter); + event RefundForwarded(uint256 indexed paymentID); + event RefundAlreadyDone(uint256 indexed paymentID); + event LockupForwarded(address indexed recipient, uint256 lockupSeconds); + + error ZeroAddress(); + error ZeroScale(); + error NotDeployer(); + error AlreadyBound(); + error NotBound(); + error NotCircleArbiter(); + error ArbiterNotWired(); + error NotPredgeArbiter(); + + constructor(uint256 amountScale_) { + if (amountScale_ == 0) revert ZeroScale(); + deployer = msg.sender; + amountScale = amountScale_; + } + + /// @notice One-time wiring, deployer only. Requires `circle_.arbiter() == this` and + /// `predgeArbiter_.refundProtocol() == this`. + function bind(address circle_, address predgeArbiter_) external { + if (msg.sender != deployer) revert NotDeployer(); + if (address(circle) != address(0)) revert AlreadyBound(); + if (circle_ == address(0) || predgeArbiter_ == address(0)) revert ZeroAddress(); + if (ICircleRefundProtocol(circle_).arbiter() != address(this)) revert NotCircleArbiter(); + if (IPredgeRefundArbiterView(predgeArbiter_).refundProtocol() != address(this)) revert ArbiterNotWired(); + circle = ICircleRefundProtocol(circle_); + predgeArbiter = predgeArbiter_; + emit Bound(circle_, predgeArbiter_); + } + + modifier onlyPredgeArbiter() { + if (msg.sender != predgeArbiter || predgeArbiter == address(0)) revert NotPredgeArbiter(); + _; + } + + /// @inheritdoc IRefundProtocolV2 + function paymentInfo(uint256 paymentID) + external + view + returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp) + { + if (address(circle) == address(0)) revert NotBound(); + (address to, uint256 amt, uint256 release, address refundTo, uint256 withdrawn, bool refunded) = + circle.payments(paymentID); + if (to == address(0) || refunded || withdrawn != 0) return (address(0), address(0), 0, 0); + return (refundTo, to, amt * amountScale, release); + } + + /// @notice Forward a Predge refund to Circle. If the payment was already refunded (for example the + /// recipient called `refundByRecipient` during the window), the outcome the ruling asks for + /// already holds, so this returns instead of reverting. + function refundByArbiter(uint256 paymentID) external onlyPredgeArbiter { + (,,,,, bool refunded) = circle.payments(paymentID); + if (refunded) { + emit RefundAlreadyDone(paymentID); + return; + } + emit RefundForwarded(paymentID); + circle.refundByArbiter(paymentID); + } + + /// @notice Forward a lockup change. Predge's own bound is (window + 14 days, 365 days]; Circle's + /// cap of 180 days also applies and reverts above it. + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyPredgeArbiter { + emit LockupForwarded(recipient, recipientLockupSeconds); + circle.setLockupSeconds(recipient, recipientLockupSeconds); + } +} diff --git a/contracts/v2/interfaces/ICircleRefundProtocol.sol b/contracts/v2/interfaces/ICircleRefundProtocol.sol new file mode 100644 index 0000000..aef900f --- /dev/null +++ b/contracts/v2/interfaces/ICircleRefundProtocol.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice The subset of Circle's Refund Protocol (github.com/circlefin/refund-protocol, +/// src/RefundProtocol.sol, read 01.11.2026) that Predge touches. Written by Predge from the +/// public function signatures; no Circle code is copied or imported. Only signatures are +/// declared, so this file carries no Circle implementation. +/// +/// Facts about the real contract that shape the adapter (all from reading its source): +/// - `arbiter` is set once in the constructor; there is no setter. Every arbiter-only call +/// (`refundByArbiter`, `setLockupSeconds`, `earlyWithdrawByArbiter`, `depositArbiterFunds`, +/// `withdrawArbiterFunds`) checks `msg.sender == arbiter`. +/// - There is no `paymentInfo`; the public `payments(id)` getter returns the struct fields +/// `(to, amount, releaseTimestamp, refundTo, withdrawnAmount, refunded)`. The address that +/// called `pay` is NOT stored: the party a refund goes to is `refundTo`, which the current +/// `refundTo` may change at any time with `updateRefundTo`. +/// - `amount` is in the ERC-20's own units (USDC: 6 decimals). +/// - `refundByArbiter` does not check `releaseTimestamp`; it reverts `PaymentRefunded` if the +/// payment was already refunded, and `InsufficientFunds` if neither the recipient's balance +/// nor the arbiter's own deposited balance covers the amount. +/// - `setLockupSeconds` reverts above `MAX_LOCKUP_SECONDS` (180 days). +/// - Payment ids start at 0 (`nonce` starts at 0). +interface ICircleRefundProtocol { + function arbiter() external view returns (address); + function fiatToken() external view returns (address); + function nonce() external view returns (uint256); + function MAX_LOCKUP_SECONDS() external view returns (uint256); + function lockupSeconds(address recipient) external view returns (uint256); + function balances(address account) external view returns (uint256); + function debts(address recipient) external view returns (uint256); + + function payments(uint256 paymentID) + external + view + returns ( + address to, + uint256 amount, + uint256 releaseTimestamp, + address refundTo, + uint256 withdrawnAmount, + bool refunded + ); + + function pay(address to, uint256 amount, address refundTo) external; + function withdraw(uint256[] calldata paymentIDs) external; + function refundByRecipient(uint256 paymentID) external; + function updateRefundTo(uint256 paymentID, address newRefundTo) external; + + // arbiter-only + function refundByArbiter(uint256 paymentID) external; + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external; + function depositArbiterFunds(uint256 amount) external; + function withdrawArbiterFunds(uint256 amount) external; +} diff --git a/contracts/v2/mocks/MockCircleRefundProtocol.sol b/contracts/v2/mocks/MockCircleRefundProtocol.sol new file mode 100644 index 0000000..7c5fd80 --- /dev/null +++ b/contracts/v2/mocks/MockCircleRefundProtocol.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice TEST / DEMO ONLY. Predge's own re-implementation of the behaviour of Circle's Refund +/// Protocol that the adapter depends on, written from the public interface and behaviour +/// described in `ICircleRefundProtocol`. It is NOT Circle's code and is not audited. +/// Same function names, argument order, getter layout and error names as Circle's contract, +/// so the adapter and tests exercise the real call shapes. Left out: EIP-712 +/// `earlyWithdrawByArbiter` (not used by Predge) and recipient debt bookkeeping beyond the +/// arbiter-funds fallback. +interface IERC20Min { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} + +contract MockCircleRefundProtocol { + struct Payment { + address to; + uint256 amount; + uint256 releaseTimestamp; + address refundTo; + uint256 withdrawnAmount; + bool refunded; + } + + uint256 public constant MAX_LOCKUP_SECONDS = 180 days; + + address public immutable arbiter; + address public immutable fiatToken; + uint256 public nonce; + + mapping(address => uint256) public lockupSeconds; + mapping(uint256 => Payment) public payments; + mapping(address => uint256) public balances; + mapping(address => uint256) public debts; + + event PaymentCreated(uint256 indexed paymentID, address indexed to, uint256 amount, uint256 releaseTimestamp, address indexed refundTo); + event Refund(uint256 indexed paymentID, address indexed refundTo, uint256 amount); + event RefundToUpdated(uint256 indexed paymentID, address indexed oldRefundTo, address indexed newRefundTo); + event Withdrawal(address indexed to, uint256 amount); + + error CallerNotAllowed(); + error PaymentIsStillLocked(uint256 paymentID); + error RefundToIsZeroAddress(); + error InsufficientFunds(); + error PaymentRefunded(uint256 paymentID); + error LockupSecondsExceedsMax(); + + modifier onlyArbiter() { + if (msg.sender != arbiter) revert CallerNotAllowed(); + _; + } + + constructor(address arbiter_, address token_) { + arbiter = arbiter_; + fiatToken = token_; + } + + function pay(address to, uint256 amount, address refundTo) external { + if (refundTo == address(0)) revert RefundToIsZeroAddress(); + uint256 release = block.timestamp + lockupSeconds[to]; + require(IERC20Min(fiatToken).transferFrom(msg.sender, address(this), amount), "transferFrom"); + uint256 id = nonce++; + payments[id] = Payment(to, amount, release, refundTo, 0, false); + balances[to] += amount; + emit PaymentCreated(id, to, amount, release, refundTo); + } + + function refundByRecipient(uint256 paymentID) external { + Payment storage p = payments[paymentID]; + if (msg.sender != p.to) revert CallerNotAllowed(); + if (balances[p.to] < p.amount) revert InsufficientFunds(); + balances[p.to] -= p.amount; + _refund(paymentID, p); + } + + /// @dev Like Circle's: no release-time check; paid from the recipient's balance, else from the + /// arbiter's deposited balance (which then becomes the recipient's debt). + function refundByArbiter(uint256 paymentID) external onlyArbiter { + Payment storage p = payments[paymentID]; + if (balances[p.to] >= p.amount) { + balances[p.to] -= p.amount; + } else { + if (balances[arbiter] < p.amount) revert InsufficientFunds(); + balances[arbiter] -= p.amount; + debts[p.to] += p.amount; + } + _refund(paymentID, p); + } + + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyArbiter { + if (recipientLockupSeconds > MAX_LOCKUP_SECONDS) revert LockupSecondsExceedsMax(); + lockupSeconds[recipient] = recipientLockupSeconds; + } + + function depositArbiterFunds(uint256 amount) external onlyArbiter { + require(IERC20Min(fiatToken).transferFrom(msg.sender, address(this), amount), "transferFrom"); + balances[arbiter] += amount; + } + + function withdrawArbiterFunds(uint256 amount) external onlyArbiter { + if (balances[arbiter] < amount) revert InsufficientFunds(); + balances[arbiter] -= amount; + require(IERC20Min(fiatToken).transfer(arbiter, amount), "transfer"); + } + + function withdraw(uint256[] calldata paymentIDs) external { + uint256 total; + for (uint256 i; i < paymentIDs.length; ++i) { + Payment storage p = payments[paymentIDs[i]]; + if (p.to != msg.sender) revert CallerNotAllowed(); + if (block.timestamp < p.releaseTimestamp) revert PaymentIsStillLocked(paymentIDs[i]); + if (p.refunded) revert PaymentRefunded(paymentIDs[i]); + total += p.amount - p.withdrawnAmount; + p.withdrawnAmount = p.amount; + } + if (balances[msg.sender] < total) revert InsufficientFunds(); + balances[msg.sender] -= total; + require(IERC20Min(fiatToken).transfer(msg.sender, total), "transfer"); + emit Withdrawal(msg.sender, total); + } + + function updateRefundTo(uint256 paymentID, address newRefundTo) external { + if (newRefundTo == address(0)) revert RefundToIsZeroAddress(); + Payment storage p = payments[paymentID]; + if (msg.sender != p.refundTo) revert CallerNotAllowed(); + emit RefundToUpdated(paymentID, p.refundTo, newRefundTo); + p.refundTo = newRefundTo; + } + + function _refund(uint256 paymentID, Payment storage p) private { + if (p.refunded) revert PaymentRefunded(paymentID); + p.refunded = true; + require(IERC20Min(fiatToken).transfer(p.refundTo, p.amount), "transfer"); + emit Refund(paymentID, p.refundTo, p.amount); + } +} diff --git a/contracts/v2/mocks/MockUSDC.sol b/contracts/v2/mocks/MockUSDC.sol new file mode 100644 index 0000000..28aeb8b --- /dev/null +++ b/contracts/v2/mocks/MockUSDC.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice TEST ONLY 6-decimal ERC-20 with open minting. Never deploy outside a local test. +contract MockUSDC { + string public constant name = "Mock USDC"; + string public constant symbol = "mUSDC"; + uint8 public constant decimals = 6; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _move(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 a = allowance[from][msg.sender]; + if (a != type(uint256).max) allowance[from][msg.sender] = a - amount; + _move(from, to, amount); + return true; + } + + function _move(address from, address to, uint256 amount) private { + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} diff --git a/scripts/foundry/DeployCircleRefundDemo.s.sol b/scripts/foundry/DeployCircleRefundDemo.s.sol new file mode 100644 index 0000000..07fb425 --- /dev/null +++ b/scripts/foundry/DeployCircleRefundDemo.s.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script, console2} from "forge-std/Script.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {CircleRefundProtocolAdapter} from "../../contracts/v2/adapters/CircleRefundProtocolAdapter.sol"; +import {MockCircleRefundProtocol} from "../../contracts/v2/mocks/MockCircleRefundProtocol.sol"; + +/// @notice Demo deploy for Arc TESTNET: adapter -> Refund Protocol -> PredgeRefundArbiterV2 -> bind. +/// Simulate first (no --broadcast). Env: +/// OWNER, OPERATOR required; Predge arbiter owner (Safe or EOA) and operator key +/// USDC ERC-20 the Refund Protocol escrows (Arc testnet USDC by default) +/// CIRCLE_RP optional: an existing Circle RefundProtocol whose `arbiter` is the +/// adapter address this script will create. If unset, Predge's own +/// MockCircleRefundProtocol is deployed instead. +/// AMOUNT_SCALE default 1e12 (USDC 6dp -> Arc native 18dp) +/// WINDOW, MIN_BOND, BPS default 86400, 5e18, 0 (the live Arc v2.1 config) +contract DeployCircleRefundDemo is Script { + function run() external { + address owner = vm.envAddress("OWNER"); + address operator = vm.envAddress("OPERATOR"); + address usdc = vm.envOr("USDC", address(0x3600000000000000000000000000000000000000)); + address circleRp = vm.envOr("CIRCLE_RP", address(0)); + uint256 scale = vm.envOr("AMOUNT_SCALE", uint256(1e12)); + uint64 window = uint64(vm.envOr("WINDOW", uint256(86400))); + uint256 minBond = vm.envOr("MIN_BOND", uint256(5e18)); + uint16 bps = uint16(vm.envOr("BPS", uint256(0))); + + vm.startBroadcast(); + CircleRefundProtocolAdapter adapter = new CircleRefundProtocolAdapter(scale); + if (circleRp == address(0)) { + circleRp = address(new MockCircleRefundProtocol(address(adapter), usdc)); + } + PredgeRefundArbiterV2 arb = new PredgeRefundArbiterV2(owner, operator, address(adapter), window, minBond, bps); + adapter.bind(circleRp, address(arb)); + vm.stopBroadcast(); + + console2.log("adapter ", address(adapter)); + console2.log("refundProtocol ", circleRp); + console2.log("predgeArbiter ", address(arb)); + console2.log("next: OWNER calls predgeArbiter.setLockupSeconds(seller, >15 days, <=180 days)"); + } +} diff --git a/test/foundry-v2/CircleRefundAdapter.t.sol b/test/foundry-v2/CircleRefundAdapter.t.sol new file mode 100644 index 0000000..dc6c776 --- /dev/null +++ b/test/foundry-v2/CircleRefundAdapter.t.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test, console2} from "forge-std/Test.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {CircleRefundProtocolAdapter} from "../../contracts/v2/adapters/CircleRefundProtocolAdapter.sol"; +import {MockCircleRefundProtocol} from "../../contracts/v2/mocks/MockCircleRefundProtocol.sol"; +import {MockUSDC} from "../../contracts/v2/mocks/MockUSDC.sol"; + +/// @notice PredgeRefundArbiterV2 (unchanged, as deployed) driving a Circle-shaped Refund Protocol +/// through CircleRefundProtocolAdapter. Parameters mirror the live Arc config: 1-day window, +/// 5 USDC native bond floor, 0 bps. +contract CircleRefundAdapterTest is Test { + address internal safe = makeAddr("safe"); + address internal op = makeAddr("operator"); + address internal buyer = makeAddr("buyerAgent"); + address internal seller = makeAddr("sellerAgent"); + address internal stranger = makeAddr("stranger"); + + uint64 internal constant WINDOW = 1 days; + uint256 internal constant MIN_BOND = 5e18; // 5 USDC in Arc native wei + uint16 internal constant BPS = 0; + uint256 internal constant SCALE = 1e12; // USDC 6 decimals -> 18 + uint256 internal constant LOCKUP = 20 days; // > WINDOW + RESOLUTION_TIMEOUT (15 days) + uint256 internal constant PRICE = 10e6; // 10 USDC + + bytes32 internal constant EV_BUYER = keccak256("terms+delivery hash, buyer"); + bytes32 internal constant EV_SELLER = keccak256("terms+delivery hash, seller"); + bytes32 internal constant EV_COUNTER = keccak256("counter evidence"); + + MockUSDC internal usdc; + MockCircleRefundProtocol internal circle; + CircleRefundProtocolAdapter internal adapter; + PredgeRefundArbiterV2 internal arb; + + function setUp() public { + vm.warp(1_762_000_000); + usdc = new MockUSDC(); + adapter = new CircleRefundProtocolAdapter(SCALE); + circle = new MockCircleRefundProtocol(address(adapter), address(usdc)); + arb = new PredgeRefundArbiterV2(safe, op, address(adapter), WINDOW, MIN_BOND, BPS); + adapter.bind(address(circle), address(arb)); + + vm.prank(safe); + arb.setLockupSeconds(seller, LOCKUP); // Safe -> Predge arbiter -> adapter -> Circle + + vm.deal(op, 100 ether); + usdc.mint(buyer, 100e6); + vm.prank(buyer); + usdc.approve(address(circle), type(uint256).max); + } + + // ---------- helpers ---------- + + function _pay() internal returns (uint256 id) { + id = circle.nonce(); + vm.prank(buyer); + circle.pay(seller, PRICE, buyer); + } + + function _fileBoth(uint256 id) internal { + vm.prank(buyer); + arb.fileEvidence(id, EV_BUYER); + vm.prank(seller); + arb.fileEvidence(id, EV_SELLER); + } + + function _rule(uint256 id, address from, bytes32 ev, PredgeRefundArbiterV2.Direction d) internal { + vm.prank(op); + arb.rule{value: MIN_BOND}(id, from, ev, d); + } + + function _refunded(uint256 id) internal view returns (bool r) { + (,,,,, r) = circle.payments(id); + } + + // ---------- wiring ---------- + + function test_wiring() public view { + assertEq(circle.arbiter(), address(adapter)); + assertEq(address(arb.refundProtocol()), address(adapter)); + assertEq(address(adapter.circle()), address(circle)); + assertEq(adapter.predgeArbiter(), address(arb)); + assertEq(circle.lockupSeconds(seller), LOCKUP); + } + + function test_bind_onceOnly_deployerOnly() public { + vm.expectRevert(CircleRefundProtocolAdapter.AlreadyBound.selector); + adapter.bind(address(circle), address(arb)); + + CircleRefundProtocolAdapter a2 = new CircleRefundProtocolAdapter(SCALE); + vm.prank(stranger); + vm.expectRevert(CircleRefundProtocolAdapter.NotDeployer.selector); + a2.bind(address(circle), address(arb)); + // Circle's arbiter is the first adapter, not a2 + vm.expectRevert(CircleRefundProtocolAdapter.NotCircleArbiter.selector); + a2.bind(address(circle), address(arb)); + // Predge arbiter points to the first adapter, not a2 + MockCircleRefundProtocol c2 = new MockCircleRefundProtocol(address(a2), address(usdc)); + vm.expectRevert(CircleRefundProtocolAdapter.ArbiterNotWired.selector); + a2.bind(address(c2), address(arb)); + vm.expectRevert(CircleRefundProtocolAdapter.NotBound.selector); + a2.paymentInfo(0); + } + + function test_paymentInfo_mapsCircleFields() public { + uint256 id = _pay(); + assertEq(id, 0, "Circle ids start at 0"); + (address payer, address payee, uint256 amount, uint256 release) = adapter.paymentInfo(id); + assertEq(payer, buyer, "payer = refundTo"); + assertEq(payee, seller, "payee = to"); + assertEq(amount, PRICE * SCALE, "6 -> 18 decimals"); + assertEq(release, block.timestamp + LOCKUP); + (payer, payee, amount, release) = adapter.paymentInfo(99); + assertEq(payee, address(0), "unknown payment reads as zero"); + } + + function test_onlyPredgeArbiterCanDriveCircle() public { + uint256 id = _pay(); + vm.prank(stranger); + vm.expectRevert(CircleRefundProtocolAdapter.NotPredgeArbiter.selector); + adapter.refundByArbiter(id); + vm.prank(op); + vm.expectRevert(CircleRefundProtocolAdapter.NotPredgeArbiter.selector); + adapter.refundByArbiter(id); + vm.prank(stranger); + vm.expectRevert(MockCircleRefundProtocol.CallerNotAllowed.selector); + circle.refundByArbiter(id); + vm.prank(stranger); + vm.expectRevert(CircleRefundProtocolAdapter.NotPredgeArbiter.selector); + adapter.setLockupSeconds(seller, 1); + } + + // ---------- 1. happy path: release ---------- + + function test_release_sellerPaidAfterLockup() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, seller, EV_SELLER, PredgeRefundArbiterV2.Direction.Release); + + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Finalized)); + assertFalse(_refunded(id)); + assertEq(arb.credit(op), MIN_BOND, "bond back to operator"); + + vm.warp(block.timestamp + LOCKUP); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.prank(seller); + circle.withdraw(ids); + assertEq(usdc.balanceOf(seller), PRICE); + assertEq(usdc.balanceOf(address(circle)), 0); + + uint256 before = op.balance; + vm.prank(op); + arb.withdraw(); + assertEq(op.balance - before, MIN_BOND); + } + + // ---------- 2. refund on verdict ---------- + + function test_refundOnVerdict_buyerGetsUsdcBack() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + assertTrue(_refunded(id)); + assertEq(usdc.balanceOf(buyer), 100e6, "buyer whole again"); + assertEq(circle.balances(seller), 0); + + vm.warp(block.timestamp + LOCKUP); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.prank(seller); + vm.expectRevert(abi.encodeWithSelector(MockCircleRefundProtocol.PaymentRefunded.selector, id)); + circle.withdraw(ids); + } + + // ---------- 3. dispute ---------- + + function test_dispute_releaseOverturnedToRefund() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, seller, EV_SELLER, PredgeRefundArbiterV2.Direction.Release); + + vm.prank(seller); + vm.expectRevert(PredgeRefundArbiterV2.NotHarmedParty.selector); + arb.dispute(id, EV_COUNTER); + + vm.prank(buyer); + arb.dispute(id, EV_COUNTER); + vm.warp(block.timestamp + WINDOW); + vm.expectRevert(PredgeRefundArbiterV2.BadStatus.selector); + arb.finalize(id); // frozen + + vm.prank(safe); + arb.resolve(id, PredgeRefundArbiterV2.Direction.Refund); + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Overturned)); + assertTrue(_refunded(id)); + assertEq(usdc.balanceOf(buyer), 100e6); + assertEq(arb.credit(buyer), MIN_BOND, "operator bond to the harmed buyer"); + assertEq(arb.overturnCount(), 1); + } + + function test_dispute_refundUpheld() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + vm.prank(seller); + arb.dispute(id, EV_COUNTER); + vm.prank(safe); + arb.resolve(id, PredgeRefundArbiterV2.Direction.Refund); + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Finalized)); + assertTrue(_refunded(id)); + assertEq(arb.credit(op), MIN_BOND); + } + + // ---------- edge cases from the gap analysis ---------- + + function test_sellerRefundsFirst_finalizeStillSucceeds() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + vm.prank(seller); + circle.refundByRecipient(id); + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); // adapter treats "already refunded" as done + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Finalized)); + assertEq(usdc.balanceOf(buyer), 100e6); + } + + function test_refundedPayment_notArbitrable() public { + uint256 id = _pay(); + vm.prank(seller); + circle.refundByRecipient(id); + vm.prank(buyer); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); + arb.fileEvidence(id, EV_BUYER); + } + + function test_zeroLockup_ruleRefused() public { + address other = makeAddr("noLockupSeller"); + uint256 id = circle.nonce(); + vm.prank(buyer); + circle.pay(other, PRICE, buyer); + vm.prank(buyer); + arb.fileEvidence(id, EV_BUYER); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.LockupTooShort.selector); + arb.rule{value: MIN_BOND}(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + } + + function test_lockupCap_circle180dBelowPredge365d() public { + vm.prank(safe); + vm.expectRevert(MockCircleRefundProtocol.LockupSecondsExceedsMax.selector); + arb.setLockupSeconds(seller, 200 days); + } + + function test_refundToChangedAfterRuling_refundGoesToNewAddress() public { + address newTo = makeAddr("buyerTreasury"); + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + vm.prank(buyer); + circle.updateRefundTo(id, newTo); + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + assertEq(usdc.balanceOf(newTo), PRICE, "Circle pays the current refundTo"); + assertEq(arb.rulings(id).payer, buyer, "Predge keeps the payer snapshotted at ruling"); + } + + // ---------- the 6-line demo log ---------- + + function test_demoLog() public { + uint256 id = _pay(); + console2.log("[1/6] PAY buyer-agent -> RefundProtocol (Circle interface): 10.00 USDC to seller-agent, id", id, "lockup 20d"); + _fileBoth(id); + console2.log("[2/6] EVIDENCE buyer + seller filed hashes of terms and delivery with Predge"); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + console2.log("[3/6] VERDICT Predge operator: REFUND (delivery did not meet the fixed terms), 5 USDC bond posted"); + console2.log("[4/6] WINDOW 24h challenge window open; seller may dispute to the owner Safe"); + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + console2.log("[5/6] FINALIZE no dispute -> Predge arbiter -> adapter -> RefundProtocol.refundByArbiter"); + console2.log("[6/6] RESULT buyer USDC back (6dp):", usdc.balanceOf(buyer) - 90e6, "refunded:", _refunded(id)); + assertTrue(_refunded(id)); + } +} From ae5488d84cfabbc54692985b2e1559499181c269 Mon Sep 17 00:00:00 2001 From: PREDGE <307976007+predge-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:24:47 +0530 Subject: [PATCH 19/19] rootstock-testnet: v2.1 set live on chainId 31 (testnet PoC), record + resume mode (#9) * deployments: v2.1 live on Arbitrum, Avalanche, BNB Chain, Celo, Optimism, Sonic, Unichain (same addresses as Arc/Base) * rootstock-testnet: v2.1 set wired for a testnet PoC with throwaway roles Adds chain rootstock-testnet (chainId 31, public-node.testnet.rsk.co). v2 on a testnet chain reads roles and the deployer key from .env.rsk-testnet (gitignored, 0600), refuses any mainnet Predge identity and v1, starts a fresh deployer at nonce 0, and broadcasts only with CONFIRM_TESTNET=rootstock-testnet. Contracts unchanged since 013ad4c. Fork run on RSK testnet: 6 deploys, 30 smoke checks, 49 honest-path txs, 16 attack sequences blocked. * rootstock-testnet: v2.1 set live on chainId 31, record rebuilt from receipts The broadcast landed all six contracts (nonces 0..5, blocks 8254116..8254127) but crashed in the read-only smoke: the public node returned 0x from eth_getCode for a contract whose receipt was already in, and maskImmutables threw a RangeError on the empty buffer, so no record was written. - smoke (v1 + v2): waitCode() retries eth_getCode until the code is visible - maskImmutables: short/empty code is a mismatch, not a RangeError - deploy.mjs --record-only --txs : sends nothing, loads no key; rebuilds the record from the creation receipts (status, from, nonce, initcode == compiled, contractAddress), then the same smoke and verification as --broadcast - deployments/v2/rootstock-testnet.json: 30/30 smoke checks, all six verified (Blockscout + Sourcify exact match) --- .gitignore | 1 + README.md | 32 ++ config/chains.json | 19 + config/v2.chains.json | 7 + deploy.mjs | 80 +++- deployments/fork-v2/rootstock-testnet.json | 425 +++++++++++++++++++++ deployments/v2/rootstock-testnet.json | 307 +++++++++++++++ lib/chains.mjs | 12 + lib/compile.mjs | 6 +- lib/smoke-v2.mjs | 4 +- lib/smoke.mjs | 4 +- lib/v2.mjs | 49 ++- package.json | 4 +- 13 files changed, 932 insertions(+), 18 deletions(-) create mode 100644 deployments/fork-v2/rootstock-testnet.json create mode 100644 deployments/v2/rootstock-testnet.json diff --git a/.gitignore b/.gitignore index fd82614..8062937 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ *.key deployer.json .DS_Store +.env.* diff --git a/README.md b/README.md index 8b9f267..52bfb35 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,38 @@ node scripts/v2-post-deploy.mjs --chains arc,base # after a v2.1 broadca Real v2 runs (`--set v2` without `--fork`) refuse to start until the owner fills `config/v2.config.json` (new deployer address, the Safe, the ops key) and, to broadcast, pass `--key-file `. v2 never reads `~/.predge-deployer`. `--fork` uses throwaway accounts only. +## Rootstock testnet (chainId 31) PoC + +The v2.1 set (contracts unchanged since 013ad4c) on Rootstock testnet for the public IBUKI PoC. Testnet only, never a mainnet key: +roles and the deployer key are throwaway testnet keys in `.env.rsk-testnet` (gitignored, 0600); `config/v2.config.json` is not read. +The fresh deployer starts at nonce 0 (`v2.nonceBase: 0` in `config/chains.json`), so these addresses differ from the mainnet set. + +```bash +npm run v2:rsk-testnet:dry # plan, addresses, nonce/balance, gas estimate on the live testnet. Sends nothing. +node deploy.mjs --set v2 --chain rootstock-testnet --fork # anvil fork: deploy + 49 honest-path txs + 16 attack sequences +npm run v2:rsk-testnet:deploy # after the deployer is funded from https://faucet.rootstock.io (CONFIRM_TESTNET guard) +``` + +Live since 3 Nov 2026 (blocks 8254116..8254127), all six verified on [Blockscout](https://rootstock-testnet.blockscout.com) and +exact-match on Sourcify. Receipts, constructor args and the 30 read-only smoke checks: [deployments/v2/rootstock-testnet.json](deployments/v2/rootstock-testnet.json). + +| Nonce | Contract | Address | +|---|---|---| +| 0 | `PredgeAgentValidatorV2` | [0xb7A912662E03eEE453204538D5F67880b079A862](https://rootstock-testnet.blockscout.com/address/0xb7A912662E03eEE453204538D5F67880b079A862) | +| 1 | `AgentJobV2` | [0x5D6E0290cBA483CE53c834A1783FE1073915235B](https://rootstock-testnet.blockscout.com/address/0x5D6E0290cBA483CE53c834A1783FE1073915235B) | +| 2 | `PredgeValidatorBondV2` | [0xDf2cf20B011FC8d5df16070c7c25F806C63935fD](https://rootstock-testnet.blockscout.com/address/0xDf2cf20B011FC8d5df16070c7c25F806C63935fD) | +| 3 | `PredgeSettlementV2` | [0x9F0cbf53CCEB9D9474b6802Bb496684D976aD977](https://rootstock-testnet.blockscout.com/address/0x9F0cbf53CCEB9D9474b6802Bb496684D976aD977) | +| 4 | `MockRefundProtocolV2` | [0x3ffBFcFb6D5621E3A43e22adcBC572824Ae452C5](https://rootstock-testnet.blockscout.com/address/0x3ffBFcFb6D5621E3A43e22adcBC572824Ae452C5) | +| 5 | `PredgeRefundArbiterV2` | [0x879D437362022e54d257af68605D53ec52372621](https://rootstock-testnet.blockscout.com/address/0x879D437362022e54d257af68605D53ec52372621) | + +The broadcast run stopped after the sixth deploy (the public node answered `eth_getCode` with `0x` for a contract whose receipt +was already in, and the smoke check crashed on the empty code). The smoke check now waits for the code, and the record was rebuilt +without sending anything: + +```bash +node deploy.mjs --set v2 --chain rootstock-testnet --record-only --txs <{"": "0x"}.json> # sends nothing, loads no key +``` + ## The set Order is the deployer's nonce. Contract *i* is created at nonce *i*. diff --git a/config/chains.json b/config/chains.json index 9451794..2d24f9f 100644 --- a/config/chains.json +++ b/config/chains.json @@ -251,6 +251,25 @@ }, "note": "RskJ VM, legacy (type 0) txs only, no baseFee; min gas price ~0.024 gwei. eth_call on init code returns 0x instead of an error on INVALID (REVERT does error), so the probe's INVALID negative control reads as a false NO; PUSH0/MCOPY verified by return data. Explorer for humans: explorer.rootstock.io; verification via rootstock.blockscout.com." }, + "rootstock-testnet": { + "chainId": 31, + "testnet": true, + "rpc": [ + "https://public-node.testnet.rsk.co" + ], + "native": "tRBTC", + "coingecko": null, + "explorer": "https://explorer.testnet.rootstock.io", + "verify": { + "kind": "blockscout", + "api": "https://rootstock-testnet.blockscout.com/api" + }, + "v2": { + "nonceBase": 0, + "envFile": ".env.rsk-testnet" + }, + "note": "TESTNET (tRBTC, faucet https://faucet.rootstock.io). Same RskJ VM as rootstock: legacy (type 0) txs only, no baseFee. v2 here uses throwaway testnet-only roles from .env.rsk-testnet (gitignored), never config/v2.config.json, and a fresh deployer at nonce 0 (nonceBase 0), so addresses differ from the mainnet v2.1 set. Broadcast needs CONFIRM_TESTNET=rootstock-testnet." + }, "plasma": { "chainId": 9745, "rpc": [ diff --git a/config/v2.chains.json b/config/v2.chains.json index 165546f..89bee02 100644 --- a/config/v2.chains.json +++ b/config/v2.chains.json @@ -122,6 +122,13 @@ "priceUsd": 0.99895, "minBond": "5000000000000000000", "arbiterMinBond": "5000000000000000000" + }, + "rootstock-testnet": { + "native": "tRBTC", + "priceUsd": 81371, + "minBond": "60000000000000", + "arbiterMinBond": "60000000000000", + "_note": "testnet: same floor as rootstock mainnet (~$5 at the 20.10 RBTC price), paid in tRBTC" } } } diff --git a/deploy.mjs b/deploy.mjs index e6b5887..1ecff87 100755 --- a/deploy.mjs +++ b/deploy.mjs @@ -5,6 +5,11 @@ // node deploy.mjs --chain base --fork deploy + smoke + end-to-end on a local `anvil --fork-url` of the chain. Sends nothing to the chain. // CONFIRM_MAINNET=base node deploy.mjs --chain base --broadcast // the real thing: deploy, verify source, read-only smoke, deployments/base.json. +// node deploy.mjs --chain base --record-only --txs +// resume after a crash between the last deploy and the record: sends NOTHING and loads no key. +// Rebuilds deployments/.json from the creation receipts ({"": "0x"}), +// checking each tx (status 1, from the deployer, at its nonce, initcode == compiled, contractAddress), +// then runs the same read-only smoke and source verification as --broadcast. // Options: --rpc (override), --no-verify, --no-e2e (fork), --keep-anvil (fork) // // --set v2 the v2 contract set (contracts/v2, config/set.v2.json). Roles (deployer / owner Safe / operator) come from @@ -15,13 +20,13 @@ import { spawn } from "node:child_process"; import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; import { createServer } from "node:net"; import { formatEther, Wallet } from "ethers"; -import { chainConfig, pickRpc, providerFor, withRetry, rawRpc, waitReceipt, SET, SET_V2 } from "./lib/chains.mjs"; +import { chainConfig, pickRpc, providerFor, withRetry, rawRpc, waitReceipt, waitCode, SET, SET_V2 } from "./lib/chains.mjs"; import { loadDeployer } from "./lib/key.mjs"; import { resolvePlan } from "./lib/plan.mjs"; import { maskImmutables, SOLC_VERSION, SETTINGS } from "./lib/compile.mjs"; import { readOnly, e2e } from "./lib/smoke.mjs"; import { readOnlyV2, e2eV2 } from "./lib/smoke-v2.mjs"; -import { realRoles, forkRoles, chainParams, anvilWallets, v2KeyFile } from "./lib/v2.mjs"; +import { realRoles, forkRoles, chainParams, anvilWallets, v2KeyFile, testnetSetup } from "./lib/v2.mjs"; import { verifyStep } from "./lib/verify.mjs"; import { l1FeeWei } from "./lib/fees.mjs"; @@ -107,6 +112,32 @@ async function deploySet(provider, wallet, plan, { log, gasLimitPct = 125 }) { return results; } +/** --record-only: the creation receipts of a set that is already on chain, checked against the plan. Sends nothing. */ +async function recordSet(provider, deployerAddress, plan, txs, { log }) { + const results = []; + for (const step of plan) { + const hash = txs[step.name]; + if (!hash) throw new Error(`--txs has no tx hash for ${step.name}`); + const [tx, r] = await Promise.all([withRetry("getTransaction", () => provider.getTransaction(hash)), withRetry("receipt", () => provider.getTransactionReceipt(hash))]); + if (!tx || !r) throw new Error(`${step.name}: no tx/receipt for ${hash}`); + const bad = [ + r.status !== 1 && `status ${r.status}`, + tx.from !== deployerAddress && `from ${tx.from} != deployer ${deployerAddress}`, + tx.nonce !== step.nonce && `nonce ${tx.nonce} != ${step.nonce}`, + tx.to !== null && `tx.to ${tx.to} (not a creation)`, + tx.data.toLowerCase() !== step.initcode.toLowerCase() && "initcode != compiled creation code + constructor args", + r.contractAddress !== step.address && `contractAddress ${r.contractAddress} != ${step.address}`, + ].filter(Boolean); + if (bad.length) throw new Error(`${step.name} ${hash}: ${bad.join("; ")}`); + const code = await waitCode(provider, step.address); + const c = step.compiled; + if (maskImmutables(code, c.immutableReferences) !== maskImmutables(c.deployedBytecode, c.immutableReferences)) throw new Error(`${step.address} does not hold ${step.name}`); + log(` = ${step.name.padEnd(22)} ${step.address} gas ${r.gasUsed} tx ${hash} (receipt checked)`); + results.push({ ...pick(step), txHash: hash, block: r.blockNumber, gasUsed: r.gasUsed.toString(), effectiveGasPrice: (r.gasPrice ?? tx.gasPrice ?? 0n).toString() }); + } + return results; +} + function pick(step) { return { name: step.name, address: step.address, nonce: step.nonce, args: step.args.map(String), constructorArgs: step.constructorArgs, sourceSha256: step.compiled.sourceSha256, source: step.source }; } @@ -117,32 +148,50 @@ async function main() { const name = opt("--chain"); if (!name) { console.error("usage: node deploy.mjs --chain [--fork | --broadcast]"); process.exit(2); } const chain = chainConfig(name); - const mode = flag("--broadcast") ? "broadcast" : flag("--fork") ? "fork" : "dry-run"; + const mode = flag("--record-only") ? "record" : flag("--broadcast") ? "broadcast" : flag("--fork") ? "fork" : "dry-run"; + if (mode === "record" && (flag("--broadcast") || flag("--fork"))) throw new Error("--record-only sends nothing: do not combine it with --broadcast or --fork"); const T0 = Date.now(); const log = (...a) => console.log(`[${((Date.now() - T0) / 1000).toFixed(1)}s]`, ...a); log(`\n=== predge-deploy ${chain.name} (chainId ${chain.chainId}) mode=${mode} solc ${SOLC_VERSION} opt ${SETTINGS.optimizer.runs} evm ${SETTINGS.evmVersion} ===`); const liveUrl = await pickRpc(chain, opt("--rpc")); const live = providerFor(liveUrl, chain.chainId); + // Testnet chains (config "testnet": true) are v2-only and use throwaway roles + key from the chain's env file, never a mainnet key. + if (chain.testnet && !V2) throw new Error(`${chain.name} is a testnet: only --set v2 is wired for it (v1 would load the mainnet v1 key)`); + if (chain.testnet && opt("--key-file")) throw new Error(`${chain.name}: --key-file is not used on testnets; the key comes from ${chain.v2?.envFile}`); + const TN = V2 && chain.testnet && mode !== "fork" ? testnetSetup(chain, ROOT, live) : null; // v1: the one deployer key. v2: roles from config (real runs) or anvil accounts (fork); a v2 key is only loaded to broadcast. - const roles = V2 ? (mode === "fork" ? forkRoles() : realRoles()) : null; + const roles = V2 ? (mode === "fork" ? forkRoles() : TN ? TN.roles : realRoles()) : null; let deployer; - if (V2) deployer = mode === "broadcast" ? loadDeployer(live, v2KeyFile(opt("--key-file"))) : { address: roles.deployer }; + if (TN) deployer = mode === "broadcast" ? TN.loadKey() : { address: roles.deployer }; + else if (V2) deployer = mode === "broadcast" ? loadDeployer(live, v2KeyFile(opt("--key-file"))) : { address: roles.deployer }; else deployer = loadDeployer(live); if (V2 && mode === "broadcast" && deployer.address !== roles.deployer) throw new Error(`--key-file address ${deployer.address} != config deployer ${roles.deployer}`); + // A chain may pin its own nonceBase (a fresh testnet deployer starts at 0); addresses then differ from the mainnet set. + const SETV2 = chain.v2?.nonceBase !== undefined ? { ...SET_V2, nonceBase: chain.v2.nonceBase } : SET_V2; const plan = V2 - ? resolvePlan(deployer.address, { owner: roles.owner, operator: roles.operator, ...chainParams(chain.name) }, SET_V2) + ? resolvePlan(deployer.address, { owner: roles.owner, operator: roles.operator, ...chainParams(chain.name) }, SETV2) : resolvePlan(deployer.address); log(`deployer ${deployer.address} rpc ${liveUrl}${V2 ? ` (v2: owner ${roles.owner}, operator ${roles.operator})` : ""}`); for (const s of plan) log(` nonce ${s.nonce} ${s.name.padEnd(22)} -> ${s.address}`); - if (mode === "broadcast") { + if (mode === "record") { + const id = Number((await live.getNetwork()).chainId); + if (id !== chain.chainId) throw new Error(`rpc ${liveUrl} is chainId ${id}, expected ${chain.chainId}`); + } else if (mode === "broadcast" && chain.testnet) { + if (process.env.CONFIRM_TESTNET !== chain.name) { + console.error(`Refusing to broadcast: set CONFIRM_TESTNET=${chain.name} to deploy to the ${chain.name} testnet.`); + process.exit(3); + } + const id = Number((await live.getNetwork()).chainId); + if (id !== chain.chainId) throw new Error(`rpc ${liveUrl} is chainId ${id}, expected testnet ${chain.chainId}`); + } else if (mode === "broadcast") { if (process.env.CONFIRM_MAINNET !== chain.name) { console.error(`Refusing to broadcast: set CONFIRM_MAINNET=${chain.name} to deploy to ${chain.name} mainnet.`); process.exit(3); } } - if (mode === "dry-run" || mode === "broadcast") { + if (mode === "dry-run" || mode === "broadcast" || mode === "record") { const [bal, nonce, fee] = await Promise.all([live.getBalance(deployer.address), live.getTransactionCount(deployer.address, "pending"), live.getFeeData()]); const price = fee.gasPrice ?? fee.maxFeePerGas; let gas = 0n; @@ -159,9 +208,18 @@ async function main() { if (done > 0 && (await live.getCode(plan[done - 1].address)).length <= 2) throw new Error(`deployer nonce ${nonce} on ${chain.name} but the contract for nonce ${nonce - 1} is missing: nonce desync.`); if (mode === "dry-run") { log("dry run only. Nothing sent."); return; } - if (bal < (gas * price)) { console.error(`Balance too low. Fund ${deployer.address} with >= ${formatEther(need)} ${chain.native} on ${chain.name}.`); process.exit(4); } + if (mode !== "record" && bal < (gas * price)) { console.error(`Balance too low. Fund ${deployer.address} with >= ${formatEther(need)} ${chain.native} on ${chain.name}.`); process.exit(4); } - const results = await deploySet(live, deployer, plan, { log, gasLimitPct: chain.gasLimitPct }); + let results, deployedAt = new Date().toISOString(); + if (mode === "record") { + const f = opt("--txs"); + if (!f) throw new Error("--record-only needs --txs mapping contract name -> creation tx hash"); + results = await recordSet(live, deployer.address, plan, JSON.parse(readFileSync(f, "utf8")), { log }); + const last = await live.getBlock(Math.max(...results.map((r) => r.block))); + deployedAt = new Date(Number(last.timestamp) * 1000).toISOString(); + } else { + results = await deploySet(live, deployer, plan, { log, gasLimitPct: chain.gasLimitPct }); + } const smoke = V2 ? await readOnlyV2(live, plan, roles) : await readOnly(live, plan, deployer.address); log(`smoke: ${smoke.length} checks passed`); const verification = {}; @@ -175,7 +233,7 @@ async function main() { const record = { chain: chain.name, chainId: chain.chainId, deployer: deployer.address, operator: V2 ? roles.operator : plan[0].args[0], ...(V2 ? { owner: roles.owner } : {}), setVersion: SETUSED.version, compiler: { solc: plan[0].compiled.compilerVersion, settings: { optimizer: SETTINGS.optimizer, evmVersion: SETTINGS.evmVersion } }, - deployedAt: new Date().toISOString(), explorer: chain.explorer, + deployedAt, ...(mode === "record" ? { recordedAt: new Date().toISOString(), recordedFrom: "receipts (--record-only: the broadcast run stopped after the last deploy, before writing this record)" } : {}), explorer: chain.explorer, contracts: Object.fromEntries(results.map(({ rawTx, ...r }) => [r.name, { ...r, verified: verification[r.name] }])), erc8004: await registries(live), smoke, legacy: chain.legacy ?? null, }; diff --git a/deployments/fork-v2/rootstock-testnet.json b/deployments/fork-v2/rootstock-testnet.json new file mode 100644 index 0000000..df592e7 --- /dev/null +++ b/deployments/fork-v2/rootstock-testnet.json @@ -0,0 +1,425 @@ +{ + "chain": "rootstock-testnet", + "chainId": 31, + "mode": "anvil-fork", + "forkedFrom": "https://public-node.testnet.rsk.co", + "forkBlock": 8254073, + "at": "2026-11-03T10:19:26.999Z", + "seconds": 70, + "deployer": "0x33275DDdb0cB5Bc120740e048B030F107F144Ac8", + "startNonce": 0, + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xDC11e47D869e8FA2918A3e5fe09D76e8C11Fe675", + "nonce": 0, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc0000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0x741184119d6336ff6653915e79aef59b83155c91bd5c511ed5041de465d8dde3", + "block": 8254072, + "gasUsed": "1167799", + "effectiveGasPrice": "2000000000", + "l1FeeWei": "0" + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0x083748955576ec1dbdd92b291449b685ea7c4c54866ce5c0a3db622510c17072", + "block": 8254073, + "gasUsed": "920066", + "effectiveGasPrice": "1899329146", + "l1FeeWei": "0" + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0x500A9cE7CADC357C8b477B24e7Bf7D7A7CD3df22", + "nonce": 2, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x93F70a097Ed45889f69759e5F62cDe0B9319E616", + "60000000000000", + "0" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000093f70a097ed45889f69759e5f62cde0b9319e61600000000000000000000000000000000000000000000000000003691d6afc0000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0x167892c30c9a882369436e773ce427e2faea8dabf4334cfd06203f1c5b7974e0", + "block": 8254074, + "gasUsed": "1453356", + "effectiveGasPrice": "1804151382", + "l1FeeWei": "0" + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0xef3450a304FD3B239ea90254370adD900b40C710", + "nonce": 3, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x615cdee757511e6faa4945c6014768e2921d9d6b5be678cb5864aaab01bad988", + "block": 8254075, + "gasUsed": "369071", + "effectiveGasPrice": "1727980756", + "l1FeeWei": "0" + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0xd01f1026c372749873EA32BDB017434a05226837", + "nonce": 4, + "args": [ + "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553" + ], + "constructorArgs": "0x0000000000000000000000005c18b53c186d76fcb8c7e4ba4d6b6af79dd38553", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0xc80241868112fd0253d1e7c458e551548e1eff798dc406fe6a0650d1a63ec694", + "block": 8254076, + "gasUsed": "408763", + "effectiveGasPrice": "1642580591", + "l1FeeWei": "0" + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x5C18B53C186d76fcB8c7E4Ba4D6b6aF79dD38553", + "nonce": 5, + "args": [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0xd01f1026c372749873EA32BDB017434a05226837", + "86400", + "60000000000000", + "0" + ], + "constructorArgs": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000d01f1026c372749873ea32bdb017434a05226837000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000003691d6afc0000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0x52f26106d4b939050ed2cf11155ef6d3752f2b106097578d11630e0afdd19e60", + "block": 8254077, + "gasUsed": "1930207", + "effectiveGasPrice": "1567730167", + "l1FeeWei": "0" + } + }, + "totals": { + "gasUsed": "6249262", + "liveGasPriceWei": "6810232", + "l1FeeWei": "0", + "l1FeeMethod": "none (execution gas only)", + "costWei": "42558924048784", + "cost": "0.000042558924048784", + "native": "tRBTC" + }, + "smoke": [ + "PredgeAgentValidatorV2 runtime == compiled", + "AgentJobV2 runtime == compiled", + "PredgeValidatorBondV2 runtime == compiled", + "PredgeSettlementV2 runtime == compiled", + "MockRefundProtocolV2 runtime == compiled", + "PredgeRefundArbiterV2 runtime == compiled", + "registry.owner == owner (not the deployer)", + "registry.pendingOwner == 0", + "bond.owner == owner (not the deployer)", + "bond.pendingOwner == 0", + "settlement.owner == owner (not the deployer)", + "settlement.pendingOwner == 0", + "arbiter.owner == owner (not the deployer)", + "arbiter.pendingOwner == 0", + "registry.operator == operator", + "bond.operator == operator", + "arbiter.operator == operator", + "no contract has a renounceOwnership", + "registry.identityRegistry", + "job.evaluationWindow", + "job.jobCount == 0", + "bond.job == AgentJobV2", + "bond params", + "bond.verdictFor(no job) == 0", + "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "arbiter.refundProtocol == mock", + "mock.arbiter == arbiter", + "arbiter params", + "registry.requestHashFor is domain separated", + "bond.specHashFor is domain separated" + ], + "e2e": [ + { + "step": "registry.validationRequest", + "gasUsed": 123282 + }, + { + "step": "job.createJob #1", + "gasUsed": 203597 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 165934 + }, + { + "step": "job.submit (bytes, sha256 on-chain)", + "gasUsed": 55445 + }, + { + "step": "bond.recordScore(100) completes the job", + "gasUsed": 221752 + }, + { + "step": "registry.validationResponse", + "gasUsed": 132370 + }, + { + "step": "job.withdraw (provider pulls payout)", + "gasUsed": 32248 + }, + { + "step": "settlement.payForRoute", + "gasUsed": 25567 + }, + { + "step": "settlement.withdraw (owner)", + "gasUsed": 32279 + }, + { + "step": "job.createJob #2", + "gasUsed": 186497 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 165922 + }, + { + "step": "job.createJob #3", + "gasUsed": 169397 + }, + { + "step": "job.submit (j3, wrong output)", + "gasUsed": 54547 + }, + { + "step": "job.createJob #4", + "gasUsed": 169397 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 148834 + }, + { + "step": "job.submit (j4, provider submits the public commitment)", + "gasUsed": 54547 + }, + { + "step": "bond.recordScore(0) honest fail", + "gasUsed": 197054 + }, + { + "step": "job.createJob #5", + "gasUsed": 169385 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 148822 + }, + { + "step": "job.submit (j5, wrong output bytes)", + "gasUsed": 55205 + }, + { + "step": "bond.recordScore(0) on wrong bytes", + "gasUsed": 162854 + }, + { + "step": "job.createJob #7", + "gasUsed": 169397 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 148822 + }, + { + "step": "job.submit (j7, leaked expected, no bytes)", + "gasUsed": 54547 + }, + { + "step": "bond.recordScore(0) j7", + "gasUsed": 162854 + }, + { + "step": "job.createJob #8", + "gasUsed": 169397 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 148834 + }, + { + "step": "job.submit (j8, correct bytes)", + "gasUsed": 55445 + }, + { + "step": "bond.recordScore(100) j8", + "gasUsed": 180040 + }, + { + "step": "job.createJob #6", + "gasUsed": 169397 + }, + { + "step": "bond.stakeAndCommit", + "gasUsed": 148834 + }, + { + "step": "job.submit (j6, bytes, operator stays silent)", + "gasUsed": 55445 + }, + { + "step": "job.claimRefund (expired, never submitted)", + "gasUsed": 47142 + }, + { + "step": "bond.release (no verdict was given)", + "gasUsed": 69812 + }, + { + "step": "job.claimRefund (j6, silent evaluator)", + "gasUsed": 47292 + }, + { + "step": "bond.release (j6, forfeited to the provider)", + "gasUsed": 81906 + }, + { + "step": "bond.withdraw (provider gets the forfeited bond)", + "gasUsed": 35571 + }, + { + "step": "bond.withdraw (staker)", + "gasUsed": 32297 + }, + { + "step": "mock.recordPayment", + "gasUsed": 138491 + }, + { + "step": "arbiter.fileEvidence (payer)", + "gasUsed": 60606 + }, + { + "step": "arbiter.rule(Refund)", + "gasUsed": 250666 + }, + { + "step": "arbiter.dispute (harmed payee)", + "gasUsed": 59516 + }, + { + "step": "arbiter.resolve(Release) overturns", + "gasUsed": 86634 + }, + { + "step": "arbiter.withdraw (payee gets the operator bond)", + "gasUsed": 32316 + }, + { + "step": "mock.recordPayment", + "gasUsed": 121391 + }, + { + "step": "arbiter.fileEvidence (payer, p2)", + "gasUsed": 60606 + }, + { + "step": "arbiter.rule(Refund, p2)", + "gasUsed": 233566 + }, + { + "step": "arbiter.finalize (undisputed, after window)", + "gasUsed": 130353 + }, + { + "step": "arbiter.withdraw (operator bond back)", + "gasUsed": 32316 + } + ], + "attacksBlocked": [ + { + "attack": "Olympix 5.1.1 operator commits a value the client did not fix", + "blockedBy": "CommitmentNotBound" + }, + { + "attack": "H-01 recordScore before the provider submits", + "blockedBy": "NotSubmitted" + }, + { + "attack": "H-01 bond back without a verdict", + "blockedBy": "JobNotExpired" + }, + { + "attack": "H-02 commit after submission", + "blockedBy": "NotOpen" + }, + { + "attack": "H-02 pass on a copied public value", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "false pass on wrong on-chain bytes", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "Olympix 5.1.1 pass on a leaked hash without bytes", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "false fail on correct on-chain bytes", + "blockedBy": "VerdictContradictsEvidence" + }, + { + "attack": "late submit after expiry", + "blockedBy": "PastDeadline" + }, + { + "attack": "Olympix 5.2.2 silence on delivered work is free", + "blockedBy": "bond forfeited to provider" + }, + { + "attack": "H-03 stranger files evidence for someone else's payment", + "blockedBy": "NotAParty" + }, + { + "attack": "H-03 operator cites a non-party", + "blockedBy": "NotAParty" + }, + { + "attack": "H-03 refund does not execute inside rule()", + "blockedBy": "optimistic window" + }, + { + "attack": "H-03 finalize inside the window", + "blockedBy": "WindowOpen" + }, + { + "attack": "non-owner setOperator", + "blockedBy": "NotOwner" + }, + { + "attack": "non-owner transferOwnership", + "blockedBy": "NotOwner" + } + ], + "setVersion": "2026-10-23-v2.1", + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + } +} diff --git a/deployments/v2/rootstock-testnet.json b/deployments/v2/rootstock-testnet.json new file mode 100644 index 0000000..9d87b4c --- /dev/null +++ b/deployments/v2/rootstock-testnet.json @@ -0,0 +1,307 @@ +{ + "chain": "rootstock-testnet", + "chainId": 31, + "deployer": "0x555F7Db71f5b71A36bD6e5EE4dEba5D784f88a2A", + "operator": "0x8A18993b8936F54Bbc6936ba4181Be027acD3528", + "owner": "0x395377325C55683D0861C6541513c0E00C4A1B00", + "setVersion": "2026-10-23-v2.1", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-11-03T10:42:33.000Z", + "recordedAt": "2026-11-03T10:48:38.375Z", + "recordedFrom": "receipts (--record-only: the broadcast run stopped after the last deploy, before writing this record)", + "explorer": "https://explorer.testnet.rootstock.io", + "contracts": { + "PredgeAgentValidatorV2": { + "name": "PredgeAgentValidatorV2", + "address": "0xb7A912662E03eEE453204538D5F67880b079A862", + "nonce": 0, + "args": [ + "0x395377325C55683D0861C6541513c0E00C4A1B00", + "0x8A18993b8936F54Bbc6936ba4181Be027acD3528", + "0x0000000000000000000000000000000000000000" + ], + "constructorArgs": "0x000000000000000000000000395377325c55683d0861c6541513c0e00c4a1b000000000000000000000000008a18993b8936f54bbc6936ba4181be027acd35280000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "188c8c4f4d75ca748cad006a4b30a825d2bc9e9725e42085685000e3ee0232dd", + "txHash": "0xc05972d0c3a97542dfe9810cd2b3e0cbc2484186029b24b781c6f4864b743ddb", + "block": 8254116, + "gasUsed": "1163999", + "effectiveGasPrice": "6812977", + "verified": { + "ok": true, + "via": "https://rootstock-testnet.blockscout.com/api", + "guid": "b7a912662e03eee453204538d5f67880b079a8626ae9bbcf" + } + }, + "AgentJobV2": { + "name": "AgentJobV2", + "address": "0x5D6E0290cBA483CE53c834A1783FE1073915235B", + "nonce": 1, + "args": [ + "259200" + ], + "constructorArgs": "0x000000000000000000000000000000000000000000000000000000000003f480", + "sourceSha256": "c83bd1c9ce4e8643b39246586619b7a12e203653b3f03386de03ab58b58c4a9a", + "txHash": "0xdbbaea9a4694613ea5c6baaf1c207225d43010d8d791e8f605fc0696f93d2ffc", + "block": 8254117, + "gasUsed": "920066", + "effectiveGasPrice": "6809169", + "verified": { + "ok": true, + "via": "https://rootstock-testnet.blockscout.com/api", + "guid": "5d6e0290cba483ce53c834a1783fe1073915235b6ae9bbe9" + } + }, + "PredgeValidatorBondV2": { + "name": "PredgeValidatorBondV2", + "address": "0xDf2cf20B011FC8d5df16070c7c25F806C63935fD", + "nonce": 2, + "args": [ + "0x395377325C55683D0861C6541513c0E00C4A1B00", + "0x8A18993b8936F54Bbc6936ba4181Be027acD3528", + "0x5D6E0290cBA483CE53c834A1783FE1073915235B", + "60000000000000", + "0" + ], + "constructorArgs": "0x000000000000000000000000395377325c55683d0861c6541513c0e00c4a1b000000000000000000000000008a18993b8936f54bbc6936ba4181be027acd35280000000000000000000000005d6e0290cba483ce53c834a1783fe1073915235b00000000000000000000000000000000000000000000000000003691d6afc0000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "0b84858a48a63548f0b889ca451587c6e57c81cf3b239e6521188f58aa8c694d", + "txHash": "0xae5aa0f04ca7951f1c02ef38cfe6bcc10b57d4c036cbcc6e24757d17b9d915ac", + "block": 8254122, + "gasUsed": "1450456", + "effectiveGasPrice": "6809169", + "verified": { + "ok": true, + "via": "https://rootstock-testnet.blockscout.com/api", + "guid": "df2cf20b011fc8d5df16070c7c25f806c63935fd6ae9bbee" + } + }, + "PredgeSettlementV2": { + "name": "PredgeSettlementV2", + "address": "0x9F0cbf53CCEB9D9474b6802Bb496684D976aD977", + "nonce": 3, + "args": [ + "0x395377325C55683D0861C6541513c0E00C4A1B00" + ], + "constructorArgs": "0x000000000000000000000000395377325c55683d0861c6541513c0e00c4a1b00", + "sourceSha256": "fb87e5ef6756eb261c854ccf1f711234a2e657718aecb2e2d116557aa4ab84b7", + "txHash": "0x3035236bcbc0cac6fabadfaea0668d804653cf827d08bed91def216b7b817e9f", + "block": 8254124, + "gasUsed": "367159", + "effectiveGasPrice": "6809169", + "verified": { + "ok": true, + "via": "sourcify", + "note": "already verified" + } + }, + "MockRefundProtocolV2": { + "name": "MockRefundProtocolV2", + "address": "0x3ffBFcFb6D5621E3A43e22adcBC572824Ae452C5", + "nonce": 4, + "args": [ + "0x879D437362022e54d257af68605D53ec52372621" + ], + "constructorArgs": "0x000000000000000000000000879d437362022e54d257af68605d53ec52372621", + "sourceSha256": "e4f170d51e3a526a89bd4c0d1090b1441334fe5a610a0b43fef0dca078d3e66d", + "txHash": "0x8d5b5913a0a88facc014021d5bc39377de5815fc7188bc6b912e462e9dc8d9d1", + "block": 8254125, + "gasUsed": "408763", + "effectiveGasPrice": "6809169", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiterV2": { + "name": "PredgeRefundArbiterV2", + "address": "0x879D437362022e54d257af68605D53ec52372621", + "nonce": 5, + "args": [ + "0x395377325C55683D0861C6541513c0E00C4A1B00", + "0x8A18993b8936F54Bbc6936ba4181Be027acD3528", + "0x3ffBFcFb6D5621E3A43e22adcBC572824Ae452C5", + "86400", + "60000000000000", + "0" + ], + "constructorArgs": "0x000000000000000000000000395377325c55683d0861c6541513c0e00c4a1b000000000000000000000000008a18993b8936f54bbc6936ba4181be027acd35280000000000000000000000003ffbfcfb6d5621e3a43e22adcbc572824ae452c5000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000003691d6afc0000000000000000000000000000000000000000000000000000000000000000000", + "sourceSha256": "b5dc07973805879660216a2047ce1471f6a05abeb49c07466efc77e0c5f56bcf", + "txHash": "0xfef29daaaf2e6cc2a47ba3c0fce009df9caf5279323d59eb0ff7d64eda74a37b", + "block": 8254127, + "gasUsed": "1925407", + "effectiveGasPrice": "6812977", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + }, + "smoke": [ + { + "check": "PredgeAgentValidatorV2 runtime == compiled", + "ok": true, + "detail": "0xb7A912662E03eEE453204538D5F67880b079A862" + }, + { + "check": "AgentJobV2 runtime == compiled", + "ok": true, + "detail": "0x5D6E0290cBA483CE53c834A1783FE1073915235B" + }, + { + "check": "PredgeValidatorBondV2 runtime == compiled", + "ok": true, + "detail": "0xDf2cf20B011FC8d5df16070c7c25F806C63935fD" + }, + { + "check": "PredgeSettlementV2 runtime == compiled", + "ok": true, + "detail": "0x9F0cbf53CCEB9D9474b6802Bb496684D976aD977" + }, + { + "check": "MockRefundProtocolV2 runtime == compiled", + "ok": true, + "detail": "0x3ffBFcFb6D5621E3A43e22adcBC572824Ae452C5" + }, + { + "check": "PredgeRefundArbiterV2 runtime == compiled", + "ok": true, + "detail": "0x879D437362022e54d257af68605D53ec52372621" + }, + { + "check": "registry.owner == owner (not the deployer)", + "ok": true, + "detail": "0x395377325C55683D0861C6541513c0E00C4A1B00" + }, + { + "check": "registry.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == owner (not the deployer)", + "ok": true, + "detail": "0x395377325C55683D0861C6541513c0E00C4A1B00" + }, + { + "check": "bond.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == owner (not the deployer)", + "ok": true, + "detail": "0x395377325C55683D0861C6541513c0E00C4A1B00" + }, + { + "check": "settlement.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.owner == owner (not the deployer)", + "ok": true, + "detail": "0x395377325C55683D0861C6541513c0E00C4A1B00" + }, + { + "check": "arbiter.pendingOwner == 0", + "ok": true, + "detail": "" + }, + { + "check": "registry.operator == operator", + "ok": true, + "detail": "0x8A18993b8936F54Bbc6936ba4181Be027acD3528" + }, + { + "check": "bond.operator == operator", + "ok": true, + "detail": "0x8A18993b8936F54Bbc6936ba4181Be027acD3528" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x8A18993b8936F54Bbc6936ba4181Be027acD3528" + }, + { + "check": "no contract has a renounceOwnership", + "ok": true, + "detail": "" + }, + { + "check": "registry.identityRegistry", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.evaluationWindow", + "ok": true, + "detail": "" + }, + { + "check": "job.jobCount == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond.job == AgentJobV2", + "ok": true, + "detail": "0x5D6E0290cBA483CE53c834A1783FE1073915235B" + }, + { + "check": "bond params", + "ok": true, + "detail": "" + }, + { + "check": "bond.verdictFor(no job) == 0", + "ok": true, + "detail": "" + }, + { + "check": "bond has no challenge() (v2.1: false verdicts are refused, not slashed)", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0x3ffBFcFb6D5621E3A43e22adcBC572824Ae452C5" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0x879D437362022e54d257af68605D53ec52372621" + }, + { + "check": "arbiter params", + "ok": true, + "detail": "" + }, + { + "check": "registry.requestHashFor is domain separated", + "ok": true, + "detail": "" + }, + { + "check": "bond.specHashFor is domain separated", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/lib/chains.mjs b/lib/chains.mjs index 31778ea..c26828c 100644 --- a/lib/chains.mjs +++ b/lib/chains.mjs @@ -63,3 +63,15 @@ export async function waitReceipt(provider, hash, timeoutMs = 180000) { } throw new Error(`no receipt for ${hash} after ${timeoutMs / 1000}s`); } + +/** eth_getCode that waits for the code to appear: a load-balanced or lagging node can answer "0x" for a contract whose + * deploy receipt we already hold (seen on Rootstock testnet). Returns "0x" only if it is still empty after ~`tries`*2s. */ +export async function waitCode(provider, address, tries = 30) { + let code = "0x"; + for (let i = 0; i < tries; i++) { + code = await withRetry("getCode", () => provider.getCode(address)); + if (code.length > 2) return code; + await new Promise((r) => setTimeout(r, 2000)); + } + return code; +} diff --git a/lib/compile.mjs b/lib/compile.mjs index 33b93d3..96f955d 100644 --- a/lib/compile.mjs +++ b/lib/compile.mjs @@ -65,6 +65,10 @@ export function compile(name, file) { /** Runtime code with immutable slots zeroed, so on-chain code can be compared to the compiler's. */ export function maskImmutables(hex, refs) { const b = Buffer.from(hex.replace(/^0x/, ""), "hex"); - for (const list of Object.values(refs)) for (const { start, length } of list) b.fill(0, start, start + length); + for (const list of Object.values(refs)) for (const { start, length } of list) { + // Empty or short code (e.g. an RPC node that has not caught up with the deploy block yet) is a mismatch, not a RangeError. + if (start + length > b.length) return `0x<${b.length}-byte code, shorter than immutable slot ${start}+${length}>`; + b.fill(0, start, start + length); + } return "0x" + b.toString("hex"); } diff --git a/lib/smoke-v2.mjs b/lib/smoke-v2.mjs index aa1a8e1..0619501 100644 --- a/lib/smoke-v2.mjs +++ b/lib/smoke-v2.mjs @@ -3,7 +3,7 @@ // H-03) and the Olympix ones (5.1.1, 5.2.2, false verdicts) which must now revert or be harmless. import { Contract, keccak256, toUtf8Bytes, sha256, AbiCoder, ZeroAddress, ZeroHash } from "ethers"; import { maskImmutables } from "./compile.mjs"; -import { waitReceipt } from "./chains.mjs"; +import { waitReceipt, waitCode } from "./chains.mjs"; const coder = AbiCoder.defaultAbiCoder(); const h = (s) => keccak256(toUtf8Bytes(s)); @@ -15,7 +15,7 @@ export async function readOnlyV2(provider, plan, roles) { const ok = (name, cond, detail) => { checks.push({ check: name, ok: !!cond, detail: String(detail ?? "") }); if (!cond) throw new Error(`v2 smoke failed: ${name} (${detail})`); }; for (const s of plan) { - const code = await provider.getCode(s.address); + const code = await waitCode(provider, s.address); const c = s.compiled; ok(`${s.name} runtime == compiled`, maskImmutables(code, c.immutableReferences) === maskImmutables(c.deployedBytecode, c.immutableReferences), s.address); } diff --git a/lib/smoke.mjs b/lib/smoke.mjs index 329722d..ad57fe3 100644 --- a/lib/smoke.mjs +++ b/lib/smoke.mjs @@ -3,7 +3,7 @@ // every contract through its honest path with throwaway anvil accounts. import { Contract, keccak256, toUtf8Bytes, ZeroHash } from "ethers"; import { compile, maskImmutables } from "./compile.mjs"; -import { waitReceipt } from "./chains.mjs"; +import { waitReceipt, waitCode } from "./chains.mjs"; const at = (plan, name, runner) => new Contract(plan.find((s) => s.name === name).address, compile(name).abi, runner); const h = (s) => keccak256(toUtf8Bytes(s)); @@ -14,7 +14,7 @@ export async function readOnly(provider, plan, deployer) { const op = plan[0].args[0]; for (const s of plan) { - const code = await provider.getCode(s.address); + const code = await waitCode(provider, s.address); const c = s.compiled; ok(`${s.name} runtime == compiled`, maskImmutables(code, c.immutableReferences) === maskImmutables(c.deployedBytecode, c.immutableReferences), s.address); } diff --git a/lib/v2.mjs b/lib/v2.mjs index 8be0674..e2e6125 100644 --- a/lib/v2.mjs +++ b/lib/v2.mjs @@ -1,7 +1,7 @@ // v2 deployment inputs: who the roles are, and the per-chain bond floors. // real runs (dry-run / --broadcast): roles come from config/v2.config.json and MUST be set by the owner. // --fork: throwaway anvil accounts (the public anvil test keys), never touching ~/.predge-deployer or any real key. -import { readFileSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { getAddress, keccak256, toUtf8Bytes, Wallet } from "ethers"; @@ -60,3 +60,50 @@ export function v2KeyFile(path) { if (path.split(/[\\/]/).includes(".predge-deployer")) throw new Error("refusing to use a key from ~/.predge-deployer for v2"); return path; } + +/** Reads KEY=VALUE lines from a testnet env file (refuses one other users can read). Never logs values. */ +function readEnvFile(path) { + const st = statSync(path); + if (st.mode & 0o077) throw new Error(`${path} is readable by others (mode ${(st.mode & 0o777).toString(8)}). chmod 600 it.`); + const out = {}; + for (const line of readFileSync(path, "utf8").split("\n")) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/); + if (m) out[m[1]] = m[2]; + } + return out; +} + +/** The mainnet identities a testnet run must never use: config/v2.config.json roles and the v1 deployer. */ +function mainnetAddresses() { + const c = read("v2.config.json"); + const s = new Set(["deployer", "owner", "operator"].map((k) => c[k]).filter(Boolean).map((a) => getAddress(a))); + try { s.add(getAddress(JSON.parse(readFileSync(`${homedir()}/.predge-deployer/deployer.json`, "utf8")).address)); } catch {} + return s; +} + +/** Testnet chains only: throwaway roles + deployer key from the chain's env file (e.g. .env.rsk-testnet), never v2.config.json. + * Refuses a non-testnet chain and any address that is a mainnet Predge identity. */ +export function testnetSetup(chain, rootDir, provider) { + if (!chain.testnet || !chain.v2?.envFile) throw new Error(`${chain.name} is not a testnet chain with a v2.envFile`); + const path = `${rootDir}${chain.v2.envFile}`; + const env = readEnvFile(path); + const P = chain.v2.envPrefix ?? "RSK_TESTNET_"; + const roles = {}; + for (const k of ["deployer", "owner", "operator"]) { + const v = env[`${P}${k.toUpperCase()}_ADDRESS`]; + if (!v) throw new Error(`${path}: ${P}${k.toUpperCase()}_ADDRESS missing`); + roles[k] = getAddress(v); + if (ANVIL_ADDRS.has(roles[k])) throw new Error(`${path}: ${k} is a public anvil test address`); + } + if (new Set(Object.values(roles)).size !== 3) throw new Error(`${path}: deployer, owner and operator must differ`); + const main = mainnetAddresses(); + for (const [k, a] of Object.entries(roles)) if (main.has(a)) throw new Error(`${path}: ${k} ${a} is a MAINNET Predge identity; testnet keys must be throwaway`); + const loadKey = () => { + const pk = env[`${P}DEPLOYER_PRIVATE_KEY`]; + if (!pk) throw new Error(`${path}: ${P}DEPLOYER_PRIVATE_KEY missing`); + const w = new Wallet(pk, provider); + if (w.address !== roles.deployer) throw new Error(`${path}: deployer key does not match ${P}DEPLOYER_ADDRESS`); + return w; + }; + return { roles, loadKey, path }; +} diff --git a/package.json b/package.json index f8ef35a..a02aaaa 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "v2:test": "FOUNDRY_CONFIG=foundry-v2.toml forge test", "v2:coverage": "FOUNDRY_CONFIG=foundry-v2.toml forge coverage --no-match-coverage \"(test/|foundry-lib|contracts/[A-Za-z]+\\.sol)\"", "v2:fork": "node deploy.mjs --set v2 --fork --chain", - "v2:mutate": "node scripts/mutate-v2.mjs" + "v2:mutate": "node scripts/mutate-v2.mjs", + "v2:rsk-testnet:dry": "node deploy.mjs --set v2 --chain rootstock-testnet", + "v2:rsk-testnet:deploy": "CONFIRM_TESTNET=rootstock-testnet node deploy.mjs --set v2 --chain rootstock-testnet --broadcast" }, "engines": { "node": ">=20" }, "dependencies": {