@agenticprimitives/contracts 1.0.0-alpha.7 → 1.0.0-alpha.8

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.
@@ -0,0 +1,194 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../agency/ICaveatEnforcer.sol";
5
+ import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6
+
7
+ interface IPaymentReceiptRegistry {
8
+ function record(
9
+ bytes32 mandateId,
10
+ address payer,
11
+ address payee,
12
+ address asset,
13
+ uint256 amount,
14
+ bytes32 resourceHash,
15
+ bytes32 delegationHash,
16
+ bytes32 nonce
17
+ ) external;
18
+ }
19
+
20
+ /**
21
+ * @title PaymentEnforcer
22
+ * @notice x402 pay-per-use caveat enforcer (spec 272, PAY-CON-1). Constrains a redeemed PAYMENT
23
+ * delegation to EXACTLY a capped `IERC20.transfer(treasury, amount)`, with a per-delegation
24
+ * aggregate spend cap, a windowed frequency cap, and a single-use nonce (on-chain replay
25
+ * guard). Fuses smart-agent's AllocationLimit (spend) + RateLimit (window) into one
26
+ * `beforeHook` so spend, frequency, and binding are checked ATOMICALLY — a charge that passes
27
+ * the rate limit but busts the budget reverts the whole redemption as a unit (spec §2 divergence).
28
+ *
29
+ * @dev terms = abi.encode(
30
+ * address treasury, address asset, uint256 maxAmountPerCharge,
31
+ * uint256 maxAggregate, uint32 maxRedemptionsPerWindow, uint32 windowSeconds) // 192 bytes
32
+ * args = abi.encode(bytes32 mandateId, bytes32 nonce, bytes32 resourceHash)
33
+ *
34
+ * State is keyed [delegator][delegationHash] so sibling delegations never cross-interfere
35
+ * (a redeemer can't bypass the cap by re-anchoring under a different delegation).
36
+ *
37
+ * Fail-closed (spec §9): wrong/zero asset or treasury, non-`transfer` calldata, wrong
38
+ * recipient/amount, ANY cap breach, or a reused nonce reverts and blocks the whole
39
+ * `DelegationManager.redeemDelegation`. The state SSTOREs are atomic with the underlying
40
+ * transfer — if Phase-2 execution reverts, the budget/nonce writes revert with it.
41
+ *
42
+ * `transfer`-only by construction (spec §163): `target == asset` rejects multicall/router
43
+ * targets; the 68-byte length + selector check rejects `transferFrom`/`approve`/extra-arg
44
+ * smuggling; `value == 0` rejects ETH; the DelegationManager's single (target,value,data)
45
+ * redemption shape rejects batch/delegatecall.
46
+ */
47
+ contract PaymentEnforcer is ICaveatEnforcer {
48
+ bytes4 private constant TRANSFER_SELECTOR = IERC20.transfer.selector; // 0xa9059cbb
49
+
50
+ struct Budget {
51
+ uint256 spent; // cumulative against maxAggregate
52
+ uint64 windowStart; // start of the current frequency window
53
+ uint32 callsInWindow; // redemptions so far in the current window
54
+ }
55
+
56
+ /// delegator => delegationHash => budget/window state
57
+ mapping(address => mapping(bytes32 => Budget)) private _budgets;
58
+ /// delegator => delegationHash => nonce => consumed (on-chain replay guard)
59
+ mapping(address => mapping(bytes32 => mapping(bytes32 => bool))) private _nonceUsed;
60
+
61
+ /// @notice Trustless receipt sink (PAY-CON-4). Immutable; `address(0)` disables registry receipts
62
+ /// (the `PaymentCharged` event below is always the primary on-chain receipt).
63
+ IPaymentReceiptRegistry public immutable receiptRegistry;
64
+
65
+ event PaymentCharged(
66
+ address indexed delegator,
67
+ bytes32 indexed delegationHash,
68
+ bytes32 indexed mandateId,
69
+ address treasury,
70
+ address asset,
71
+ uint256 amount,
72
+ bytes32 nonce,
73
+ bytes32 resourceHash,
74
+ uint256 totalSpent,
75
+ uint32 callsInWindow
76
+ );
77
+
78
+ error InvalidTerms();
79
+ error ValueNotZero();
80
+ error AssetMismatch(address expectedAsset, address target);
81
+ error NotTransferCall();
82
+ error WrongRecipient(address treasury, address to);
83
+ error ChargeExceedsMax(uint256 amount, uint256 maxAmountPerCharge);
84
+ error AggregateExceeded(uint256 spent, uint256 maxAggregate, uint256 amount);
85
+ error FrequencyExceeded(uint32 callsInWindow, uint32 maxRedemptionsPerWindow);
86
+ error NonceReused(bytes32 nonce);
87
+
88
+ constructor(address receiptRegistry_) {
89
+ receiptRegistry = IPaymentReceiptRegistry(receiptRegistry_);
90
+ }
91
+
92
+ function beforeHook(
93
+ bytes calldata terms,
94
+ bytes calldata args,
95
+ bytes32 delegationHash,
96
+ address delegator,
97
+ address, /*redeemer*/
98
+ address target,
99
+ uint256 value,
100
+ bytes calldata callData
101
+ ) external override {
102
+ // ── terms (immutable, set at delegation creation) ──
103
+ if (terms.length != 192) revert InvalidTerms();
104
+ (
105
+ address treasury,
106
+ address asset,
107
+ uint256 maxPerCharge,
108
+ uint256 maxAggregate,
109
+ uint32 maxPerWindow,
110
+ uint32 windowSeconds
111
+ ) = abi.decode(terms, (address, address, uint256, uint256, uint32, uint32));
112
+ if (
113
+ treasury == address(0) || asset == address(0) || maxPerCharge == 0 || maxAggregate < maxPerCharge
114
+ || maxPerWindow == 0 || windowSeconds == 0
115
+ ) {
116
+ revert InvalidTerms();
117
+ }
118
+
119
+ // ── calldata MUST be exactly IERC20.transfer(treasury, amount): single call, no ETH ──
120
+ if (value != 0) revert ValueNotZero();
121
+ if (target != asset) revert AssetMismatch(asset, target);
122
+ if (callData.length != 68) revert NotTransferCall(); // 4 + 32 + 32; rejects transferFrom/approve/extra args
123
+ if (bytes4(callData[0:4]) != TRANSFER_SELECTOR) revert NotTransferCall();
124
+ (address to, uint256 amount) = abi.decode(callData[4:], (address, uint256));
125
+ if (to != treasury) revert WrongRecipient(treasury, to);
126
+ if (amount > maxPerCharge) revert ChargeExceedsMax(amount, maxPerCharge);
127
+
128
+ // ── replay: single-use nonce per delegation (on-chain idempotency, PAY-RAIL-4) ──
129
+ (bytes32 mandateId, bytes32 nonce, bytes32 resourceHash) = abi.decode(args, (bytes32, bytes32, bytes32));
130
+ if (_nonceUsed[delegator][delegationHash][nonce]) revert NonceReused(nonce);
131
+ _nonceUsed[delegator][delegationHash][nonce] = true;
132
+
133
+ Budget storage b = _budgets[delegator][delegationHash];
134
+
135
+ // ── aggregate spend cap (port AllocationLimitEnforcer) ──
136
+ uint256 nextSpent = b.spent + amount;
137
+ if (nextSpent > maxAggregate) revert AggregateExceeded(b.spent, maxAggregate, amount);
138
+
139
+ // ── windowed frequency cap; roll on expiry (port RateLimitEnforcer) ──
140
+ uint64 nowTs = uint64(block.timestamp);
141
+ if (b.windowStart == 0 || nowTs >= b.windowStart + windowSeconds) {
142
+ b.windowStart = nowTs;
143
+ b.callsInWindow = 0;
144
+ }
145
+ uint32 nextCalls = b.callsInWindow + 1;
146
+ if (nextCalls > maxPerWindow) revert FrequencyExceeded(b.callsInWindow, maxPerWindow);
147
+
148
+ b.spent = nextSpent;
149
+ b.callsInWindow = nextCalls;
150
+
151
+ emit PaymentCharged(
152
+ delegator, delegationHash, mandateId, treasury, asset, amount, nonce, resourceHash, nextSpent, nextCalls
153
+ );
154
+
155
+ // Trustless settlement receipt (PAY-CON-4). Reverting here reverts the whole redemption, so a
156
+ // settled charge always has a receipt and an unsettled one never does (atomic).
157
+ if (address(receiptRegistry) != address(0)) {
158
+ receiptRegistry.record(mandateId, delegator, treasury, asset, amount, resourceHash, delegationHash, nonce);
159
+ }
160
+ }
161
+
162
+ function afterHook(bytes calldata, bytes calldata, bytes32, address, address, address, uint256, bytes calldata)
163
+ external
164
+ pure
165
+ override
166
+ {}
167
+
168
+ // ── views (off-chain budget display + idempotency checks) ──
169
+
170
+ function getBudget(address delegator, bytes32 delegationHash)
171
+ external
172
+ view
173
+ returns (uint256 spent, uint64 windowStart, uint32 callsInWindow)
174
+ {
175
+ Budget storage b = _budgets[delegator][delegationHash];
176
+ return (b.spent, b.windowStart, b.callsInWindow);
177
+ }
178
+
179
+ function isNonceUsed(address delegator, bytes32 delegationHash, bytes32 nonce) external view returns (bool) {
180
+ return _nonceUsed[delegator][delegationHash][nonce];
181
+ }
182
+
183
+ /// @notice Encode the immutable caveat terms (mirrors `delegation.buildPaymentMandateCaveats`).
184
+ function encodeTerms(
185
+ address treasury,
186
+ address asset,
187
+ uint256 maxAmountPerCharge,
188
+ uint256 maxAggregate,
189
+ uint32 maxRedemptionsPerWindow,
190
+ uint32 windowSeconds
191
+ ) external pure returns (bytes memory) {
192
+ return abi.encode(treasury, asset, maxAmountPerCharge, maxAggregate, maxRedemptionsPerWindow, windowSeconds);
193
+ }
194
+ }
@@ -0,0 +1,152 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5
+ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
6
+
7
+ /**
8
+ * @title MockUSDC
9
+ * @notice Test USDC for the Base Sepolia x402 demos (spec 272, PAY-CON-3). 6 decimals, open mint +
10
+ * faucet, and full EIP-3009 (`transferWithAuthorization` / `receiveWithAuthorization` /
11
+ * `cancelAuthorization` / `authorizationState`) so the Wave-5 Coinbase-x402 interop adapter
12
+ * is a no-redeploy add (X402-D2 / PAY-WIRE-4).
13
+ *
14
+ * @dev DEV-ONLY: `mint`/`faucet` are open. NEVER a production asset. Wave-1 settlement is
15
+ * delegation-native (the {PaymentEnforcer}-gated `transfer`), so EIP-3009 is present but
16
+ * unused until Wave 5; shipping it now avoids a token redeploy.
17
+ */
18
+ contract MockUSDC is ERC20 {
19
+ using ECDSA for bytes32;
20
+
21
+ // EIP-3009 type hashes
22
+ bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256(
23
+ "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
24
+ );
25
+ bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = keccak256(
26
+ "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
27
+ );
28
+ bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH =
29
+ keccak256("CancelAuthorization(address authorizer,bytes32 nonce)");
30
+
31
+ bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
32
+ uint256 private immutable _CACHED_CHAIN_ID;
33
+
34
+ /// @notice EIP-3009: authorizer => nonce => used-or-canceled.
35
+ mapping(address => mapping(bytes32 => bool)) public authorizationState;
36
+
37
+ event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);
38
+ event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce);
39
+
40
+ error AuthNotYetValid();
41
+ error AuthExpired();
42
+ error AuthUsedOrCanceled();
43
+ error InvalidSignature();
44
+ error CallerMustBePayee();
45
+
46
+ constructor() ERC20("Mock USD Coin", "USDC") {
47
+ _CACHED_CHAIN_ID = block.chainid;
48
+ _CACHED_DOMAIN_SEPARATOR = _computeDomainSeparator();
49
+ }
50
+
51
+ function decimals() public pure override returns (uint8) {
52
+ return 6;
53
+ }
54
+
55
+ // ── dev faucet ──
56
+ function mint(address to, uint256 amount) external {
57
+ _mint(to, amount);
58
+ }
59
+
60
+ function faucet(uint256 amount) external {
61
+ _mint(msg.sender, amount);
62
+ }
63
+
64
+ // ── EIP-3009 ──
65
+
66
+ function DOMAIN_SEPARATOR() public view returns (bytes32) {
67
+ return block.chainid == _CACHED_CHAIN_ID ? _CACHED_DOMAIN_SEPARATOR : _computeDomainSeparator();
68
+ }
69
+
70
+ function transferWithAuthorization(
71
+ address from,
72
+ address to,
73
+ uint256 value,
74
+ uint256 validAfter,
75
+ uint256 validBefore,
76
+ bytes32 nonce,
77
+ bytes calldata signature
78
+ ) external {
79
+ _requireValidAuthorization(from, nonce, validAfter, validBefore);
80
+ _verify(
81
+ from,
82
+ keccak256(
83
+ abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)
84
+ ),
85
+ signature
86
+ );
87
+ _markAuthorizationUsed(from, nonce);
88
+ _transfer(from, to, value);
89
+ }
90
+
91
+ function receiveWithAuthorization(
92
+ address from,
93
+ address to,
94
+ uint256 value,
95
+ uint256 validAfter,
96
+ uint256 validBefore,
97
+ bytes32 nonce,
98
+ bytes calldata signature
99
+ ) external {
100
+ if (to != msg.sender) revert CallerMustBePayee();
101
+ _requireValidAuthorization(from, nonce, validAfter, validBefore);
102
+ _verify(
103
+ from,
104
+ keccak256(
105
+ abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)
106
+ ),
107
+ signature
108
+ );
109
+ _markAuthorizationUsed(from, nonce);
110
+ _transfer(from, to, value);
111
+ }
112
+
113
+ function cancelAuthorization(address authorizer, bytes32 nonce, bytes calldata signature) external {
114
+ if (authorizationState[authorizer][nonce]) revert AuthUsedOrCanceled();
115
+ _verify(authorizer, keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)), signature);
116
+ authorizationState[authorizer][nonce] = true;
117
+ emit AuthorizationCanceled(authorizer, nonce);
118
+ }
119
+
120
+ // ── internals ──
121
+
122
+ function _requireValidAuthorization(address from, bytes32 nonce, uint256 validAfter, uint256 validBefore)
123
+ private
124
+ view
125
+ {
126
+ if (block.timestamp <= validAfter) revert AuthNotYetValid();
127
+ if (block.timestamp >= validBefore) revert AuthExpired();
128
+ if (authorizationState[from][nonce]) revert AuthUsedOrCanceled();
129
+ }
130
+
131
+ function _markAuthorizationUsed(address from, bytes32 nonce) private {
132
+ authorizationState[from][nonce] = true;
133
+ emit AuthorizationUsed(from, nonce);
134
+ }
135
+
136
+ function _verify(address signer, bytes32 structHash, bytes calldata signature) private view {
137
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash));
138
+ if (digest.recover(signature) != signer) revert InvalidSignature();
139
+ }
140
+
141
+ function _computeDomainSeparator() private view returns (bytes32) {
142
+ return keccak256(
143
+ abi.encode(
144
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
145
+ keccak256(bytes("Mock USD Coin")),
146
+ keccak256(bytes("2")),
147
+ block.chainid,
148
+ address(this)
149
+ )
150
+ );
151
+ }
152
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ /**
5
+ * @title PaymentReceiptRegistry
6
+ * @notice Trustless, queryable settlement log for x402 pay-per-use (spec 272, PAY-CON-4 / X402-D9.3).
7
+ * Only the authorized {PaymentEnforcer} may `record` — a receipt is a byproduct of a gated
8
+ * `DelegationManager.redeemDelegation`, so a service cannot fabricate one and the reader's
9
+ * charge history needs no trust in service logs.
10
+ *
11
+ * @dev HASHES + ADDRESSES ONLY — no URLs, queries, licensed text, or passage refs ever reach the
12
+ * chain (PAY-WIRE-6). Event-only (no per-row storage): apps read settlements via an indexer /
13
+ * app cache, never inline `eth_getLogs` in product hot paths (ADR-0012). The
14
+ * `PaymentEnforcer.PaymentCharged` event is the primary on-chain receipt; `PaymentSettled`
15
+ * here adds payer/payee/mandateId-indexed querying for treasury views.
16
+ */
17
+ contract PaymentReceiptRegistry {
18
+ /// @notice The deployer who wires the sole authorized recorder once.
19
+ address public immutable owner;
20
+ /// @notice The single PaymentEnforcer permitted to `record` (set once via {setEnforcer}).
21
+ address public enforcer;
22
+
23
+ event PaymentSettled(
24
+ bytes32 indexed mandateId,
25
+ address indexed payer,
26
+ address indexed payee,
27
+ address asset,
28
+ uint256 amount,
29
+ bytes32 resourceHash,
30
+ bytes32 delegationHash,
31
+ bytes32 nonce
32
+ );
33
+ event EnforcerSet(address indexed enforcer);
34
+
35
+ error NotOwner();
36
+ error NotEnforcer();
37
+ error EnforcerAlreadySet();
38
+ error ZeroAddress();
39
+
40
+ constructor() {
41
+ owner = msg.sender;
42
+ }
43
+
44
+ /// @notice One-time wiring: authorize the deployed {PaymentEnforcer} as the sole recorder.
45
+ function setEnforcer(address enforcer_) external {
46
+ if (msg.sender != owner) revert NotOwner();
47
+ if (enforcer != address(0)) revert EnforcerAlreadySet();
48
+ if (enforcer_ == address(0)) revert ZeroAddress();
49
+ enforcer = enforcer_;
50
+ emit EnforcerSet(enforcer_);
51
+ }
52
+
53
+ /// @notice Record a settled payment. Reverts unless called by the authorized enforcer.
54
+ function record(
55
+ bytes32 mandateId,
56
+ address payer,
57
+ address payee,
58
+ address asset,
59
+ uint256 amount,
60
+ bytes32 resourceHash,
61
+ bytes32 delegationHash,
62
+ bytes32 nonce
63
+ ) external {
64
+ if (msg.sender != enforcer) revert NotEnforcer();
65
+ emit PaymentSettled(mandateId, payer, payee, asset, amount, resourceHash, delegationHash, nonce);
66
+ }
67
+ }