Status: DRAFT · Author: BNB Chain · Last updated: 2026-04-28
Authoritative design document for APEX v1. It reflects the current on-chain code and is updated whenever behaviour changes. ERC-8183 conformance is tracked separately in
docs/erc-8183-compliance.md.
Three decoupled layers:
AgenticCommerceUpgradeable— ERC-8183 kernel. UUPS-upgradeable. Lightweight: retains the full normative ERC-8183 surface and drops every non-spec feature (meta-transactions, permit, role-based access control, hook whitelist, evaluator fee).EvaluatorRouterUpgradeable— routing layer. UUPS-upgradeable. Acts simultaneously as thejob.evaluatorandjob.hookfor every job registered with it. MaintainsjobId → policyand pulls verdicts from policies on demand.OptimisticPolicy— reference policy. Immutable (non-upgradeable). Default-approve; the client MAYdispute; whitelisted voters MAYvoteReject; the Router's permissionlesssettle()path applies the verdict.
┌────────────────────────────────────────────────────────┐
│ AgenticCommerceUpgradeable (UUPS) │
│ - Full ERC-8183 kernel │
│ - 6 states · 8 core functions · hook support │
│ - Ownable2Step + Pausable │
└───────────────┬────────────────────────────────────────┘
│ job.evaluator == router
│ job.hook == router
│ commerce ──► afterAction(SUBMIT) ──► router
▼
┌────────────────────────────────────────────────────────┐
│ EvaluatorRouterUpgradeable (UUPS) │
│ - IACPHook (submit notification goes through here) │
│ - registerJob(jobId, policy) │
│ - settle(jobId, evidence) → pulls verdict from policy │
│ - Ownable2Step + ERC-7201 namespaced storage │
└───────────────┬────────────────────────────────────────┘
│ router ──► policy.onSubmitted() (once, at submit)
│ router ──► policy.check() (every settle)
▼
┌────────────────────────────────────────────────────────┐
│ IPolicy │
│ - onSubmitted(jobId, deliverable, optParams) │
│ - check(jobId, evidence) → (verdict, reason) │
└───────────────┬────────────────────────────────────────┘
│ implemented by
▼
┌────────────────────────────────────────────────────────┐
│ OptimisticPolicy (immutable) │
│ - Client → dispute(jobId) │
│ - Voter → voteReject(jobId) │
│ - Router → check() → Pending / Approve / Reject │
│ - Admin → addVoter / removeVoter / setQuorum │
│ - Admin → transferAdmin / acceptAdmin (two-step) │
└────────────────────────────────────────────────────────┘
- Commerce is the protocol layer. It stays minimal and compliant so off-chain agents, wallets and indexers can treat it as a standard ERC-8183 node.
- Router is the orchestration layer. It is the only external address
the kernel sees; policies can be swapped behind the Router without the
kernel noticing. It also carries hook duties, so individual policies do
not need to implement
IACPHook. - Policy is the strategy layer. Per-job pluggable. Each policy has its own rules, voter set, window configuration and failure modes.
| Role | Permissions | Typical holder |
|---|---|---|
| Commerce Owner | setPlatformFee (≤ 10%), pause, UUPS upgrade |
Multisig |
| Router Owner | setPolicyWhitelist, setCommerce (paused + drained), pause, UUPS upgrade |
Multisig |
| Policy Admin | addVoter, removeVoter, setQuorum, transferAdmin |
Per-policy |
| Voter | voteReject(jobId) |
Whitelist addr |
| Client | createJob, setBudget, fund, registerJob, dispute |
1 per job |
| Provider | submit, settle (permissionless but usually provider) |
1 per job |
| Anyone | claimRefund (after expiry), router.markExpired (closes the bookkeeping after) |
— |
CommerceOwner
1. Deploy Commerce proxy
commerce.initialize(paymentToken, treasury, commerceOwner)
2. commerce.setPlatformFee(500, treasury) // optional 5% platform fee
RouterOwner
3. Deploy Router proxy
router.initialize(commerce, routerOwner)
PolicyAdmin
4. Deploy OptimisticPolicy(commerce, router, admin, disputeWindow, initialQuorum)
5. policy.addVoter(voter1)
6. policy.addVoter(voter2)
7. policy.addVoter(voter3) // ≥ quorum, more recommended
RouterOwner
8. router.setPolicyWhitelist(policy, true)
The canonical one-shot deploy is
scripts/deploy.ts. Ownership initially lands on the deployer; hand it to a multisig viatransferOwnership/transferAdminas printed by the script.
Day 0 │ Client
│ ├─ commerce.createJob(provider, evaluator = router,
│ │ expiredAt = now + 30d, description,
│ │ hook = router) → jobId
│ ├─ router.registerJob(jobId, policy)
│ ├─ commerce.setBudget(jobId, 100 USDC, "")
│ ├─ USDC.approve(commerce, 100)
│ └─ commerce.fund(jobId, 100, "") [Funded]
│
Day 1 │ Provider
│ └─ commerce.submit(jobId, deliverableHash, optParams)
│ ├─ commerce → router.afterAction(SUBMIT)
│ └─ router → policy.onSubmitted(jobId, deliverable, optParams)
│ └─ submittedAt[jobId] = Day 1 [Submitted]
│
Day 1-4│ Client inspects the deliverable and takes no action
│
Day 4 │ Anyone (typically Provider, to collect payment)
│ └─ router.settle(jobId, "")
│ ├─ policy.check() → (Approve, REASON_APPROVED)
│ └─ commerce.complete(jobId, reason, "") [Completed]
│ ├─ 5 USDC → treasury (platform fee)
│ └─ 95 USDC → Provider ✅
Day 0-1 │ … same as Flow A (createJob → fund → submit)
│ submittedAt[jobId] = Day 1
│
Day 2 │ Client (unhappy with the deliverable)
│ └─ policy.dispute(jobId) [disputed = true]
│
Day 2-4 │ Voter A → policy.voteReject(jobId) rejectVotes = 1
│ Voter B → policy.voteReject(jobId) rejectVotes = 2
│ Voter C → policy.voteReject(jobId) rejectVotes = 3 (= quorum)
│
Day 4 │ Anyone (typically Client)
│ └─ router.settle(jobId, "")
│ ├─ policy.check() → (Reject, REASON_REJECTED)
│ └─ commerce.reject(jobId, reason, "") [Rejected]
│ └─ 100 USDC → Client ✅
Rule 1 (
disputed && rejectVotes ≥ snapshot quorum → Reject) does not requiredisputeWindowto elapse first. Once quorum is reached,settlecan reject immediately. The "snapshot quorum" is the value ofvoteQuorumrecorded at the timedispute()was called — later admin updates do not change the rejection threshold for a dispute already in flight (audit L08).
Day 0-1 │ … same as Flow A (expiredAt = Day 30, disputeWindow = 3d)
Day 2 │ Client disputes
Day 2-5 │ Only 1 voter casts voteReject (quorum = 3, never reached)
Day 5 │ disputeWindow elapses with quorum unreached.
│ policy.check() falls through to the default-approve branch:
│ (Approve, REASON_APPROVED) ← audit H01: silence by
│ voters approves regardless
│ of whether dispute was raised
Day 5-30 │ Race window:
│ Anyone (typically Provider) → router.settle(jobId, "")
│ → commerce.complete(jobId, ...) [Completed]
│ → 95 USDC → Provider ✅ (5% fee → treasury)
│ OR
│ Anyone (typically Client) → commerce.claimRefund(jobId)
│ → 100 USDC → Client ✅ [Expired]
│
│ Whoever moves first wins. Providers MUST settle before
│ expiredAt to collect; otherwise the client can refund.
Rationale for the auto-approve fall-through (audit H01): without it, a zero-cost
dispute()would pin every legitimately submitted job at PENDING forever, letting the client recover the escrow at expiry while the provider receives nothing. Treating voter silence as approval — identical to the undisputed path — restores the optimistic game- theoretic balance: a dispute is only effective when voters back it up within the window.
Day 0 │ Client createJob → setBudget → (not funded yet)
Day 0 │ Client
│ └─ commerce.reject(jobId, reason, "") [Rejected]
│ (Open state client reject; no escrow, no refund)
Day 0 │ Client fund [Funded]
Day 0-30│ Provider never submits
Day 30 │ Anyone → commerce.claimRefund(jobId) [Expired]
│ 100 USDC → Client ✅
│
│ (Router drain bookkeeping, audit L03)
│ Anyone → router.markExpired(jobId)
│ jobInflightCount−−
│ jobPolicy[jobId] = 0
claimRefundis intentionally non-hookable, so the Router cannot observe this exit throughafterAction.markExpiredis the permissionless reconciliation entry that lets the Router-side counter return to zero — required beforesetCommercewill accept a kernel switch (R6 drain SOP).
| Path | Client balance | Provider balance | Duration |
|---|---|---|---|
| A · Happy | −100 | +95 (5% fee) | Min 3 days |
| B · Rejected | 0 | 0 | ≤3 days (prompt voting) |
| C · Disputed → window elapses → 1st-mover race: | |||
| C₁ Provider settles first | −100 | +95 (5% fee) | After disputeWindow |
| C₂ Client refunds first | 0 | 0 | After expiredAt |
| D · Open cancel | 0 | 0 | Immediate |
| E · Never submitted | 0 | 0 | After expiredAt |
-
Inheritance:
Initializable+Ownable2StepUpgradeable+PausableUpgradeable+UUPSUpgradeable+ReentrancyGuardTransient. -
Storage: flat upgradeable layout, 6 slots +
__gap[44]. Fields:paymentToken,platformFeeBP,platformTreasury,jobCounter,mapping(uint256 => Job) jobs,mapping(uint256 => bool) jobHasBudget. Never reorder or remove fields; only append by shrinking__gap. -
ERC-8183 surface (all
MUST+SHOULD):createJob,setProvider,setBudget,fund,submit,complete,reject,claimRefund.setProviderreverts withProviderAlreadySet(audit I05) whenjob.provider != 0. The dedicated error lets clients distinguish "already bound" from a generic status mismatch.setBudgetcallable by client or provider.amount == 0reverts withZeroBudget(audit I02), so the kernel can treatFunded ⇒ budget > 0as a hard invariant.fund(jobId, expectedBudget, optParams)enforcesjob.budget == expectedBudgetas front-running protection.claimRefundis notwhenNotPausedand not hookable — this is the universal escape hatch.- Hook dispatch goes through
HOOK_GAS_LIMIT = 1_000_000and verifiesIACPHookvia ERC-165 atcreateJobtime. createJobrejectshook == address(0)withHookRequired(audit L05) and rejectsexpiredAt > now + MAX_EXPIRY_DURATION(365 days, audit L01) so escrow can never be locked beyond a well-defined horizon.createJobinvokes_afterHookonly —_beforeHookis intentionally absent so a hook cannot veto its own installation (audit I09; documented as Delta 1.3 in the compliance doc).submitrejectsblock.timestamp >= job.expiredAtwithWrongStatus, mirroringfund(audit L02). Late submissions cannot be front-run byclaimRefund. Thedeliverablehash is persisted to theJobstruct in addition to theJobSubmittedevent (audit I05) so on-chain consumers — verifying policies, arbitration contracts, reputation registries — can read it directly viagetJob(jobId)without rebuilding state from logs.
-
Events: aligned with the ERC-8183 standard set. Two deliberate ABI superset deviations:
JobCreatedappends a non-indexedhookaddress.JobFundedappends an indexedprovidertopic so providers caneth_getLogsfor jobs assigned to them without joining againstJobCreated(audit I03). Both are documented indocs/erc-8183-compliance.mdDelta 1.
-
Admin:
setPlatformFee(feeBP, treasury),pause,unpause.setPlatformFeeis hard-capped atMAX_PLATFORM_FEE_BP = 1_000(10%, audit I07) — even a compromised owner cannot route more than 10% of any future settlement to the treasury. The cap is aconstant, so raising it requires a UUPS upgrade. -
Token assumption — deploy contract, not a v2 todo (audit I01): the kernel supports plain ERC-20s only.
fundperforms a singlesafeTransferFrom(client, this, budget)and trusts the post-transfer balance to equalbudget; it does NOT reconcile pre/postbalanceOf. This is intentional v1 scope. The following classes will cause silent escrow drift and revert at settlement (clients still recover viaclaimRefundafterexpiredAt, but providers and treasury cannot collect):- fee-on-transfer / reflection / deflationary tokens,
- rebasing / elastic-supply tokens,
- tokens with mid-lifecycle blocklists or fee toggles,
- any token whose
balanceOf(address)can decrease without an outgoingtransferfromaddress.
Confirming
paymentTokenagainst the token's source is part of the pre-deploy checklist inREADME.md. The runtime warning lives on thepaymentTokenstorage NatSpec and on theinitializeandfundfunction NatSpec, so etherscan / IDE / SDK introspection all surface it. AddingbalanceOf-delta reconciliation infundis out of scope for v1 (BNB Chain stablecoins do not need it; the gas cost is not justified for the typical case). -
Not implemented (intentional):
fundWithPermit, ERC-2771 meta-transactions,AccessControlmulti-role (we useOwnable2Step), hook whitelist,evaluatorFeeBP.
- Inheritance:
Initializable+Ownable2StepUpgradeable+PausableUpgradeable+UUPSUpgradeable+ReentrancyGuardTransientIACPHook.
- Storage: ERC-7201 namespace
apex.router.storage.v1. Fields:commerce,mapping(uint256 => address) jobPolicy,mapping(address => bool) policyWhitelist,uint256 jobInflightCount(audit L03; appended in PR-3, gatessetCommerce). Never change the namespace; only append toRouterStorage. - Pause semantics:
pause()is the emergency brake. It blocksregisterJobandsettle(new jobs and new verdict write-backs).beforeAction/afterActionare not gated by pause — they are invoked synchronously by the kernel on every mutating call, and pausing them would cascade reverts into unrelated kernel flows (e.g. another job'sfund/submit). Clients always keep thecommerce.claimRefundescape (neither pausable nor hookable).- This serves two goals:
- When a Router bug is discovered, admin can freeze all pending verdict write-backs before shipping a UUPS upgrade.
- It enables the "stop new / drain old" SOP in R6 — pause first, then either upgrade in place or deploy a fresh Router.
- This serves two goals:
- Public functions:
registerJob(uint256 jobId, address policy)—whenNotPaused+nonReentrant(audit I06; defence-in-depth — current external surface is aview, but the guard locks in CEI for any future policy upgrade). Caller MUST becommerce.jobs(jobId).client. Job MUST be Open.job.evaluator == address(this)andjob.hook == address(this).policyWhitelist[policy] == true. One-shot:jobPolicy[jobId] == address(0). On success, incrementsjobInflightCount(audit L03).settle(uint256 jobId, bytes calldata evidence)— permissionless,nonReentrant+whenNotPaused. Readspolicy = jobPolicy[jobId], callspolicy.check(jobId, evidence), then:verdict == 1 (Approve)→commerce.complete(jobId, reason, "")verdict == 2 (Reject)→commerce.reject(jobId, reason, "")verdict == 0 (Pending)→ revertNotDecided- any other value → revert
UnknownVerdict(verdict)
markExpired(uint256 jobId)— permissionless,nonReentrant(audit L03). Closes the bookkeeping gap left by the non-hookableclaimRefundpath: readscommerce.getJob(jobId), requiresstatus == Expired, then deletesjobPolicy[jobId]and decrementsjobInflightCount. Required beforesetCommercecan succeed once any routed job has exited viaclaimRefund.beforeAction(jobId, selector, data)—IACPHook. Requiresmsg.sender == commerce. Onfundselector, enforcesjobPolicy[jobId] != 0(prevents funding an unregistered job). Other selectors: noop. Markedview. NotnonReentrant— access control ismsg.sender == commerceand the function sits on the reentrant pathsettle → commerce.complete → router.afterAction.afterAction(jobId, selector, data)—IACPHook. Requiresmsg.sender == commerce. - Onsubmitselector, decodes(bytes32 deliverable, bytes optParams)and forwards both verbatim topolicy.onSubmitted(jobId, deliverable, optParams). The Router does NOT interpretoptParams— it is transported so policies can bind extra commitments (URI, manifest hash, ZK public inputs, ...) without a Router upgrade. - Oncomplete/rejectselectors, whenjobPolicy[jobId] != 0the Router deletes the binding and decrementsjobInflightCount(audit L03; mirrors the bookkeeping driven bymarkExpiredfor theclaimRefundexit). The guard absorbs the legitimate Open-staterejectof a routed job that never made it pastregisterJob—jobPolicyis zero in that case and the counter is left untouched. - Other selectors: noop. - SamenonReentrantrationale asbeforeAction.supportsInterface— declaresIACPHookandIERC165.inflightJobCount() → uint256— view; mirrorsRouterStorage.jobInflightCount.
- Admin:
setPolicyWhitelist(address policy, bool status).setCommerce(address newCommerce)— allowed only while paused AND whilejobInflightCount == 0(audit L03). Migration hatch (see R6 drain SOP).pause()/unpause()—onlyOwner._authorizeUpgrade—onlyOwner.
Reject path under Router (audit L06): the Router is the evaluator for every routed job, so the kernel's "evaluator-only reject in
Funded/Submitted" branch is reachable only throughsettle()returningVERDICT_REJECT. There is no Router-level admin reject — rejections are policy-driven (e.g.OptimisticPolicyrequiresdispute() + quorum). Open-state rejections remain client-driven viacommerce.reject(jobId, …), and the Router observes the terminal transition throughafterAction.
- Inheritance: plain contract, non-upgradeable. No
Pausable, noReentrancyGuard— every state-mutating function writes one mapping slot and emits one event, no external calls. - Constructor:
(commerce_, router_, admin_, disputeWindow_, initialQuorum_). Storescommerce,router,disputeWindowas immutable; seedsadminandvoteQuorum.initialQuorum_ == 0reverts (QuorumZero). - Mutable config:
voteQuorum(admin-updatable). Live updates do not affect in-flight disputes — seedisputeQuorumSnapshotbelow (audit L08). - Per-job state:
mapping(uint256 => uint64) submittedAtmapping(uint256 => bool) disputedmapping(uint256 => uint16) rejectVotesmapping(uint256 => uint16) disputeQuorumSnapshot— quorum threshold captured atdispute()time; used bycheck()/voteReject()so admin updates after the dispute is open cannot move the goalposts (audit L08).mapping(uint256 => mapping(address => bool)) hasVoted
- Voter whitelist:
mapping(address => bool) isVoterwithuint16 activeVoterCount. - Functions:
onSubmitted(jobId, deliverable, optParams)— router-only.submittedAt[jobId]is recorded on first call; a second call reverts (AlreadyInitialised). Reverts withSubmissionTooLatewhenblock.timestamp + disputeWindow > job.expiredAtso the dispute window is structurally guaranteed to fit beforeclaimRefundbecomes callable (audit L07).optParamsis accepted forIPolicycompatibility and intentionally ignored by the optimistic policy (not persisted to storage).dispute(jobId)— readscommerce.getJob(jobId)and requires the caller to bejob.client. Reverts ifsubmittedAt == 0(NotSubmitted), if already disputed (AlreadyDisputed), or if the dispute window has elapsed (OutsideDisputeWindow). Flipsdisputedtotrueand snapshots the currentvoteQuorumintodisputeQuorumSnapshot[jobId](audit L08).voteReject(jobId)— voter-only (NotVoter). Requiresdisputed == true(NotDisputed), first-time voter (AlreadyVoted), the kernel job still inSubmittedstate (WrongJobStatus, audit I04 — voting on a Completed/Rejected/Expired job has no settlement effect and only wastes gas), andblock.timestamp < submittedAt + disputeWindow(OutsideDisputeWindow). The voting window mirrors the dispute window so a voter cannot front-run a pendingsettleafter the window has closed and flip a default-approve verdict into REJECT — enforcing the §4.4 statement that "a dispute is only effective when voters back it up within the window". Records the vote and incrementsrejectVotes[jobId]. EmitsQuorumReachedexactly on the vote that first crossesdisputeQuorumSnapshot[jobId].check(jobId, evidence)— router-only (enforced by caller context);view:submittedAt == 0→(Pending, 0)disputed && rejectVotes ≥ disputeQuorumSnapshot→(Reject, REASON_REJECTED).- Otherwise —
now ≥ submittedAt + disputeWindow→(Approve, REASON_APPROVED). This is also reached for disputed jobs that fail to muster quorum within the window (audit H01); silence by voters auto-approves identically to the undisputed path. - Else →
(Pending, 0).
- Admin:
addVoter(addr)— requires!isVoter[addr]; incrementsactiveVoterCount.removeVoter(addr)— requiresisVoter[addr]; reverts withWouldBreakQuorumifactiveVoterCount - 1 < voteQuorum(invariant:voteQuorum ≤ activeVoterCount).setQuorum(uint16 newQuorum)— reverts if0(QuorumZero) or> activeVoterCount(QuorumOutOfRange). Takes effect on all in-flight jobs immediately.transferAdmin(newAdmin)+acceptAdmin()— two-step transfer, mirrorsOwnable2Stepsemantics.
- Reason codes (public constants):
REASON_APPROVED = keccak256("OPTIMISTIC_APPROVED")REASON_REJECTED = keccak256("OPTIMISTIC_REJECTED")
Not a strict ERC-8183 subset. Internal contract between Router / Policy and the Commerce kernel. Integrating a third-party ERC-8183 kernel requires writing an adapter against this interface.
Declares:
enum JobStatus { Open, Funded, Submitted, Completed, Rejected, Expired }struct Job { ... }— mirrors the kernel storage layout.getJob(uint256) → Job memorycomplete(uint256, bytes32, bytes)reject(uint256, bytes32, bytes)paymentToken() → address
interface IPolicy {
function onSubmitted(uint256 jobId, bytes32 deliverable, bytes calldata optParams) external;
function check(
uint256 jobId,
bytes calldata evidence
) external view returns (uint8 verdict, bytes32 reason);
}Verdict values:
0= Pending (no action; Router reverts withNotDecided).1= Approve (Router callsCommerce.complete).2= Reject (Router callsCommerce.reject).
For a walk-through on authoring a new IPolicy implementation — required
invariants, worked example, deployment + whitelisting flow — see
docs/custom-policy.md.
ERC-8183 normative hook interface. Unchanged from the spec:
interface IACPHook is IERC165 {
function beforeAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
function afterAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
}Spec text: "Hooks SHOULD NOT be upgradeable after a job is created."
The Router is UUPS, and every job registered with it has the Router as its hook. For all active routed jobs, the hook is therefore upgradeable.
This is a SHOULD deviation, not a MUST violation. Every ERC-8183
MUST clause at the kernel layer remains satisfied. We disclose the
deviation in contract NatSpec and in README.md, so strict-compliance
integrators can audit or skip the Router.
Defence-in-depth mitigations (audit I08 — governance is MUST,
not SHOULD):
- Governance — multisig everywhere. Commerce Owner, Router Owner,
and Policy Admin MUST be multisigs (Gnosis Safe or equivalent);
owner-level thresholds MUST be
≥ 3-of-5. Single-key ownership on either proxy is an immediate post-deploy mis-configuration that blocks production rollout. - Timelock between Safe and proxies — non-optional. A
TimelockControllerMUST sit between the multisig and each UUPS proxy. Required delays: 48h for Commerce, 24h for Router. Policy admin ops (addVoter/setQuorum) may stay at 0h since they are not safety-critical, but ownership transfer of the policy admin role itself SHOULD go through the same timelock. - Operational default: never upgrade. Treat the Router as effectively immutable; only ship upgrades for critical bugs, and treat each upgrade as a security incident — incident report, blameless post-mortem, and external review of the upgrade diff.
- Explicit NatSpec disclosure on the Router contract header
already encodes the deviation; the README and
docs/erc-8183-compliance.mdmirror it. Strict-compliance integrators can audit or skip the Router accordingly. - Upgrade review SOP. Every Router upgrade proposal MUST include:
(a) the new impl's git SHA; (b) a testnet-verified etherscan link;
(c) a diff of
beforeAction/afterAction(expected: no behaviour change); (d) explicit multisig sign-off that hook semantics for in-flight jobs are unchanged; (e) confirmation thatjobInflightCountinvariants survive the upgrade (storage append only, no slot collision). - Prefer drain-and-redeploy over upgrade. When a Router defect
needs a structural fix, prefer running the §6 R6 drain SOP and
deploying a fresh
Router2rather than upgrading the existing proxy in place. Upgrades are reserved for fixes that are too urgent for the drain timeline.
A Router hook bug (whether or not introduced by an upgrade) affects every
in-flight routed job: a buggy afterAction breaks submit; a buggy
beforeAction breaks fund.
Mitigations: (a) keep the Router hook surface minimal — only FUND
and SUBMIT have real logic, all other selectors are noop; (b) exhaustive
selector-path tests; (c) Router owner on multisig; (d) clients always have
claimRefund as an escape hatch — it is explicitly not hookable.
If the entire voter set is offline or disengaged, every legitimately
disputed job auto-approves once disputeWindow elapses. Silence is
designed to mean approval but can instead mean absence. This applies
both to undisputed jobs (Flow A) and to disputed jobs whose voters
fail to reach quorum within the window (Flow C₁ in §4.7) — the audit
H01 fix makes the two paths identical so a zero-cost dispute cannot
freeze settlement.
Mitigations: (a) this is an accepted trade-off for the optimistic
design — no on-chain change in v1; (b) run ≥ 3 × voteQuorum voters with
24/7 monitoring to lower absence probability; (c) disputeWindow MUST
fit fully before expiredAt — OptimisticPolicy.onSubmitted enforces
this on every submit (audit L07) so a provider cannot be set up to lose
payment by submitting too close to expiry; (d) v2 may introduce
per-job disputeWindow so clients can opt into mandatory-review jobs.
After a dispute, voteQuorum colluding voters can reject arbitrary jobs,
letting the client claim endless refunds. This is the root trust
assumption of OptimisticPolicy.
Mitigations: (a) curate voters carefully; (b) each voter uses its own multisig; (c) when trust degrades, deploy a fresh policy with a new voter set and un-whitelist the old policy.
IACP is the internal contract between Router/Policy and Commerce; it is
not a strict ERC-8183 subset. The Router cannot plug directly into a
third-party ERC-8183 kernel without an adapter.
Mitigations: Router.setCommerce(newCommerce) is gated by
whenPaused AND jobInflightCount == 0 (audit L03 — see R6),
giving admin a migration hatch once in-flight jobs are demonstrably
drained. A future ACPAdapter contract can bridge any spec-compliant
kernel.
job.evaluator and job.hook are pinned at createJob time with no
override path. If Commerce or Router develops a state bug, in-flight jobs
cannot be moved to a fresh contract.
Mitigation: use the pause switches on both contracts to run a "stop new / drain old" SOP — never attempt in-flight rewrites.
Router drain SOP (audit L03 + L04)
RouterOwner → router.pause()blocks newregisterJobandsettle.beforeAction/afterActionremain unaffected, so other kernel paths are not cascade-reverted. This is the intentionally asymmetric pause semantics flagged by audit L04 — asymmetry exists so a Router bug does not cascade-revert unrelatedfund/submitcalls on the kernel; the universal client escape (commerce.claimRefund) is never pausable nor hookable.- Wait for routed jobs to reach a terminal kernel status. Three
exit paths exist; the Router-side counter
jobInflightCounttracks reconciliation:- Settle (Approve / Reject) —
router.settle(jobId, …)(or a kernel-directcomplete/rejectfor routed jobs that the evaluator handles directly) drivescommerce.complete/commerce.reject, which firesafterAction(complete | reject)and decrementsjobInflightCountautomatically. - claimRefund (kernel-direct refund) — anyone calls
commerce.claimRefund(jobId)afterexpiredAt; the kernel skips hooks (per ERC-8183MUST), so the Router-side counter is not decremented automatically. Anyone then callsrouter.markExpired(jobId)to reconcile bookkeeping. - Open-state cancellation — client calls
commerce.reject(jobId, …)while the job is still Open. The Router observes the terminal transition throughafterAction(reject)and decrements automatically.
- Settle (Approve / Reject) —
- Once
router.inflightJobCount() == 0,router.setCommerce(...)succeeds. Until that point, attempting to repoint the Router at a new kernel revertsHasInflightJobs— escrow on the old kernel can never be orphaned by a hot switch. - Optional: permanently pause or deprecate the old Router after
migration; clients still keep
claimRefundas the universal escape hatch on the kernel.
Commerce drain SOP
CommerceOwner → commerce.pause()blockscreateJob,fund,submit,complete,reject.claimRefundis not gated by pause, so refund remains available.- In-flight jobs past
expiredAtcan refund immediately viaclaimRefund(jobId); jobs still within the window wait forexpiredAt. After every routed job has refunded, run the Router SOP above (markExpiredper job) so the Router can also be migrated cleanly if needed. - Deploy
Commerce2; new jobs flow through it. - Permanently pause the old Commerce; the only live path is
claimRefund, returning escrow to clients.
Intentionally unsupported capabilities
- Rewriting
job.evaluatororjob.hookon an existing job. - Migrating in-flight jobs between Commerce instances (only path is
claimRefundon the old instance and re-create on the new one). - Force-settling an in-flight job stuck in a broken policy (wait for
expiredAtandclaimRefund). Router.setCommercewhile any routed job is still in flight on the current kernel — the kernel switch would orphan their escrow and break theinflightJobCountinvariant.
All fallback paths rely on the same invariant: claimRefund is the
universal escape hatch — never pausable, never hookable, always callable
after expiredAt. Every dead-end path ultimately returns the client's
escrow.
ERC-8183 is still in Draft. Future spec revisions may invalidate parts
of this implementation.
Mitigations: (a) minor drift → UUPS upgrade; (b) moderate drift →
UUPS upgrade + Router interface change; (c) major drift → fresh
deployment with manual migration. docs/erc-8183-compliance.md tracks
the spec version we have reviewed against and is refreshed per the
protocol in CLAUDE.md.
No blocking issues. Revisit in v2:
- Per-job policy configuration (e.g. client-selected
disputeWindow). - Voter staking / slashing.
- Voter incentives (funded from platform fee or an evaluator fee).
- ERC-8004 reputation registry integration.
- Adapter for third-party ERC-8183 kernels.
- "Freeze + drain" admin path for emergency migration.
- ERC-2771 meta-transactions if agent relayers become a requirement.
contracts/AgenticCommerceUpgradeable.solcontracts/EvaluatorRouterUpgradeable.solcontracts/OptimisticPolicy.solcontracts/IACP.solcontracts/IPolicy.solcontracts/IACPHook.solcontracts/ERC1967Proxy.sol(thin wrapper around OZ's proxy for hardhat-viem deploys in tests and scripts)contracts/mocks/MockERC20.solcontracts/mocks/RevertingHook.solcontracts/mocks/NoopHook.solcontracts/mocks/AgenticCommerceV2Mock.solcontracts/mocks/EvaluatorRouterV2Mock.soltest/helpers.tstest/AgenticCommerce.test.tstest/EvaluatorRouter.test.tstest/OptimisticPolicy.test.tstest/Lifecycle.test.tstest/Upgrades.test.tsscripts/deploy.tsscripts/fund-local.tsscripts/addresses.ts
hardhat.config.ts,package.json,.solhint.json,.prettierrc,tsconfig.json,.nvmrc,.env.example,README.md,CLAUDE.md.
bun run compilepasses undersolc 0.8.28 + viaIRwith no warnings.bun test— all cases green.- ERC-8183 conformance:
- Complete 6-state transition matrix.
setBudgetcallable by client or provider.fund(expectedBudget)front-running protection.claimRefundstill callable while paused.claimRefundnever invokes the hook.hook == address(0)path skips hook dispatch entirely.
- OptimisticPolicy path coverage:
- Happy (no dispute, optimistic approve).
- Disputed + quorum → Reject.
- Disputed + quorum not reached → stalemate → Expired.
disputepast window → revert.voteRejectwithout dispute → revert.- Repeated
voteRejectby the same voter → revert. check/onSubmittedcalled by a non-router → revert.
- Router:
registerJobpermission / status / whitelist checks.settlethree-branch dispatch (Pending revert, Approve → complete, Reject → reject, unknown verdict → revert)._authorizeUpgradeis owner-only.setCommerceallowed only while paused.pause()blocks bothregisterJobandsettle;beforeAction/afterActionstill callable so unrelated kernel paths continue.unpause()restores both.
- Policy voter bookkeeping:
addVoterincrementsactiveVoterCount; re-adding reverts.removeVoterdecrements; reverts when it would breakvoteQuorum ≤ activeVoterCount.setQuorumreverts at0or> activeVoterCount.transferAdmin+acceptAdmintwo-step flow.
- Mock UUPS upgrade on Commerce and Router (add a field, ensure old state still reads correctly).
scripts/deploy.tsruns end-to-end onbscTestnetand prints the address block for manual copy-paste intoscripts/addresses.ts.