@agenticprimitives/contracts 0.1.0-alpha.2 → 1.0.0-alpha.10

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.
Files changed (74) hide show
  1. package/AUDIT.md +324 -47
  2. package/README.md +45 -10
  3. package/deployments-base-sepolia.json +36 -1
  4. package/deployments-registries-base-sepolia.json +1 -0
  5. package/dist/abi/AgentAccount.json +1670 -0
  6. package/dist/abi/AgentAccountFactory.json +477 -0
  7. package/dist/abi/AgentNameRegistry.json +51 -0
  8. package/dist/abi/AgenticGovernance.json +32 -0
  9. package/dist/abi/AgreementRegistry.json +427 -0
  10. package/dist/abi/AttestationRegistry.json +436 -0
  11. package/dist/abi/CallDataHashEnforcer.json +119 -0
  12. package/dist/abi/CustodyPolicy.json +1122 -0
  13. package/dist/abi/DelegationManager.json +645 -0
  14. package/dist/abi/GeoFeatureRegistry.json +625 -0
  15. package/dist/abi/IAgentAccount.json +262 -0
  16. package/dist/abi/IAgentAccountFactoryAccessor.json +15 -0
  17. package/dist/abi/IAgentAccountFactoryView.json +41 -0
  18. package/dist/abi/IAgentAccountPauseView.json +15 -0
  19. package/dist/abi/IAgenticPrimitivesAgentAccount.json +15 -0
  20. package/dist/abi/IApprovedHashRegistryView.json +26 -0
  21. package/dist/abi/ICustodyPolicyFactoryView.json +15 -0
  22. package/dist/abi/ICustodyPolicyPauseView.json +15 -0
  23. package/dist/abi/IDelegationManager.json +171 -0
  24. package/dist/abi/IERC7579HookLike.json +44 -0
  25. package/dist/abi/IERC7579ModuleLike.json +28 -0
  26. package/dist/abi/IPaymentReceiptRegistry.json +50 -0
  27. package/dist/abi/MockUSDC.json +621 -0
  28. package/dist/abi/PaymentEnforcer.json +407 -0
  29. package/dist/abi/PaymentEscrow.json +299 -0
  30. package/dist/abi/PaymentReceiptRegistry.json +182 -0
  31. package/dist/abi/PermissionlessSubregistry.json +5 -0
  32. package/dist/abi/QuorumEnforcer.json +311 -0
  33. package/dist/abi/SignatureSlotRecovery.json +88 -0
  34. package/dist/abi/SkillDefinitionRegistry.json +491 -0
  35. package/dist/abi/SmartAgentPaymaster.json +873 -0
  36. package/dist/abi/UniversalSignatureValidator.json +65 -0
  37. package/dist/abi/index.d.ts +28 -0
  38. package/dist/abi/index.js +28 -0
  39. package/dist/deployments/base-sepolia.d.ts +41 -0
  40. package/dist/deployments/base-sepolia.js +39 -0
  41. package/dist/deployments/index.d.ts +9 -0
  42. package/dist/deployments/index.js +16 -0
  43. package/dist/deployments/registries-base-sepolia.d.ts +9 -0
  44. package/dist/deployments/registries-base-sepolia.js +7 -0
  45. package/package.json +28 -7
  46. package/src/AgentAccount.sol +191 -11
  47. package/src/AgentAccountFactory.sol +66 -12
  48. package/src/SmartAgentPaymaster.sol +186 -20
  49. package/src/UniversalSignatureValidator.sol +3 -0
  50. package/src/agency/DelegationManager.sol +170 -0
  51. package/src/agency/IDelegationManager.sol +38 -0
  52. package/src/agreement/AgreementRegistry.sol +331 -0
  53. package/src/attestation/AttestationRegistry.sol +384 -0
  54. package/src/custody/CustodyPolicy.sol +138 -8
  55. package/src/enforcers/AllowedMethodsEnforcer.sol +6 -0
  56. package/src/enforcers/AllowedTargetsEnforcer.sol +6 -0
  57. package/src/enforcers/CallDataHashEnforcer.sol +61 -0
  58. package/src/enforcers/PaymentEnforcer.sol +194 -0
  59. package/src/enforcers/QuorumEnforcer.sol +20 -0
  60. package/src/enforcers/TimestampEnforcer.sol +7 -0
  61. package/src/enforcers/ValueEnforcer.sol +6 -0
  62. package/src/geo/GeoFeatureRegistry.sol +197 -0
  63. package/src/governance/AgenticGovernance.sol +26 -4
  64. package/src/libraries/SignatureSlotRecovery.sol +8 -3
  65. package/src/libraries/WebAuthnLib.sol +13 -0
  66. package/src/mocks/MockUSDC.sol +152 -0
  67. package/src/naming/AgentNameRegistry.sol +52 -15
  68. package/src/naming/AgentNameUniversalResolver.sol +1 -1
  69. package/src/naming/PermissionlessSubregistry.sol +19 -2
  70. package/src/payments/PaymentEscrow.sol +137 -0
  71. package/src/payments/PaymentReceiptRegistry.sol +67 -0
  72. package/src/relationships/AgentRelationship.sol +8 -0
  73. package/src/skills/SkillDefinitionRegistry.sol +187 -0
  74. package/deployments-anvil.json +0 -1
@@ -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
+ }
@@ -1,11 +1,13 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.28;
3
3
 
4
+ import "../governance/GovernanceManaged.sol";
5
+
4
6
  /**
5
7
  * @title AgentNameRegistry
6
8
  * @notice Hierarchical multi-root name registry for Smart Agents.
7
9
  *
8
- * Names are keyed by `node` (the ENS-style namehash:
10
+ * Names are keyed by `node` (the recursive namehash:
9
11
  * `keccak256(parentNode || labelhash)`). Each node has an owner (a Smart
10
12
  * Agent address), a resolver, an optional subregistry delegate that may
11
13
  * issue children, a parent pointer, the labelhash, and an optional
@@ -36,7 +38,11 @@ pragma solidity ^0.8.28;
36
38
  * discipline (a primary name only counts when the forward record
37
39
  * agrees).
38
40
  */
39
- contract AgentNameRegistry {
41
+ /// @dev R6.8 — inherits `GovernanceManaged` to gain the
42
+ /// `whenNotPaused` modifier on its mutating surface. The registry
43
+ /// still owns its own per-node auth (`_requireParentAuth` /
44
+ /// `msg.sender == owner`); governance only owns the pause flag.
45
+ contract AgentNameRegistry is GovernanceManaged {
40
46
  // ─── H7-C.4 / CON-NAMING-001 — root-initializer gate ───────────────
41
47
  //
42
48
  // `initializeRoot` was permissionless: any mempool observer could
@@ -71,6 +77,12 @@ contract AgentNameRegistry {
71
77
  error NameExpired();
72
78
  error RootAlreadyInitialized();
73
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();
74
86
  error ZeroOwner();
75
87
  /// @notice H7-C.4 / CON-NAMING-001 — `initializeRoot` may only be called
76
88
  /// by the immutable `initializer` set at construction. Closes the
@@ -129,10 +141,13 @@ contract AgentNameRegistry {
129
141
 
130
142
  // ─── Constructor (H7-C.4) ───────────────────────────────────────
131
143
 
132
- /// @notice Set the immutable root-initializer. The deploy script
133
- /// deploys the registry + calls `initializeRoot` in the same
134
- /// transaction so the TLD can't be frontrun.
135
- constructor(address initializer_) {
144
+ /// @notice Set the immutable root-initializer + governance pointer.
145
+ /// The deploy script deploys the registry + calls
146
+ /// `initializeRoot` in the same transaction so the TLD
147
+ /// can't be frontrun. R6.8 added `governance_` so the
148
+ /// registry can read the system pause flag through
149
+ /// `GovernanceManaged._pausedSafe()`.
150
+ constructor(address initializer_, address governance_) GovernanceManaged(governance_) {
136
151
  if (initializer_ == address(0)) revert ZeroOwner();
137
152
  initializer = initializer_;
138
153
  }
@@ -151,6 +166,25 @@ contract AgentNameRegistry {
151
166
  return keccak256(abi.encodePacked(bytes32(0), keccak256(bytes("agent"))));
152
167
  }
153
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
+
154
188
  // ─── Root Initialization (multi-root) ───────────────────────────
155
189
 
156
190
  /**
@@ -170,7 +204,7 @@ contract AgentNameRegistry {
170
204
  // TLD. The deploy script bundles deploy+init in one transaction so no
171
205
  // frontrun window exists.
172
206
  if (msg.sender != initializer) revert NotInitializer(msg.sender, initializer);
173
- if (bytes(label).length == 0) revert EmptyLabel();
207
+ _validateLabel(bytes(label)); // AN-1-ONCHAIN: canonical charset on chain
174
208
  if (rootOwner == address(0)) revert ZeroOwner();
175
209
  rootNode = namehashRoot(label);
176
210
  if (_records[rootNode].registeredAt != 0) revert RootAlreadyInitialized();
@@ -219,8 +253,8 @@ contract AgentNameRegistry {
219
253
  address newOwner,
220
254
  address resolverContract,
221
255
  uint64 expiry
222
- ) external returns (bytes32 childNode) {
223
- if (bytes(label).length == 0) revert EmptyLabel();
256
+ ) external whenNotPaused returns (bytes32 childNode) {
257
+ _validateLabel(bytes(label)); // AN-1-ONCHAIN: canonical charset on chain
224
258
  if (newOwner == address(0)) revert ZeroOwner();
225
259
  _requireParentAuth(parentNode);
226
260
  _requireNotExpired(parentNode);
@@ -252,7 +286,7 @@ contract AgentNameRegistry {
252
286
  * Authorized to the node's owner ONLY — labels can't be
253
287
  * changed once set, preventing display spoofing.
254
288
  */
255
- function backfillLabel(bytes32 node, string calldata label_) external {
289
+ function backfillLabel(bytes32 node, string calldata label_) external whenNotPaused {
256
290
  _requireNodeAuth(node);
257
291
  if (bytes(label_).length == 0) revert EmptyLabel();
258
292
  if (keccak256(bytes(label_)) != _records[node].labelhash) revert NotAuthorized();
@@ -262,26 +296,26 @@ contract AgentNameRegistry {
262
296
 
263
297
  // ─── Setters ────────────────────────────────────────────────────
264
298
 
265
- function setOwner(bytes32 node, address newOwner) external {
299
+ function setOwner(bytes32 node, address newOwner) external whenNotPaused {
266
300
  _requireNodeAuth(node);
267
301
  if (newOwner == address(0)) revert ZeroOwner();
268
302
  _records[node].owner = newOwner;
269
303
  emit OwnerChanged(node, newOwner);
270
304
  }
271
305
 
272
- function setResolver(bytes32 node, address resolverContract) external {
306
+ function setResolver(bytes32 node, address resolverContract) external whenNotPaused {
273
307
  _requireNodeAuth(node);
274
308
  _records[node].resolver = resolverContract;
275
309
  emit ResolverChanged(node, resolverContract);
276
310
  }
277
311
 
278
- function setSubregistry(bytes32 node, address subregistryContract) external {
312
+ function setSubregistry(bytes32 node, address subregistryContract) external whenNotPaused {
279
313
  _requireNodeAuth(node);
280
314
  _records[node].subregistry = subregistryContract;
281
315
  emit SubregistryChanged(node, subregistryContract);
282
316
  }
283
317
 
284
- function renew(bytes32 node, uint64 newExpiry) external {
318
+ function renew(bytes32 node, uint64 newExpiry) external whenNotPaused {
285
319
  _requireNodeAuth(node);
286
320
  _records[node].expiry = newExpiry;
287
321
  emit NameRenewed(node, newExpiry);
@@ -293,7 +327,10 @@ contract AgentNameRegistry {
293
327
  * NOT verify the forward record points back here. The
294
328
  * universal resolver enforces the round-trip on reads.
295
329
  */
296
- function setPrimaryName(bytes32 node) external {
330
+ function setPrimaryName(bytes32 node) external whenNotPaused {
331
+ // R6.3: `registeredAt == 0` is a sentinel "record-never-created" check.
332
+ // Storage default IS exactly 0; not a numeric precision concern.
333
+ // slither-disable-next-line incorrect-equality
297
334
  if (node != bytes32(0) && _records[node].registeredAt == 0) revert NodeNotFound();
298
335
  _primaryName[msg.sender] = node;
299
336
  if (node == bytes32(0)) emit PrimaryNameCleared(msg.sender);
@@ -122,7 +122,7 @@ contract AgentNameUniversalResolver {
122
122
  /**
123
123
  * @notice Reverse-resolve a Smart Agent address to its primary name
124
124
  * STRING in a single external call. Per spec/222 this is
125
- * the ENS-aligned reverse path — no log walks, no name
125
+ * the on-chain reverse path — no log walks, no name
126
126
  * reconstruction in the SDK, no event indexer required.
127
127
  *
128
128
  * Walks `parent(node)` up the registry, reading `label(node)`
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.28;
3
3
 
4
4
  import "./AgentNameRegistry.sol";
5
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
5
6
 
6
7
  /**
7
8
  * @title PermissionlessSubregistry
@@ -36,7 +37,15 @@ import "./AgentNameRegistry.sol";
36
37
  * through whatever authority `newOwner` represents (EOA wallet,
37
38
  * PSA CustodyPolicy quorum, etc.).
38
39
  */
39
- contract PermissionlessSubregistry {
40
+ /// @dev R6.2 — `ReentrancyGuard` closes the Slither finding
41
+ /// `reentrancy-no-eth` on `register()`: the prior-claim check
42
+ /// (`claimedBy[msg.sender] != 0`) was followed by an external
43
+ /// call to `REGISTRY.register(...)` BEFORE the corresponding
44
+ /// state write. A malicious resolver invoked by the registry
45
+ /// could re-enter `register()` and pass the check a second time.
46
+ /// The guard prevents any nested call into `register()`, closing
47
+ /// the window. Recon doc § 4.1 / `docs/audits/r6-contracts-recon-2026-05-31.md`.
48
+ contract PermissionlessSubregistry is ReentrancyGuard {
40
49
  AgentNameRegistry public immutable REGISTRY;
41
50
  bytes32 public immutable PARENT_NODE;
42
51
  address public immutable DEFAULT_RESOLVER;
@@ -77,12 +86,20 @@ contract PermissionlessSubregistry {
77
86
  * fee — fee gating belongs in a different subregistry
78
87
  * shape if needed.
79
88
  */
80
- function register(string calldata label, address newOwner) external returns (bytes32 childNode) {
89
+ function register(string calldata label, address newOwner) external nonReentrant returns (bytes32 childNode) {
81
90
  if (bytes(label).length == 0) revert EmptyLabel();
82
91
  if (bytes(label).length < MIN_LABEL_LENGTH) revert LabelTooShort();
83
92
  if (newOwner == address(0)) revert ZeroNewOwner();
84
93
  bytes32 prior = claimedBy[msg.sender];
85
94
  if (prior != bytes32(0)) revert AlreadyClaimed(prior);
95
+ // R9 / docs/audits/r9-static-analysis-triage.md § Slither M-1:
96
+ // Slither flags this as cross-function reentrancy because state is
97
+ // written AFTER the external call. The function is `nonReentrant`
98
+ // (line 89) + REGISTRY is an immutable contract we control
99
+ // (`AgentNameRegistry`) that does not call back into this contract.
100
+ // The cross-function reader (`hasClaimed`) is view-only. No invariant
101
+ // is violated by the pre-write-window. Triaged false positive.
102
+ // slither-disable-next-line reentrancy-no-eth
86
103
  childNode = REGISTRY.register(PARENT_NODE, label, newOwner, DEFAULT_RESOLVER, 0);
87
104
  claimedBy[msg.sender] = childNode;
88
105
  unchecked {
@@ -0,0 +1,137 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
7
+
8
+ /**
9
+ * @title PaymentEscrow
10
+ * @notice Hold-and-capture for an exchange order (spec 243 §5.5 escrow rail, FG-PAY-7).
11
+ * A payer funds a hold keyed by `orderHash`; funds then move on exactly ONE terminal path:
12
+ * - release → captured to the payee (on fulfillment evidence)
13
+ * - refund → returned to the payer (payee-consented, before capture)
14
+ * - reclaim → returned to the payer (payer-initiated, after expiry)
15
+ * Symmetric-escrow doctrine (273 EXC-D4): any escrowable leg can be held. Ports the
16
+ * smart-agent `CommitmentRegistry` status-machine pattern; no third-party escrow dependency.
17
+ *
18
+ * @dev HASH-ONLY events (orderHash only — no amounts/addresses leak; privacy posture §6). Apps read
19
+ * hold state via the `getHold` view (`readContract`), never inline `eth_getLogs` (ADR-0012).
20
+ * Fail-closed: a hold moves Held → {Captured|Refunded|Reclaimed} once and never again
21
+ * (checks-effects-interactions + ReentrancyGuard). Uses OZ SafeERC20 for fee/no-return tokens.
22
+ */
23
+ contract PaymentEscrow is ReentrancyGuard {
24
+ using SafeERC20 for IERC20;
25
+
26
+ enum Status {
27
+ None,
28
+ Held,
29
+ Captured,
30
+ Refunded,
31
+ Reclaimed
32
+ }
33
+
34
+ struct Hold {
35
+ address payer; // funded the hold
36
+ address asset; // ERC-20
37
+ uint256 amount;
38
+ address payee; // captures on release
39
+ address refundTo; // receives refund / reclaim
40
+ address releaser; // OPTIONAL extra party authorized to release (0 = payee-only)
41
+ uint64 expiry; // payer may reclaim at/after this time
42
+ Status status;
43
+ }
44
+
45
+ mapping(bytes32 orderHash => Hold) private _holds;
46
+
47
+ event EscrowDeposited(bytes32 indexed orderHash);
48
+ event EscrowReleased(bytes32 indexed orderHash);
49
+ event EscrowRefunded(bytes32 indexed orderHash);
50
+ event EscrowReclaimed(bytes32 indexed orderHash);
51
+
52
+ error HoldExists();
53
+ error NoHold();
54
+ error NotHeld();
55
+ error ZeroAmount();
56
+ error ZeroAddress();
57
+ error BadExpiry();
58
+ error NotAuthorized();
59
+ error NotYetExpired();
60
+
61
+ /**
62
+ * @notice Fund a hold for `orderHash`. Pulls `amount` of `asset` from the caller (the payer).
63
+ * @param refundTo Where refund/reclaim returns funds; 0 defaults to the payer.
64
+ * @param releaser Extra address allowed to `release` besides `payee`; 0 = payee-only.
65
+ */
66
+ function deposit(
67
+ bytes32 orderHash,
68
+ address asset,
69
+ uint256 amount,
70
+ address payee,
71
+ address refundTo,
72
+ address releaser,
73
+ uint64 expiry
74
+ ) external nonReentrant {
75
+ if (_holds[orderHash].status != Status.None) revert HoldExists();
76
+ if (amount == 0) revert ZeroAmount();
77
+ if (asset == address(0) || payee == address(0)) revert ZeroAddress();
78
+ if (expiry <= block.timestamp) revert BadExpiry();
79
+
80
+ _holds[orderHash] = Hold({
81
+ payer: msg.sender,
82
+ asset: asset,
83
+ amount: amount,
84
+ payee: payee,
85
+ refundTo: refundTo == address(0) ? msg.sender : refundTo,
86
+ releaser: releaser,
87
+ expiry: expiry,
88
+ status: Status.Held
89
+ });
90
+ emit EscrowDeposited(orderHash);
91
+ IERC20(asset).safeTransferFrom(msg.sender, address(this), amount);
92
+ }
93
+
94
+ /// @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);
97
+ if (msg.sender != h.payee && (h.releaser == address(0) || msg.sender != h.releaser)) revert NotAuthorized();
98
+ h.status = Status.Captured;
99
+ emit EscrowReleased(orderHash);
100
+ IERC20(h.asset).safeTransfer(h.payee, h.amount);
101
+ }
102
+
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);
106
+ if (msg.sender != h.payee) revert NotAuthorized();
107
+ h.status = Status.Refunded;
108
+ emit EscrowRefunded(orderHash);
109
+ IERC20(h.asset).safeTransfer(h.refundTo, h.amount);
110
+ }
111
+
112
+ /// @notice Payer reclaims a held order after expiry (never released).
113
+ function reclaim(bytes32 orderHash) external nonReentrant {
114
+ Hold storage h = _held(orderHash);
115
+ if (msg.sender != h.payer) revert NotAuthorized();
116
+ if (block.timestamp < h.expiry) revert NotYetExpired();
117
+ h.status = Status.Reclaimed;
118
+ emit EscrowReclaimed(orderHash);
119
+ IERC20(h.asset).safeTransfer(h.refundTo, h.amount);
120
+ }
121
+
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];
125
+ }
126
+
127
+ function statusOf(bytes32 orderHash) external view returns (Status) {
128
+ return _holds[orderHash].status;
129
+ }
130
+
131
+ /// @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];
134
+ if (h.status == Status.None) revert NoHold();
135
+ if (h.status != Status.Held) revert NotHeld();
136
+ }
137
+ }
@@ -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
+ }
@@ -34,6 +34,13 @@ pragma solidity ^0.8.28;
34
34
  * Off-chain JSON published at `metadataURI` MUST hash to
35
35
  * `metadataHash`; consumers reject mismatched fetches.
36
36
  */
37
+ // R6.3 — Every `e.createdAt == 0` comparison in this contract is a
38
+ // sentinel "edge-never-created" check. Storage default IS exactly
39
+ // 0 for unset records, so the equality is semantically a
40
+ // "does-not-exist" probe — not a numeric-precision concern.
41
+ // Disabling Slither's `incorrect-equality` at the contract scope
42
+ // keeps these inline reads idiomatic.
43
+ // slither-disable-start incorrect-equality
37
44
  contract AgentRelationship {
38
45
  enum EdgeStatus {
39
46
  NONE, // 0 — never set (sentinel)
@@ -287,3 +294,4 @@ contract AgentRelationship {
287
294
  }
288
295
  }
289
296
  }
297
+ // slither-disable-end incorrect-equality