@agenticprimitives/contracts 1.0.0-alpha.11 → 1.0.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AUDIT.md CHANGED
@@ -65,7 +65,8 @@ This package ships the **on-chain enforcement layer** for the agenticprimitives
65
65
 
66
66
  ### 3.2 `agency/DelegationManager.sol` + `enforcers/*`
67
67
 
68
- - **5 enforcers.** AllowedMethods, AllowedTargets, Timestamp, Value, Quorum. All five are **stateless validators**: `beforeHook` / `afterHook` are pure / view, no SSTORE, no events. Invariant locked in `test/EnforcerPauseInvariantR67.t.sol` (R6.7) — see § 3.3.
68
+ - **5 stateless enforcers.** AllowedMethods, AllowedTargets, Timestamp, Value, Quorum. All five are **stateless validators**: `beforeHook` / `afterHook` are pure / view, no SSTORE, no events — safe to call externally. Invariant locked in `test/EnforcerPauseInvariantR67.t.sol` (R6.7) — see § 3.3.
69
+ - **PaymentEnforcer is the STATEFUL exception (EXT-PMT-1, was C-NEW-1).** It SSTOREs budget/nonce and writes a settlement receipt, so it is explicitly NOT safe to call externally. It is bound to an immutable `delegationManager` at construction and both hooks carry `onlyDelegationManager`; a direct caller reverts `NotDelegationManager` and can neither forge a `PaymentSettled` receipt nor grief the victim's budget/nonce. Fail-closed regression: `test/PaymentEnforcer.t.sol::test_EXTPMT1_*`. (Pre-fix, the unbound `beforeHook` let any EOA forge receipts + burn nonces — see `docs/audits/findings.yaml` EXT-PMT-1.)
69
70
  - **EIP-712 Caveat typehash** = `keccak256("Caveat(address enforcer,bytes terms)")` — EXCLUDES `args` (audit F-1 — the inclusion bug caused every caveat-carrying delegation to fail on-chain ERC-1271 redeem AND let a redeemer's chosen args ride inside the delegator's signature). `packages/delegation/src/hash.ts` mirrors this.
70
71
  - **Test posture:** DelegationManager 95.8% lines / 88.2% branches (post-R6 aggregator).
71
72
  - **QuorumEnforcer:** payload-hash binding closed in H7 (`QuorumEnforcer.sol` checks `keccak256(args) == decoded_payload_hash`). R6.9 aggregator confirms 95.5% lines / 100% branches.
@@ -29,8 +29,8 @@
29
29
  "timestampEnforcer": "0x7c452434314A5bf2048f9263aeCd0cC20Fa07965",
30
30
  "universalSignatureValidator": "0xe8F3e531429F44D77dC3A331C18032bfa1550D84",
31
31
  "valueEnforcer": "0x7c4533e0A9fB0B1cb5aa70327b222a80d72d52db",
32
- "paymentEnforcer": "0xAF48273e11435cC2D56f4AFBfD091Abb162458E9",
33
- "paymentReceiptRegistry": "0x366616E265cd3cDE0F042A592C17838fe210D1d4",
32
+ "paymentEnforcer": "0xCB5D2dbfc11e146d568764471516C9DB734D2c60",
33
+ "paymentReceiptRegistry": "0x391Ed4D7538f80a221F910539005bB00b996Ff9F",
34
34
  "mockUsdc": "0x8fb56ff3C13347DFC4E1287aE83E88deE5a7211C",
35
- "paymentEscrow": "0x954Ba6B3A02E02c5a3Fcd570943126633071cbdD"
35
+ "paymentEscrow": "0xC85EB1831e9180F5e2cA7854f362f6C9a6D727EA"
36
36
  }
@@ -2,6 +2,11 @@
2
2
  {
3
3
  "type": "constructor",
4
4
  "inputs": [
5
+ {
6
+ "name": "delegationManager_",
7
+ "type": "address",
8
+ "internalType": "address"
9
+ },
5
10
  {
6
11
  "name": "receiptRegistry_",
7
12
  "type": "address",
@@ -56,7 +61,7 @@
56
61
  }
57
62
  ],
58
63
  "outputs": [],
59
- "stateMutability": "pure"
64
+ "stateMutability": "view"
60
65
  },
61
66
  {
62
67
  "type": "function",
@@ -106,6 +111,19 @@
106
111
  "outputs": [],
107
112
  "stateMutability": "nonpayable"
108
113
  },
114
+ {
115
+ "type": "function",
116
+ "name": "delegationManager",
117
+ "inputs": [],
118
+ "outputs": [
119
+ {
120
+ "name": "",
121
+ "type": "address",
122
+ "internalType": "address"
123
+ }
124
+ ],
125
+ "stateMutability": "view"
126
+ },
109
127
  {
110
128
  "type": "function",
111
129
  "name": "encodeTerms",
@@ -378,6 +396,17 @@
378
396
  }
379
397
  ]
380
398
  },
399
+ {
400
+ "type": "error",
401
+ "name": "NotDelegationManager",
402
+ "inputs": [
403
+ {
404
+ "name": "caller",
405
+ "type": "address",
406
+ "internalType": "address"
407
+ }
408
+ ]
409
+ },
381
410
  {
382
411
  "type": "error",
383
412
  "name": "NotTransferCall",
@@ -46,6 +46,11 @@
46
46
  "type": "function",
47
47
  "name": "getHold",
48
48
  "inputs": [
49
+ {
50
+ "name": "payer",
51
+ "type": "address",
52
+ "internalType": "address"
53
+ },
49
54
  {
50
55
  "name": "orderHash",
51
56
  "type": "bytes32",
@@ -120,6 +125,11 @@
120
125
  "type": "function",
121
126
  "name": "refund",
122
127
  "inputs": [
128
+ {
129
+ "name": "payer",
130
+ "type": "address",
131
+ "internalType": "address"
132
+ },
123
133
  {
124
134
  "name": "orderHash",
125
135
  "type": "bytes32",
@@ -133,6 +143,11 @@
133
143
  "type": "function",
134
144
  "name": "release",
135
145
  "inputs": [
146
+ {
147
+ "name": "payer",
148
+ "type": "address",
149
+ "internalType": "address"
150
+ },
136
151
  {
137
152
  "name": "orderHash",
138
153
  "type": "bytes32",
@@ -146,6 +161,11 @@
146
161
  "type": "function",
147
162
  "name": "statusOf",
148
163
  "inputs": [
164
+ {
165
+ "name": "payer",
166
+ "type": "address",
167
+ "internalType": "address"
168
+ },
149
169
  {
150
170
  "name": "orderHash",
151
171
  "type": "bytes32",
@@ -240,6 +260,11 @@
240
260
  "name": "BadExpiry",
241
261
  "inputs": []
242
262
  },
263
+ {
264
+ "type": "error",
265
+ "name": "Expired",
266
+ "inputs": []
267
+ },
243
268
  {
244
269
  "type": "error",
245
270
  "name": "FailedInnerCall",
@@ -31,9 +31,9 @@ export const CONTRACTS = {
31
31
  "timestampEnforcer": "0x7c452434314A5bf2048f9263aeCd0cC20Fa07965",
32
32
  "universalSignatureValidator": "0xe8F3e531429F44D77dC3A331C18032bfa1550D84",
33
33
  "valueEnforcer": "0x7c4533e0A9fB0B1cb5aa70327b222a80d72d52db",
34
- "paymentEnforcer": "0xAF48273e11435cC2D56f4AFBfD091Abb162458E9",
35
- "paymentReceiptRegistry": "0x366616E265cd3cDE0F042A592C17838fe210D1d4",
34
+ "paymentEnforcer": "0xCB5D2dbfc11e146d568764471516C9DB734D2c60",
35
+ "paymentReceiptRegistry": "0x391Ed4D7538f80a221F910539005bB00b996Ff9F",
36
36
  "mockUsdc": "0x8fb56ff3C13347DFC4E1287aE83E88deE5a7211C",
37
- "paymentEscrow": "0x954Ba6B3A02E02c5a3Fcd570943126633071cbdD"
37
+ "paymentEscrow": "0xC85EB1831e9180F5e2cA7854f362f6C9a6D727EA"
38
38
  };
39
39
  export default CONTRACTS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticprimitives/contracts",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.12",
4
4
  "description": "Solidity contracts + ABIs + flattened sources + per-network deployment addresses for the agenticprimitives stack.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,6 +58,14 @@ contract PaymentEnforcer is ICaveatEnforcer {
58
58
  /// delegator => delegationHash => nonce => consumed (on-chain replay guard)
59
59
  mapping(address => mapping(bytes32 => mapping(bytes32 => bool))) private _nonceUsed;
60
60
 
61
+ /// @notice The ONLY authorized caller of the hooks (EXT-PMT-1 / C-NEW-1). Unlike the stateless
62
+ /// enforcers (timestamp/value/targets/methods), this enforcer MUTATES state (budget, nonce)
63
+ /// and writes a settlement receipt — so it is NOT safe to call externally. A direct caller
64
+ /// that is not the DelegationManager could otherwise forge a `PaymentSettled` receipt and
65
+ /// grief the victim's budget/nonce with no actual transfer. Bound immutably at deploy; the
66
+ /// hooks revert `NotDelegationManager` for any other `msg.sender`.
67
+ address public immutable delegationManager;
68
+
61
69
  /// @notice Trustless receipt sink (PAY-CON-4). Immutable; `address(0)` disables registry receipts
62
70
  /// (the `PaymentCharged` event below is always the primary on-chain receipt).
63
71
  IPaymentReceiptRegistry public immutable receiptRegistry;
@@ -84,8 +92,21 @@ contract PaymentEnforcer is ICaveatEnforcer {
84
92
  error AggregateExceeded(uint256 spent, uint256 maxAggregate, uint256 amount);
85
93
  error FrequencyExceeded(uint32 callsInWindow, uint32 maxRedemptionsPerWindow);
86
94
  error NonceReused(bytes32 nonce);
95
+ error NotDelegationManager(address caller);
96
+
97
+ /// @dev The hooks are state-mutating + receipt-emitting, so ONLY the DelegationManager may invoke
98
+ /// them (EXT-PMT-1). `_runBeforeHooks`/`_runAfterHooks` call the enforcer directly, so during a
99
+ /// real `redeemDelegation` msg.sender IS the DelegationManager. The `verifyAuthorization` view
100
+ /// path staticcalls the hook from the same DelegationManager and still reverts on the SSTORE
101
+ /// under staticcall (yields `false`), exactly as before — the modifier does not change that.
102
+ modifier onlyDelegationManager() {
103
+ if (msg.sender != delegationManager) revert NotDelegationManager(msg.sender);
104
+ _;
105
+ }
87
106
 
88
- constructor(address receiptRegistry_) {
107
+ constructor(address delegationManager_, address receiptRegistry_) {
108
+ require(delegationManager_ != address(0), "PaymentEnforcer: zero DelegationManager");
109
+ delegationManager = delegationManager_;
89
110
  receiptRegistry = IPaymentReceiptRegistry(receiptRegistry_);
90
111
  }
91
112
 
@@ -98,7 +119,7 @@ contract PaymentEnforcer is ICaveatEnforcer {
98
119
  address target,
99
120
  uint256 value,
100
121
  bytes calldata callData
101
- ) external override {
122
+ ) external override onlyDelegationManager {
102
123
  // ── terms (immutable, set at delegation creation) ──
103
124
  if (terms.length != 192) revert InvalidTerms();
104
125
  (
@@ -161,8 +182,9 @@ contract PaymentEnforcer is ICaveatEnforcer {
161
182
 
162
183
  function afterHook(bytes calldata, bytes calldata, bytes32, address, address, address, uint256, bytes calldata)
163
184
  external
164
- pure
185
+ view
165
186
  override
187
+ onlyDelegationManager
166
188
  {}
167
189
 
168
190
  // ── views (off-chain budget display + idempotency checks) ──
@@ -42,7 +42,10 @@ contract PaymentEscrow is ReentrancyGuard {
42
42
  Status status;
43
43
  }
44
44
 
45
- mapping(bytes32 orderHash => Hold) private _holds;
45
+ // EXT-PMT-3: holds are keyed by (payer, orderHash), NOT orderHash alone — so a stranger cannot
46
+ // squat a predictable orderHash to block the real payer's deposit (HoldExists griefing). Each payer
47
+ // has an independent namespace for any given orderHash.
48
+ mapping(bytes32 holdKey => Hold) private _holds;
46
49
 
47
50
  event EscrowDeposited(bytes32 indexed orderHash);
48
51
  event EscrowReleased(bytes32 indexed orderHash);
@@ -57,6 +60,13 @@ contract PaymentEscrow is ReentrancyGuard {
57
60
  error BadExpiry();
58
61
  error NotAuthorized();
59
62
  error NotYetExpired();
63
+ error Expired();
64
+
65
+ /// @dev The (payer, orderHash) namespace key. Terminal calls by the payee/releaser pass `payer`;
66
+ /// the payer's own `reclaim`/`deposit` derive it from `msg.sender`.
67
+ function _key(address payer, bytes32 orderHash) private pure returns (bytes32) {
68
+ return keccak256(abi.encode(payer, orderHash));
69
+ }
60
70
 
61
71
  /**
62
72
  * @notice Fund a hold for `orderHash`. Pulls `amount` of `asset` from the caller (the payer).
@@ -72,15 +82,30 @@ contract PaymentEscrow is ReentrancyGuard {
72
82
  address releaser,
73
83
  uint64 expiry
74
84
  ) external nonReentrant {
75
- if (_holds[orderHash].status != Status.None) revert HoldExists();
85
+ bytes32 key = _key(msg.sender, orderHash);
86
+ if (_holds[key].status != Status.None) revert HoldExists();
76
87
  if (amount == 0) revert ZeroAmount();
77
88
  if (asset == address(0) || payee == address(0)) revert ZeroAddress();
78
89
  if (expiry <= block.timestamp) revert BadExpiry();
79
90
 
80
- _holds[orderHash] = Hold({
91
+ // EXT-PMT-3: credit the REALIZED amount received (balance delta), not the requested `amount`. A
92
+ // fee-on-transfer / rebasing asset delivers less than `amount`, which would otherwise over-credit
93
+ // the hold and let it draw from other holds' funds in the shared pool (cross-hold drain). The
94
+ // external transfer happens before the storage write, but nonReentrant + the status check above
95
+ // keep it safe (a reentrant deposit is blocked; a duplicate (payer,orderHash) reverts HoldExists).
96
+ uint256 beforeBal = IERC20(asset).balanceOf(address(this));
97
+ // Triaged false positive: `deposit` is `nonReentrant`, so the balanceOf delta cannot be
98
+ // manipulated by re-entering between the two reads — the guard blocks any callback. The
99
+ // before/after balance read is the EXT-PMT-3 realized-amount fix for fee-on-transfer assets.
100
+ // slither-disable-next-line reentrancy-balance
101
+ IERC20(asset).safeTransferFrom(msg.sender, address(this), amount);
102
+ uint256 received = IERC20(asset).balanceOf(address(this)) - beforeBal;
103
+ if (received == 0) revert ZeroAmount();
104
+
105
+ _holds[key] = Hold({
81
106
  payer: msg.sender,
82
107
  asset: asset,
83
- amount: amount,
108
+ amount: received,
84
109
  payee: payee,
85
110
  refundTo: refundTo == address(0) ? msg.sender : refundTo,
86
111
  releaser: releaser,
@@ -88,49 +113,51 @@ contract PaymentEscrow is ReentrancyGuard {
88
113
  status: Status.Held
89
114
  });
90
115
  emit EscrowDeposited(orderHash);
91
- IERC20(asset).safeTransferFrom(msg.sender, address(this), amount);
92
116
  }
93
117
 
94
118
  /// @notice Capture a held order to the payee. Authorized: the payee, or the configured releaser.
95
- function release(bytes32 orderHash) external nonReentrant {
96
- Hold storage h = _held(orderHash);
119
+ /// `payer` namespaces the hold (EXT-PMT-3). Cannot release at/after expiry — that window is
120
+ /// the payer's `reclaim` (removes the prior release/reclaim race; spec 243 §reclaim intent).
121
+ function release(address payer, bytes32 orderHash) external nonReentrant {
122
+ Hold storage h = _held(_key(payer, orderHash));
97
123
  if (msg.sender != h.payee && (h.releaser == address(0) || msg.sender != h.releaser)) revert NotAuthorized();
124
+ if (block.timestamp >= h.expiry) revert Expired();
98
125
  h.status = Status.Captured;
99
126
  emit EscrowReleased(orderHash);
100
127
  IERC20(h.asset).safeTransfer(h.payee, h.amount);
101
128
  }
102
129
 
103
- /// @notice Payee-consented refund of a held order to the payer (before capture).
104
- function refund(bytes32 orderHash) external nonReentrant {
105
- Hold storage h = _held(orderHash);
130
+ /// @notice Payee-consented refund of a held order to the payer (before capture). `payer` namespaces.
131
+ function refund(address payer, bytes32 orderHash) external nonReentrant {
132
+ Hold storage h = _held(_key(payer, orderHash));
106
133
  if (msg.sender != h.payee) revert NotAuthorized();
107
134
  h.status = Status.Refunded;
108
135
  emit EscrowRefunded(orderHash);
109
136
  IERC20(h.asset).safeTransfer(h.refundTo, h.amount);
110
137
  }
111
138
 
112
- /// @notice Payer reclaims a held order after expiry (never released).
139
+ /// @notice Payer reclaims a held order after expiry (never released). Payer-initiated → the hold is
140
+ /// derived from `msg.sender`, so only the payer can reach their own hold.
113
141
  function reclaim(bytes32 orderHash) external nonReentrant {
114
- Hold storage h = _held(orderHash);
115
- if (msg.sender != h.payer) revert NotAuthorized();
142
+ Hold storage h = _held(_key(msg.sender, orderHash));
116
143
  if (block.timestamp < h.expiry) revert NotYetExpired();
117
144
  h.status = Status.Reclaimed;
118
145
  emit EscrowReclaimed(orderHash);
119
146
  IERC20(h.asset).safeTransfer(h.refundTo, h.amount);
120
147
  }
121
148
 
122
- /// @notice Full hold record (app reads via this view, not log scans — ADR-0012).
123
- function getHold(bytes32 orderHash) external view returns (Hold memory) {
124
- return _holds[orderHash];
149
+ /// @notice Full hold record for (payer, orderHash) — app reads via this view, not log scans (ADR-0012).
150
+ function getHold(address payer, bytes32 orderHash) external view returns (Hold memory) {
151
+ return _holds[_key(payer, orderHash)];
125
152
  }
126
153
 
127
- function statusOf(bytes32 orderHash) external view returns (Status) {
128
- return _holds[orderHash].status;
154
+ function statusOf(address payer, bytes32 orderHash) external view returns (Status) {
155
+ return _holds[_key(payer, orderHash)].status;
129
156
  }
130
157
 
131
158
  /// @dev Load a hold that MUST be in `Held` (fail-closed): missing → NoHold; terminal → NotHeld.
132
- function _held(bytes32 orderHash) private view returns (Hold storage h) {
133
- h = _holds[orderHash];
159
+ function _held(bytes32 key) private view returns (Hold storage h) {
160
+ h = _holds[key];
134
161
  if (h.status == Status.None) revert NoHold();
135
162
  if (h.status != Status.Held) revert NotHeld();
136
163
  }