Brief: ClearPact v2 — Phase 2bis ERC-8183 ABI compliance gap closure

Objective

Close 3 functional gaps identified by independent line-by-line audit of the Phase 1+2 delivery (commit 55ab185) against the engineering brief dated 2026-05-10, plus 1 naming consistency fix identified by the Phase 3 audit (commit 8957d87):

  1. Fix #1 (E1): event-only discoverability of conditionRef is broken.
  2. Fix #2 (E2): global default payment token at initialize is missing.
  3. Fix #3 (E3): reject() lifecycle coverage of Open and Funded states is missing, with symmetric automatic refund on Funded → Rejected AND Submitted → Rejected.
  4. Fix #4 (P8): ClearPactEvaluator.rejectResult() emits the wrong event name (DisputeResolved instead of EvaluatorRejected).

Outcome expected: 3 surgical code changes to ClearPactJob.sol + 1 event rename in ClearPactEvaluator.sol, fully ABI-preserving for the 12 standard ERC-8183 selectors and 12 standard events on the Job contract. No regression on the existing 5/5 closed divergences from Phase 1+2 nor on the Phase 3 thin-wrapper architecture.

⚠️ Test suite impact : the existing Foundry test suite in contracts/test/ClearPactJob.t.sol (60 tests) was built against the Phase 2 non-corrected code (commit 55ab185). Phase 2bis WILL break ~85-90% of the tests mechanically (signature initialize changes from 4 args to 5 args, all setUp() and _createJob/_fundJob/_submitJob helpers ripple) and 1 test logically (test_state_cannotRejectBeforeSubmit becomes contre-pertinent). The agent MUST update the existing test suite as part of this PR, plus add new tests for the 4 fixes. See § « Test suite changes required » below for the full list.

Inputs provided

  1. Authoritative brief: the 2026-05-10 engineering brief — same source of truth as Phase 1+2 — owner can paste again if needed. Architectural Decisions 1-5 remain in force unchanged.
  2. Independent audit triage: line-by-line verdict on Phase 1+2 (note-phase-1-2-erc-8183-triage in the owner wiki). Confirms 5/5 original divergences closed and exposes E1/E2/E3 as residual.
  3. Source-of-truth code locations (in the working copy /c/Users/renau/Documents/ClearPact/clearpact-2/contracts/src/):
    • interfaces/IClearPactJob.sol — ERC-8183 ABI surface (no change expected)
    • interfaces/IACPHook.sol — hook interface (no change expected)
    • interfaces/IClearPactExtensions.sol — ClearPact extensions getters (may need 1 minor addition for E2, see below)
    • ClearPactJob.sol — main implementation (3 targeted edits)

Pre-execution checkpoint

Before writing any code, the agent must confirm the following understanding back to the owner:

  • Fix #1 (E1) modifies the emission point of ClearPactJobMetadata only. The event signature is unchanged: event ClearPactJobMetadata(uint256 indexed jobId, bytes32 conditionRef, string description);. The agent must NOT change the topic0 hash.
  • Fix #2 (E2) adds a parameter to initialize (address paymentToken_) and adds storage (address public paymentToken). Because initialize is called only once via the proxy at deployment, this is a breaking change to the initializer ABI — the owner must coordinate the deployment script accordingly. Storage layout safety: the new variable must be inserted before the uint256[50] private __gap; reserved slot at the end of the storage block, in a position that does NOT shift any existing variable’s storage slot.
  • Fix #3 (E3) does not change any function selector or event signature. It only expands the access control conditions inside the reject() function body and adds symmetric automatic refund on both Funded → Rejected and Submitted → Rejected paths.
  • Fix #4 (P8) is a pure event rename in ClearPactEvaluator.solDisputeResolved → EvaluatorRejected with the same parameter signature. The event topic0 hash WILL change for this event — this is acceptable because it is a ClearPact-specific custom event on the Evaluator contract, NOT a standard ERC-8183 event on the Job contract. No indexer compatibility impact.
  • None of the 4 fixes touches the 12 standard ERC-8183 selectors or the 12 standard event topic0 hashes on ClearPactJob verified at Phase 2 (table in day-44-summary-ceo-briefing).
  • The existing 60-test Foundry suite in contracts/test/ClearPactJob.t.sol will be modified as part of this PR: setUp() migrated to 5-arg initialize, 1 test reformulated, ~10 new tests added, 2 Evaluator tests enriched. See § « Test suite changes required ».

If any of these statements is unclear or contested, the agent must ask the owner BEFORE implementing.

Gap analysis and required fixes

Fix #1 — E1 — Re-emit ClearPactJobMetadata at setBudget when conditionRef is set
Problem statement

Current behavior in ClearPactJob.sol lines 167-172 (verbatim):

_jobCreatedAt[jobId] = block.timestamp;

// Standard 6-param ERC-8183 event (no description in spec event).
emit JobCreated(jobId, msg.sender, provider, evaluator, expiredAt, hook);
// ClearPact-specific: description + conditionRef (zero until setBudget sets it).
emit ClearPactJobMetadata(jobId, bytes32(0), description);

The conditionRef is later stored at setBudget (lines 214-219, verbatim):

if (optParams.length >= 64) {
    (, bytes32 conditionRef) = abi.decode(optParams[:64], (address, bytes32));
    if (conditionRef != bytes32(0)) {
        _jobConditionRefs[jobId] = conditionRef;
    }
}

But no event is emitted at this point. Consequence: an event-only indexer subscribed to ClearPactJobMetadata will always see conditionRef = 0. The real conditionRef is only retrievable via the RPC call getJobConditionRef(jobId).

Why this matters

The 2026-05-10 brief explicitly justified ClearPactJobMetadata as:

« Émis à createJob. Expose les deux champs ClearPact-significatifs en une seule lecture event-only : conditionRef (hash off-chain, intégrité) et description (texte human-readable pour discoverability). Permet aux indexers de récupérer ces deux champs sans faire d’appel RPC supplémentaire à getJob(). »

The current implementation breaks this promise.

Recommended fix

Emit a second ClearPactJobMetadata event at setBudget when the optParams payload includes a non-zero conditionRef. The event signature stays exactly the same — only the emission frequency changes (1 at createJob with conditionRef=0, then 1 more at setBudget if conditionRef != 0).

Verbatim diff to ClearPactJob.sol:192-222 (replace the existing setBudget body):

function setBudget(
    uint256 jobId,
    uint256 amount,
    bytes calldata optParams
) external override nonReentrant whenNotPaused {
    Job storage job = _jobs[jobId];
    if (job.client == address(0)) revert WrongState();
    if (msg.sender != job.client) revert OnlyClient();
    if (job.status != JobStatus.Open) revert WrongState();
    if (amount == 0) revert BudgetZero();

    job.budget = amount;

    // Decode optional params: (address token, bytes32 conditionRef).
    // Both are optional — partial decoding is allowed; missing fields default to zero.
    if (optParams.length >= 32) {
        address tokenOverride = abi.decode(optParams[:32], (address));
        if (tokenOverride != address(0)) {
            _jobTokens[jobId] = tokenOverride;
            emit ClearPactJobTokenSet(jobId, tokenOverride);
        }
    }
    if (optParams.length >= 64) {
        (, bytes32 conditionRef) = abi.decode(optParams[:64], (address, bytes32));
        if (conditionRef != bytes32(0)) {
            _jobConditionRefs[jobId] = conditionRef;
            // E1 fix: re-emit ClearPactJobMetadata with the real conditionRef so that
            // event-only indexers can pick it up without an RPC enrichment call.
            emit ClearPactJobMetadata(jobId, conditionRef, job.description);
        }
    }

    emit BudgetSet(jobId, amount);
}

Notes:

  • The second ClearPactJobMetadata carries the same description as the one emitted at createJob. Indexers that miss the first event can still reconstitute the full Metadata from the second one alone.
  • If setBudget is called multiple times with different conditionRef values (unlikely but possible while job is Open), the event will be re-emitted each time. Acceptable — indexer keeps the latest.
Acceptance criteria
  • setBudget(jobId, amount, abi.encode(address(0), bytes32(0x123...))) emits ClearPactJobMetadata(jobId, bytes32(0x123...), <description>) in addition to BudgetSet.
  • setBudget(jobId, amount, "") does NOT emit any additional ClearPactJobMetadata.
  • setBudget(jobId, amount, abi.encode(USDC, bytes32(0))) does NOT emit any additional ClearPactJobMetadata (conditionRef is zero).
  • No regression on ClearPactJobTokenSet emission logic.
  • Test coverage for this fix is out of scope of this ticket — it belongs to Phase 3 Foundry test suite (#1552253). The agent must NOT add tests in this ticket. The Phase 3 ticket will be extended to cover the 3 new behaviors of Phase 2bis.

Fix #2 — E2 — Add global default paymentToken to initialize and fallback in _resolveToken
Problem statement

Current initialize signature in ClearPactJob.sol:104-109 (verbatim):

function initialize(
    address admin,
    address treasury_,
    uint256 platformFeeBP_,
    uint256 evaluatorFeeBP_
) external initializer {

No paymentToken_ parameter. The helper _resolveToken at lines 427-430 (verbatim):

function _resolveToken(uint256 jobId) internal view returns (address token) {
    token = _jobTokens[jobId];
    require(token != address(0), "no token configured");
}

Reverts if setBudget was not called with a token override. There is no fallback to a global default token.

Why this matters

The 2026-05-10 brief Decision 5 was explicit:

« Si optParams est vide, le contract utilise le default token global (USDC Base Sepolia, fixé à initialize(paymentToken_, treasury_)). »

The current code requires the caller (typically the ClearPact backend SDK) to always pass an explicit token via setBudget optParams. This works as a backend workaround but contradicts the documented contract behavior. If a third party reads the contract docs and calls setBudget(jobId, amount, "") expecting USDC by default, they will revert.

Recommended fix

Three small changes:

Change 1 — Add paymentToken_ parameter to initialize and store it in a new state variable. Update ClearPactJob.sol:65-77 storage block and :104-125 initializer:

// ─── Storage ────────────────────────────────────────────────────────────

// ... existing storage variables 1-9 unchanged ...

mapping(address => bool) public whitelistedHooks;

// E2 fix: global default payment token. Inserted BEFORE the __gap reserved slot
// so existing storage slots are not shifted (UUPS safety).
address public paymentToken;

/// @dev UUPS upgrade safety gap. 50 slots reserved for future storage.
/// E2 fix: reduced from [50] to [49] to make room for paymentToken without
/// breaking the upgrade story (one slot consumed by paymentToken).
uint256[49] private __gap;

CRITICAL storage layout safety note: the new address public paymentToken variable consumes exactly one storage slot. The __gap array MUST be reduced from [50] to [49] to keep the total storage footprint identical. If the existing contract is already deployed before this fix lands (which is the case at commit 55ab185), the upgrade is binary-safe only if paymentToken is appended at the position immediately preceding __gap. The agent MUST verify with forge inspect ClearPactJob storage-layout before and after the change to confirm no slot of any other variable shifts.

Change 2 — Update initialize signature and grant logic at ClearPactJob.sol:104-125:

function initialize(
    address admin,
    address treasury_,
    address paymentToken_,        // <-- NEW (E2)
    uint256 platformFeeBP_,
    uint256 evaluatorFeeBP_
) external initializer {
    require(admin     != address(0), "admin = zero");
    require(treasury_ != address(0), "treasury = zero");
    require(paymentToken_ != address(0), "paymentToken = zero");   // <-- NEW (E2)
    require(platformFeeBP_ + evaluatorFeeBP_ <= MAX_FEE_BP, "fees exceed 50%");

    __AccessControl_init();
    __Pausable_init();

    _grantRole(DEFAULT_ADMIN_ROLE, admin);
    _grantRole(OPERATOR_ROLE,     admin);
    _grantRole(PAUSER_ROLE,       admin);

    treasury       = treasury_;
    paymentToken   = paymentToken_;            // <-- NEW (E2)
    platformFeeBP  = platformFeeBP_;
    evaluatorFeeBP = evaluatorFeeBP_;
}

Change 3 — Update _resolveToken at ClearPactJob.sol:427-430 to fall back to the global default:

/// @dev Resolve the token for a job: per-job override or global default.
///      Reverts only if neither the per-job override nor the global default is set.
function _resolveToken(uint256 jobId) internal view returns (address token) {
    token = _jobTokens[jobId];
    if (token == address(0)) {
        token = paymentToken;  // E2 fix: fallback to global default
    }
    require(token != address(0), "no token configured");
}
Acceptance criteria
  • initialize now requires address paymentToken_ as the 3rd parameter; reverts on address(0).
  • setBudget(jobId, amount, "") (empty optParams) → _resolveToken returns paymentToken (default), no revert.
  • setBudget(jobId, amount, abi.encode(customToken)) → _resolveToken returns customToken (per-job override wins).
  • forge inspect ClearPactJob storage-layout before and after Fix #2: no slot of any existing variable shifts. paymentToken occupies the slot immediately preceding __gap__gap length is 49.
  • Deploy script (Phase 4) updated to pass paymentToken_ = USDC Base Sepolia address.
  • Test coverage for this fix is out of scope of this ticket — it belongs to Phase 3 Foundry test suite (#1552253). The agent must NOT add tests in this ticket.

Fix #3 — E3 — Expand reject() access control to cover Open (client) and Funded (evaluator)
Problem statement

Current reject() in ClearPactJob.sol:314-332 (verbatim):

function reject(
    uint256 jobId,
    bytes32 reason,
    bytes calldata optParams
) external override nonReentrant whenNotPaused {
    Job storage job = _jobs[jobId];
    if (job.client == address(0)) revert WrongState();
    if (msg.sender != job.evaluator && msg.sender != job.client) revert OnlyEvaluator();
    if (job.status != JobStatus.Submitted) revert WrongState();
    // ... hook + transition + event ...
}

The state check job.status != JobStatus.Submitted makes reject() callable only when the job is in Submitted state. The 2026-05-10 brief access control table specified three reject paths:

reject fromCallerReason
Openclient onlyClient cancels before funding
Fundedevaluator onlyEvaluator pre-emptively rejects funded job (provider non-performance, etc.)
Submittedevaluator only (brief) — or client (current code, accepted as E5)Evaluator rejects the submission

Current code blocks the first two paths. Consequence for the Funded path: a job that is funded and whose provider never delivers can only be unblocked by claimRefund AFTER expiredAt. For long expiries (weeks/months), the client is stuck.

Why this matters

The brief was precise on the lifecycle coverage. The current implementation creates a UX hole: clients have no fast-path remedy when a provider goes dark on a funded job.

The Open path is less critical (no escrowed funds at stake), but still a divergence from the brief and a usability nicety.

Recommended fix

Rewrite the access control and state check in reject() to cover the 3 paths. Note: this fix is compatible with E5 (which we accept as documented — complete and reject are callable by client OR evaluator at Submitted). For the Funded path, we restrict to evaluator only (as per brief) to preserve the trust model.

Refund policy (owner decision 2026-05-14): symmetric automatic refund in both Funded → Rejected AND Submitted → Rejected paths. The spec EIP-8183 leaves Submitted → Rejected implementation-defined; ClearPact’s chosen policy is to always return escrowed funds to the client on rejection. This guarantees a fast on-chain recovery path without requiring claimRefund + expiredAt and without leaving funds in administrative limbo.

Verbatim diff to ClearPactJob.sol:314-332 (replace the existing reject body, hook positions preserved):

function reject(
    uint256 jobId,
    bytes32 reason,
    bytes calldata optParams
) external override nonReentrant whenNotPaused {
    Job storage job = _jobs[jobId];
    if (job.client == address(0)) revert WrongState();

    JobStatus s = job.status;
    // E3 fix: 3 lifecycle paths supported.
    if (s == JobStatus.Open) {
        // Pre-funding cancel: client only.
        if (msg.sender != job.client) revert OnlyClient();
    } else if (s == JobStatus.Funded) {
        // Pre-submission reject: evaluator only (brief access control).
        if (msg.sender != job.evaluator) revert OnlyEvaluator();
    } else if (s == JobStatus.Submitted) {
        // Post-submission reject: evaluator OR client (E5 accepted decision).
        if (msg.sender != job.evaluator && msg.sender != job.client) revert OnlyEvaluator();
    } else {
        revert WrongState();
    }

    bytes4 sel = IClearPactJob.reject.selector;
    _callBeforeHook(jobId, sel, optParams, job.hook);

    job.status = JobStatus.Rejected;

    // E3 fix: symmetric automatic refund when funds are escrowed.
    // - Open state has no escrowed funds → no transfer.
    // - Funded and Submitted both have escrowed funds → transfer back to client.
    if (s == JobStatus.Funded || s == JobStatus.Submitted) {
        uint256 amount = job.budget;
        address token  = _resolveToken(jobId);
        _jobSettledAt[jobId] = block.timestamp;
        IERC20(token).safeTransfer(job.client, amount);
        emit Refunded(jobId, job.client, amount);
    }

    emit JobRejected(jobId, msg.sender, reason);

    _callAfterHook(jobId, sel, optParams, job.hook);
}
Notes on the symmetric refund policy
  • Spec compliance: EIP-8183 does not mandate refund behavior on Submitted → Rejected; it leaves the policy implementation-defined. ClearPact’s symmetric refund choice is explicitly compatible with the spec.
  • Off-chain dispute resolution: if a client and evaluator want to negotiate an alternative outcome (partial release, custom split) instead of a full refund, that negotiation must happen BEFORE calling reject. Once reject is called, the refund is automatic and final. This is a deliberate design — it prevents funds from being stuck in administrative limbo while preserving an off-chain coordination window pre-call.
  • _jobSettledAt is set on the refund path (Funded or Submitted reject), matching the behavior of complete and claimRefund. This keeps the timestamp accounting consistent across all terminal-state transitions.
  • Reentrancy: the existing nonReentrant modifier on reject already guards the safeTransfer call. No additional reentrancy guard is needed. The refund happens AFTER the state transition (job.status = JobStatus.Rejected;) to prevent re-entry into reject for the same job — even if the token contract is malicious.
Acceptance criteria
  • reject(jobId, reason, optParams) from Open state by client → state transitions to Rejected, no token transfer, JobRejected emitted, no revert.
  • reject(...) from Open state by non-client → reverts with OnlyClient.
  • reject(...) from Funded state by evaluator → state transitions to Rejectedbudget refunded to client in the resolved token, JobRejected AND Refunded events emitted, _jobSettledAt set.
  • reject(...) from Funded state by client (or anyone except evaluator) → reverts with OnlyEvaluator.
  • reject(...) from Submitted state by evaluator or client → state transitions to Rejectedbudget refunded to client (symmetric policy 2026-05-14), JobRejected AND Refunded events emitted, _jobSettledAt set.
  • reject(...) from CompletedRejected, or Expired → reverts with WrongState.
  • Hook beforeAction(jobId, reject.selector, optParams) / afterAction(...) called for ALL three paths (Open, Funded, Submitted) at the existing positions.
  • State transition (job.status = JobStatus.Rejected;) happens BEFORE the safeTransfer call (reentrancy safety).
  • Test coverage for this fix is mandated in this ticket (revised 2026-05-15) — see § « Test suite changes required » below. Update test_state_cannotRejectBeforeSubmit (becomes contre-pertinent) and add new tests for the 3 lifecycle paths + symmetric refund.

Fix #4 — P8 — Rename event DisputeResolved → EvaluatorRejected in ClearPactEvaluator.sol
Problem statement

Current ClearPactEvaluator.sol:46-51, 118:

event DisputeResolved(
    address indexed jobContract,
    uint256 indexed jobId,
    address indexed evaluator,
    bytes32         reason
);
// ...
function rejectResult(...) external {
    // ...
    emit DisputeResolved(jobContract, jobId, msg.sender, reason);
}

The event DisputeResolved is emitted by rejectResult, which is the standard evaluator-side reject path (post-submission). The name suggests an arbitrage/admin override has occurred — incorrect semantics for the standard reject flow.

Why this matters

For audit trail symmetry with EvaluatorApproved, the event should be EvaluatorRejected. Off-chain indexers (8004scan.io, ClearPact internal) will display a more accurate semantic. No functional impact.

Recommended fix

Pure rename. Replace the event declaration and the emit site:

// Old:
event DisputeResolved(address indexed jobContract, uint256 indexed jobId, address indexed evaluator, bytes32 reason);
// New:
event EvaluatorRejected(address indexed jobContract, uint256 indexed jobId, address indexed evaluator, bytes32 reason);

// Old:
emit DisputeResolved(jobContract, jobId, msg.sender, reason);
// New:
emit EvaluatorRejected(jobContract, jobId, msg.sender, reason);
Acceptance criteria
  • Event DisputeResolved is removed from ClearPactEvaluator.sol.
  • Event EvaluatorRejected with identical parameter signature is declared and emitted by rejectResult().
  • Event EvaluatorApproved is unchanged.
  • The event topic0 hash changes for the rejection event (DisputeResolved topic0 deprecated, EvaluatorRejected topic0 active). This is acceptable per Pre-execution checkpoint #4.
  • Update relevant test references (the existing test suite does NOT currently assert this event name — confirm via grep before edit).

Test suite changes required (revised 2026-05-15)

Owner decision 2026-05-15: Phase 2bis transmise comme v3 diff with extension of P3 tests. The Phase 3 Foundry suite at contracts/test/ClearPactJob.t.sol (60 tests) was built against the Phase 2 non-corrected code (commit 55ab185) and will be partially broken by Fix #2 and Fix #3. The agent MUST update the suite as part of this PR.

A. Mechanical migration

setUp() (l. 52-79) and test_zeroFee_providerGetsAll (l. 207-211) both call ClearPactJob.initialize(admin, treasury, FEE_BP, EVAL_BP) with the current 4-arg signature. Migrate to the new 5-arg signature:

// Old:
bytes memory initData = abi.encodeCall(
    ClearPactJob.initialize,
    (admin, treasury, FEE_BP, EVAL_BP)
);
// New (Fix #2):
bytes memory initData = abi.encodeCall(
    ClearPactJob.initialize,
    (admin, treasury, address(token), FEE_BP, EVAL_BP)
);

This single migration fixes ~85-90% of the 60 tests that ripple through setUp() and helpers _createJob / _fundJob / _submitJob.

B. Logical test reformulation (1 test + 1 comment)

test_state_cannotRejectBeforeSubmit (l. 405-411) currently asserts that reject from Funded reverts:

function test_state_cannotRejectBeforeSubmit() public {
    uint256 jobId = _fundJob(address(0));
    vm.prank(evaluator);
    vm.expectRevert(IClearPactExtensions.WrongState.selector);
    job.reject(jobId, keccak256("no"), "");
}

Post-Fix #3 (E3), reject from Funded by evaluator is allowed and triggers symmetric refund. This test becomes contre-pertinent. Replace it with test_reject_fromFunded_byEvaluator_refundsClient (see § C below).

Comment in test_revisionReverts_afterRejected (l. 166-170) currently documents the broken behavior:

« In our contract, reject sets Rejected (no re-submit possible from Rejected). Correct revision model: submit → reject → fund still held; provider can re-submit only if contract allows it. Our impl requires Funded state for submit, so: submit → complete is the acceptance path; reject leaves it Rejected. »

Reformulate post-Fix #3 to reflect the new policy: « reject from Submitted triggers symmetric automatic refund to client (per Phase 2bis Fix #3 owner decision 2026-05-14). Job ends in Rejected state, no re-submit possible, funds are returned. »

C. New tests to add (~10)
Test nameCovers
test_setBudget_emitsClearPactJobMetadata_whenConditionRefSetFix #1 E1 — re-emit event with non-zero conditionRef
test_setBudget_doesNotEmit_whenConditionRefZeroFix #1 E1 — idempotence (no spurious emit)
test_initialize_revertsOnZeroPaymentTokenFix #2 E2 — initialize validation
test_resolveToken_fallbackToGlobal_whenSetBudgetEmptyFix #2 E2 — default token fallback
test_resolveToken_overrideWins_whenSetBudgetWithTokenFix #2 E2 — per-job override wins
test_reject_fromOpen_byClient_successFix #3 E3 — Open path
test_reject_fromOpen_byNonClient_revertsFix #3 E3 — ACL Open
test_reject_fromFunded_byEvaluator_refundsClient_symmetricFix #3 E3 — Funded path + refund
test_reject_fromFunded_byClient_revertsFix #3 E3 — ACL Funded (evaluator only)
test_reject_fromSubmitted_refundsClient_symmetricFix #3 — Submitted path + refund symétrique
test_reject_reentrancy_onFundedRefundFix #3 — reentrancy safety on new refund path
D. Evaluator tests to enrich (2)

test_evaluator_rejectResult (l. 806-826) currently asserts only state transition to Rejected. Post-Fix #3, the symmetric refund applies. Enrich with:

// After existing assertions, add:
assertEq(token.balanceOf(client), clientBalanceBefore + BUDGET, "client refunded symmetric");
// Assert Refunded event was emitted on Job.
// Assert _jobSettledAt set.

New test recommendedtest_evaluator_rejectResult_fromFunded — verify the new Funded path via Evaluator. Requires job.evaluator == address(evaluatorContract) for msg.sender == job.evaluator check post-Fix #3.


Scope and exclusions

In-scope:

  • Fix #1 (E1) — setBudget re-emits ClearPactJobMetadata when conditionRef ≠ 0.
  • Fix #2 (E2) — Add paymentToken_ to initialize + fallback in _resolveToken + storage layout safe.
  • Fix #3 (E3) — Expand reject() access control to cover Open (client) + Funded (evaluator) + Submitted (evaluator or client per E5), with symmetric automatic refund on Funded and Submitted paths.
  • Fix #4 (P8) — Rename event DisputeResolved → EvaluatorRejected in ClearPactEvaluator.sol (revised 2026-05-15 post-audit Phase 3).
  • Test suite update: mechanical migration of setUp() to 5-arg initialize, reformulation of 1 test, addition of ~10 new tests, enrichment of 2 Evaluator tests (revised 2026-05-15).
  • forge inspect storage-layout verification before/after Fix #2.
  • NatSpec updates for setBudget and reject (no signature change).

Out-of-scope (separate decisions or other phases):

  • PausableUpgradeable removal or PAUSER_ROLE rework → owner-side governance decision, separate from this ticket.
  • 2 admin events (PlatformFeeUpdatedEvaluatorFeeUpdated) hors brief → accepted documented (E4/E5), no change.
  • Decision 6 formalization (E5 souplesse complete/reject client) → owner-side, separate.
  • cancel from Open (E6) → covered by Fix #3’s new client-callable Open-reject; no separate cancel function.
  • P7 (terminology approveResult / rejectResult vs brief resolveDispute) → owner decision archi 7ᵉ acted 2026-05-15 ; no code change required.
  • P9 (no ERC-165 check on jobContract in Evaluator) → owner decision multi-tenant open acted 2026-05-15 ; no code change required.
  • P15 (EVALUATOR_ROLE granted to admin at init) → owner decision: document in Phase 4 deploy runbook (renounceRole(EVALUATOR_ROLE, admin) post-init + grant to dedicated evaluator EOAs).
  • Phase 4 deploy + verification → separate.
  • Phase 5 API + SDK + Docs + Webhooks → separate.
  • Sync .sol Polsia → github.com/clearpact/clearpact public repo → owner-side, separate (see entities/clearpact Actions Renaud).

Recommended phasing

StepDescription
1Pre-execution checkpoint: agent confirms understanding (6 statements above) to owner
2Implement Fix #1 in setBudget (smallest change, no storage impact)
3Implement Fix #2: initialize + storage + _resolveToken + storage-layout verification with forge inspect
4Implement Fix #3: reject 3-path access control + symmetric refund (Funded and Submitted)
5Implement Fix #4: rename event DisputeResolved → EvaluatorRejected in ClearPactEvaluator.sol (pure rename)
6Test suite mechanical migrationsetUp() + test_zeroFee_providerGetsAll to 5-arg initialize
7Test suite logical changes: reformulate test_state_cannotRejectBeforeSubmit + update comment in test_revisionReverts_afterRejected + add ~10 new tests + enrich 2 Evaluator tests
8Update IClearPactJob.sol and ClearPactEvaluator.sol NatSpec for new behaviors (no signature change on Job)
9Deliver PR with diff + storage layout snapshot before/after + selectors confirmation + test results (60+ tests passing)

Definition of Done

  • Fix #1: ClearPactJobMetadata emitted twice when conditionRef is set (once at createJob with zero, once at setBudget with real value); single emission when conditionRef is zero.
  • Fix #2: initialize accepts paymentToken__resolveToken falls back to paymentToken when _jobTokens[jobId] == address(0); storage layout binary-identical except for the new paymentToken slot inserted before a shortened __gap[49].
  • Fix #3: reject() supports 3 lifecycle paths with correct access control per path; both Funded → Rejected AND Submitted → Rejected trigger automatic refund + Refunded event (symmetric policy per owner decision 2026-05-14); state transition happens before safeTransfer (reentrancy safety).
  • Fix #4: event DisputeResolved renamed to EvaluatorRejected in ClearPactEvaluator.sol (parameter signature unchanged).
  • Test suite: setUp() migrated to 5-arg initializetest_state_cannotRejectBeforeSubmit replaced by test_reject_fromFunded_byEvaluator_refundsClient_symmetric; ~10 new tests added (see § C); 2 Evaluator tests enriched (see § D); all tests pass (60+ tests total).
  • All 12 standard ERC-8183 function selectors unchanged on ClearPactJob (re-verify with cast sig or forge inspect methods).
  • All 12 standard ERC-8183 event topic0 hashes unchanged on ClearPactJob.
  • forge build clean (no new warnings beyond existing block.timestamp).
  • forge inspect ClearPactJob storage-layout output included in PR description, before/after diff confirming only paymentToken slot added and __gap shortened by 1.
  • NatSpec updated for setBudget and reject in IClearPactJob.sol and/or ClearPactJob.sol (no signature change on Job).
  • NatSpec updated for rejectResult in ClearPactEvaluator.sol to reference the new event name.
  • PR description references this ticket, the audit triages note-phase-1-2-erc-8183-triage and note-phase-3-erc-8183-triage.

Points of vigilance

  • Storage layout (Fix #2): the new paymentToken MUST be inserted at exactly the position preceding __gap to avoid shifting any other variable’s slot. The __gap length MUST be reduced from 50 to 49. If a UUPS upgrade is performed from 55ab185 to the post-Phase-2bis code, the storage layout must remain compatible.
  • Selector / topic0 stability: none of the 3 fixes should change any function selector or any event topic0 hash. Re-verify with forge inspect methods and cast sig-event if needed. Any drift here breaks indexer compatibility (the whole point of Axe 1).
  • Refund logic in Fix #3 (Funded AND Submitted paths): introduces two code paths that move tokens. Both are protected by the existing nonReentrant modifier on reject and use safeTransferThe state transition (job.status = JobStatus.Rejected;) MUST happen BEFORE the safeTransfer call — this is the checks-effects-interactions pattern and a hard requirement for reentrancy safety. Adversarial scenarios will be covered in Phase 3 test suite.
  • setBudget re-emit (Fix #1) is idempotent and indexer-safe: emitting the same ClearPactJobMetadata event multiple times for the same jobId is acceptable; indexers should retain the latest payload.
  • Spec EIP-8183 still in draft: lock on the 2026-05-10 extraction (in concepts/erc-8183 of the owner wiki). Flag any amendment ASAP.
  • Hook gas cap (100 000) and claimRefund non-hookable: unchanged. The agent must NOT touch _callBeforeHook_callAfterHook, or claimRefund for this ticket.
  • No test files: do NOT create or modify any file under contracts/test/. Tests are Phase 3 scope.

Coordination with Renaud (owner)

  • ✅ Pre-existing: brief 2026-05-10 + 5 architectural decisions are in force. No re-validation needed.
  • Pre-execution checkpoint: agent confirms the 4 understanding statements (above) before writing code.
  • Mid-execution check: storage-layout diff from forge inspect must be shown to owner before PR merge (Fix #2 only).
  • Post-execution: PR includes tests output + storage layout diff + selector/topic0 confirmation table.
  • Deploy coordination: when Phase 4 follows, the deploy script must be updated to pass paymentToken_ = USDC Base Sepolia (0x036CbD53842c5426634e7929541eC2318f3dCF7e).

Deliverable format

Pull Request on github.com/Polsia-Inc/clearpact (Polsia managed infra repo). PR title: Phase 2bis ERC-8183: fix E1/E2/E3/P8 residual gaps + test suite extension (setBudget event re-emit, default token, reject access control + symmetric refund, event rename).

PR content:

  • contracts/src/ClearPactJob.sol — 3 surgical edits (Fix #1, #2, #3)
  • contracts/src/ClearPactEvaluator.sol — 1 event rename (Fix #4)
  • contracts/src/interfaces/IClearPactJob.sol — NatSpec updates only (no signature change)
  • contracts/test/ClearPactJob.t.sol — modifiedsetUp() migration + 1 test replaced + ~10 new tests + 2 Evaluator tests enriched
  • contracts/storage-layout-before.txt and contracts/storage-layout-after.txt — output of forge inspect ClearPactJob storage-layout before and after Fix #2
  • contracts/selectors-confirmation.txt — output of forge inspect methods confirming 12 selectors unchanged on Job
  • PR description: copy-paste the 4 acceptance criteria sections + test suite changes summary + reference this ticket