@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.
- package/AUDIT.md +324 -47
- package/README.md +45 -10
- package/deployments-base-sepolia.json +36 -1
- package/deployments-registries-base-sepolia.json +1 -0
- package/dist/abi/AgentAccount.json +1670 -0
- package/dist/abi/AgentAccountFactory.json +477 -0
- package/dist/abi/AgentNameRegistry.json +51 -0
- package/dist/abi/AgenticGovernance.json +32 -0
- package/dist/abi/AgreementRegistry.json +427 -0
- package/dist/abi/AttestationRegistry.json +436 -0
- package/dist/abi/CallDataHashEnforcer.json +119 -0
- package/dist/abi/CustodyPolicy.json +1122 -0
- package/dist/abi/DelegationManager.json +645 -0
- package/dist/abi/GeoFeatureRegistry.json +625 -0
- package/dist/abi/IAgentAccount.json +262 -0
- package/dist/abi/IAgentAccountFactoryAccessor.json +15 -0
- package/dist/abi/IAgentAccountFactoryView.json +41 -0
- package/dist/abi/IAgentAccountPauseView.json +15 -0
- package/dist/abi/IAgenticPrimitivesAgentAccount.json +15 -0
- package/dist/abi/IApprovedHashRegistryView.json +26 -0
- package/dist/abi/ICustodyPolicyFactoryView.json +15 -0
- package/dist/abi/ICustodyPolicyPauseView.json +15 -0
- package/dist/abi/IDelegationManager.json +171 -0
- package/dist/abi/IERC7579HookLike.json +44 -0
- package/dist/abi/IERC7579ModuleLike.json +28 -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/PaymentEscrow.json +299 -0
- package/dist/abi/PaymentReceiptRegistry.json +182 -0
- package/dist/abi/PermissionlessSubregistry.json +5 -0
- package/dist/abi/QuorumEnforcer.json +311 -0
- package/dist/abi/SignatureSlotRecovery.json +88 -0
- package/dist/abi/SkillDefinitionRegistry.json +491 -0
- package/dist/abi/SmartAgentPaymaster.json +873 -0
- package/dist/abi/UniversalSignatureValidator.json +65 -0
- package/dist/abi/index.d.ts +28 -0
- package/dist/abi/index.js +28 -0
- package/dist/deployments/base-sepolia.d.ts +41 -0
- package/dist/deployments/base-sepolia.js +39 -0
- package/dist/deployments/index.d.ts +9 -0
- package/dist/deployments/index.js +16 -0
- package/dist/deployments/registries-base-sepolia.d.ts +9 -0
- package/dist/deployments/registries-base-sepolia.js +7 -0
- package/package.json +28 -7
- package/src/AgentAccount.sol +191 -11
- package/src/AgentAccountFactory.sol +66 -12
- package/src/SmartAgentPaymaster.sol +186 -20
- package/src/UniversalSignatureValidator.sol +3 -0
- package/src/agency/DelegationManager.sol +170 -0
- package/src/agency/IDelegationManager.sol +38 -0
- package/src/agreement/AgreementRegistry.sol +331 -0
- package/src/attestation/AttestationRegistry.sol +384 -0
- package/src/custody/CustodyPolicy.sol +138 -8
- package/src/enforcers/AllowedMethodsEnforcer.sol +6 -0
- package/src/enforcers/AllowedTargetsEnforcer.sol +6 -0
- package/src/enforcers/CallDataHashEnforcer.sol +61 -0
- package/src/enforcers/PaymentEnforcer.sol +194 -0
- package/src/enforcers/QuorumEnforcer.sol +20 -0
- package/src/enforcers/TimestampEnforcer.sol +7 -0
- package/src/enforcers/ValueEnforcer.sol +6 -0
- package/src/geo/GeoFeatureRegistry.sol +197 -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 +52 -15
- package/src/naming/AgentNameUniversalResolver.sol +1 -1
- package/src/naming/PermissionlessSubregistry.sol +19 -2
- package/src/payments/PaymentEscrow.sol +137 -0
- package/src/payments/PaymentReceiptRegistry.sol +67 -0
- package/src/relationships/AgentRelationship.sol +8 -0
- package/src/skills/SkillDefinitionRegistry.sol +187 -0
- 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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|