@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,384 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "@openzeppelin/contracts/interfaces/IERC1271.sol";
5
+ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
6
+
7
+ /**
8
+ * @title AttestationRegistry
9
+ * @notice EAS-aligned, bilateral-consent attestation registry per ADR-0023.
10
+ *
11
+ * ─── Substrate role ───
12
+ *
13
+ * Layers 12-15 of the coordination spine (Evidence / Outcome / Validation /
14
+ * TrustUpdate) plus the substrate's Association + JointAgreement attestations
15
+ * all share THIS one contract. Per ADR-0024 Decision 2 — the architectural
16
+ * inverse of the smart-contract-per-credential anti-pattern.
17
+ *
18
+ * ─── Key invariants (AR-*) ───
19
+ *
20
+ * AR-01 deterministic UID = keccak256(subject, issuer, credentialType, credentialHash, refUID, salt)
21
+ * AR-02 issuer signature verified (ERC-1271 for SAs, low-s ECDSA for EOAs) over a CANONICAL TYPED
22
+ * digest recomputed on-chain — ASSOCIATION_ATTESTATION_TYPEHASH (unilateral) /
23
+ * JOINT_ISSUER_TYPEHASH (joint), each binding the parties/subject + schema + credentialType +
24
+ * credentialHash + chainId + this contract (SC-2 / ATT-1). A bare credentialHash is never trusted.
25
+ * AR-03 schemaId is an OPAQUE tag stored on the attestation. This registry does NOT dereference
26
+ * ShapeRegistry on-chain; shape/governance validation of schemaId is an off-chain/consumer
27
+ * concern. (Earlier drafts claimed on-chain ShapeRegistry enforcement — that is not implemented
28
+ * here and consumers MUST NOT assume it.)
29
+ * AR-04 bilateral consent is VERIFIED on-chain by recomputing JOINT_CONSENT_TYPEHASH (party1, party2,
30
+ * agreementCommitment, credentialHash, chainId, this contract) and requiring BOTH parties'
31
+ * signatures over it (ERC-1271 / low-s ECDSA). This entrypoint uses the direct two-party-signature
32
+ * form; the DelegationManager.verifyAuthorization predicate path is spec 249's alternative (b),
33
+ * NOT this contract's direct joint path.
34
+ * AR-05 holder-only revocation for unilateral; either-party for joint
35
+ * AR-06 NO issuerRevoke(...) entrypoint — by static analysis
36
+ * AR-07 epoch-bucket timestamps; raw block.timestamp never stored
37
+ * AR-08 four indexed event topics (EAS-like)
38
+ * AR-09 immutable + non-upgradeable; no admin
39
+ *
40
+ * Spec 242 §9 + ADR-0023.
41
+ */
42
+ contract AttestationRegistry {
43
+ // ─── Constants ──────────────────────────────────────────────────────
44
+
45
+ uint64 public constant EPOCH_SECONDS = 3600;
46
+
47
+ // ─── Errors ─────────────────────────────────────────────────────────
48
+
49
+ error InvalidIssuerSignature();
50
+ error AttestationAlreadyExists();
51
+ error AttestationNotFound();
52
+ error NotSubject();
53
+ error NotParty();
54
+ error AlreadyRevoked();
55
+ error InvalidParties();
56
+ error InvalidPartyConsent();
57
+ error EmptyCredentialHash();
58
+
59
+ /// @dev RW1-1 (ADR-0027) — the canonical consent payload each party signs to consent to a
60
+ /// joint agreement. The contract RECOMPUTES this digest from calldata; a stored/supplied
61
+ /// reference is not consent. Binds the consent to the exact parties + agreement + credential.
62
+ /// ATT-3 (audit 2026-06-10): also binds chainId + this registry, mirroring the issuer side
63
+ /// (ATT-1) and the association path (SC-2). Without it a party's consent signature could be
64
+ /// replayed on another chain or a redeployed registry (the attestation map is per-instance
65
+ /// storage, so a cross-instance replay anchors a fresh spoofed attestation).
66
+ bytes32 internal constant JOINT_CONSENT_TYPEHASH = keccak256(
67
+ "JointAgreementConsent(address party1,address party2,bytes32 agreementCommitment,bytes32 credentialHash,uint256 chainId,address verifyingContract)"
68
+ );
69
+
70
+ /// @dev ATT-1 (audit 2026-06-10): the SC-2 bug class lived on in the JOINT issuer signature — it was
71
+ /// verified over a bare `credentialHash`, so a known issuer signature could be reused to anchor a
72
+ /// spoofed issuer-backed joint attestation with different parties/schema. The issuer now signs a
73
+ /// digest binding parties + schema + credential type + commitment + chain + this contract.
74
+ bytes32 internal constant JOINT_ISSUER_TYPEHASH = keccak256(
75
+ "JointAgreementIssuerAttestation(address party1,address party2,address issuer,bytes32 schemaId,bytes32 credentialType,bytes32 credentialHash,bytes32 agreementCommitment,uint256 chainId,address verifyingContract)"
76
+ );
77
+
78
+ /// @dev SC-2 (audit 2026-06-09): the issuer attestation digest binds the SUBJECT (and schema /
79
+ /// credential type / chain / this contract) — not just `credentialHash`. Previously the issuer
80
+ /// signed only the credentialHash, so anyone who learned that public hash could anchor it on
81
+ /// chain against a DIFFERENT subject. Recomputed on-chain; the TS side derives the same constant.
82
+ bytes32 internal constant ASSOCIATION_ATTESTATION_TYPEHASH = keccak256(
83
+ "AssociationAttestation(address subject,address issuer,bytes32 schemaId,bytes32 credentialType,bytes32 credentialHash,uint256 chainId,address verifyingContract)"
84
+ );
85
+
86
+ // ─── Storage ────────────────────────────────────────────────────────
87
+
88
+ /// @notice The canonical attestation row. EAS-aligned shape.
89
+ struct Attestation {
90
+ bytes32 uid;
91
+ bytes32 schemaId; // → ShapeRegistry row
92
+ bytes32 credentialType; // VC type class
93
+ bytes32 credentialHash; // RFC 8785 JCS hash of off-chain VC body
94
+ bytes32 refUID; // EAS-style back-pointer (e.g. AgreementCommitment for JointAgreement)
95
+ bytes32 bilateralConsentRef; // 0 for unilateral; hash for joint
96
+ bytes32 offchainCredentialStatusList; // W3C VC StatusList2021 pointer
97
+ uint64 epochBucket; // attest time / EPOCH_SECONDS
98
+ uint64 revocationEpochBucket; // 0 if not revoked
99
+ address subject; // holder (unilateral) or party[0] (joint)
100
+ address party2; // address(0) for unilateral; party[1] for joint
101
+ address issuer; // off-chain VC signer SA
102
+ }
103
+
104
+ mapping(bytes32 => Attestation) private _attestations;
105
+
106
+ // ─── Requests ───────────────────────────────────────────────────────
107
+
108
+ struct AssociationAttestationRequest {
109
+ bytes32 schemaId;
110
+ bytes32 credentialType;
111
+ bytes32 credentialHash;
112
+ bytes32 offchainCredentialStatusList;
113
+ address subject;
114
+ address issuer;
115
+ /// @dev EIP-712 signature by `issuer` SA over the credentialHash;
116
+ /// verified via ERC-1271.
117
+ bytes issuerSignature;
118
+ uint256 salt;
119
+ }
120
+
121
+ struct JointAgreementAttestationRequest {
122
+ bytes32 schemaId;
123
+ bytes32 credentialType;
124
+ bytes32 credentialHash;
125
+ bytes32 refUID; // AgreementCommitment hash (spec 241)
126
+ bytes32 bilateralConsentRef; // DEPRECATED / IGNORED (RW1-1, ADR-0027): consent is now
127
+ // VERIFIED on-chain from party1Signature + party2Signature over the RECOMPUTED
128
+ // consent digest; the stored `bilateralConsentRef` is that recomputed digest, not this.
129
+ bytes32 offchainCredentialStatusList;
130
+ address party1;
131
+ address party2;
132
+ address issuer;
133
+ bytes issuerSignature;
134
+ bytes party1Signature; // RW1-1: party1's consent over JOINT_CONSENT_TYPEHASH digest
135
+ bytes party2Signature; // RW1-1: party2's consent over JOINT_CONSENT_TYPEHASH digest
136
+ uint256 salt;
137
+ }
138
+
139
+ // ─── Events (EAS-like; 4 indexed topics each) ───────────────────────
140
+
141
+ /// @dev Solidity caps indexed-args at 3 per non-anonymous event (EVM
142
+ /// topic0 = event signature). Matches EAS event shape:
143
+ /// `uid` is non-indexed payload; subject + issuer + credentialType
144
+ /// are indexed for filtering.
145
+ event Attested(
146
+ address indexed subject,
147
+ address indexed issuer,
148
+ bytes32 indexed credentialType,
149
+ bytes32 uid
150
+ );
151
+
152
+ event JointAttested(
153
+ address indexed party1,
154
+ address indexed party2,
155
+ bytes32 indexed credentialType,
156
+ bytes32 uid
157
+ );
158
+
159
+ event Revoked(
160
+ address indexed subjectOrParty,
161
+ bytes32 indexed credentialType,
162
+ bytes32 uid,
163
+ bytes32 reasonHash
164
+ );
165
+
166
+ // ─── Public surface ─────────────────────────────────────────────────
167
+
168
+ /// @notice Unilateral attestation by a holder against an issued credential.
169
+ function assertAssociation(AssociationAttestationRequest calldata req) external returns (bytes32) {
170
+ if (req.credentialHash == bytes32(0)) revert EmptyCredentialHash();
171
+
172
+ // SC-2: RECOMPUTE the issuer-attestation digest binding the SUBJECT (+ schema / type / chain /
173
+ // this contract) and verify the issuer signed THAT. A known credentialHash can no longer be
174
+ // anchored against an attacker-chosen subject.
175
+ bytes32 issuerDigest = keccak256(
176
+ abi.encode(
177
+ ASSOCIATION_ATTESTATION_TYPEHASH,
178
+ req.subject,
179
+ req.issuer,
180
+ req.schemaId,
181
+ req.credentialType,
182
+ req.credentialHash,
183
+ block.chainid,
184
+ address(this)
185
+ )
186
+ );
187
+ if (!_isValidSignatureBool(req.issuer, issuerDigest, req.issuerSignature)) {
188
+ revert InvalidIssuerSignature();
189
+ }
190
+
191
+ bytes32 uid = _computeUid(
192
+ req.subject,
193
+ address(0),
194
+ req.issuer,
195
+ req.credentialType,
196
+ req.credentialHash,
197
+ bytes32(0),
198
+ req.salt
199
+ );
200
+
201
+ if (_attestations[uid].uid != bytes32(0)) revert AttestationAlreadyExists();
202
+
203
+ uint64 bucket = uint64(block.timestamp) / EPOCH_SECONDS;
204
+
205
+ _attestations[uid] = Attestation({
206
+ uid: uid,
207
+ schemaId: req.schemaId,
208
+ credentialType: req.credentialType,
209
+ credentialHash: req.credentialHash,
210
+ refUID: bytes32(0),
211
+ bilateralConsentRef: bytes32(0),
212
+ offchainCredentialStatusList: req.offchainCredentialStatusList,
213
+ epochBucket: bucket,
214
+ revocationEpochBucket: 0,
215
+ subject: req.subject,
216
+ party2: address(0),
217
+ issuer: req.issuer
218
+ });
219
+
220
+ emit Attested(req.subject, req.issuer, req.credentialType, uid);
221
+ return uid;
222
+ }
223
+
224
+ /// @notice Joint attestation by two parties against an agreement.
225
+ /// @dev RW1-1 (ADR-0027) — bilateral consent is VERIFIED on-chain, not trusted. The contract
226
+ /// recomputes the canonical consent digest (`JOINT_CONSENT_TYPEHASH` over party1, party2,
227
+ /// the agreement commitment, and the credential hash) and requires BOTH parties' signatures
228
+ /// over it (ERC-1271 for smart accounts, ECDSA for EOAs). A nonzero `bilateralConsentRef` is
229
+ /// NOT consent (the prior "caller's responsibility" model, AR-04). The recomputed digest is
230
+ /// what gets stored. Alternative consent proof via `DelegationManager.verifyAuthorizationForCall`
231
+ /// (an exact-call sub-delegation per party) is spec 249's option (b); this entrypoint takes
232
+ /// the direct two-party-signature form.
233
+ function assertJointAgreement(
234
+ JointAgreementAttestationRequest calldata req
235
+ ) external returns (bytes32) {
236
+ if (req.credentialHash == bytes32(0)) revert EmptyCredentialHash();
237
+ if (req.party1 == address(0) || req.party2 == address(0)) revert InvalidParties();
238
+ if (req.party1 == req.party2) revert InvalidParties();
239
+
240
+ // ATT-1 (audit 2026-06-10): the issuer signs a FULL typed digest binding the parties, schema,
241
+ // credential type/hash, the agreement commitment (refUID), chain, and this registry — not a bare
242
+ // `credentialHash`. A leaked/observed issuer signature can no longer be replayed to anchor an
243
+ // issuer-backed joint attestation for different parties/schema/chain.
244
+ bytes32 issuerDigest = keccak256(
245
+ abi.encode(
246
+ JOINT_ISSUER_TYPEHASH,
247
+ req.party1,
248
+ req.party2,
249
+ req.issuer,
250
+ req.schemaId,
251
+ req.credentialType,
252
+ req.credentialHash,
253
+ req.refUID,
254
+ block.chainid,
255
+ address(this)
256
+ )
257
+ );
258
+ if (!_isValidSignatureBool(req.issuer, issuerDigest, req.issuerSignature)) {
259
+ revert InvalidIssuerSignature();
260
+ }
261
+
262
+ // VERIFY bilateral consent: recompute the digest, require both parties' signatures over it.
263
+ // ATT-3: chainId + address(this) bound (anti cross-chain / redeployed-registry replay).
264
+ bytes32 consentDigest = keccak256(
265
+ abi.encode(
266
+ JOINT_CONSENT_TYPEHASH,
267
+ req.party1,
268
+ req.party2,
269
+ req.refUID,
270
+ req.credentialHash,
271
+ block.chainid,
272
+ address(this)
273
+ )
274
+ );
275
+ if (!_isValidSignatureBool(req.party1, consentDigest, req.party1Signature)) revert InvalidPartyConsent();
276
+ if (!_isValidSignatureBool(req.party2, consentDigest, req.party2Signature)) revert InvalidPartyConsent();
277
+
278
+ bytes32 uid = _computeUid(
279
+ req.party1,
280
+ req.party2,
281
+ req.issuer,
282
+ req.credentialType,
283
+ req.credentialHash,
284
+ req.refUID,
285
+ req.salt
286
+ );
287
+ if (_attestations[uid].uid != bytes32(0)) revert AttestationAlreadyExists();
288
+
289
+ uint64 bucket = uint64(block.timestamp) / EPOCH_SECONDS;
290
+
291
+ _attestations[uid] = Attestation({
292
+ uid: uid,
293
+ schemaId: req.schemaId,
294
+ credentialType: req.credentialType,
295
+ credentialHash: req.credentialHash,
296
+ refUID: req.refUID,
297
+ bilateralConsentRef: consentDigest, // the VERIFIED, recomputed consent digest (RW1-1)
298
+ offchainCredentialStatusList: req.offchainCredentialStatusList,
299
+ epochBucket: bucket,
300
+ revocationEpochBucket: 0,
301
+ subject: req.party1,
302
+ party2: req.party2,
303
+ issuer: req.issuer
304
+ });
305
+
306
+ emit JointAttested(req.party1, req.party2, req.credentialType, uid);
307
+ return uid;
308
+ }
309
+
310
+ /// @notice Holder-only revocation for unilateral attestations (AR-05).
311
+ function revokeOwnAssociation(bytes32 uid, bytes32 reasonHash) external {
312
+ Attestation storage a = _attestations[uid];
313
+ if (a.uid == bytes32(0)) revert AttestationNotFound();
314
+ if (a.party2 != address(0)) revert NotSubject(); // joint — wrong entrypoint
315
+ if (msg.sender != a.subject) revert NotSubject();
316
+ if (a.revocationEpochBucket != 0) revert AlreadyRevoked();
317
+
318
+ a.revocationEpochBucket = uint64(block.timestamp) / EPOCH_SECONDS;
319
+ emit Revoked(msg.sender, a.credentialType, uid, reasonHash);
320
+ }
321
+
322
+ /// @notice Either-party revocation for joint attestations (D-26 + AR-05).
323
+ function revokeOwnJointAgreement(bytes32 uid, bytes32 reasonHash) external {
324
+ Attestation storage a = _attestations[uid];
325
+ if (a.uid == bytes32(0)) revert AttestationNotFound();
326
+ if (a.party2 == address(0)) revert NotParty(); // unilateral — wrong entrypoint
327
+ if (msg.sender != a.subject && msg.sender != a.party2) revert NotParty();
328
+ if (a.revocationEpochBucket != 0) revert AlreadyRevoked();
329
+
330
+ a.revocationEpochBucket = uint64(block.timestamp) / EPOCH_SECONDS;
331
+ emit Revoked(msg.sender, a.credentialType, uid, reasonHash);
332
+ }
333
+
334
+ /// @notice Get the full attestation row.
335
+ function getAttestation(bytes32 uid) external view returns (Attestation memory) {
336
+ Attestation memory a = _attestations[uid];
337
+ if (a.uid == bytes32(0)) revert AttestationNotFound();
338
+ return a;
339
+ }
340
+
341
+ /// @notice Convenience — `!revoked` view.
342
+ function isValid(bytes32 uid) external view returns (bool) {
343
+ return _attestations[uid].uid != bytes32(0) && _attestations[uid].revocationEpochBucket == 0;
344
+ }
345
+
346
+ // ─── Internal ───────────────────────────────────────────────────────
347
+
348
+ function _computeUid(
349
+ address subject,
350
+ address party2,
351
+ address issuer,
352
+ bytes32 credentialType,
353
+ bytes32 credentialHash,
354
+ bytes32 refUID,
355
+ uint256 salt
356
+ ) internal pure returns (bytes32) {
357
+ return
358
+ keccak256(
359
+ abi.encode(subject, party2, issuer, credentialType, credentialHash, refUID, salt)
360
+ );
361
+ }
362
+
363
+ /// @dev ERC-1271-first signature verification (smart accounts), with EOA fallback.
364
+ function _isValidSignatureBool(
365
+ address signer,
366
+ bytes32 digest,
367
+ bytes memory signature
368
+ ) internal view returns (bool) {
369
+ if (signer.code.length > 0) {
370
+ try IERC1271(signer).isValidSignature(digest, signature) returns (bytes4 result) {
371
+ return result == IERC1271.isValidSignature.selector;
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
376
+ // EOA fallback via eth-signed message hash + malleability-safe ECDSA.
377
+ // P2-2 (audit 2026-06-10): route EOA recovery through OpenZeppelin ECDSA.tryRecover instead of
378
+ // raw ecrecover — it rejects high-s (malleable) signatures and malformed lengths, matching the
379
+ // DelegationManager idiom so every signature-verifying contract shares one low-s-safe path.
380
+ bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));
381
+ (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(ethHash, signature);
382
+ return err == ECDSA.RecoverError.NoError && recovered == signer;
383
+ }
384
+ }
@@ -4,6 +4,22 @@ pragma solidity ^0.8.28;
4
4
  import {IAgentAccount, AgentAccountRecoveryArgs, AgentAccountRecoveryPasskeyAdd} from "../IAgentAccount.sol";
5
5
  import {SignatureSlotRecovery} from "../libraries/SignatureSlotRecovery.sol";
6
6
 
7
+ /// @dev R6.6 — minimal interface for the `account → factory → governance.isPaused()`
8
+ /// staticcall chain. Mirrors the same patterns introduced by R6.5
9
+ /// in `AgentAccount` (see `IAgentAccountFactoryView` /
10
+ /// `IAgentAccountPauseView` in `AgentAccount.sol`).
11
+ interface IAgentAccountFactoryAccessor {
12
+ function factory() external view returns (address);
13
+ }
14
+
15
+ interface ICustodyPolicyFactoryView {
16
+ function governance() external view returns (address);
17
+ }
18
+
19
+ interface ICustodyPolicyPauseView {
20
+ function isPaused() external view returns (bool);
21
+ }
22
+
7
23
  /**
8
24
  * @title CustodyPolicy
9
25
  * @notice ERC-7579 module that owns the schedule / apply / cancel
@@ -125,6 +141,64 @@ contract CustodyPolicy {
125
141
  _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator();
126
142
  }
127
143
 
144
+ // ─── R6.6 / CON-CustodyPolicy-005 — system-pause modifier ──────
145
+ //
146
+ // `whenAccountNotPaused(account)` reverts when
147
+ // `AgenticGovernance.isPaused() == true`, where governance is
148
+ // resolved per-call via the account's factory:
149
+ //
150
+ // account.factory() → factory.governance() → governance.isPaused()
151
+ //
152
+ // 3 staticcalls. Custody schedule/apply are rare; the gas cost
153
+ // is acceptable. Non-conforming hops (EOA factory, EOA governance,
154
+ // missing fn) treat the system as "not paused" so legacy / test
155
+ // deploys keep working — mirrors the R6.5 pattern in AgentAccount
156
+ // and `GovernanceManaged._pausedSafe()`.
157
+ //
158
+ // Applied to: `scheduleCustodyChange`, `applyCustodyChange`.
159
+ //
160
+ // Deliberately NOT applied to (recovery primitives that must
161
+ // work during an incident):
162
+ // - `cancelScheduledChange` — defensive de-escalation
163
+ // - `onUninstall` — removing attack surface
164
+ // - `onInstall` — gated upstream by paused
165
+ // `installModule` on AgentAccount (R6.5)
166
+ // and paused `createAgentAccount`
167
+ // on AgentAccountFactory
168
+ modifier whenAccountNotPaused(address account) {
169
+ if (_systemPausedFor(account)) revert SystemPaused();
170
+ _;
171
+ }
172
+
173
+ /// @notice R6.6 — thrown when a `whenAccountNotPaused`-guarded
174
+ /// entrypoint is called while the governance pause flag
175
+ /// is set.
176
+ error SystemPaused();
177
+
178
+ function _systemPausedFor(address account) internal view returns (bool) {
179
+ if (account == address(0) || account.code.length == 0) return false;
180
+ // Hop 1: account → factory
181
+ (bool okF, bytes memory dataF) = account.staticcall(
182
+ abi.encodeWithSelector(IAgentAccountFactoryAccessor.factory.selector)
183
+ );
184
+ if (!okF || dataF.length < 32) return false;
185
+ address fac = abi.decode(dataF, (address));
186
+ if (fac == address(0) || fac.code.length == 0) return false;
187
+ // Hop 2: factory → governance
188
+ (bool okG, bytes memory dataG) = fac.staticcall(
189
+ abi.encodeWithSelector(ICustodyPolicyFactoryView.governance.selector)
190
+ );
191
+ if (!okG || dataG.length < 32) return false;
192
+ address gov = abi.decode(dataG, (address));
193
+ if (gov == address(0) || gov.code.length == 0) return false;
194
+ // Hop 3: governance → isPaused
195
+ (bool okP, bytes memory dataP) = gov.staticcall(
196
+ abi.encodeWithSelector(ICustodyPolicyPauseView.isPaused.selector)
197
+ );
198
+ if (!okP || dataP.length < 32) return false;
199
+ return abi.decode(dataP, (bool));
200
+ }
201
+
128
202
  function _buildDomainSeparator() internal view returns (bytes32) {
129
203
  return keccak256(
130
204
  abi.encode(
@@ -239,6 +313,9 @@ contract CustodyPolicy {
239
313
  error TrusteeDoesNotExist(address guardian);
240
314
  error InvalidMode(uint8 mode_);
241
315
  error InvalidThresholdValue(uint8 thr);
316
+ /// @dev CP-1: a tier that must be configured (T4/T5 at install, T6 at read)
317
+ /// was left unset — refuse rather than fall to the 1-of-n read fallback.
318
+ error UnconfiguredTier(uint8 tier);
242
319
  error RecoveryRequiresGuardians();
243
320
  error EmptyOwnerSet();
244
321
  error CustodyActionNotYetImplemented(uint8 action);
@@ -296,11 +373,7 @@ contract CustodyPolicy {
296
373
  c.t3HighValueCeiling = t3Ceiling;
297
374
  c.approvedHashRegistry = approvedHashReg;
298
375
 
299
- for (uint8 t = 1; t <= 6; t++) {
300
- if (thresholds[t] > 0) c.approvalsRequiredByTier[t] = thresholds[t];
301
- if (timelocks[t] > 0) c.safetyDelayByTier[t] = timelocks[t];
302
- }
303
-
376
+ // Trustees first so `trusteeCount` is known for the CP-2 recovery check.
304
377
  for (uint256 i; i < trustees.length; i++) {
305
378
  address g = trustees[i];
306
379
  if (g == address(0)) revert ZeroAddress();
@@ -309,6 +382,51 @@ contract CustodyPolicy {
309
382
  c.trusteeCount += 1;
310
383
  }
311
384
 
385
+ // CP-2: a recovery threshold the trustee set can NEVER satisfy bricks the
386
+ // T6 recovery lifeline from birth. Validate at install exactly as
387
+ // `_applySetRecoveryThreshold` does at runtime. `recoveryApprovals == 0`
388
+ // is a legitimate state (trustee recovery simply disabled); a positive
389
+ // threshold MUST be reachable by the configured trustees.
390
+ if (recThr > c.trusteeCount) revert RecoveryRequiresGuardians();
391
+
392
+ // CP-1: floor each unset tier to the spec default-approvals matrix so a
393
+ // direct onInstall that bypasses the factory with a zeroed/partial
394
+ // threshold array can't collapse the admin/critical tiers to the 1-of-n
395
+ // read fallback in `_approvalsValue`. T1–T3 legitimately stay at the
396
+ // 1-of-n floor (read/write/value are single-signer tiers by design);
397
+ // T4/T5 get the council default. T6 (recovery) lives in
398
+ // `recoveryApprovals`, not this map.
399
+ //
400
+ // The custodian count is read best-effort from the installing account
401
+ // (the ERC-7579 flow guarantees `msg.sender` is the deployed account).
402
+ // If it can't be determined (the account isn't yet/at all an
403
+ // `IAgentAccount`), `nCustodians` stays 0 so no flooring happens — and
404
+ // the hard-revert below then REQUIRES the installer to have passed
405
+ // explicit T4/T5 thresholds. Either way the admin/critical tiers can
406
+ // never silently resolve to 1-of-n; the floor is a convenience, the
407
+ // hard-revert is the security invariant.
408
+ uint8 nCustodians;
409
+ if (account.code.length > 0) {
410
+ try IAgentAccount(account).custodianCount() returns (uint256 cnt) {
411
+ nCustodians = cnt > 255 ? 255 : uint8(cnt);
412
+ } catch {}
413
+ }
414
+ for (uint8 t = 1; t <= 6; t++) {
415
+ uint8 thr = thresholds[t];
416
+ if (thr == 0) thr = _defaultApprovals(nCustodians, t);
417
+ if (thr > 0) c.approvalsRequiredByTier[t] = thr;
418
+ if (timelocks[t] > 0) c.safetyDelayByTier[t] = timelocks[t];
419
+ }
420
+
421
+ // CP-1 hard-revert: a non-single account MUST have the admin (T4) +
422
+ // critical (T5) custody quorum configured (explicitly or via the floor
423
+ // above) — never the 1-of-n read fallback. Mode 0 (single signer) has no
424
+ // custody council, so thresholds are moot and this is skipped.
425
+ if (modeVal >= 1) {
426
+ if (c.approvalsRequiredByTier[4] == 0) revert UnconfiguredTier(4);
427
+ if (c.approvalsRequiredByTier[5] == 0) revert UnconfiguredTier(5);
428
+ }
429
+
312
430
  emit CustodyPolicyInstalled(account, modeVal, recThr);
313
431
  }
314
432
 
@@ -335,7 +453,7 @@ contract CustodyPolicy {
335
453
  CustodyAction action,
336
454
  bytes calldata args,
337
455
  bytes calldata quorumSigs
338
- ) external returns (uint256 changeId) {
456
+ ) external whenAccountNotPaused(account) returns (uint256 changeId) {
339
457
  Config storage c = _configs[account];
340
458
  if (!c.installed) revert NotInstalledOn(account);
341
459
 
@@ -394,7 +512,10 @@ contract CustodyPolicy {
394
512
  emit CustodyChangeScheduled(account, changeId, action, eta, msg.sender);
395
513
  }
396
514
 
397
- function applyCustodyChange(address account, uint256 changeId, bytes calldata quorumSigs) external {
515
+ function applyCustodyChange(address account, uint256 changeId, bytes calldata quorumSigs)
516
+ external
517
+ whenAccountNotPaused(account)
518
+ {
398
519
  Config storage c = _configs[account];
399
520
  if (!c.installed) revert NotInstalledOn(account);
400
521
 
@@ -556,7 +677,16 @@ contract CustodyPolicy {
556
677
  function _approvalsValue(Config storage c, uint8 tier) internal view returns (uint8) {
557
678
  if (tier == 0 || tier > 6) revert InvalidTier(tier);
558
679
  uint8 v = c.approvalsRequiredByTier[tier];
559
- return v == 0 ? 1 : v;
680
+ if (v == 0) {
681
+ // CP-1 defense-in-depth: the T6 (recovery) quorum lives in
682
+ // `recoveryApprovals`, never in this map — a T6 read here is a
683
+ // mis-wire, so fail closed instead of returning the 1-of-n fallback.
684
+ // T1–T5 keep the 1-of-n floor (T4/T5 are install-floored, so the
685
+ // fallback only ever applies to the low single-signer tiers).
686
+ if (tier == 6) revert UnconfiguredTier(6);
687
+ return 1;
688
+ }
689
+ return v;
560
690
  }
561
691
 
562
692
  function _safetyDelayValue(Config storage c, uint8 tier) internal view returns (uint32) {
@@ -8,6 +8,12 @@ import "../agency/ICaveatEnforcer.sol";
8
8
  * @notice Restricts delegated calls to specific function selectors.
9
9
  * @dev terms = abi.encode(bytes4[] allowedSelectors)
10
10
  * Follows ERC-7710 / MetaMask DeleGator beforeHook/afterHook pattern.
11
+ *
12
+ * R6.7 — Stateless validator; no `whenNotPaused` modifier needed
13
+ * (hooks are `pure`, contract has zero storage). The DM-side pause
14
+ * check in `DelegationManager.redeemDelegation` gates all
15
+ * reachable enforcer dispatch. See `docs/audits/r6-contracts-recon-2026-05-31.md`
16
+ * § 4.4 + `test/EnforcerPauseInvariantR67.t.sol`.
11
17
  */
12
18
  contract AllowedMethodsEnforcer is ICaveatEnforcer {
13
19
  error MethodNotAllowed();
@@ -8,6 +8,12 @@ import "../agency/ICaveatEnforcer.sol";
8
8
  * @notice Restricts delegated calls to a specific set of target contracts.
9
9
  * @dev terms = abi.encode(address[] allowedTargets)
10
10
  * Follows ERC-7710 / MetaMask DeleGator beforeHook/afterHook pattern.
11
+ *
12
+ * R6.7 — Stateless validator; no `whenNotPaused` modifier needed
13
+ * (hooks are `pure`, contract has zero storage). The DM-side pause
14
+ * check in `DelegationManager.redeemDelegation` gates all
15
+ * reachable enforcer dispatch. See `docs/audits/r6-contracts-recon-2026-05-31.md`
16
+ * § 4.4 + `test/EnforcerPauseInvariantR67.t.sol`.
11
17
  */
12
18
  contract AllowedTargetsEnforcer is ICaveatEnforcer {
13
19
  error TargetNotAllowed();
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../agency/ICaveatEnforcer.sol";
5
+
6
+ /**
7
+ * @title CallDataHashEnforcer
8
+ * @notice Pins a delegation to EXACTLY one calldata payload — the exact-call
9
+ * binding ADR-0027 / spec 249 (RW1-4b) needs for sensitive ops.
10
+ * @dev terms = abi.encode(bytes32 expectedHash), where
11
+ * `expectedHash == keccak256(callData)` for the single call this
12
+ * delegation authorizes. `beforeHook` recomputes `keccak256(callData)`
13
+ * and reverts on mismatch.
14
+ *
15
+ * Ported from smart-agent's `CallDataHashEnforcer` (the "P4 — sensitive
16
+ * ops require exact-call sub-delegation" doctrine). Combined with
17
+ * `AllowedTargetsEnforcer` (target) + a tight `TimestampEnforcer`
18
+ * window + `ValueEnforcer`, a consent sub-delegation can pin the precise
19
+ * call — not merely its selector — so a registry that checks
20
+ * `DelegationManager.verifyAuthorizationForCall(...)` binds consent to the
21
+ * exact assertion being stored (RW1-1).
22
+ *
23
+ * R6.7 — Stateless validator: hooks are `pure`, the contract has zero
24
+ * storage, so no `whenNotPaused` is needed; the DM-side pause check in
25
+ * `DelegationManager.redeemDelegation` gates all reachable enforcer
26
+ * dispatch, and the read-only `verifyAuthorizationForCall` evaluates it
27
+ * via `staticcall`.
28
+ */
29
+ contract CallDataHashEnforcer is ICaveatEnforcer {
30
+ error BadTermsLength();
31
+ error CallDataMismatch(bytes32 expected, bytes32 actual);
32
+
33
+ function beforeHook(
34
+ bytes calldata terms,
35
+ bytes calldata,
36
+ bytes32,
37
+ address,
38
+ address,
39
+ address,
40
+ uint256,
41
+ bytes calldata callData
42
+ ) external pure override {
43
+ if (terms.length != 32) revert BadTermsLength();
44
+ bytes32 expected = abi.decode(terms, (bytes32));
45
+ bytes32 actual = keccak256(callData);
46
+ if (expected != actual) revert CallDataMismatch(expected, actual);
47
+ }
48
+
49
+ function afterHook(
50
+ bytes calldata,
51
+ bytes calldata,
52
+ bytes32,
53
+ address,
54
+ address,
55
+ address,
56
+ uint256,
57
+ bytes calldata
58
+ ) external pure override {
59
+ // No post-execution check needed.
60
+ }
61
+ }