Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
560e043
feat: make setProvider hookable with optParams and actor in ProviderSet
yongqjn Jun 15, 2026
4e7c551
feat: make setPayoutReceiver hookable with optParams and actor in Pay…
yongqjn Jun 15, 2026
0310b99
feat: make claimRefund hookable with optParams
yongqjn Jun 15, 2026
48b9cc3
feat: fire afterAction hook on createJob with optParams
yongqjn Jun 15, 2026
d2d2a04
feat: add indexed actor to BudgetSet for meta-tx attribution
yongqjn Jun 15, 2026
9deae83
docs: document hookable lifecycle additions and ordering guarantees
yongqjn Jun 15, 2026
9b5b527
fix: canonicalize createJob hook agentId payload and sync eip.md
yongqjn Jun 15, 2026
afb4093
docs: fix residual hookability inconsistencies
yongqjn Jun 15, 2026
fd2167d
docs: document claimRefund blocking-hook rationale; test atomic forward
yongqjn Jun 15, 2026
b252350
docs: expand claimRefund contract-client forwarding rationale
yongqjn Jun 15, 2026
14ca4b7
docs: adopt audit + admin emergency-withdraw trust model for claimRef…
yongqjn Jun 16, 2026
21ca690
Merge remote-tracking branch 'origin/feature/meta-transactions-and-cl…
yongqjn Jun 30, 2026
9845360
docs: sync createJob hook sequence diagram with afterAction behavior
yongqjn Jul 2, 2026
3236045
test: replace RevertingHook mock with SelectiveRevertHook
yongqjn Jul 2, 2026
dfcd5fa
feat: add admin forceRefund as job-scoped break-glass for hook-blocke…
yongqjn Jul 2, 2026
a6c0dea
docs: point break-glass guidance at forceRefund; scope emergencyWithd…
yongqjn Jul 2, 2026
136ddf4
feat: add recipient override to forceRefund
yongqjn Jul 2, 2026
07232e4
docs: revise eip.md wording and add internal cross-links
yongqjn Jul 16, 2026
595e268
docs: fold claim settlement into core functions
yongqjn Jul 16, 2026
dbd3aa8
docs: tighten hook-liveness prose and deduplicate refund-forwarding r…
yongqjn Jul 16, 2026
9f180c3
docs: define claim deliverable semantics in claim-settlement intro
yongqjn Jul 16, 2026
577de19
feat: emit ForceRefunded event for break-glass attribution
yongqjn Jul 16, 2026
6400d78
docs: state gas-exhaustion and no-op-forwarding hook risks explicitly
yongqjn Jul 16, 2026
30b1a88
docs: trim redundant natspec added in review resolution
yongqjn Jul 16, 2026
40462ba
Merge branch 'hookable-lifecycle-cleanup/2-security-review-fixes' int…
yongqjn Jul 16, 2026
3bcdad3
Merge remote-tracking branch 'origin/feature/hookable-lifecycle-clean…
yongqjn Jul 16, 2026
665ed5e
docs: split hook liveness into consideration-per-bullet form
yongqjn Jul 16, 2026
3d44824
docs: sync events with ForceRefunded and trim hook prose
yongqjn Jul 16, 2026
d62e262
docs: fix state-machine drift against reference implementation
yongqjn Jul 16, 2026
c06aef4
docs: align job data types and IDisburser pragma with contracts
yongqjn Jul 16, 2026
b26c82e
docs: mark claimRefund optParams optional and drop dangling 'if provi…
yongqjn Jul 16, 2026
9d3e77c
docs: deduplicate setProvider/fund specs and reframe hook gating
yongqjn Jul 16, 2026
be8fab3
docs: restore upstream gas-limit SHOULD and bind whitelist MUST to ho…
yongqjn Jul 16, 2026
cc93e30
fix: rename Refunded event param client to recipient
yongqjn Jul 16, 2026
fbc2fc8
docs: fix mermaid parse errors from semicolons in diagram text
yongqjn Jul 16, 2026
9ef249f
Merge branch 'hookable-lifecycle-cleanup/1-eip-doc-fixes' into 'featu…
yongqjn Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ contracts/
- **Pending claim resolution** — after expiry, a Funded job with a pending provider claim cannot be force-refunded until the claim is approved, rejected, or withdrawn; if all parties stay idle, escrow remains parked
- **Claim replay guard** — rejected claim hashes stay consumed, so providers must vary `cumulativeAmount`, the deliverable, or `optParams` to refile
- **Authorization extension** — `ERC8183WithAuthorization` uses the base `ERC8183` EIP-712 domain so relayed entrypoints extend the same protocol identity; signers can call `cancelAuthorization` directly to burn one of their own outstanding nonces
- **Hook safety** — `claimRefund` is intentionally not hookable; pending claims must be resolved before refund
- **Hook safety** — `claimRefund` is hookable and intentionally blocking: the refund is paid to `job.client` (which may be a contract) and the `afterAction` hook runs after the transfer so a contract-client can atomically forward funds to the real beneficiary. The trade-off (a reverting hook can block the permissionless refund) is handled by governance: hooks are whitelisted only after an audit that they don't block lifecycle paths, with admin `pause()` + `forceRefund` as the on-chain break-glass — it pays the refund (to the client, or to an admin-chosen recipient when the client is itself an intermediary contract that cannot receive funds without its blocked hook) and expires the job in one step, bypassing hooks, so a rescued job can never be refunded twice (`emergencyWithdraw` is reserved for funds not attributed to any job). Pending claims must still be resolved before refund

See [docs/01-architecture.md](docs/01-architecture.md) for state machine and sequence diagrams.

Expand Down
154 changes: 133 additions & 21 deletions contracts/ERC8183.sol
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,15 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
);
/// @notice Emitted when a provider is assigned to a job
event ProviderSet(
uint256 indexed jobId,
address indexed provider,
uint256 indexed jobId,
address indexed actor,
address indexed provider,
uint256 agentId
);
/// @notice Emitted when the payout receiver for a job is set or updated
event PayoutReceiverSet(
uint256 indexed jobId,
address indexed actor,
address indexed payoutReceiver
);
/// @notice Emitted when onDisbursement is invoked for a receiver that advertises IDisburser
Expand All @@ -131,8 +133,9 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
);
/// @notice Emitted when the provider sets or updates the job budget
event BudgetSet(
uint256 indexed jobId,
address indexed token,
uint256 indexed jobId,
address indexed actor,
address indexed token,
uint256 amount
);
/// @notice Emitted when the client funds the job escrow
Expand Down Expand Up @@ -508,16 +511,18 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
/// @param description Human-readable job description
/// @param hook Hook contract address (address(0) = no hook, must be whitelisted)
/// @param providerAgentId Optional ERC-8004 agent identity for provider
/// @param optParams Hook-specific parameters (passed to the afterAction hook)
/// @return The new job ID
function createJob(
address provider,
address evaluator,
uint48 expiredAt,
string calldata description,
address hook,
uint256 providerAgentId
uint256 providerAgentId,
bytes calldata optParams
) external whenNotPaused nonReentrant returns (uint256) {
return _createJob(msg.sender, provider, evaluator, expiredAt, description, hook, providerAgentId);
return _createJob(msg.sender, provider, evaluator, expiredAt, description, hook, providerAgentId, optParams);
}

function _createJob(
Expand All @@ -527,7 +532,8 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
uint48 expiredAt,
string calldata description,
address hook,
uint256 providerAgentId
uint256 providerAgentId,
bytes calldata optParams
) internal returns (uint256) {
if (client == address(0)) revert ZeroAddress();
if (expiredAt <= block.timestamp + 5 minutes) revert ExpiryTooShort();
Expand Down Expand Up @@ -555,6 +561,7 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
budget: 0,
hook: hook,
paymentToken: address(0),
// A providerless job has no provider to attribute, so its agent id is zero.
providerAgentId: provider != address(0) ? providerAgentId : 0,
description: description,
settledAmount: 0,
Expand All @@ -569,36 +576,78 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
expiredAt,
hook
);

// afterAction only: the hook is now attached and the job exists, so it can
// initialize per-job bookkeeping or revert to reject a job it will not service.
// No beforeAction — there is no attached hook before creation, and attachment
// is already gated by the whitelist + ERC-165 check above.
// The payload reads providerAgentId back from storage so the hook never sees an
// agent id that disagrees with jobs[jobId] (e.g. a providerless job canonicalizes to 0).
_afterHook(
hook,
jobId,
this.createJob.selector,
abi.encode(client, provider, evaluator, expiredAt, hook, jobs[jobId].providerAgentId, optParams)
);

return jobId;
}

/// @notice Provider updates the payout receiver while the job is still Open.
/// Locked once the job is funded.
/// @param jobId The job to update
/// @param payoutReceiver New payout receiver (address(0) = pay provider directly)
function setPayoutReceiver(uint256 jobId, address payoutReceiver) external whenNotPaused nonReentrant {
_setPayoutReceiver(msg.sender, jobId, payoutReceiver);
/// @param optParams Hook-specific parameters (passed to before/after hooks)
function setPayoutReceiver(uint256 jobId, address payoutReceiver, bytes calldata optParams)
external
whenNotPaused
nonReentrant
{
_setPayoutReceiver(msg.sender, jobId, payoutReceiver, optParams);
}

function _setPayoutReceiver(address actor, uint256 jobId, address payoutReceiver) internal {
function _setPayoutReceiver(
address actor,
uint256 jobId,
address payoutReceiver,
bytes calldata optParams
) internal {
Job storage job = jobs[jobId];
if (jobId == 0 || jobId > jobCounter) revert InvalidJob();
if (job.status != JobStatus.Open) revert WrongStatus();
if (block.timestamp >= job.expiredAt) revert WrongStatus();
if (actor != job.provider) revert Unauthorized();
_validatePayoutReceiver(payoutReceiver, job.paymentToken);

bytes memory data = abi.encode(actor, payoutReceiver, optParams);
_beforeHook(job.hook, jobId, this.setPayoutReceiver.selector, data);

job.payoutReceiver = payoutReceiver;
emit PayoutReceiverSet(jobId, payoutReceiver);
emit PayoutReceiverSet(jobId, actor, payoutReceiver);

_afterHook(job.hook, jobId, this.setPayoutReceiver.selector, data);
}

/// @notice Assigns a provider to an Open job that has no provider yet. Client only.
/// @param jobId The job to assign a provider to
/// @param provider_ The provider address
function setProvider(uint256 jobId, address provider_, uint256 agentId) external whenNotPaused nonReentrant {
_setProvider(msg.sender, jobId, provider_, agentId);
/// @param agentId Optional ERC-8004 agent identity for the provider
/// @param optParams Hook-specific parameters (passed to before/after hooks)
function setProvider(uint256 jobId, address provider_, uint256 agentId, bytes calldata optParams)
external
whenNotPaused
nonReentrant
{
_setProvider(msg.sender, jobId, provider_, agentId, optParams);
}

function _setProvider(address actor, uint256 jobId, address provider_, uint256 agentId) internal {
function _setProvider(
address actor,
uint256 jobId,
address provider_,
uint256 agentId,
bytes calldata optParams
) internal {
Job storage job = jobs[jobId];
if (jobId == 0 || jobId > jobCounter) revert InvalidJob();
if (job.status != JobStatus.Open) revert WrongStatus();
Expand All @@ -608,9 +657,15 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
if (provider_ == address(0)) revert ZeroAddress();
if (provider_ == job.client) revert ClientCannotBeProvider();
if (provider_ == job.evaluator) revert ProviderCannotBeEvaluator();

bytes memory data = abi.encode(actor, provider_, agentId, optParams);
_beforeHook(job.hook, jobId, this.setProvider.selector, data);

job.provider = provider_;
job.providerAgentId = agentId;
emit ProviderSet(jobId, provider_, agentId);
emit ProviderSet(jobId, actor, provider_, agentId);

_afterHook(job.hook, jobId, this.setProvider.selector, data);
}

/// @notice Provider sets or updates the job budget. Can be called multiple times while Open and not expired.
Expand Down Expand Up @@ -648,7 +703,7 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable

job.paymentToken = token;
job.budget = amount;
emit BudgetSet(jobId, token, amount);
emit BudgetSet(jobId, actor, token, amount);

_afterHook(job.hook, jobId, this.setBudget.selector, data);
}
Expand Down Expand Up @@ -858,10 +913,63 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable

/// @notice Claims a refund for an expired job. Anyone can call.
/// Transitions Open/Funded/Submitted -> Expired after expiry time.
/// Not hookable; pending claims must still be resolved before refund.
/// Pending claims must still be resolved before refund.
/// @dev Hookable, and intentionally BLOCKING. The refund is paid to `job.client`,
/// which may be a contract standing in for the real beneficiary (e.g. a
/// smart account or router). The `afterAction` callback runs after the refund
/// transfer (see ordering below) so that client contract can atomically
/// forward the funds to the rightful end-client. The callbacks therefore MAY
/// revert to roll the whole refund back — this is required: a failed forward
/// MUST NOT leave funds stranded in the intermediary client contract.
/// Trade-off: this removes the unconditional post-expiry refund guarantee — a
/// buggy/reverting hook could otherwise block the refund. The trust model
/// mitigates this: hooks are admin-whitelisted + ERC-165-checked at attach time
/// and are expected to be audited as part of whitelisting to never block
/// lifecycle paths (i.e. never revert on claimRefund except for a genuine
/// forward failure) and to never derive routing/authorization from the
/// caller-supplied optParams on this permissionless path. Break-glass recovery if
/// a hook still blocks a refund: batchDetachHook for a non-forwarding hook (after
/// which claimRefund proceeds with no callbacks), or admin forceRefund, which
/// refunds and expires the job in one step while bypassing hooks. emergencyWithdraw
/// is reserved for funds NOT attributed to any job (e.g. stray transfers) — using
/// it for a job-tied refund leaves the job Funded and refundable a second time.
/// @param jobId The expired job to claim refund for
function claimRefund(uint256 jobId) external whenNotPaused nonReentrant {
Job storage job = jobs[jobId];
/// @param optParams Hook-specific parameters (passed to before/after hooks)
function claimRefund(uint256 jobId, bytes calldata optParams) external whenNotPaused nonReentrant {
Job storage job = _validateRefundEligibility(jobId);

bytes memory data = abi.encode(msg.sender, optParams);
_beforeHook(job.hook, jobId, this.claimRefund.selector, data);

_expireWithRefund(jobId, job, job.client);

_afterHook(job.hook, jobId, this.claimRefund.selector, data);
}

/// @notice Admin break-glass: refunds and expires a job whose hook blocks claimRefund,
/// bypassing hook callbacks. Requires the contract to be paused.
/// @dev Grants no state-transition power beyond the permissionless path: eligibility is
/// exactly claimRefund's (post-expiry, grace period elapsed for Submitted, pending
/// claims still block) — only the hook calls are skipped. Because the job
/// transitions to Expired atomically with the payout, a rescued job can never be
/// refunded again, which is why this MUST be used instead of emergencyWithdraw for
/// job-tied funds (emergencyWithdraw moves tokens without closing the job's
/// ledger entry).
/// The destination override exists because job.client may itself be an
/// intermediary contract (smart account, router) that cannot receive or forward
/// funds without its — now blocked — hook: paying it would re-strand the refund
/// outside the escrow's reach. This grants no destination power the admin does
/// not already hold via emergencyWithdraw's free-form (token, to, amount).
/// @param jobId The expired job to rescue
/// @param to Refund recipient; address(0) defaults to job.client
function forceRefund(uint256 jobId, address to) external onlyRole(ADMIN_ROLE) whenPaused nonReentrant {
Job storage job = _validateRefundEligibility(jobId);
_expireWithRefund(jobId, job, to == address(0) ? job.client : to);
}

/// @dev Shared eligibility checks for claimRefund/forceRefund.
function _validateRefundEligibility(uint256 jobId) internal view returns (Job storage job) {
job = jobs[jobId];
if (jobId == 0 || jobId > jobCounter) revert InvalidJob();
if (job.status != JobStatus.Open && job.status != JobStatus.Funded && job.status != JobStatus.Submitted)
revert WrongStatus();
Expand All @@ -873,14 +981,18 @@ contract ERC8183 is Initializable, AccessControlUpgradeable, PausableUpgradeable
} else {
if (block.timestamp < job.expiredAt) revert WrongStatus();
}
}

/// @dev Shared state transition for claimRefund/forceRefund: expire the job and pay the
/// unsettled remainder to `recipient` (always job.client on the permissionless path).
function _expireWithRefund(uint256 jobId, Job storage job, address recipient) internal {
JobStatus prev = job.status;
job.status = JobStatus.Expired;

uint256 refundAmount = job.budget - job.settledAmount;
if (refundAmount > 0 && (prev == JobStatus.Funded || prev == JobStatus.Submitted)) {
IERC20(job.paymentToken).safeTransfer(job.client, refundAmount);
emit Refunded(jobId, job.client, refundAmount);
IERC20(job.paymentToken).safeTransfer(recipient, refundAmount);
emit Refunded(jobId, recipient, refundAmount);
}

emit JobExpired(jobId);
Expand Down
29 changes: 22 additions & 7 deletions contracts/ERC8183WithAuthorization.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import "./ERC8183.sol";
/// @notice Adds EIP-712 signed authorization entrypoints to ERC8183.
contract ERC8183WithAuthorization is ERC8183 {
bytes32 public constant CREATE_JOB_AUTHORIZATION_TYPEHASH = keccak256(
"CreateJobAuthorization(address signer,address provider,address evaluator,uint48 expiredAt,bytes32 descriptionHash,address hook,uint256 providerAgentId,uint72 nonce,uint256 deadline)"
"CreateJobAuthorization(address signer,address provider,address evaluator,uint48 expiredAt,bytes32 descriptionHash,address hook,uint256 providerAgentId,bytes32 optParamsHash,uint72 nonce,uint256 deadline)"
);
bytes32 public constant SET_PAYOUT_RECEIVER_AUTHORIZATION_TYPEHASH = keccak256(
"SetPayoutReceiverAuthorization(address signer,uint256 jobId,address payoutReceiver,uint72 nonce,uint256 deadline)"
"SetPayoutReceiverAuthorization(address signer,uint256 jobId,address payoutReceiver,bytes32 optParamsHash,uint72 nonce,uint256 deadline)"
);
bytes32 public constant SET_PROVIDER_AUTHORIZATION_TYPEHASH = keccak256(
"SetProviderAuthorization(address signer,uint256 jobId,address provider,uint256 agentId,uint72 nonce,uint256 deadline)"
"SetProviderAuthorization(address signer,uint256 jobId,address provider,uint256 agentId,bytes32 optParamsHash,uint72 nonce,uint256 deadline)"
);
bytes32 public constant SET_BUDGET_AUTHORIZATION_TYPEHASH = keccak256(
"SetBudgetAuthorization(address signer,uint256 jobId,address token,uint256 amount,bytes32 optParamsHash,uint72 nonce,uint256 deadline)"
Expand Down Expand Up @@ -63,6 +63,7 @@ contract ERC8183WithAuthorization is ERC8183 {
string description;
address hook;
uint256 providerAgentId;
bytes optParams;
}

event AuthorizationUsed(address indexed signer, bytes32 indexed nonce);
Expand Down Expand Up @@ -115,6 +116,7 @@ contract ERC8183WithAuthorization is ERC8183 {
keccak256(bytes(params.description)),
params.hook,
params.providerAgentId,
keccak256(params.optParams),
auth.nonce,
auth.deadline
)
Expand All @@ -128,13 +130,15 @@ contract ERC8183WithAuthorization is ERC8183 {
params.expiredAt,
params.description,
params.hook,
params.providerAgentId
params.providerAgentId,
params.optParams
);
}

function setPayoutReceiverWithAuthorization(
uint256 jobId,
address payoutReceiver,
bytes calldata optParams,
Authorization calldata auth
) external whenNotPaused nonReentrant {
_verifyAuthorization(
Expand All @@ -147,31 +151,42 @@ contract ERC8183WithAuthorization is ERC8183 {
auth.signer,
jobId,
payoutReceiver,
keccak256(optParams),
auth.nonce,
auth.deadline
)
),
auth.sig
);
_setPayoutReceiver(auth.signer, jobId, payoutReceiver);
_setPayoutReceiver(auth.signer, jobId, payoutReceiver, optParams);
}

function setProviderWithAuthorization(
uint256 jobId,
address provider_,
uint256 agentId,
bytes calldata optParams,
Authorization calldata auth
) external whenNotPaused nonReentrant {
_verifyAuthorization(
auth.signer,
auth.nonce,
auth.deadline,
keccak256(
abi.encode(SET_PROVIDER_AUTHORIZATION_TYPEHASH, auth.signer, jobId, provider_, agentId, auth.nonce, auth.deadline)
abi.encode(
SET_PROVIDER_AUTHORIZATION_TYPEHASH,
auth.signer,
jobId,
provider_,
agentId,
keccak256(optParams),
auth.nonce,
auth.deadline
)
),
auth.sig
);
_setProvider(auth.signer, jobId, provider_, agentId);
_setProvider(auth.signer, jobId, provider_, agentId, optParams);
}

function setBudgetWithAuthorization(
Expand Down
Loading