@agenticprimitives/contracts 1.0.0-alpha.6 → 1.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AUDIT.md +21 -1
  2. package/README.md +36 -12
  3. package/deployments-base-sepolia.json +35 -1
  4. package/deployments-registries-base-sepolia.json +1 -0
  5. package/dist/abi/AgentAccount.json +23 -0
  6. package/dist/abi/AgentAccountFactory.json +5 -0
  7. package/dist/abi/AgentNameRegistry.json +5 -0
  8. package/dist/abi/AgenticGovernance.json +32 -0
  9. package/dist/abi/AgreementRegistry.json +0 -5
  10. package/dist/abi/CustodyPolicy.json +11 -0
  11. package/dist/abi/IPaymentReceiptRegistry.json +50 -0
  12. package/dist/abi/MockUSDC.json +621 -0
  13. package/dist/abi/PaymentEnforcer.json +407 -0
  14. package/dist/abi/PaymentReceiptRegistry.json +182 -0
  15. package/dist/abi/QuorumEnforcer.json +16 -0
  16. package/dist/abi/SmartAgentPaymaster.json +209 -0
  17. package/dist/abi/index.d.ts +4 -0
  18. package/dist/abi/index.js +4 -0
  19. package/dist/deployments/base-sepolia.d.ts +3 -0
  20. package/dist/deployments/base-sepolia.js +31 -28
  21. package/dist/deployments/index.d.ts +2 -1
  22. package/dist/deployments/index.js +3 -0
  23. package/dist/deployments/registries-base-sepolia.d.ts +9 -0
  24. package/dist/deployments/registries-base-sepolia.js +7 -0
  25. package/package.json +4 -1
  26. package/src/AgentAccount.sol +61 -2
  27. package/src/AgentAccountFactory.sol +32 -7
  28. package/src/SmartAgentPaymaster.sol +149 -12
  29. package/src/agency/DelegationManager.sol +17 -2
  30. package/src/agency/IDelegationManager.sol +3 -0
  31. package/src/agreement/AgreementRegistry.sol +46 -21
  32. package/src/attestation/AttestationRegistry.sol +86 -22
  33. package/src/custody/CustodyPolicy.sol +59 -6
  34. package/src/enforcers/PaymentEnforcer.sol +194 -0
  35. package/src/enforcers/QuorumEnforcer.sol +12 -0
  36. package/src/governance/AgenticGovernance.sol +26 -4
  37. package/src/libraries/SignatureSlotRecovery.sol +8 -3
  38. package/src/libraries/WebAuthnLib.sol +13 -0
  39. package/src/mocks/MockUSDC.sol +152 -0
  40. package/src/naming/AgentNameRegistry.sol +27 -2
  41. package/src/payments/PaymentReceiptRegistry.sol +67 -0
@@ -71,6 +71,20 @@ import "./governance/IGovernance.sol";
71
71
  * `SenderNotAccepted` until governance explicitly opts a sender
72
72
  * in. That is the documented safe state.
73
73
  * 2. Monitor `getDeposit()` and alert below a runway threshold.
74
+ * 3. PM-2 — hand the Ownable owner to the governance TimelockController via
75
+ * `Ownable2Step` (`transferOwnership(governance)` then a timelocked
76
+ * `acceptOwnership`) once deploy-time staking/funding is done. The owner
77
+ * controls the INHERITED instant `withdrawTo`/stake drains; in production
78
+ * it MUST be the timelock, not the bootstrap deployer EOA. The
79
+ * governance-native, TIMELOCKED `scheduleDepositWithdrawal` /
80
+ * `executeDepositWithdrawal` path below is the sanctioned drain that stays
81
+ * coupled to governance + delayed regardless of the owner handoff state.
82
+ * The deployer-owned window is bootstrap-only (tracked under the
83
+ * governance-key custody item).
84
+ * 4. PM-1 — after governance pauses, call `syncPauseFromGovernance()` (or
85
+ * `setPauseMirror(true)` from governance) so the validation-time mirror
86
+ * halts sponsorship. Validation never reads governance storage directly
87
+ * (ERC-7562), so the mirror must be pushed/synced.
74
88
  *
75
89
  * @dev Inherits `addStake`, `unlockStake`, `withdrawStake`, `deposit`,
76
90
  * and `withdrawTo` from `BasePaymaster`. Ownable owner is set in
@@ -95,19 +109,62 @@ contract SmartAgentPaymaster is BasePaymaster {
95
109
  /// validation. Stored immutable.
96
110
  address public immutable governance;
97
111
 
112
+ /// @notice PM-1 — local mirror of the governance pause flag. This is the
113
+ /// ONLY pause signal read during ERC-4337 validation. Reading the
114
+ /// external governance contract's storage inside
115
+ /// `_validatePaymasterUserOp` violates the ERC-7562 validation-scope
116
+ /// rules (a paymaster may touch only its own / associated storage),
117
+ /// so reputable bundlers (Pimlico/Stackup/Alchemy) drop the sponsored
118
+ /// op during simulation. We read this own-storage mirror instead;
119
+ /// `syncPauseFromGovernance()` — a normal call OUTSIDE validation —
120
+ /// refreshes it from the canonical governance flag. The mirror is a
121
+ /// validation-time cache of governance's pause bit, never a second
122
+ /// independent source (ADR-0013: cache-of-canonical, no fallback).
123
+ bool private _pausedMirror;
124
+
125
+ /// @notice PM-2 — a scheduled, governance-authorized EntryPoint-deposit
126
+ /// withdrawal. The inherited owner `withdrawTo` is an INSTANT drain
127
+ /// gated only by the Ownable owner; this path couples a withdrawal to
128
+ /// governance (`onlyGovernance`) AND a fixed timelock, so the
129
+ /// sponsorship treasury cannot be emptied in a single transaction.
130
+ struct PendingWithdrawal {
131
+ address payable to;
132
+ uint64 eta;
133
+ uint256 amount;
134
+ }
135
+ PendingWithdrawal private _pendingWithdrawal;
136
+
137
+ /// @notice PM-2 — delay between scheduling and executing a governance
138
+ /// deposit withdrawal. Gives the guardian a window to pause if a
139
+ /// withdrawal is unexpected (incident response).
140
+ uint64 public constant DEPOSIT_WITHDRAWAL_TIMELOCK = 48 hours;
141
+
98
142
  error SenderNotAccepted(address sender);
99
143
  error SystemPaused();
100
144
  error ZeroGovernance();
101
145
  error NotGovernance();
102
146
  error PaymasterDataMalformed();
103
147
  error PaymasterSignatureInvalid();
148
+ /// @dev PM-2 withdrawal-lifecycle errors.
149
+ error NoPendingWithdrawal();
150
+ error WithdrawalNotReady(uint64 eta);
151
+ error WithdrawalAmountZero();
152
+ error WithdrawalToZero();
104
153
 
105
154
  event DevModeSet(bool dev);
106
155
  event SenderAcceptedSet(address indexed sender, bool accepted);
107
156
  event VerifyingSignerSet(address indexed oldSigner, address indexed newSigner);
157
+ /// @notice PM-1 — emitted whenever the local pause mirror is updated.
158
+ event PauseMirrorSynced(bool paused, address indexed by);
159
+ /// @notice PM-2 — deposit-withdrawal lifecycle.
160
+ event DepositWithdrawalScheduled(address indexed to, uint256 amount, uint64 eta);
161
+ event DepositWithdrawalExecuted(address indexed to, uint256 amount);
162
+ event DepositWithdrawalCancelled(address indexed to, uint256 amount);
108
163
 
109
164
  /// @dev Storage gap reserves slots for future state. Phase A.5 §3.1.
110
- uint256[49] private __gap;
165
+ /// PM-1/PM-2 consumed 3 slots (`_pausedMirror` packs into 1;
166
+ /// `PendingWithdrawal` is 2) — gap reduced 49 → 46 to preserve layout.
167
+ uint256[46] private __gap;
111
168
 
112
169
  /// @dev Length of the paymaster-data tail when in verifying mode:
113
170
  /// 6 (validUntil) + 6 (validAfter) + 65 (sig) = 77 bytes.
@@ -173,6 +230,90 @@ contract SmartAgentPaymaster is BasePaymaster {
173
230
  emit VerifyingSignerSet(old, newSigner);
174
231
  }
175
232
 
233
+ // ─── PM-1: pause mirror (own-storage, ERC-7562-compliant) ──────────
234
+
235
+ /// @notice PM-1 — refresh the local pause mirror from the canonical
236
+ /// governance flag. Permissionless ON PURPOSE: validation may not
237
+ /// read governance storage, so the mirror must be refreshable by
238
+ /// anyone (a keeper, a bundler, the guardian's incident response)
239
+ /// the moment governance pauses. This call is a NORMAL transaction
240
+ /// (not paymaster validation), so the ERC-7562 scope rules don't
241
+ /// apply to its cross-contract read. The mirror only ever holds the
242
+ /// canonical governance value — no independent pause source.
243
+ function syncPauseFromGovernance() external {
244
+ bool p = _governanceIsPaused();
245
+ _pausedMirror = p;
246
+ emit PauseMirrorSynced(p, msg.sender);
247
+ }
248
+
249
+ /// @notice PM-1 — direct governance push of the pause mirror, for when
250
+ /// governance wants to halt sponsorship without depending on a
251
+ /// third party to sync. `onlyGovernance` ⇒ inherits the 24h
252
+ /// timelock (or guardian-driven governance flow).
253
+ function setPauseMirror(bool paused_) external onlyGovernance {
254
+ _pausedMirror = paused_;
255
+ emit PauseMirrorSynced(paused_, msg.sender);
256
+ }
257
+
258
+ /// @notice The pause bit read during validation (the local mirror).
259
+ function paused() external view returns (bool) {
260
+ return _pausedMirror;
261
+ }
262
+
263
+ /// @dev Read the canonical governance pause flag. Used ONLY by the
264
+ /// out-of-band sync path, never during validation. Mirrors the prior
265
+ /// defensive decode: a non-conforming governance (EOA / missing fn)
266
+ /// reads as "not paused" so legacy / test deploys keep working.
267
+ function _governanceIsPaused() internal view returns (bool) {
268
+ if (governance.code.length == 0) return false;
269
+ (bool ok, bytes memory data) = governance.staticcall(
270
+ abi.encodeWithSelector(IGovernanceView.isPaused.selector)
271
+ );
272
+ return ok && data.length >= 32 && abi.decode(data, (bool));
273
+ }
274
+
275
+ // ─── PM-2: governance-timelocked deposit withdrawal ────────────────
276
+
277
+ /// @notice PM-2 — schedule a governance-authorized withdrawal of the
278
+ /// EntryPoint deposit. Coupled to governance AND delayed by
279
+ /// `DEPOSIT_WITHDRAWAL_TIMELOCK`, unlike the inherited owner
280
+ /// `withdrawTo` (instant, owner-only). Overwrites any prior pending
281
+ /// withdrawal (governance is the single scheduler).
282
+ function scheduleDepositWithdrawal(address payable to, uint256 amount) external onlyGovernance {
283
+ if (to == address(0)) revert WithdrawalToZero();
284
+ if (amount == 0) revert WithdrawalAmountZero();
285
+ uint64 eta = uint64(block.timestamp) + DEPOSIT_WITHDRAWAL_TIMELOCK;
286
+ _pendingWithdrawal = PendingWithdrawal({to: to, eta: eta, amount: amount});
287
+ emit DepositWithdrawalScheduled(to, amount, eta);
288
+ }
289
+
290
+ /// @notice PM-2 — execute a previously-scheduled withdrawal once its
291
+ /// timelock has elapsed. `onlyGovernance` so the same authority
292
+ /// that scheduled it confirms; the delay gives the guardian time to
293
+ /// pause if the withdrawal is unexpected.
294
+ function executeDepositWithdrawal() external onlyGovernance {
295
+ PendingWithdrawal memory w = _pendingWithdrawal;
296
+ if (w.eta == 0) revert NoPendingWithdrawal();
297
+ if (block.timestamp < w.eta) revert WithdrawalNotReady(w.eta);
298
+ delete _pendingWithdrawal;
299
+ entryPoint().withdrawTo(w.to, w.amount);
300
+ emit DepositWithdrawalExecuted(w.to, w.amount);
301
+ }
302
+
303
+ /// @notice PM-2 — cancel a pending withdrawal (governance de-escalation).
304
+ function cancelDepositWithdrawal() external onlyGovernance {
305
+ PendingWithdrawal memory w = _pendingWithdrawal;
306
+ if (w.eta == 0) revert NoPendingWithdrawal();
307
+ delete _pendingWithdrawal;
308
+ emit DepositWithdrawalCancelled(w.to, w.amount);
309
+ }
310
+
311
+ /// @notice PM-2 — view the pending withdrawal (zero `eta` ⇒ none).
312
+ function pendingWithdrawal() external view returns (address to, uint256 amount, uint64 eta) {
313
+ PendingWithdrawal memory w = _pendingWithdrawal;
314
+ return (w.to, w.amount, w.eta);
315
+ }
316
+
176
317
  // ─── Views ──────────────────────────────────────────────────────────
177
318
 
178
319
  function devMode() external view returns (bool) {
@@ -229,17 +370,13 @@ contract SmartAgentPaymaster is BasePaymaster {
229
370
  bytes32 /*userOpHash*/,
230
371
  uint256 /*maxCost*/
231
372
  ) internal view override returns (bytes memory context, uint256 validationData) {
232
- // H7-C.10 / EXT3-010: gated behind system-wide governance pause.
233
- // Skipped when `governance` is an EOA or non-conforming contract
234
- // (legacy / test deploys); production deploys MUST pass an
235
- // AgenticGovernance address, which the production-deploy preflight
236
- // (`check:production-deploy`) enforces.
237
- if (governance.code.length > 0) {
238
- (bool ok, bytes memory data) = governance.staticcall(
239
- abi.encodeWithSelector(IGovernanceView.isPaused.selector)
240
- );
241
- if (ok && data.length >= 32 && abi.decode(data, (bool))) revert SystemPaused();
242
- }
373
+ // PM-1 / H7-C.10 / EXT3-010: gated behind the system-wide governance
374
+ // pause — but read from the LOCAL mirror (`_pausedMirror`), never the
375
+ // external governance contract's storage. A cross-contract staticcall
376
+ // here violates the ERC-7562 validation-scope rules and gets the op
377
+ // dropped by bundlers. The mirror is refreshed out-of-band via
378
+ // `syncPauseFromGovernance()` (a normal call) or `setPauseMirror()`.
379
+ if (_pausedMirror) revert SystemPaused();
243
380
 
244
381
  if (_dev) {
245
382
  // Dev mode: accept all. validationData = 0 → "valid sig,
@@ -251,6 +251,13 @@ contract DelegationManager is IDelegationManager, ReentrancyGuard {
251
251
  /// the chain is well-formed (signatures, authority chain, not
252
252
  /// revoked) WITHOUT executing it.
253
253
  ///
254
+ /// @dev ⚠️ DANGER — CHAIN-ONLY, NOT A CALL AUTHORIZATION (audit 2026-06-10). This validates ONLY
255
+ /// the chain (signatures / authority / revocation). It does NOT evaluate caveats, so a `true`
256
+ /// result is NOT permission to perform any specific (target, value, calldata). Anything
257
+ /// authorizing a concrete action MUST use {verifyAuthorizationForCall} (evaluates every caveat,
258
+ /// fail-closed) or live {redeemDelegation}. Treating this boolean as call authorization
259
+ /// re-opens the exact over-trust the caveats exist to prevent.
260
+ ///
254
261
  /// Spec 242 §6 (PD-9): the `attestations` package uses this to verify
255
262
  /// bilateral-consent delegations as signed authorization predicates,
256
263
  /// rather than redeeming them as cross-account execution. Returning
@@ -288,8 +295,12 @@ contract DelegationManager is IDelegationManager, ReentrancyGuard {
288
295
  /// stateful enforcer attempting a write under staticcall) yields `false` — so `true`
289
296
  /// is a genuine authorization guarantee for this exact call, and `false` means "use
290
297
  /// live redemption" (which evaluates stateful caveats properly). The caveat
291
- /// evaluation mirrors `_runBeforeHooks` exactly (same args, same `sender`-as-redeemer),
292
- /// so an unknown/no-code enforcer is a no-op here just as it is in redemption. Pure
298
+ /// evaluation mirrors `_runBeforeHooks` exactly (same args, same `sender`-as-redeemer).
299
+ /// SC-3 (audit 2026-06-09): a no-code enforcer is rejected here with `false`. A raw
300
+ /// `staticcall` to an empty address SUCCEEDS, which would otherwise make a delegation
301
+ /// whose caveat enforcer is a typo / undeployed pass as "constraint-checked" — fail-OPEN.
302
+ /// Live redemption fails CLOSED on the same case (solc's extcodesize check on the
303
+ /// high-level void-returning hook call reverts), so this restores symmetry. Pure
293
304
  /// addition — `redeemDelegation` is untouched.
294
305
  /// @param delegations Chain (leaf first; root authority last)
295
306
  /// @param sender The address that would be the redeemer
@@ -312,6 +323,10 @@ contract DelegationManager is IDelegationManager, ReentrancyGuard {
312
323
  Delegation calldata d = delegations[i];
313
324
  bytes32 dHash = hashDelegation(d);
314
325
  for (uint256 j = 0; j < d.caveats.length; j++) {
326
+ // SC-3: a no-code enforcer constrains NOTHING — a raw staticcall to an empty address
327
+ // returns success, which would fail-open. Reject explicitly so `true` is a genuine
328
+ // guarantee (live redemption fails closed here too via extcodesize on the high-level call).
329
+ if (d.caveats[j].enforcer.code.length == 0) return (false, "no-code-enforcer");
315
330
  // staticcall the same hook redemption would call; success == satisfied read-only.
316
331
  // Gas-bounded: a stateful enforcer (which would revert on its SSTORE under
317
332
  // staticcall) is capped so an on-chain caller (e.g. RW1-1) sees a cheap, predictable
@@ -72,6 +72,9 @@ interface IDelegationManager {
72
72
  /// Used by trust-substrate contracts (e.g. AttestationRegistry's
73
73
  /// bilateral-consent path per spec 242 PD-9) to validate a packed
74
74
  /// delegation as a signed authorization predicate.
75
+ /// @dev ⚠️ DANGER — chain-only; does NOT evaluate caveats. `true` is NOT permission for any
76
+ /// specific call. Use {verifyAuthorizationForCall} (fail-closed, evaluates caveats) or live
77
+ /// redemption to authorize a concrete (target, value, calldata).
75
78
  /// @param delegations Delegation chain, leaf first
76
79
  /// @param sender The address that would be the redeemer
77
80
  /// @return ok True if the chain verifies
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.28;
3
3
 
4
4
  import "@openzeppelin/contracts/interfaces/IERC1271.sol";
5
+ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
5
6
 
6
7
  /**
7
8
  * @title AgreementRegistry
@@ -46,8 +47,20 @@ contract AgreementRegistry {
46
47
  /// digest model; the TS side derives the same constant (cross-stack
47
48
  /// typehash-equality gate). Plain struct hash (no domain separator),
48
49
  /// matching the attestation registry's JOINT_CONSENT_TYPEHASH.
49
- bytes32 internal constant TRANSITION_TYPEHASH =
50
- keccak256("AgreementTransition(bytes32 agreementCommitment,uint8 toStatus,bytes32 nullifier)");
50
+ /// AGR-1 (audit 2026-06-10): also bind chainId + this contract so a transition signature
51
+ /// cannot be replayed on another chain or a redeployed registry.
52
+ bytes32 internal constant TRANSITION_TYPEHASH = keccak256(
53
+ "AgreementTransition(bytes32 agreementCommitment,uint8 toStatus,bytes32 nullifier,uint256 chainId,address verifyingContract)"
54
+ );
55
+
56
+ /// @dev SC-1 (audit 2026-06-09): the issuer attestation digest is RECOMPUTED on-chain from the
57
+ /// agreement's contents + chain + this contract — it is NOT a caller-supplied free-form hash.
58
+ /// Binds (agreementCommitment, schemaHash, issuer, chainId, verifyingContract) so a signature
59
+ /// lifted from any other context cannot back an attacker-chosen commitment. The TS side derives
60
+ /// the same constant (cross-stack typehash-equality gate).
61
+ bytes32 internal constant AGREEMENT_ISSUER_TYPEHASH = keccak256(
62
+ "AgreementIssuerAttestation(bytes32 agreementCommitment,bytes32 schemaHash,address issuer,uint256 chainId,address verifyingContract)"
63
+ );
51
64
 
52
65
  // ─── Errors ─────────────────────────────────────────────────────────
53
66
 
@@ -85,9 +98,8 @@ contract AgreementRegistry {
85
98
  struct AgreementIssuancePayload {
86
99
  bytes32 schemaHash;
87
100
  address issuer;
88
- /// @dev EIP-712 attestation hash by the issuer (over agreementCommitment + schemaHash).
89
- bytes32 attestationStructHash;
90
- /// @dev issuer's signature over `attestationStructHash`.
101
+ /// @dev issuer's signature over the RECOMPUTED issuer-attestation digest (SC-1). The contract
102
+ /// derives the digest from the payload contents — no caller-supplied attestation hash.
91
103
  bytes issuerSignature;
92
104
  /// @dev The asserted canonical commitment hash; we verify recomputation.
93
105
  bytes32 agreementCommitment;
@@ -153,8 +165,20 @@ contract AgreementRegistry {
153
165
  revert AlreadyRegistered();
154
166
  }
155
167
 
156
- // AR-02 — verify issuer signature over the attestation hash
157
- if (!_isValidSignatureBool(p.issuer, p.attestationStructHash, p.issuerSignature)) {
168
+ // AR-02 / SC-1 — RECOMPUTE the issuer-attestation digest from the agreement contents (+ chain +
169
+ // this contract) and verify the issuer signed THAT. A signature over an arbitrary digest from
170
+ // any other context can no longer back an attacker-chosen commitment.
171
+ bytes32 issuerDigest = keccak256(
172
+ abi.encode(
173
+ AGREEMENT_ISSUER_TYPEHASH,
174
+ p.agreementCommitment,
175
+ p.schemaHash,
176
+ p.issuer,
177
+ block.chainid,
178
+ address(this)
179
+ )
180
+ );
181
+ if (!_isValidSignatureBool(p.issuer, issuerDigest, p.issuerSignature)) {
158
182
  revert InvalidIssuerSignature();
159
183
  }
160
184
 
@@ -222,7 +246,7 @@ contract AgreementRegistry {
222
246
  // caller-supplied hash. The signed payload is canonically bound to (commitment, toStatus,
223
247
  // nullifier) here, on chain.
224
248
  bytes32 transitionDigest = keccak256(
225
- abi.encode(TRANSITION_TYPEHASH, p.agreementCommitment, p.toStatus, p.nullifier)
249
+ abi.encode(TRANSITION_TYPEHASH, p.agreementCommitment, p.toStatus, p.nullifier, block.chainid, address(this))
226
250
  );
227
251
 
228
252
  bool bilateralRequired = (p.toStatus != STATUS_DISPUTED);
@@ -253,6 +277,15 @@ contract AgreementRegistry {
253
277
  /// that permits public joint assertion (currently any non-NONE
254
278
  /// status; revoked agreements can still be jointly disclosed as
255
279
  /// historical record).
280
+ ///
281
+ /// @dev NOT a consent or validity oracle (audit 2026-06-10). A `true`
282
+ /// result means only that `agreementCommitment` is REGISTERED — it is
283
+ /// an opaque back-reference, NOT proof that any particular party
284
+ /// consented, that the agreement is currently valid, or that a given
285
+ /// credential is bound to it. Bilateral consent for a joint assertion
286
+ /// is established separately by `AttestationRegistry.assertJointAgreement`,
287
+ /// which recomputes both parties' consent signatures. Consumers MUST
288
+ /// NOT treat the boolean here as authorization.
256
289
  function isAssertableCommitment(
257
290
  bytes32 agreementCommitment,
258
291
  address /* actor */
@@ -288,19 +321,11 @@ contract AgreementRegistry {
288
321
  return false;
289
322
  }
290
323
  }
324
+ // P2-2 (audit 2026-06-10): route EOA recovery through OpenZeppelin ECDSA.tryRecover instead of
325
+ // raw ecrecover — it rejects high-s (malleable) signatures and malformed lengths, matching the
326
+ // DelegationManager idiom so every signature-verifying contract shares one low-s-safe path.
291
327
  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;
328
+ (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(ethHash, signature);
329
+ return err == ECDSA.RecoverError.NoError && recovered == signer;
305
330
  }
306
331
  }
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.28;
3
3
 
4
4
  import "@openzeppelin/contracts/interfaces/IERC1271.sol";
5
+ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
5
6
 
6
7
  /**
7
8
  * @title AttestationRegistry
@@ -17,9 +18,19 @@ import "@openzeppelin/contracts/interfaces/IERC1271.sol";
17
18
  * ─── Key invariants (AR-*) ───
18
19
  *
19
20
  * 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
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.
23
34
  * AR-05 holder-only revocation for unilateral; either-party for joint
24
35
  * AR-06 NO issuerRevoke(...) entrypoint — by static analysis
25
36
  * AR-07 epoch-bucket timestamps; raw block.timestamp never stored
@@ -48,8 +59,28 @@ contract AttestationRegistry {
48
59
  /// @dev RW1-1 (ADR-0027) — the canonical consent payload each party signs to consent to a
49
60
  /// joint agreement. The contract RECOMPUTES this digest from calldata; a stored/supplied
50
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).
51
66
  bytes32 internal constant JOINT_CONSENT_TYPEHASH = keccak256(
52
- "JointAgreementConsent(address party1,address party2,bytes32 agreementCommitment,bytes32 credentialHash)"
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)"
53
84
  );
54
85
 
55
86
  // ─── Storage ────────────────────────────────────────────────────────
@@ -138,8 +169,22 @@ contract AttestationRegistry {
138
169
  function assertAssociation(AssociationAttestationRequest calldata req) external returns (bytes32) {
139
170
  if (req.credentialHash == bytes32(0)) revert EmptyCredentialHash();
140
171
 
141
- // AR-02: verify issuer signature over credentialHash
142
- if (!_isValidSignatureBool(req.issuer, req.credentialHash, req.issuerSignature)) {
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)) {
143
188
  revert InvalidIssuerSignature();
144
189
  }
145
190
 
@@ -192,13 +237,40 @@ contract AttestationRegistry {
192
237
  if (req.party1 == address(0) || req.party2 == address(0)) revert InvalidParties();
193
238
  if (req.party1 == req.party2) revert InvalidParties();
194
239
 
195
- if (!_isValidSignatureBool(req.issuer, req.credentialHash, req.issuerSignature)) {
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)) {
196
259
  revert InvalidIssuerSignature();
197
260
  }
198
261
 
199
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).
200
264
  bytes32 consentDigest = keccak256(
201
- abi.encode(JOINT_CONSENT_TYPEHASH, req.party1, req.party2, req.refUID, req.credentialHash)
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
+ )
202
274
  );
203
275
  if (!_isValidSignatureBool(req.party1, consentDigest, req.party1Signature)) revert InvalidPartyConsent();
204
276
  if (!_isValidSignatureBool(req.party2, consentDigest, req.party2Signature)) revert InvalidPartyConsent();
@@ -301,20 +373,12 @@ contract AttestationRegistry {
301
373
  return false;
302
374
  }
303
375
  }
304
- // EOA fallback via eth-signed message hash + ecrecover
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.
305
380
  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;
381
+ (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(ethHash, signature);
382
+ return err == ECDSA.RecoverError.NoError && recovered == signer;
319
383
  }
320
384
  }
@@ -313,6 +313,9 @@ contract CustodyPolicy {
313
313
  error TrusteeDoesNotExist(address guardian);
314
314
  error InvalidMode(uint8 mode_);
315
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);
316
319
  error RecoveryRequiresGuardians();
317
320
  error EmptyOwnerSet();
318
321
  error CustodyActionNotYetImplemented(uint8 action);
@@ -370,11 +373,7 @@ contract CustodyPolicy {
370
373
  c.t3HighValueCeiling = t3Ceiling;
371
374
  c.approvedHashRegistry = approvedHashReg;
372
375
 
373
- for (uint8 t = 1; t <= 6; t++) {
374
- if (thresholds[t] > 0) c.approvalsRequiredByTier[t] = thresholds[t];
375
- if (timelocks[t] > 0) c.safetyDelayByTier[t] = timelocks[t];
376
- }
377
-
376
+ // Trustees first so `trusteeCount` is known for the CP-2 recovery check.
378
377
  for (uint256 i; i < trustees.length; i++) {
379
378
  address g = trustees[i];
380
379
  if (g == address(0)) revert ZeroAddress();
@@ -383,6 +382,51 @@ contract CustodyPolicy {
383
382
  c.trusteeCount += 1;
384
383
  }
385
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
+
386
430
  emit CustodyPolicyInstalled(account, modeVal, recThr);
387
431
  }
388
432
 
@@ -633,7 +677,16 @@ contract CustodyPolicy {
633
677
  function _approvalsValue(Config storage c, uint8 tier) internal view returns (uint8) {
634
678
  if (tier == 0 || tier > 6) revert InvalidTier(tier);
635
679
  uint8 v = c.approvalsRequiredByTier[tier];
636
- 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;
637
690
  }
638
691
 
639
692
  function _safetyDelayValue(Config storage c, uint8 tier) internal view returns (uint32) {