@agenticprimitives/contracts 1.0.0-alpha.7 → 1.0.0-alpha.9
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/deployments-base-sepolia.json +5 -1
- package/dist/abi/IPaymentReceiptRegistry.json +50 -0
- package/dist/abi/MockUSDC.json +621 -0
- package/dist/abi/PaymentEnforcer.json +407 -0
- package/dist/abi/PaymentEscrow.json +299 -0
- package/dist/abi/PaymentReceiptRegistry.json +182 -0
- package/dist/abi/index.d.ts +5 -0
- package/dist/abi/index.js +5 -0
- package/dist/deployments/base-sepolia.d.ts +4 -0
- package/dist/deployments/base-sepolia.js +5 -1
- package/package.json +3 -1
- package/src/enforcers/PaymentEnforcer.sol +194 -0
- package/src/mocks/MockUSDC.sol +152 -0
- package/src/payments/PaymentEscrow.sol +137 -0
- package/src/payments/PaymentReceiptRegistry.sol +67 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"type": "constructor",
|
|
4
|
+
"inputs": [],
|
|
5
|
+
"stateMutability": "nonpayable"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"type": "function",
|
|
9
|
+
"name": "enforcer",
|
|
10
|
+
"inputs": [],
|
|
11
|
+
"outputs": [
|
|
12
|
+
{
|
|
13
|
+
"name": "",
|
|
14
|
+
"type": "address",
|
|
15
|
+
"internalType": "address"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"stateMutability": "view"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "function",
|
|
22
|
+
"name": "owner",
|
|
23
|
+
"inputs": [],
|
|
24
|
+
"outputs": [
|
|
25
|
+
{
|
|
26
|
+
"name": "",
|
|
27
|
+
"type": "address",
|
|
28
|
+
"internalType": "address"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"stateMutability": "view"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"type": "function",
|
|
35
|
+
"name": "record",
|
|
36
|
+
"inputs": [
|
|
37
|
+
{
|
|
38
|
+
"name": "mandateId",
|
|
39
|
+
"type": "bytes32",
|
|
40
|
+
"internalType": "bytes32"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "payer",
|
|
44
|
+
"type": "address",
|
|
45
|
+
"internalType": "address"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "payee",
|
|
49
|
+
"type": "address",
|
|
50
|
+
"internalType": "address"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "asset",
|
|
54
|
+
"type": "address",
|
|
55
|
+
"internalType": "address"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "amount",
|
|
59
|
+
"type": "uint256",
|
|
60
|
+
"internalType": "uint256"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "resourceHash",
|
|
64
|
+
"type": "bytes32",
|
|
65
|
+
"internalType": "bytes32"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "delegationHash",
|
|
69
|
+
"type": "bytes32",
|
|
70
|
+
"internalType": "bytes32"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "nonce",
|
|
74
|
+
"type": "bytes32",
|
|
75
|
+
"internalType": "bytes32"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"outputs": [],
|
|
79
|
+
"stateMutability": "nonpayable"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"type": "function",
|
|
83
|
+
"name": "setEnforcer",
|
|
84
|
+
"inputs": [
|
|
85
|
+
{
|
|
86
|
+
"name": "enforcer_",
|
|
87
|
+
"type": "address",
|
|
88
|
+
"internalType": "address"
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"outputs": [],
|
|
92
|
+
"stateMutability": "nonpayable"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"type": "event",
|
|
96
|
+
"name": "EnforcerSet",
|
|
97
|
+
"inputs": [
|
|
98
|
+
{
|
|
99
|
+
"name": "enforcer",
|
|
100
|
+
"type": "address",
|
|
101
|
+
"indexed": true,
|
|
102
|
+
"internalType": "address"
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"anonymous": false
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "event",
|
|
109
|
+
"name": "PaymentSettled",
|
|
110
|
+
"inputs": [
|
|
111
|
+
{
|
|
112
|
+
"name": "mandateId",
|
|
113
|
+
"type": "bytes32",
|
|
114
|
+
"indexed": true,
|
|
115
|
+
"internalType": "bytes32"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "payer",
|
|
119
|
+
"type": "address",
|
|
120
|
+
"indexed": true,
|
|
121
|
+
"internalType": "address"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"name": "payee",
|
|
125
|
+
"type": "address",
|
|
126
|
+
"indexed": true,
|
|
127
|
+
"internalType": "address"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "asset",
|
|
131
|
+
"type": "address",
|
|
132
|
+
"indexed": false,
|
|
133
|
+
"internalType": "address"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "amount",
|
|
137
|
+
"type": "uint256",
|
|
138
|
+
"indexed": false,
|
|
139
|
+
"internalType": "uint256"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "resourceHash",
|
|
143
|
+
"type": "bytes32",
|
|
144
|
+
"indexed": false,
|
|
145
|
+
"internalType": "bytes32"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"name": "delegationHash",
|
|
149
|
+
"type": "bytes32",
|
|
150
|
+
"indexed": false,
|
|
151
|
+
"internalType": "bytes32"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"name": "nonce",
|
|
155
|
+
"type": "bytes32",
|
|
156
|
+
"indexed": false,
|
|
157
|
+
"internalType": "bytes32"
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
"anonymous": false
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"type": "error",
|
|
164
|
+
"name": "EnforcerAlreadySet",
|
|
165
|
+
"inputs": []
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"type": "error",
|
|
169
|
+
"name": "NotEnforcer",
|
|
170
|
+
"inputs": []
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"type": "error",
|
|
174
|
+
"name": "NotOwner",
|
|
175
|
+
"inputs": []
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"type": "error",
|
|
179
|
+
"name": "ZeroAddress",
|
|
180
|
+
"inputs": []
|
|
181
|
+
}
|
|
182
|
+
]
|
package/dist/abi/index.d.ts
CHANGED
|
@@ -40,10 +40,15 @@ export declare const IERC7579Module: Abi;
|
|
|
40
40
|
export declare const IERC7579ModuleLifecycle: Abi;
|
|
41
41
|
export declare const IERC7579ModuleLike: Abi;
|
|
42
42
|
export declare const IGovernanceView: Abi;
|
|
43
|
+
export declare const IPaymentReceiptRegistry: Abi;
|
|
44
|
+
export declare const MockUSDC: Abi;
|
|
43
45
|
export declare const MultiSendCallOnly: Abi;
|
|
44
46
|
export declare const MultiSendCallOnlyHarness: Abi;
|
|
45
47
|
export declare const OntologyTermRegistry: Abi;
|
|
46
48
|
export declare const P256Verifier: Abi;
|
|
49
|
+
export declare const PaymentEnforcer: Abi;
|
|
50
|
+
export declare const PaymentEscrow: Abi;
|
|
51
|
+
export declare const PaymentReceiptRegistry: Abi;
|
|
47
52
|
export declare const PermissionlessSubregistry: Abi;
|
|
48
53
|
export declare const QuorumEnforcer: Abi;
|
|
49
54
|
export declare const RelationshipTypeRegistry: Abi;
|
package/dist/abi/index.js
CHANGED
|
@@ -40,10 +40,15 @@ export { default as IERC7579Module } from './IERC7579Module.json' with { type: '
|
|
|
40
40
|
export { default as IERC7579ModuleLifecycle } from './IERC7579ModuleLifecycle.json' with { type: 'json' };
|
|
41
41
|
export { default as IERC7579ModuleLike } from './IERC7579ModuleLike.json' with { type: 'json' };
|
|
42
42
|
export { default as IGovernanceView } from './IGovernanceView.json' with { type: 'json' };
|
|
43
|
+
export { default as IPaymentReceiptRegistry } from './IPaymentReceiptRegistry.json' with { type: 'json' };
|
|
44
|
+
export { default as MockUSDC } from './MockUSDC.json' with { type: 'json' };
|
|
43
45
|
export { default as MultiSendCallOnly } from './MultiSendCallOnly.json' with { type: 'json' };
|
|
44
46
|
export { default as MultiSendCallOnlyHarness } from './MultiSendCallOnlyHarness.json' with { type: 'json' };
|
|
45
47
|
export { default as OntologyTermRegistry } from './OntologyTermRegistry.json' with { type: 'json' };
|
|
46
48
|
export { default as P256Verifier } from './P256Verifier.json' with { type: 'json' };
|
|
49
|
+
export { default as PaymentEnforcer } from './PaymentEnforcer.json' with { type: 'json' };
|
|
50
|
+
export { default as PaymentEscrow } from './PaymentEscrow.json' with { type: 'json' };
|
|
51
|
+
export { default as PaymentReceiptRegistry } from './PaymentReceiptRegistry.json' with { type: 'json' };
|
|
47
52
|
export { default as PermissionlessSubregistry } from './PermissionlessSubregistry.json' with { type: 'json' };
|
|
48
53
|
export { default as QuorumEnforcer } from './QuorumEnforcer.json' with { type: 'json' };
|
|
49
54
|
export { default as RelationshipTypeRegistry } from './RelationshipTypeRegistry.json' with { type: 'json' };
|
|
@@ -31,6 +31,10 @@ export interface Deployments {
|
|
|
31
31
|
readonly "timestampEnforcer": Address;
|
|
32
32
|
readonly "universalSignatureValidator": Address;
|
|
33
33
|
readonly "valueEnforcer": Address;
|
|
34
|
+
readonly "paymentEnforcer": Address;
|
|
35
|
+
readonly "paymentReceiptRegistry": Address;
|
|
36
|
+
readonly "mockUsdc": Address;
|
|
37
|
+
readonly "paymentEscrow": Address;
|
|
34
38
|
readonly "chainId": number;
|
|
35
39
|
}
|
|
36
40
|
export declare const CONTRACTS: Deployments;
|
|
@@ -30,6 +30,10 @@ export const CONTRACTS = {
|
|
|
30
30
|
"smartAgentPaymaster": "0x8eF92B9D62826052D8F7e6dcaB630dC3890bF540",
|
|
31
31
|
"timestampEnforcer": "0x7c452434314A5bf2048f9263aeCd0cC20Fa07965",
|
|
32
32
|
"universalSignatureValidator": "0xe8F3e531429F44D77dC3A331C18032bfa1550D84",
|
|
33
|
-
"valueEnforcer": "0x7c4533e0A9fB0B1cb5aa70327b222a80d72d52db"
|
|
33
|
+
"valueEnforcer": "0x7c4533e0A9fB0B1cb5aa70327b222a80d72d52db",
|
|
34
|
+
"paymentEnforcer": "0xAF48273e11435cC2D56f4AFBfD091Abb162458E9",
|
|
35
|
+
"paymentReceiptRegistry": "0x366616E265cd3cDE0F042A592C17838fe210D1d4",
|
|
36
|
+
"mockUsdc": "0x8fb56ff3C13347DFC4E1287aE83E88deE5a7211C",
|
|
37
|
+
"paymentEscrow": "0x954Ba6B3A02E02c5a3Fcd570943126633071cbdD"
|
|
34
38
|
};
|
|
35
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.
|
|
3
|
+
"version": "1.0.0-alpha.9",
|
|
4
4
|
"description": "Solidity contracts + ABIs + flattened sources + per-network deployment addresses for the agenticprimitives stack.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
"deploy:paymaster:base-sepolia": "DEPLOY_NETWORK=base-sepolia forge script script/DeployPaymaster.s.sol --rpc-url $BASE_SEPOLIA_RPC --broadcast --private-key $PRIVATE_KEY --sig 'run()'",
|
|
61
61
|
"deploy:paymaster:anvil": "DEPLOY_NETWORK=anvil forge script script/DeployPaymaster.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --sig 'run()'",
|
|
62
62
|
"deploy:registries:base-sepolia": "DEPLOY_NETWORK=base-sepolia forge script script/DeployRegistries.s.sol --rpc-url $BASE_SEPOLIA_RPC --broadcast --private-key $PRIVATE_KEY --sig 'run()'",
|
|
63
|
+
"deploy:payments:base-sepolia": "DEPLOY_NETWORK=base-sepolia forge script script/DeployPayments.s.sol --rpc-url $BASE_SEPOLIA_RPC --broadcast --private-key $PRIVATE_KEY --sig 'run()'",
|
|
64
|
+
"deploy:payment-escrow:base-sepolia": "DEPLOY_NETWORK=base-sepolia forge script script/DeployPaymentEscrow.s.sol --rpc-url $BASE_SEPOLIA_RPC --broadcast --private-key $PRIVATE_KEY --sig 'run()'",
|
|
63
65
|
"deploy:registries:merge": "node scripts/merge-registry-deploy.mjs",
|
|
64
66
|
"verify:base-sepolia": "bash scripts/verify-base-sepolia.sh",
|
|
65
67
|
"clean": "rm -rf out cache broadcast dist"
|
|
@@ -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
|
+
}
|