@agenticprimitives/contracts 1.0.0-alpha.6 → 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.
- package/AUDIT.md +21 -1
- package/README.md +36 -12
- package/deployments-base-sepolia.json +35 -1
- package/deployments-registries-base-sepolia.json +1 -0
- package/dist/abi/AgentAccount.json +23 -0
- package/dist/abi/AgentAccountFactory.json +5 -0
- package/dist/abi/AgentNameRegistry.json +5 -0
- package/dist/abi/AgenticGovernance.json +32 -0
- package/dist/abi/AgreementRegistry.json +0 -5
- package/dist/abi/CustodyPolicy.json +11 -0
- package/dist/abi/IPaymentReceiptRegistry.json +50 -0
- package/dist/abi/MockUSDC.json +621 -0
- package/dist/abi/PaymentEnforcer.json +407 -0
- package/dist/abi/PaymentReceiptRegistry.json +182 -0
- package/dist/abi/QuorumEnforcer.json +16 -0
- package/dist/abi/SmartAgentPaymaster.json +209 -0
- package/dist/abi/index.d.ts +4 -0
- package/dist/abi/index.js +4 -0
- package/dist/deployments/base-sepolia.d.ts +3 -0
- package/dist/deployments/base-sepolia.js +31 -28
- package/dist/deployments/index.d.ts +2 -1
- package/dist/deployments/index.js +3 -0
- package/dist/deployments/registries-base-sepolia.d.ts +9 -0
- package/dist/deployments/registries-base-sepolia.js +7 -0
- package/package.json +4 -1
- package/src/AgentAccount.sol +61 -2
- package/src/AgentAccountFactory.sol +32 -7
- package/src/SmartAgentPaymaster.sol +149 -12
- package/src/agency/DelegationManager.sol +17 -2
- package/src/agency/IDelegationManager.sol +3 -0
- package/src/agreement/AgreementRegistry.sol +46 -21
- package/src/attestation/AttestationRegistry.sol +86 -22
- package/src/custody/CustodyPolicy.sol +59 -6
- package/src/enforcers/PaymentEnforcer.sol +194 -0
- package/src/enforcers/QuorumEnforcer.sol +12 -0
- package/src/governance/AgenticGovernance.sol +26 -4
- package/src/libraries/SignatureSlotRecovery.sol +8 -3
- package/src/libraries/WebAuthnLib.sol +13 -0
- package/src/mocks/MockUSDC.sol +152 -0
- package/src/naming/AgentNameRegistry.sol +27 -2
- package/src/payments/PaymentReceiptRegistry.sol +67 -0
|
@@ -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
|
+
}
|
|
@@ -82,6 +82,9 @@ contract QuorumEnforcer is ICaveatEnforcer {
|
|
|
82
82
|
error UnauthorizedSigner(address signer);
|
|
83
83
|
error DuplicateOrUnsortedSigner(address signer);
|
|
84
84
|
error PayloadHashMismatch(bytes32 expected, bytes32 supplied);
|
|
85
|
+
/// @notice EN-11: a degenerate quorum caveat (threshold 0, or a threshold the
|
|
86
|
+
/// signer set can never satisfy) must fail closed, not pass with zero sigs.
|
|
87
|
+
error InvalidThreshold(uint8 threshold, uint256 signerSetSize);
|
|
85
88
|
|
|
86
89
|
/// @notice Typehash for the quorum-action binding (contract audit C-4).
|
|
87
90
|
/// @dev Bound to (chainId, enforcer, delegationHash, delegator, redeemer,
|
|
@@ -135,6 +138,15 @@ contract QuorumEnforcer is ICaveatEnforcer {
|
|
|
135
138
|
abi.decode(terms, (address[], uint8, address));
|
|
136
139
|
(bytes32 suppliedPayloadHash, bytes memory signatures) = abi.decode(args, (bytes32, bytes));
|
|
137
140
|
|
|
141
|
+
// EN-11: fail closed on a degenerate quorum. threshold == 0 would skip the
|
|
142
|
+
// verification loop entirely AND make `signatures.length < 0` unreachable, so
|
|
143
|
+
// the caveat would pass with ZERO signatures. A threshold larger than the
|
|
144
|
+
// signer set is unsatisfiable; reject it up front rather than running out of
|
|
145
|
+
// slots mid-loop.
|
|
146
|
+
if (threshold == 0 || threshold > signerSet.length) {
|
|
147
|
+
revert InvalidThreshold(threshold, signerSet.length);
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
// Contract audit C-4: bind the signed payload to the actual
|
|
139
151
|
// execution context. Previously the redeemer chose payloadHash
|
|
140
152
|
// freely, so a quorum signed off-chain to "approve a transfer
|
|
@@ -42,15 +42,21 @@ contract AgenticGovernance is IGovernanceView {
|
|
|
42
42
|
/// construction; rotate by redeploying.
|
|
43
43
|
address public immutable timelock;
|
|
44
44
|
|
|
45
|
-
/// @notice Fast-path emergency-pauser role. Can pause without delay
|
|
46
|
-
/// but CANNOT unpause (unpause requires the timelock).
|
|
47
|
-
address public immutable guardian;
|
|
48
|
-
|
|
49
45
|
// ─── State ────────────────────────────────────────────────────
|
|
50
46
|
|
|
51
47
|
bool private _paused;
|
|
52
48
|
mapping(address => bool) private _signers;
|
|
53
49
|
|
|
50
|
+
/// @notice Fast-path emergency-pauser role. Can pause without delay but
|
|
51
|
+
/// CANNOT unpause (unpause requires the timelock). GOV-1: this is a
|
|
52
|
+
/// STORAGE var (not immutable) so the timelock can ROTATE it — a
|
|
53
|
+
/// guardian whose key is compromised and grief-pauses the system can
|
|
54
|
+
/// be replaced WITHOUT a full governance redeploy, bounding the
|
|
55
|
+
/// re-pause DoS to one timelock window instead of "permanent". Set
|
|
56
|
+
/// (`setGuardian`) only by the timelock. Declared after the existing
|
|
57
|
+
/// storage vars so their slot assignments don't shift.
|
|
58
|
+
address public guardian;
|
|
59
|
+
|
|
54
60
|
// ─── Errors ───────────────────────────────────────────────────
|
|
55
61
|
|
|
56
62
|
error NotTimelock(address caller);
|
|
@@ -64,6 +70,8 @@ contract AgenticGovernance is IGovernanceView {
|
|
|
64
70
|
event Unpaused(address indexed by);
|
|
65
71
|
event SignerSet(address indexed who, bool isSigner);
|
|
66
72
|
event Executed(address indexed target, uint256 value, bytes data);
|
|
73
|
+
/// @notice GOV-1 — the guardian (fast-pause) role was rotated by the timelock.
|
|
74
|
+
event GuardianRotated(address indexed oldGuardian, address indexed newGuardian);
|
|
67
75
|
|
|
68
76
|
// ─── Constructor ──────────────────────────────────────────────
|
|
69
77
|
|
|
@@ -134,6 +142,20 @@ contract AgenticGovernance is IGovernanceView {
|
|
|
134
142
|
emit SignerSet(who, sig);
|
|
135
143
|
}
|
|
136
144
|
|
|
145
|
+
/// @notice GOV-1 — rotate the guardian (fast-pause) role. Timelock-only, so a
|
|
146
|
+
/// compromised guardian that is perpetually re-pausing the system can
|
|
147
|
+
/// be replaced after one timelock window WITHOUT a governance redeploy.
|
|
148
|
+
/// To retire the fast-pause capability entirely, rotate to a burn
|
|
149
|
+
/// address. Cannot be the zero address (would make `pause()` an
|
|
150
|
+
/// accidental no-op while looking live).
|
|
151
|
+
function setGuardian(address newGuardian) external {
|
|
152
|
+
if (msg.sender != timelock) revert NotTimelock(msg.sender);
|
|
153
|
+
if (newGuardian == address(0)) revert ZeroAddress();
|
|
154
|
+
address old = guardian;
|
|
155
|
+
guardian = newGuardian;
|
|
156
|
+
emit GuardianRotated(old, newGuardian);
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
// ─── Receive ETH ──────────────────────────────────────────────
|
|
138
160
|
|
|
139
161
|
receive() external payable {}
|
|
@@ -173,9 +173,14 @@ library SignatureSlotRecovery {
|
|
|
173
173
|
abi.decode(dyn, (uint256, uint256, bytes32, WebAuthnLib.Assertion));
|
|
174
174
|
address derived = address(uint160(uint256(keccak256(abi.encode(pubX, pubY)))));
|
|
175
175
|
if (derived != signer) revert PasskeyPubKeyMismatch(signer, derived);
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
176
|
+
// WA-2: require UV at the slot level. This path verifies custody-COUNCIL
|
|
177
|
+
// passkey signatures (CustodyPolicy quorum: trustees/custodians authorizing
|
|
178
|
+
// a recovery or admin change) — the highest-assurance signatures in the
|
|
179
|
+
// system. Consistent with the native ERC-1271 path (AgentAccount, R8.2):
|
|
180
|
+
// a UP-only assertion is rejected; browser ceremonies for custody signers
|
|
181
|
+
// MUST use `userVerification: 'required'`. A lower-assurance flow that needs
|
|
182
|
+
// UP-only must install a separate validator module that relaxes this.
|
|
183
|
+
if (!WebAuthnLib.verify(assertion, payloadHash, pubX, pubY, rpIdHash, true)) {
|
|
179
184
|
revert PasskeySigInvalid(signer);
|
|
180
185
|
}
|
|
181
186
|
} else if (v == 27 || v == 28) {
|
|
@@ -36,6 +36,13 @@ import "./P256Verifier.sol";
|
|
|
36
36
|
* PasskeyValidator (for external 1271-style verification).
|
|
37
37
|
*/
|
|
38
38
|
library WebAuthnLib {
|
|
39
|
+
/// @dev secp256r1 (P-256) curve order n ÷ 2 — the low-s malleability bound (WA-1).
|
|
40
|
+
/// RIP-7212 accepts BOTH (r, s) and (r, n−s) for the same message, so without
|
|
41
|
+
/// this bound a second valid signature always exists. Any path that treats a
|
|
42
|
+
/// signature as unique (quorum slot recovery, dedup) would be malleable.
|
|
43
|
+
uint256 internal constant P256_N_DIV_2 =
|
|
44
|
+
0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8;
|
|
45
|
+
|
|
39
46
|
/// @notice A WebAuthn-wrapped P-256 signature bundle.
|
|
40
47
|
struct Assertion {
|
|
41
48
|
bytes authenticatorData;
|
|
@@ -74,6 +81,12 @@ library WebAuthnLib {
|
|
|
74
81
|
if (!_checkClientData(assertion.clientDataJSON, assertion.typeIndex, assertion.challengeIndex, expectedChallengeHash)) {
|
|
75
82
|
return false;
|
|
76
83
|
}
|
|
84
|
+
// WA-1: enforce low-s. RIP-7212 verifies both (r, s) and (r, n−s); rejecting
|
|
85
|
+
// the high-s half makes the assertion signature canonical, closing P-256
|
|
86
|
+
// malleability for every caller (native ERC-1271 + custody-quorum slot recovery).
|
|
87
|
+
if (assertion.s > P256_N_DIV_2) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
77
90
|
bytes32 cdjHash = sha256(bytes(assertion.clientDataJSON));
|
|
78
91
|
bytes32 signingHash = sha256(abi.encodePacked(assertion.authenticatorData, cdjHash));
|
|
79
92
|
return P256Verifier.verify(signingHash, assertion.r, assertion.s, pubX, pubY);
|
|
@@ -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
|
+
}
|
|
@@ -77,6 +77,12 @@ contract AgentNameRegistry is GovernanceManaged {
|
|
|
77
77
|
error NameExpired();
|
|
78
78
|
error RootAlreadyInitialized();
|
|
79
79
|
error EmptyLabel();
|
|
80
|
+
/// @notice AN-1-ONCHAIN (audit 2026-06-10) — the label is not in canonical form. The off-chain
|
|
81
|
+
/// SDK `normalizeLabel` (NFC + lowercase + `[a-z0-9-]`, 1..63, no leading/trailing `-`) was
|
|
82
|
+
/// bypassable by any DIRECT contract caller, allowing homoglyph / mixed-case / zero-width /
|
|
83
|
+
/// embedded-dot squatting of names. The charset is now enforced ON CHAIN; because a valid
|
|
84
|
+
/// label is already lowercase ASCII, raw-byte hashing equals hashing the normalized label.
|
|
85
|
+
error InvalidLabel();
|
|
80
86
|
error ZeroOwner();
|
|
81
87
|
/// @notice H7-C.4 / CON-NAMING-001 — `initializeRoot` may only be called
|
|
82
88
|
/// by the immutable `initializer` set at construction. Closes the
|
|
@@ -160,6 +166,25 @@ contract AgentNameRegistry is GovernanceManaged {
|
|
|
160
166
|
return keccak256(abi.encodePacked(bytes32(0), keccak256(bytes("agent"))));
|
|
161
167
|
}
|
|
162
168
|
|
|
169
|
+
/// @notice AN-1-ONCHAIN — enforce the canonical single-label charset on chain. Mirrors the SDK
|
|
170
|
+
/// `normalizeLabel`: a single label of 1..63 bytes drawn from `[a-z0-9-]`, with no leading
|
|
171
|
+
/// or trailing `-`. Reverts `EmptyLabel` / `InvalidLabel`. Reusable from `register` +
|
|
172
|
+
/// `initializeRoot` so NO write path can anchor a non-canonical (squattable) label.
|
|
173
|
+
function _validateLabel(bytes memory b) internal pure {
|
|
174
|
+
uint256 len = b.length;
|
|
175
|
+
if (len == 0) revert EmptyLabel();
|
|
176
|
+
if (len > 63) revert InvalidLabel();
|
|
177
|
+
// no leading / trailing hyphen (0x2d)
|
|
178
|
+
if (b[0] == 0x2d || b[len - 1] == 0x2d) revert InvalidLabel();
|
|
179
|
+
for (uint256 i = 0; i < len; i++) {
|
|
180
|
+
uint8 c = uint8(b[i]);
|
|
181
|
+
bool ok = (c >= 0x61 && c <= 0x7a) // a-z
|
|
182
|
+
|| (c >= 0x30 && c <= 0x39) // 0-9
|
|
183
|
+
|| c == 0x2d; // -
|
|
184
|
+
if (!ok) revert InvalidLabel();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
163
188
|
// ─── Root Initialization (multi-root) ───────────────────────────
|
|
164
189
|
|
|
165
190
|
/**
|
|
@@ -179,7 +204,7 @@ contract AgentNameRegistry is GovernanceManaged {
|
|
|
179
204
|
// TLD. The deploy script bundles deploy+init in one transaction so no
|
|
180
205
|
// frontrun window exists.
|
|
181
206
|
if (msg.sender != initializer) revert NotInitializer(msg.sender, initializer);
|
|
182
|
-
|
|
207
|
+
_validateLabel(bytes(label)); // AN-1-ONCHAIN: canonical charset on chain
|
|
183
208
|
if (rootOwner == address(0)) revert ZeroOwner();
|
|
184
209
|
rootNode = namehashRoot(label);
|
|
185
210
|
if (_records[rootNode].registeredAt != 0) revert RootAlreadyInitialized();
|
|
@@ -229,7 +254,7 @@ contract AgentNameRegistry is GovernanceManaged {
|
|
|
229
254
|
address resolverContract,
|
|
230
255
|
uint64 expiry
|
|
231
256
|
) external whenNotPaused returns (bytes32 childNode) {
|
|
232
|
-
|
|
257
|
+
_validateLabel(bytes(label)); // AN-1-ONCHAIN: canonical charset on chain
|
|
233
258
|
if (newOwner == address(0)) revert ZeroOwner();
|
|
234
259
|
_requireParentAuth(parentNode);
|
|
235
260
|
_requireNotExpired(parentNode);
|
|
@@ -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
|
+
}
|