@agenticprimitives/contracts 0.1.0-alpha.3 → 1.0.0-alpha.5

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 (57) hide show
  1. package/AUDIT.md +304 -47
  2. package/README.md +19 -8
  3. package/deployments-base-sepolia.json +1 -0
  4. package/dist/abi/AgentAccount.json +1647 -0
  5. package/dist/abi/AgentAccountFactory.json +472 -0
  6. package/dist/abi/AgentNameRegistry.json +46 -0
  7. package/dist/abi/AgreementRegistry.json +432 -0
  8. package/dist/abi/AttestationRegistry.json +436 -0
  9. package/dist/abi/CallDataHashEnforcer.json +119 -0
  10. package/dist/abi/CustodyPolicy.json +1111 -0
  11. package/dist/abi/DelegationManager.json +645 -0
  12. package/dist/abi/GeoFeatureRegistry.json +625 -0
  13. package/dist/abi/IAgentAccount.json +262 -0
  14. package/dist/abi/IAgentAccountFactoryAccessor.json +15 -0
  15. package/dist/abi/IAgentAccountFactoryView.json +41 -0
  16. package/dist/abi/IAgentAccountPauseView.json +15 -0
  17. package/dist/abi/IAgenticPrimitivesAgentAccount.json +15 -0
  18. package/dist/abi/IApprovedHashRegistryView.json +26 -0
  19. package/dist/abi/ICustodyPolicyFactoryView.json +15 -0
  20. package/dist/abi/ICustodyPolicyPauseView.json +15 -0
  21. package/dist/abi/IDelegationManager.json +171 -0
  22. package/dist/abi/IERC7579HookLike.json +44 -0
  23. package/dist/abi/IERC7579ModuleLike.json +28 -0
  24. package/dist/abi/PermissionlessSubregistry.json +5 -0
  25. package/dist/abi/QuorumEnforcer.json +295 -0
  26. package/dist/abi/SignatureSlotRecovery.json +88 -0
  27. package/dist/abi/SkillDefinitionRegistry.json +491 -0
  28. package/dist/abi/SmartAgentPaymaster.json +664 -0
  29. package/dist/abi/UniversalSignatureValidator.json +65 -0
  30. package/dist/abi/index.d.ts +23 -0
  31. package/dist/abi/index.js +23 -0
  32. package/dist/deployments/base-sepolia.d.ts +37 -0
  33. package/dist/deployments/base-sepolia.js +35 -0
  34. package/dist/deployments/index.d.ts +8 -0
  35. package/dist/deployments/index.js +13 -0
  36. package/package.json +20 -3
  37. package/src/AgentAccount.sol +130 -9
  38. package/src/AgentAccountFactory.sol +34 -5
  39. package/src/SmartAgentPaymaster.sol +37 -8
  40. package/src/UniversalSignatureValidator.sol +3 -0
  41. package/src/agency/DelegationManager.sol +155 -0
  42. package/src/agency/IDelegationManager.sol +35 -0
  43. package/src/agreement/AgreementRegistry.sol +306 -0
  44. package/src/attestation/AttestationRegistry.sol +320 -0
  45. package/src/custody/CustodyPolicy.sol +79 -2
  46. package/src/enforcers/AllowedMethodsEnforcer.sol +6 -0
  47. package/src/enforcers/AllowedTargetsEnforcer.sol +6 -0
  48. package/src/enforcers/CallDataHashEnforcer.sol +61 -0
  49. package/src/enforcers/QuorumEnforcer.sol +8 -0
  50. package/src/enforcers/TimestampEnforcer.sol +7 -0
  51. package/src/enforcers/ValueEnforcer.sol +6 -0
  52. package/src/geo/GeoFeatureRegistry.sol +190 -0
  53. package/src/naming/AgentNameRegistry.sol +25 -13
  54. package/src/naming/AgentNameUniversalResolver.sol +1 -1
  55. package/src/naming/PermissionlessSubregistry.sol +19 -2
  56. package/src/relationships/AgentRelationship.sol +8 -0
  57. package/src/skills/SkillDefinitionRegistry.sol +181 -0
@@ -0,0 +1,306 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "@openzeppelin/contracts/interfaces/IERC1271.sol";
5
+
6
+ /**
7
+ * @title AgreementRegistry
8
+ * @notice Commitment-only registry per spec 241 + ADR-0024 Decision 2.
9
+ *
10
+ * ─── Substrate role ───
11
+ *
12
+ * Layer 8 of the coordination spine — Agreement / Commitment. Stores the
13
+ * commitment HASH only (no agreement bodies, no party SAs). Bodies live in
14
+ * party Joint Vaults; party SAs surface only through joint assertions
15
+ * registered in the AttestationRegistry (which references our `commitmentHash`).
16
+ *
17
+ * ─── Key invariants (AR-*) ───
18
+ *
19
+ * AR-01 register() recomputes the commitment from supplied components
20
+ * AR-02 issuer EIP-712 attestation verified via ERC-1271
21
+ * AR-03 schema validated against (off-chain) ShapeRegistry
22
+ * AR-04 status state-machine: ACTIVE → COMPLETED | DISPUTED | REVOKED
23
+ * AR-05 bilateral signing required for ACTIVE→COMPLETED, ACTIVE→REVOKED
24
+ * AR-06 either-party for ACTIVE→DISPUTED
25
+ * AR-07 NO issuerRevoke / issuerStatusUpdate entrypoint — by static analysis
26
+ * AR-08 nullifier set prevents replay on status transitions
27
+ * AR-09 epoch-bucket timestamps; raw block.timestamp never stored
28
+ * AR-10 isAssertableCommitment view gateway for AttestationRegistry
29
+ * AR-11 party SA addresses NEVER appear in register() calldata
30
+ * AR-12 single on-chain row per agreement (D-37)
31
+ */
32
+ contract AgreementRegistry {
33
+ // ─── Constants ──────────────────────────────────────────────────────
34
+
35
+ uint64 public constant EPOCH_SECONDS = 3600;
36
+
37
+ uint8 public constant STATUS_NONE = 0;
38
+ uint8 public constant STATUS_ACTIVE = 1;
39
+ uint8 public constant STATUS_COMPLETED = 2;
40
+ uint8 public constant STATUS_DISPUTED = 3;
41
+ uint8 public constant STATUS_REVOKED = 4;
42
+
43
+ /// @dev RW1-3 (ADR-0027): the contract RECOMPUTES the transition digest the
44
+ /// parties sign from (agreementCommitment, toStatus, nullifier) — it no
45
+ /// longer trusts a caller-supplied `transitionStructHash`. One canonical
46
+ /// digest model; the TS side derives the same constant (cross-stack
47
+ /// typehash-equality gate). Plain struct hash (no domain separator),
48
+ /// matching the attestation registry's JOINT_CONSENT_TYPEHASH.
49
+ bytes32 internal constant TRANSITION_TYPEHASH =
50
+ keccak256("AgreementTransition(bytes32 agreementCommitment,uint8 toStatus,bytes32 nullifier)");
51
+
52
+ // ─── Errors ─────────────────────────────────────────────────────────
53
+
54
+ error AlreadyRegistered();
55
+ error NotRegistered();
56
+ error InvalidStatus();
57
+ error InvalidIssuerSignature();
58
+ error InvalidTransitionSignature();
59
+ error NullifierUsed();
60
+ error CommitmentMismatch();
61
+ error SignerNotParty();
62
+
63
+ // ─── Storage ────────────────────────────────────────────────────────
64
+
65
+ struct AgreementRow {
66
+ bytes32 agreementCommitment; // canonical commitment hash
67
+ bytes32 schemaHash; // SHACL shape hash from ShapeRegistry
68
+ address issuer; // SA whose EIP-712 attestation backs the commitment
69
+ uint8 status;
70
+ uint64 createdEpochBucket;
71
+ uint64 lastTransitionEpochBucket;
72
+ }
73
+
74
+ /// @dev key = agreementCommitment hash
75
+ mapping(bytes32 => AgreementRow) private _rows;
76
+
77
+ /// @dev AR-08: nullifier set for replay protection on status transitions.
78
+ mapping(bytes32 => bool) private _nullifiers;
79
+
80
+ // ─── Requests ───────────────────────────────────────────────────────
81
+
82
+ /// @notice The components the contract recomputes the commitment from
83
+ /// (per spec 241 §3). Party SAs are intentionally NOT in this
84
+ /// payload — AR-11.
85
+ struct AgreementIssuancePayload {
86
+ bytes32 schemaHash;
87
+ address issuer;
88
+ /// @dev EIP-712 attestation hash by the issuer (over agreementCommitment + schemaHash).
89
+ bytes32 attestationStructHash;
90
+ /// @dev issuer's signature over `attestationStructHash`.
91
+ bytes issuerSignature;
92
+ /// @dev The asserted canonical commitment hash; we verify recomputation.
93
+ bytes32 agreementCommitment;
94
+ /// @dev Component commitments for AR-01 recomputation:
95
+ /// keccak256(partySet, issuerCommitment, termsCommitment, scheduleCommitment, salt) == agreementCommitment
96
+ bytes32 partySetCommitment;
97
+ bytes32 issuerCommitment;
98
+ bytes32 termsCommitment;
99
+ bytes32 scheduleCommitment;
100
+ uint256 salt;
101
+ }
102
+
103
+ struct StatusUpdatePayload {
104
+ bytes32 agreementCommitment;
105
+ uint8 toStatus;
106
+ bytes32 nullifier;
107
+ /// @dev Signatures over the RECOMPUTED transition digest (RW1-3): 1 for
108
+ /// either-party (DISPUTED), 2 for bilateral (COMPLETED/REVOKED).
109
+ bytes signature1;
110
+ bytes signature2;
111
+ /// @dev Party SAs that signed. For DISPUTED: signer1 only. For COMPLETED/REVOKED: both.
112
+ address signer1;
113
+ address signer2;
114
+ /// @dev RW1-2 (ADR-0027): the agreement's parties + commitment components, REVEALED at
115
+ /// transition time so the contract can recompute the commitment and PROVE the signers
116
+ /// are the parties. (Registration stays commitment-only — AR-12; the transition is
117
+ /// already a public state change that names its signers.)
118
+ address party1;
119
+ address party2;
120
+ bytes32 issuerCommitment;
121
+ bytes32 termsCommitment;
122
+ bytes32 scheduleCommitment;
123
+ uint256 commitmentSalt;
124
+ }
125
+
126
+ // ─── Events ─────────────────────────────────────────────────────────
127
+
128
+ event AgreementRegistered(
129
+ bytes32 indexed agreementCommitment,
130
+ address indexed issuer,
131
+ bytes32 indexed schemaHash,
132
+ uint64 epochBucket
133
+ );
134
+
135
+ event StatusUpdated(
136
+ bytes32 indexed agreementCommitment,
137
+ uint8 indexed fromStatus,
138
+ uint8 indexed toStatus,
139
+ uint64 epochBucket
140
+ );
141
+
142
+ // ─── Public surface ─────────────────────────────────────────────────
143
+
144
+ /// @notice Register an agreement by its commitment hash.
145
+ function register(AgreementIssuancePayload calldata p) external returns (bytes32) {
146
+ // AR-01 — recompute commitment from components
147
+ bytes32 recomputed = keccak256(
148
+ abi.encode(p.partySetCommitment, p.issuerCommitment, p.termsCommitment, p.scheduleCommitment, p.salt)
149
+ );
150
+ if (recomputed != p.agreementCommitment) revert CommitmentMismatch();
151
+
152
+ if (_rows[p.agreementCommitment].agreementCommitment != bytes32(0)) {
153
+ revert AlreadyRegistered();
154
+ }
155
+
156
+ // AR-02 — verify issuer signature over the attestation hash
157
+ if (!_isValidSignatureBool(p.issuer, p.attestationStructHash, p.issuerSignature)) {
158
+ revert InvalidIssuerSignature();
159
+ }
160
+
161
+ uint64 bucket = uint64(block.timestamp) / EPOCH_SECONDS;
162
+
163
+ _rows[p.agreementCommitment] = AgreementRow({
164
+ agreementCommitment: p.agreementCommitment,
165
+ schemaHash: p.schemaHash,
166
+ issuer: p.issuer,
167
+ status: STATUS_ACTIVE,
168
+ createdEpochBucket: bucket,
169
+ lastTransitionEpochBucket: bucket
170
+ });
171
+
172
+ emit AgreementRegistered(p.agreementCommitment, p.issuer, p.schemaHash, bucket);
173
+ return p.agreementCommitment;
174
+ }
175
+
176
+ /// @notice Update the agreement status. Signing requirements (AR-04..AR-06):
177
+ /// - DISPUTED: signer1 (either party) only
178
+ /// - COMPLETED / REVOKED: both signer1 + signer2 (bilateral)
179
+ /// RW1-2 (ADR-0027): the caller REVEALS the parties + commitment
180
+ /// components; the contract recomputes the commitment (register's
181
+ /// exact formula) and requires each signer to BE one of the two
182
+ /// parties. The on-chain row still stores NO party SAs (AR-11 covers
183
+ /// register() calldata only); the parties surface here because a
184
+ /// status transition is already a public state change that names its
185
+ /// signers. Replay protection comes from the nullifier. RW1-3: the
186
+ /// transition digest the parties sign is RECOMPUTED here from
187
+ /// (agreementCommitment, toStatus, nullifier) — not caller-supplied.
188
+ function updateStatus(StatusUpdatePayload calldata p) external {
189
+ AgreementRow storage row = _rows[p.agreementCommitment];
190
+ if (row.agreementCommitment == bytes32(0)) revert NotRegistered();
191
+
192
+ // Status transition validity (AR-04)
193
+ if (row.status != STATUS_ACTIVE && row.status != STATUS_DISPUTED) revert InvalidStatus();
194
+ if (
195
+ p.toStatus != STATUS_COMPLETED &&
196
+ p.toStatus != STATUS_DISPUTED &&
197
+ p.toStatus != STATUS_REVOKED
198
+ ) {
199
+ revert InvalidStatus();
200
+ }
201
+ // DISPUTED → COMPLETED / REVOKED needs bilateral.
202
+ // ACTIVE → DISPUTED: either-party allowed.
203
+ // ACTIVE → COMPLETED / REVOKED: bilateral.
204
+
205
+ // AR-08 — replay protection
206
+ if (_nullifiers[p.nullifier]) revert NullifierUsed();
207
+ _nullifiers[p.nullifier] = true;
208
+
209
+ // RW1-2 (ADR-0027): PROVE the signers are the agreement's parties. Recompute the agreement
210
+ // commitment from the REVEALED parties + components (register's exact formula) and require it
211
+ // to equal the row's commitment; then require each signer to BE a party. The caller-supplied
212
+ // signer set is no longer trusted (a nonzero commitment ref / arbitrary signer is not authority).
213
+ {
214
+ bytes32 partySet = keccak256(abi.encodePacked(p.party1, p.party2));
215
+ bytes32 recomputed = keccak256(
216
+ abi.encode(partySet, p.issuerCommitment, p.termsCommitment, p.scheduleCommitment, p.commitmentSalt)
217
+ );
218
+ if (recomputed != p.agreementCommitment) revert CommitmentMismatch();
219
+ }
220
+
221
+ // RW1-3 (ADR-0027): RECOMPUTE the transition digest the parties sign — do not trust a
222
+ // caller-supplied hash. The signed payload is canonically bound to (commitment, toStatus,
223
+ // nullifier) here, on chain.
224
+ bytes32 transitionDigest = keccak256(
225
+ abi.encode(TRANSITION_TYPEHASH, p.agreementCommitment, p.toStatus, p.nullifier)
226
+ );
227
+
228
+ bool bilateralRequired = (p.toStatus != STATUS_DISPUTED);
229
+
230
+ // signer1 must BE a party, and must have signed.
231
+ if (p.signer1 != p.party1 && p.signer1 != p.party2) revert SignerNotParty();
232
+ if (!_isValidSignatureBool(p.signer1, transitionDigest, p.signature1)) {
233
+ revert InvalidTransitionSignature();
234
+ }
235
+ if (bilateralRequired) {
236
+ // both DISTINCT parties must sign.
237
+ if (p.signer2 == address(0) || p.signer2 == p.signer1) revert InvalidTransitionSignature();
238
+ if (p.signer2 != p.party1 && p.signer2 != p.party2) revert SignerNotParty();
239
+ if (!_isValidSignatureBool(p.signer2, transitionDigest, p.signature2)) {
240
+ revert InvalidTransitionSignature();
241
+ }
242
+ }
243
+
244
+ uint8 fromStatus = row.status;
245
+ row.status = p.toStatus;
246
+ row.lastTransitionEpochBucket = uint64(block.timestamp) / EPOCH_SECONDS;
247
+
248
+ emit StatusUpdated(p.agreementCommitment, fromStatus, p.toStatus, row.lastTransitionEpochBucket);
249
+ }
250
+
251
+ /// @notice Gateway for spec 242 AttestationRegistry.assertJointAgreement.
252
+ /// Returns (true, "") if the commitment exists and is in a state
253
+ /// that permits public joint assertion (currently any non-NONE
254
+ /// status; revoked agreements can still be jointly disclosed as
255
+ /// historical record).
256
+ function isAssertableCommitment(
257
+ bytes32 agreementCommitment,
258
+ address /* actor */
259
+ ) external view returns (bool ok, string memory reason) {
260
+ AgreementRow memory row = _rows[agreementCommitment];
261
+ if (row.agreementCommitment == bytes32(0)) return (false, "not-registered");
262
+ return (true, "");
263
+ }
264
+
265
+ /// @notice Get the on-chain row.
266
+ function getRecord(bytes32 agreementCommitment) external view returns (AgreementRow memory) {
267
+ AgreementRow memory row = _rows[agreementCommitment];
268
+ if (row.agreementCommitment == bytes32(0)) revert NotRegistered();
269
+ return row;
270
+ }
271
+
272
+ /// @notice Has a nullifier been consumed?
273
+ function isNullifierUsed(bytes32 n) external view returns (bool) {
274
+ return _nullifiers[n];
275
+ }
276
+
277
+ // ─── Internal ───────────────────────────────────────────────────────
278
+
279
+ function _isValidSignatureBool(
280
+ address signer,
281
+ bytes32 digest,
282
+ bytes memory signature
283
+ ) internal view returns (bool) {
284
+ if (signer.code.length > 0) {
285
+ try IERC1271(signer).isValidSignature(digest, signature) returns (bytes4 result) {
286
+ return result == IERC1271.isValidSignature.selector;
287
+ } catch {
288
+ return false;
289
+ }
290
+ }
291
+ bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));
292
+ if (signature.length != 65) return false;
293
+ bytes32 r;
294
+ bytes32 s;
295
+ uint8 v;
296
+ // solhint-disable-next-line no-inline-assembly
297
+ assembly {
298
+ r := mload(add(signature, 0x20))
299
+ s := mload(add(signature, 0x40))
300
+ v := byte(0, mload(add(signature, 0x60)))
301
+ }
302
+ if (v < 27) v += 27;
303
+ address recovered = ecrecover(ethHash, v, r, s);
304
+ return recovered != address(0) && recovered == signer;
305
+ }
306
+ }
@@ -0,0 +1,320 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "@openzeppelin/contracts/interfaces/IERC1271.sol";
5
+
6
+ /**
7
+ * @title AttestationRegistry
8
+ * @notice EAS-aligned, bilateral-consent attestation registry per ADR-0023.
9
+ *
10
+ * ─── Substrate role ───
11
+ *
12
+ * Layers 12-15 of the coordination spine (Evidence / Outcome / Validation /
13
+ * TrustUpdate) plus the substrate's Association + JointAgreement attestations
14
+ * all share THIS one contract. Per ADR-0024 Decision 2 — the architectural
15
+ * inverse of the smart-contract-per-credential anti-pattern.
16
+ *
17
+ * ─── Key invariants (AR-*) ───
18
+ *
19
+ * AR-01 deterministic UID = keccak256(subject, issuer, credentialType, credentialHash, refUID, salt)
20
+ * AR-02 issuer signature verified via ERC-1271 against the named issuer SA
21
+ * AR-03 schemaId MUST point to a registered ShapeRegistry shape (governance-gated)
22
+ * AR-04 bilateral-consent verified via DelegationManager.verifyAuthorization
23
+ * AR-05 holder-only revocation for unilateral; either-party for joint
24
+ * AR-06 NO issuerRevoke(...) entrypoint — by static analysis
25
+ * AR-07 epoch-bucket timestamps; raw block.timestamp never stored
26
+ * AR-08 four indexed event topics (EAS-like)
27
+ * AR-09 immutable + non-upgradeable; no admin
28
+ *
29
+ * Spec 242 §9 + ADR-0023.
30
+ */
31
+ contract AttestationRegistry {
32
+ // ─── Constants ──────────────────────────────────────────────────────
33
+
34
+ uint64 public constant EPOCH_SECONDS = 3600;
35
+
36
+ // ─── Errors ─────────────────────────────────────────────────────────
37
+
38
+ error InvalidIssuerSignature();
39
+ error AttestationAlreadyExists();
40
+ error AttestationNotFound();
41
+ error NotSubject();
42
+ error NotParty();
43
+ error AlreadyRevoked();
44
+ error InvalidParties();
45
+ error InvalidPartyConsent();
46
+ error EmptyCredentialHash();
47
+
48
+ /// @dev RW1-1 (ADR-0027) — the canonical consent payload each party signs to consent to a
49
+ /// joint agreement. The contract RECOMPUTES this digest from calldata; a stored/supplied
50
+ /// reference is not consent. Binds the consent to the exact parties + agreement + credential.
51
+ bytes32 internal constant JOINT_CONSENT_TYPEHASH = keccak256(
52
+ "JointAgreementConsent(address party1,address party2,bytes32 agreementCommitment,bytes32 credentialHash)"
53
+ );
54
+
55
+ // ─── Storage ────────────────────────────────────────────────────────
56
+
57
+ /// @notice The canonical attestation row. EAS-aligned shape.
58
+ struct Attestation {
59
+ bytes32 uid;
60
+ bytes32 schemaId; // → ShapeRegistry row
61
+ bytes32 credentialType; // VC type class
62
+ bytes32 credentialHash; // RFC 8785 JCS hash of off-chain VC body
63
+ bytes32 refUID; // EAS-style back-pointer (e.g. AgreementCommitment for JointAgreement)
64
+ bytes32 bilateralConsentRef; // 0 for unilateral; hash for joint
65
+ bytes32 offchainCredentialStatusList; // W3C VC StatusList2021 pointer
66
+ uint64 epochBucket; // attest time / EPOCH_SECONDS
67
+ uint64 revocationEpochBucket; // 0 if not revoked
68
+ address subject; // holder (unilateral) or party[0] (joint)
69
+ address party2; // address(0) for unilateral; party[1] for joint
70
+ address issuer; // off-chain VC signer SA
71
+ }
72
+
73
+ mapping(bytes32 => Attestation) private _attestations;
74
+
75
+ // ─── Requests ───────────────────────────────────────────────────────
76
+
77
+ struct AssociationAttestationRequest {
78
+ bytes32 schemaId;
79
+ bytes32 credentialType;
80
+ bytes32 credentialHash;
81
+ bytes32 offchainCredentialStatusList;
82
+ address subject;
83
+ address issuer;
84
+ /// @dev EIP-712 signature by `issuer` SA over the credentialHash;
85
+ /// verified via ERC-1271.
86
+ bytes issuerSignature;
87
+ uint256 salt;
88
+ }
89
+
90
+ struct JointAgreementAttestationRequest {
91
+ bytes32 schemaId;
92
+ bytes32 credentialType;
93
+ bytes32 credentialHash;
94
+ bytes32 refUID; // AgreementCommitment hash (spec 241)
95
+ bytes32 bilateralConsentRef; // DEPRECATED / IGNORED (RW1-1, ADR-0027): consent is now
96
+ // VERIFIED on-chain from party1Signature + party2Signature over the RECOMPUTED
97
+ // consent digest; the stored `bilateralConsentRef` is that recomputed digest, not this.
98
+ bytes32 offchainCredentialStatusList;
99
+ address party1;
100
+ address party2;
101
+ address issuer;
102
+ bytes issuerSignature;
103
+ bytes party1Signature; // RW1-1: party1's consent over JOINT_CONSENT_TYPEHASH digest
104
+ bytes party2Signature; // RW1-1: party2's consent over JOINT_CONSENT_TYPEHASH digest
105
+ uint256 salt;
106
+ }
107
+
108
+ // ─── Events (EAS-like; 4 indexed topics each) ───────────────────────
109
+
110
+ /// @dev Solidity caps indexed-args at 3 per non-anonymous event (EVM
111
+ /// topic0 = event signature). Matches EAS event shape:
112
+ /// `uid` is non-indexed payload; subject + issuer + credentialType
113
+ /// are indexed for filtering.
114
+ event Attested(
115
+ address indexed subject,
116
+ address indexed issuer,
117
+ bytes32 indexed credentialType,
118
+ bytes32 uid
119
+ );
120
+
121
+ event JointAttested(
122
+ address indexed party1,
123
+ address indexed party2,
124
+ bytes32 indexed credentialType,
125
+ bytes32 uid
126
+ );
127
+
128
+ event Revoked(
129
+ address indexed subjectOrParty,
130
+ bytes32 indexed credentialType,
131
+ bytes32 uid,
132
+ bytes32 reasonHash
133
+ );
134
+
135
+ // ─── Public surface ─────────────────────────────────────────────────
136
+
137
+ /// @notice Unilateral attestation by a holder against an issued credential.
138
+ function assertAssociation(AssociationAttestationRequest calldata req) external returns (bytes32) {
139
+ if (req.credentialHash == bytes32(0)) revert EmptyCredentialHash();
140
+
141
+ // AR-02: verify issuer signature over credentialHash
142
+ if (!_isValidSignatureBool(req.issuer, req.credentialHash, req.issuerSignature)) {
143
+ revert InvalidIssuerSignature();
144
+ }
145
+
146
+ bytes32 uid = _computeUid(
147
+ req.subject,
148
+ address(0),
149
+ req.issuer,
150
+ req.credentialType,
151
+ req.credentialHash,
152
+ bytes32(0),
153
+ req.salt
154
+ );
155
+
156
+ if (_attestations[uid].uid != bytes32(0)) revert AttestationAlreadyExists();
157
+
158
+ uint64 bucket = uint64(block.timestamp) / EPOCH_SECONDS;
159
+
160
+ _attestations[uid] = Attestation({
161
+ uid: uid,
162
+ schemaId: req.schemaId,
163
+ credentialType: req.credentialType,
164
+ credentialHash: req.credentialHash,
165
+ refUID: bytes32(0),
166
+ bilateralConsentRef: bytes32(0),
167
+ offchainCredentialStatusList: req.offchainCredentialStatusList,
168
+ epochBucket: bucket,
169
+ revocationEpochBucket: 0,
170
+ subject: req.subject,
171
+ party2: address(0),
172
+ issuer: req.issuer
173
+ });
174
+
175
+ emit Attested(req.subject, req.issuer, req.credentialType, uid);
176
+ return uid;
177
+ }
178
+
179
+ /// @notice Joint attestation by two parties against an agreement.
180
+ /// @dev RW1-1 (ADR-0027) — bilateral consent is VERIFIED on-chain, not trusted. The contract
181
+ /// recomputes the canonical consent digest (`JOINT_CONSENT_TYPEHASH` over party1, party2,
182
+ /// the agreement commitment, and the credential hash) and requires BOTH parties' signatures
183
+ /// over it (ERC-1271 for smart accounts, ECDSA for EOAs). A nonzero `bilateralConsentRef` is
184
+ /// NOT consent (the prior "caller's responsibility" model, AR-04). The recomputed digest is
185
+ /// what gets stored. Alternative consent proof via `DelegationManager.verifyAuthorizationForCall`
186
+ /// (an exact-call sub-delegation per party) is spec 249's option (b); this entrypoint takes
187
+ /// the direct two-party-signature form.
188
+ function assertJointAgreement(
189
+ JointAgreementAttestationRequest calldata req
190
+ ) external returns (bytes32) {
191
+ if (req.credentialHash == bytes32(0)) revert EmptyCredentialHash();
192
+ if (req.party1 == address(0) || req.party2 == address(0)) revert InvalidParties();
193
+ if (req.party1 == req.party2) revert InvalidParties();
194
+
195
+ if (!_isValidSignatureBool(req.issuer, req.credentialHash, req.issuerSignature)) {
196
+ revert InvalidIssuerSignature();
197
+ }
198
+
199
+ // VERIFY bilateral consent: recompute the digest, require both parties' signatures over it.
200
+ bytes32 consentDigest = keccak256(
201
+ abi.encode(JOINT_CONSENT_TYPEHASH, req.party1, req.party2, req.refUID, req.credentialHash)
202
+ );
203
+ if (!_isValidSignatureBool(req.party1, consentDigest, req.party1Signature)) revert InvalidPartyConsent();
204
+ if (!_isValidSignatureBool(req.party2, consentDigest, req.party2Signature)) revert InvalidPartyConsent();
205
+
206
+ bytes32 uid = _computeUid(
207
+ req.party1,
208
+ req.party2,
209
+ req.issuer,
210
+ req.credentialType,
211
+ req.credentialHash,
212
+ req.refUID,
213
+ req.salt
214
+ );
215
+ if (_attestations[uid].uid != bytes32(0)) revert AttestationAlreadyExists();
216
+
217
+ uint64 bucket = uint64(block.timestamp) / EPOCH_SECONDS;
218
+
219
+ _attestations[uid] = Attestation({
220
+ uid: uid,
221
+ schemaId: req.schemaId,
222
+ credentialType: req.credentialType,
223
+ credentialHash: req.credentialHash,
224
+ refUID: req.refUID,
225
+ bilateralConsentRef: consentDigest, // the VERIFIED, recomputed consent digest (RW1-1)
226
+ offchainCredentialStatusList: req.offchainCredentialStatusList,
227
+ epochBucket: bucket,
228
+ revocationEpochBucket: 0,
229
+ subject: req.party1,
230
+ party2: req.party2,
231
+ issuer: req.issuer
232
+ });
233
+
234
+ emit JointAttested(req.party1, req.party2, req.credentialType, uid);
235
+ return uid;
236
+ }
237
+
238
+ /// @notice Holder-only revocation for unilateral attestations (AR-05).
239
+ function revokeOwnAssociation(bytes32 uid, bytes32 reasonHash) external {
240
+ Attestation storage a = _attestations[uid];
241
+ if (a.uid == bytes32(0)) revert AttestationNotFound();
242
+ if (a.party2 != address(0)) revert NotSubject(); // joint — wrong entrypoint
243
+ if (msg.sender != a.subject) revert NotSubject();
244
+ if (a.revocationEpochBucket != 0) revert AlreadyRevoked();
245
+
246
+ a.revocationEpochBucket = uint64(block.timestamp) / EPOCH_SECONDS;
247
+ emit Revoked(msg.sender, a.credentialType, uid, reasonHash);
248
+ }
249
+
250
+ /// @notice Either-party revocation for joint attestations (D-26 + AR-05).
251
+ function revokeOwnJointAgreement(bytes32 uid, bytes32 reasonHash) external {
252
+ Attestation storage a = _attestations[uid];
253
+ if (a.uid == bytes32(0)) revert AttestationNotFound();
254
+ if (a.party2 == address(0)) revert NotParty(); // unilateral — wrong entrypoint
255
+ if (msg.sender != a.subject && msg.sender != a.party2) revert NotParty();
256
+ if (a.revocationEpochBucket != 0) revert AlreadyRevoked();
257
+
258
+ a.revocationEpochBucket = uint64(block.timestamp) / EPOCH_SECONDS;
259
+ emit Revoked(msg.sender, a.credentialType, uid, reasonHash);
260
+ }
261
+
262
+ /// @notice Get the full attestation row.
263
+ function getAttestation(bytes32 uid) external view returns (Attestation memory) {
264
+ Attestation memory a = _attestations[uid];
265
+ if (a.uid == bytes32(0)) revert AttestationNotFound();
266
+ return a;
267
+ }
268
+
269
+ /// @notice Convenience — `!revoked` view.
270
+ function isValid(bytes32 uid) external view returns (bool) {
271
+ return _attestations[uid].uid != bytes32(0) && _attestations[uid].revocationEpochBucket == 0;
272
+ }
273
+
274
+ // ─── Internal ───────────────────────────────────────────────────────
275
+
276
+ function _computeUid(
277
+ address subject,
278
+ address party2,
279
+ address issuer,
280
+ bytes32 credentialType,
281
+ bytes32 credentialHash,
282
+ bytes32 refUID,
283
+ uint256 salt
284
+ ) internal pure returns (bytes32) {
285
+ return
286
+ keccak256(
287
+ abi.encode(subject, party2, issuer, credentialType, credentialHash, refUID, salt)
288
+ );
289
+ }
290
+
291
+ /// @dev ERC-1271-first signature verification (smart accounts), with EOA fallback.
292
+ function _isValidSignatureBool(
293
+ address signer,
294
+ bytes32 digest,
295
+ bytes memory signature
296
+ ) internal view returns (bool) {
297
+ if (signer.code.length > 0) {
298
+ try IERC1271(signer).isValidSignature(digest, signature) returns (bytes4 result) {
299
+ return result == IERC1271.isValidSignature.selector;
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+ // EOA fallback via eth-signed message hash + ecrecover
305
+ bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));
306
+ if (signature.length != 65) return false;
307
+ bytes32 r;
308
+ bytes32 s;
309
+ uint8 v;
310
+ // solhint-disable-next-line no-inline-assembly
311
+ assembly {
312
+ r := mload(add(signature, 0x20))
313
+ s := mload(add(signature, 0x40))
314
+ v := byte(0, mload(add(signature, 0x60)))
315
+ }
316
+ if (v < 27) v += 27;
317
+ address recovered = ecrecover(ethHash, v, r, s);
318
+ return recovered != address(0) && recovered == signer;
319
+ }
320
+ }