Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
|----------|---------|-------------|
| [BiddingHook.sol](./contracts/hooks/BiddingHook.sol) | A — Simple Policy | Off-chain signed bidding for provider selection. Providers sign bid commitments; the hook verifies the winning signature on-chain via `setProvider`. Zero direct external calls — everything flows through core → hook callbacks. |
| [FundTransferHook.sol](./contracts/hooks/FundTransferHook.sol) | B — Advanced Escrow | Two-phase fund transfer for token conversion/bridging jobs. Client capital flows to provider at `fund`; provider deposits output tokens at `submit`; buyer receives them at `complete`. |
| [UnderwritingHook.sol](./contracts/hooks/UnderwritingHook.sol) | C — Experimental | Underwriting hook shell with immutable underwriting commits, coordinator-gated protection, and an optional hook-linked follow-on close job on the unchanged ACP lifecycle. |

## Building a Hook

Expand Down
20 changes: 11 additions & 9 deletions contracts/BaseACPHook.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import "@openzeppelin/contracts/utils/introspection/ERC165.sol";
* beforeAction/afterAction calls to named virtual functions so hook
* developers only override what they need.
*
* NOT part of the ERC standard this is a helper contract that can be
* NOT part of the ERC standard - this is a helper contract that can be
* updated independently without changing the IACPHook interface.
*
* All virtual functions include an `address caller` parameter because
* AgenticCommerce supports operators, so the actual caller matters.
*
* Data encoding per selector (as produced by AgenticCommerce):
* setBudget : abi.encode(caller, amount, optParams)
* setBudget : abi.encode(caller, token, amount, optParams)
* fund : abi.encode(caller, optParams)
* submit : abi.encode(caller, deliverable, optParams)
* complete : abi.encode(caller, reason, optParams)
Expand Down Expand Up @@ -56,7 +56,7 @@ abstract contract BaseACPHook is ERC165, IACPHook {
// --- Selector constants (avoid repeated keccak at runtime) ----------------
// These match AgenticCommerce function selectors.
bytes4 private constant SEL_SET_BUDGET =
bytes4(keccak256("setBudget(uint256,uint256,bytes)"));
bytes4(keccak256("setBudget(uint256,address,uint256,bytes)"));
bytes4 private constant SEL_FUND =
bytes4(keccak256("fund(uint256,uint256,bytes)"));
bytes4 private constant SEL_SUBMIT =
Expand All @@ -74,9 +74,9 @@ abstract contract BaseACPHook is ERC165, IACPHook {
bytes calldata data
) external override onlyACP {
if (selector == SEL_SET_BUDGET) {
(address caller, uint256 amount, bytes memory optParams) = abi
.decode(data, (address, uint256, bytes));
_preSetBudget(jobId, caller, amount, optParams);
(address caller, address token, uint256 amount, bytes memory optParams) = abi
.decode(data, (address, address, uint256, bytes));
_preSetBudget(jobId, caller, token, amount, optParams);
} else if (selector == SEL_FUND) {
(address caller, bytes memory optParams) = abi.decode(
data,
Expand Down Expand Up @@ -104,9 +104,9 @@ abstract contract BaseACPHook is ERC165, IACPHook {
bytes calldata data
) external override onlyACP {
if (selector == SEL_SET_BUDGET) {
(address caller, uint256 amount, bytes memory optParams) = abi
.decode(data, (address, uint256, bytes));
_postSetBudget(jobId, caller, amount, optParams);
(address caller, address token, uint256 amount, bytes memory optParams) = abi
.decode(data, (address, address, uint256, bytes));
_postSetBudget(jobId, caller, token, amount, optParams);
} else if (selector == SEL_FUND) {
(address caller, bytes memory optParams) = abi.decode(
data,
Expand All @@ -133,12 +133,14 @@ abstract contract BaseACPHook is ERC165, IACPHook {
function _preSetBudget(
uint256 jobId,
address caller,
address token,
uint256 amount,
bytes memory optParams
) internal virtual {}
function _postSetBudget(
uint256 jobId,
address caller,
address token,
uint256 amount,
bytes memory optParams
) internal virtual {}
Expand Down
37 changes: 19 additions & 18 deletions contracts/hooks/BiddingHook.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import "@acp/AgenticCommerce.sol";
* A client wants to hire the cheapest (or best) agent for a job but does not
* know upfront who to assign. Providers bid off-chain by signing a message
* committing to a (jobId, bidAmount) pair. The client collects bids, selects
* the winner, and submits the winning bid's signature via `setProvider`. The
* hook verifies the signature on-chain — proving the provider actually
* the winner, sets the provider, and then submits the winning bid signature
* via `setBudget`. The hook verifies the signature on-chain — proving the provider actually
* committed to that price.
*
* FLOW (all interactions through core contract -> hook callbacks)
Expand All @@ -28,14 +28,13 @@ import "@acp/AgenticCommerce.sol";
* 3. Bidding happens OFF-CHAIN:
* Providers sign: keccak256(abi.encode(chainId, hookAddress, jobId, bidAmount))
* Client collects signed bids and selects the winner.
* 4. setProvider(jobId, winnerAddress, agentId) — no hook, just sets provider.
* 4. setProvider(jobId, winnerAddress, ...) — core sets provider with no hook logic.
* 5. setBudget(jobId, bidAmount, optParams=abi.encode(signature))
* -> _preSetBudget (mode 2): verify deadline passed, recover signer from
* signature, validate signer == provider, store committed bidAmount,
* enforce budget == bidAmount.
* 6. fund(jobId, ...) — _preFund enforces budget == committedAmount (blocks
* → _preSetBudget: verify deadline passed, recover signer from signature,
* validate signer == provider, store committed bidAmount.
* 6. fund(jobId, "") — _preFund enforces budget == committedAmount (blocks
* funding if client skipped step 5).
* 7. Job continues normally: submit -> complete.
* 7. Job continues normally: submit complete.
*
* TRUST MODEL
* -----------
Expand All @@ -60,20 +59,25 @@ contract BiddingHook is BaseACPHook {
error DeadlineMustBeFuture();
error BiddingStillOpen();
error InvalidBidSignature();
error NoBidDeadline();
error BudgetMismatch();
error ProviderNotSet();

constructor(address acpContract_) BaseACPHook(acpContract_) {}

// --- Hook callbacks only (no direct external functions) ---

/// @dev Three modes based on bidding state:
/// @dev Three modes:
/// 1. deadline == 0: initial call, decode deadline from optParams.
/// 2. deadline > 0 && committedAmount == 0: bid verification, decode
/// signature from optParams, verify against provider.
/// 3. committedAmount > 0: enforce budget == committedAmount.
function _preSetBudget(uint256 jobId, address, uint256 amount, bytes memory optParams) internal override {
function _preSetBudget(
uint256 jobId,
address,
address,
uint256 amount,
bytes memory optParams
) internal override {
Bidding storage b = biddings[jobId];

// Mode 3: enforce budget matches the winning bid
Expand All @@ -93,12 +97,11 @@ contract BiddingHook is BaseACPHook {

// Mode 2: verify signed bid and store committedAmount
if (block.timestamp < b.deadline) revert BiddingStillOpen();

address provider = _core().getJob(jobId).provider;
AgenticCommerce.Job memory job = _core().getJob(jobId);
address provider = job.provider;
if (provider == address(0)) revert ProviderNotSet();

bytes memory signature = abi.decode(optParams, (bytes));

bytes32 messageHash = keccak256(abi.encode(block.chainid, address(this), jobId, amount));
bytes32 ethSignedHash = messageHash.toEthSignedMessageHash();
address signer = ECDSA.recover(ethSignedHash, signature);
Expand All @@ -108,15 +111,13 @@ contract BiddingHook is BaseACPHook {
}

/// @dev Block funding if budget hasn't been set to the committed bid amount.
function _preFund(uint256 jobId, address, bytes memory) internal override {
function _preFund(uint256 jobId, address, bytes memory) internal view override {
Bidding storage b = biddings[jobId];
if (b.committedAmount == 0) return; // no bidding for this job
if (_core().getJob(jobId).budget != b.committedAmount) revert BudgetMismatch();
}

// --- Helpers --------------------------------------------------------------

/// @dev Typed accessor for the core contract
// --- Helper --------------------------------------------------------------
function _core() internal view returns (AgenticCommerce) {
return AgenticCommerce(acpContract);
}
Expand Down
14 changes: 6 additions & 8 deletions contracts/hooks/FundTransferHook.sol
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ contract FundTransferHook is BaseACPHook {
// -------------------------------------------------------------------------

/// @dev Store transfer commitment from setBudget optParams.
function _preSetBudget(uint256 jobId, address, uint256, bytes memory optParams) internal override {
function _preSetBudget(uint256 jobId, address, address, uint256, bytes memory optParams) internal override {
if (optParams.length == 0) return;
(address buyer, uint256 transferAmount) = abi.decode(optParams, (address, uint256));
if (buyer == address(0)) revert ZeroAddress();
Expand All @@ -104,11 +104,11 @@ contract FundTransferHook is BaseACPHook {
}

/// @dev Verify client has approved this hook for the committed transferAmount.
function _preFund(uint256 jobId, address, bytes memory) internal override {
function _preFund(uint256 jobId, address, bytes memory) internal view override {
TransferCommitment memory c = commitments[jobId];
if (c.buyer == address(0)) revert CommitmentNotSet();
AgenticCommerce.Job memory job = _core().getJob(jobId);
uint256 allowance = token.allowance(job.client, address(this));
address client = _core().getJob(jobId).client;
uint256 allowance = token.allowance(client, address(this));
if (allowance < c.transferAmount) revert InsufficientAllowance();
}

Expand All @@ -124,9 +124,8 @@ contract FundTransferHook is BaseACPHook {
TransferCommitment storage c = commitments[jobId];
if (c.buyer == address(0)) revert CommitmentNotSet();
if (c.providerDeposited) revert AlreadyDeposited();
AgenticCommerce.Job memory job = _core().getJob(jobId);
c.providerDeposited = true;
token.safeTransferFrom(job.provider, address(this), c.transferAmount);
token.safeTransferFrom(_core().getJob(jobId).provider, address(this), c.transferAmount);
}

/// @dev Release escrowed tokens to buyer after evaluator completes the job.
Expand All @@ -144,9 +143,8 @@ contract FundTransferHook is BaseACPHook {
delete commitments[jobId];
return;
}
AgenticCommerce.Job memory job = _core().getJob(jobId);
delete commitments[jobId];
token.safeTransfer(job.provider, c.transferAmount);
token.safeTransfer(_core().getJob(jobId).provider, c.transferAmount);
}

// -------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions contracts/hooks/IUnderwritingHookView.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./UnderwritingTypes.sol";

interface IUnderwritingHookView {
function getCommit(uint256 jobId) external view returns (UnderwritingTypes.UnderwriteCommit memory);
function jobUnderwriter(uint256 jobId) external view returns (address);
function jobSidecarState(uint256 jobId) external view returns (UnderwritingTypes.SidecarState);
function jobSettlementJobId(uint256 jobId) external view returns (uint256);
function isAwaitingClose(uint256 jobId) external view returns (bool);
function getParentJobId(uint256 closeJobId) external view returns (uint256);
function getActiveCloseJobId(uint256 parentJobId) external view returns (uint256);
}
34 changes: 34 additions & 0 deletions contracts/hooks/UnderwritingCoordinator.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@acp/AgenticCommerce.sol";
import "./UnderwritingHook.sol";
import "./UnderwritingTypes.sol";

contract UnderwritingCoordinator {
error ZeroAddress();
error WrongHook();
error WrongJobStatus();
error InvalidState();

AgenticCommerce public immutable acp;
UnderwritingHook public immutable hook;

event FundingOrchestrated(uint256 indexed jobId, uint256 indexed settlementJobId);

constructor(address acpContract_, address hook_) {
if (acpContract_ == address(0) || hook_ == address(0)) revert ZeroAddress();
acp = AgenticCommerce(acpContract_);
hook = UnderwritingHook(hook_);
}

function orchestrateFunding(uint256 jobId) external {
AgenticCommerce.Job memory job = acp.getJob(jobId);
if (job.hook != address(hook)) revert WrongHook();
if (job.status != AgenticCommerce.JobStatus.Funded) revert WrongJobStatus();
if (hook.jobSidecarState(jobId) != UnderwritingTypes.SidecarState.FeeEscrowed) revert InvalidState();

hook.markProtected(jobId);
emit FundingOrchestrated(jobId, hook.jobSettlementJobId(jobId));
}
}
91 changes: 91 additions & 0 deletions contracts/hooks/UnderwritingEvaluator.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@acp/AgenticCommerce.sol";
import "./IUnderwritingHookView.sol";
import "./UnderwritingTypes.sol";

contract UnderwritingEvaluator is EIP712 {
error ZeroAddress();
error WrongDecisionStatus();
error WrongDecisionState();
error DecisionExpired(uint64 deadline, uint64 currentTimestamp);
error NonceUsed(address underwriter, uint256 nonce);
error InvalidSigner(address expected, address actual);

bytes32 private constant COMPLETE_TYPEHASH =
keccak256("CompleteDecision(uint256 jobId,bytes32 reason,uint64 deadline,uint256 nonce)");
bytes32 private constant REJECT_TYPEHASH =
keccak256("RejectDecision(uint256 jobId,bytes32 reason,uint64 deadline,uint256 nonce)");

AgenticCommerce public immutable acp;
IUnderwritingHookView public immutable hook;

mapping(address underwriter => mapping(uint256 nonce => bool used)) public usedNonces;

constructor(address acpContract_, address hook_) EIP712("Underwriting Evaluator", "1") {
if (acpContract_ == address(0) || hook_ == address(0)) revert ZeroAddress();
acp = AgenticCommerce(acpContract_);
hook = IUnderwritingHookView(hook_);
}

function completeBySig(UnderwritingTypes.CompleteDecision calldata decision, bytes calldata signature) external {
if (block.timestamp > decision.deadline) revert DecisionExpired(decision.deadline, uint64(block.timestamp));

AgenticCommerce.Job memory job = acp.getJob(decision.jobId);
if (job.status != AgenticCommerce.JobStatus.Submitted) revert WrongDecisionStatus();
if (hook.jobSidecarState(decision.jobId) != UnderwritingTypes.SidecarState.EvidenceSubmitted) {
revert WrongDecisionState();
}

_consumeNonceAndVerifySigner(
hook.jobUnderwriter(decision.jobId),
decision.nonce,
_hashTypedDataV4(
keccak256(abi.encode(COMPLETE_TYPEHASH, decision.jobId, decision.reason, decision.deadline, decision.nonce))
),
signature
);

acp.complete(decision.jobId, decision.reason, "");
}

function rejectBySig(UnderwritingTypes.RejectDecision calldata decision, bytes calldata signature) external {
if (block.timestamp > decision.deadline) revert DecisionExpired(decision.deadline, uint64(block.timestamp));

AgenticCommerce.Job memory job = acp.getJob(decision.jobId);
if (job.status != AgenticCommerce.JobStatus.Submitted) revert WrongDecisionStatus();
if (hook.jobSidecarState(decision.jobId) != UnderwritingTypes.SidecarState.EvidenceSubmitted) {
revert WrongDecisionState();
}

_consumeNonceAndVerifySigner(
hook.jobUnderwriter(decision.jobId),
decision.nonce,
_hashTypedDataV4(
keccak256(abi.encode(REJECT_TYPEHASH, decision.jobId, decision.reason, decision.deadline, decision.nonce))
),
signature
);

acp.reject(decision.jobId, decision.reason, "");
}

function _consumeNonceAndVerifySigner(
address expectedUnderwriter,
uint256 nonce,
bytes32 digest,
bytes calldata signature
) internal {
if (usedNonces[expectedUnderwriter][nonce]) revert NonceUsed(expectedUnderwriter, nonce);

address recovered = ECDSA.recover(digest, signature);
if (recovered != expectedUnderwriter || expectedUnderwriter == address(0)) {
revert InvalidSigner(expectedUnderwriter, recovered);
}

usedNonces[expectedUnderwriter][nonce] = true;
}
}
Loading