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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/AUDIT.md +324 -47
  2. package/README.md +45 -10
  3. package/deployments-base-sepolia.json +36 -1
  4. package/deployments-registries-base-sepolia.json +1 -0
  5. package/dist/abi/AgentAccount.json +1670 -0
  6. package/dist/abi/AgentAccountFactory.json +477 -0
  7. package/dist/abi/AgentNameRegistry.json +51 -0
  8. package/dist/abi/AgenticGovernance.json +32 -0
  9. package/dist/abi/AgreementRegistry.json +427 -0
  10. package/dist/abi/AttestationRegistry.json +436 -0
  11. package/dist/abi/CallDataHashEnforcer.json +119 -0
  12. package/dist/abi/CustodyPolicy.json +1122 -0
  13. package/dist/abi/DelegationManager.json +645 -0
  14. package/dist/abi/GeoFeatureRegistry.json +625 -0
  15. package/dist/abi/IAgentAccount.json +262 -0
  16. package/dist/abi/IAgentAccountFactoryAccessor.json +15 -0
  17. package/dist/abi/IAgentAccountFactoryView.json +41 -0
  18. package/dist/abi/IAgentAccountPauseView.json +15 -0
  19. package/dist/abi/IAgenticPrimitivesAgentAccount.json +15 -0
  20. package/dist/abi/IApprovedHashRegistryView.json +26 -0
  21. package/dist/abi/ICustodyPolicyFactoryView.json +15 -0
  22. package/dist/abi/ICustodyPolicyPauseView.json +15 -0
  23. package/dist/abi/IDelegationManager.json +171 -0
  24. package/dist/abi/IERC7579HookLike.json +44 -0
  25. package/dist/abi/IERC7579ModuleLike.json +28 -0
  26. package/dist/abi/IPaymentReceiptRegistry.json +50 -0
  27. package/dist/abi/MockUSDC.json +621 -0
  28. package/dist/abi/PaymentEnforcer.json +407 -0
  29. package/dist/abi/PaymentEscrow.json +299 -0
  30. package/dist/abi/PaymentReceiptRegistry.json +182 -0
  31. package/dist/abi/PermissionlessSubregistry.json +5 -0
  32. package/dist/abi/QuorumEnforcer.json +311 -0
  33. package/dist/abi/SignatureSlotRecovery.json +88 -0
  34. package/dist/abi/SkillDefinitionRegistry.json +491 -0
  35. package/dist/abi/SmartAgentPaymaster.json +873 -0
  36. package/dist/abi/UniversalSignatureValidator.json +65 -0
  37. package/dist/abi/index.d.ts +28 -0
  38. package/dist/abi/index.js +28 -0
  39. package/dist/deployments/base-sepolia.d.ts +41 -0
  40. package/dist/deployments/base-sepolia.js +39 -0
  41. package/dist/deployments/index.d.ts +9 -0
  42. package/dist/deployments/index.js +16 -0
  43. package/dist/deployments/registries-base-sepolia.d.ts +9 -0
  44. package/dist/deployments/registries-base-sepolia.js +7 -0
  45. package/package.json +28 -7
  46. package/src/AgentAccount.sol +191 -11
  47. package/src/AgentAccountFactory.sol +66 -12
  48. package/src/SmartAgentPaymaster.sol +186 -20
  49. package/src/UniversalSignatureValidator.sol +3 -0
  50. package/src/agency/DelegationManager.sol +170 -0
  51. package/src/agency/IDelegationManager.sol +38 -0
  52. package/src/agreement/AgreementRegistry.sol +331 -0
  53. package/src/attestation/AttestationRegistry.sol +384 -0
  54. package/src/custody/CustodyPolicy.sol +138 -8
  55. package/src/enforcers/AllowedMethodsEnforcer.sol +6 -0
  56. package/src/enforcers/AllowedTargetsEnforcer.sol +6 -0
  57. package/src/enforcers/CallDataHashEnforcer.sol +61 -0
  58. package/src/enforcers/PaymentEnforcer.sol +194 -0
  59. package/src/enforcers/QuorumEnforcer.sol +20 -0
  60. package/src/enforcers/TimestampEnforcer.sol +7 -0
  61. package/src/enforcers/ValueEnforcer.sol +6 -0
  62. package/src/geo/GeoFeatureRegistry.sol +197 -0
  63. package/src/governance/AgenticGovernance.sol +26 -4
  64. package/src/libraries/SignatureSlotRecovery.sol +8 -3
  65. package/src/libraries/WebAuthnLib.sol +13 -0
  66. package/src/mocks/MockUSDC.sol +152 -0
  67. package/src/naming/AgentNameRegistry.sol +52 -15
  68. package/src/naming/AgentNameUniversalResolver.sol +1 -1
  69. package/src/naming/PermissionlessSubregistry.sol +19 -2
  70. package/src/payments/PaymentEscrow.sol +137 -0
  71. package/src/payments/PaymentReceiptRegistry.sol +67 -0
  72. package/src/relationships/AgentRelationship.sol +8 -0
  73. package/src/skills/SkillDefinitionRegistry.sol +187 -0
  74. package/deployments-anvil.json +0 -1
@@ -51,12 +51,40 @@ import "./governance/IGovernance.sol";
51
51
  * The hash deliberately omits `paymasterAndData` from the userOp
52
52
  * because the signature lives there.
53
53
  *
54
+ * Construction modes (R5.7 / PKG-PAYMASTER-002 closure — external audit P0-2):
55
+ * The constructor takes `bool devMode_` + `address verifyingSigner_`
56
+ * EXPLICITLY. There is no implicit fail-open default; pre-R5.7 the
57
+ * constructor forcibly set `_dev = true` and production deploys had to
58
+ * remember to call `setDevMode(false) + setVerifyingSigner(...)` AFTER
59
+ * the broadcast, which left a window where the paymaster would sponsor
60
+ * any userOp on the freshly-deployed network. Now: testnet deploys pass
61
+ * `devMode_=true`; production deploys pass `devMode_=false` with a
62
+ * verifying signer (or allowlist seed). See `Deploy.s.sol`.
63
+ *
54
64
  * Production checklist:
55
- * 1. Call `setDevMode(false)` (governance only) to leave dev mode.
56
- * 2. Call `setVerifyingSigner(<KMS-backed signer addr>)` to enable
57
- * verifying-paymaster mode (preferred). OR populate `_acceptList`
58
- * via `setAccepted` if you want allowlist mode.
59
- * 3. Monitor `getDeposit()` and alert below a runway threshold.
65
+ * 1. Construct with `devMode_=false` AND either:
66
+ * - a non-zero `verifyingSigner_` for verifying-paymaster mode
67
+ * (preferred — Pimlico/Stackup/Alchemy pattern), OR
68
+ * - `verifyingSigner_=address(0)` to start in allowlist mode (no
69
+ * sender is sponsored until `setAccepted` runs). The fall-back
70
+ * to allowlist is fail-closed: every userOp reverts with
71
+ * `SenderNotAccepted` until governance explicitly opts a sender
72
+ * in. That is the documented safe state.
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.
60
88
  *
61
89
  * @dev Inherits `addStake`, `unlockStake`, `withdrawStake`, `deposit`,
62
90
  * and `withdrawTo` from `BasePaymaster`. Ownable owner is set in
@@ -81,19 +109,62 @@ contract SmartAgentPaymaster is BasePaymaster {
81
109
  /// validation. Stored immutable.
82
110
  address public immutable governance;
83
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
+
84
142
  error SenderNotAccepted(address sender);
85
143
  error SystemPaused();
86
144
  error ZeroGovernance();
87
145
  error NotGovernance();
88
146
  error PaymasterDataMalformed();
89
147
  error PaymasterSignatureInvalid();
148
+ /// @dev PM-2 withdrawal-lifecycle errors.
149
+ error NoPendingWithdrawal();
150
+ error WithdrawalNotReady(uint64 eta);
151
+ error WithdrawalAmountZero();
152
+ error WithdrawalToZero();
90
153
 
91
154
  event DevModeSet(bool dev);
92
155
  event SenderAcceptedSet(address indexed sender, bool accepted);
93
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);
94
163
 
95
164
  /// @dev Storage gap reserves slots for future state. Phase A.5 §3.1.
96
- 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;
97
168
 
98
169
  /// @dev Length of the paymaster-data tail when in verifying mode:
99
170
  /// 6 (validUntil) + 6 (validAfter) + 65 (sig) = 77 bytes.
@@ -102,15 +173,28 @@ contract SmartAgentPaymaster is BasePaymaster {
102
173
  /// begins: 20 (paymaster addr) + 16 (verifGas) + 16 (postOpGas).
103
174
  uint256 private constant PM_DATA_OFFSET = 52;
104
175
 
176
+ /// @param devMode_ true → accept-all (dev/anvil); false → require
177
+ /// verifying-signer or allowlist. R5.7 removed
178
+ /// the implicit fail-open default.
179
+ /// @param verifyingSigner_ EOA that signs paymaster envelopes when
180
+ /// `devMode_=false`. Pass `address(0)` to start
181
+ /// in allowlist mode (fail-closed until
182
+ /// `setAccepted` runs).
105
183
  constructor(
106
184
  IEntryPoint entryPointAddr,
107
185
  address initialOwner,
108
- address governance_
186
+ address governance_,
187
+ bool devMode_,
188
+ address verifyingSigner_
109
189
  ) BasePaymaster(entryPointAddr, initialOwner) {
110
190
  if (governance_ == address(0)) revert ZeroGovernance();
111
191
  governance = governance_;
112
- _dev = true;
113
- emit DevModeSet(true);
192
+ _dev = devMode_;
193
+ emit DevModeSet(devMode_);
194
+ if (verifyingSigner_ != address(0)) {
195
+ verifyingSigner = verifyingSigner_;
196
+ emit VerifyingSignerSet(address(0), verifyingSigner_);
197
+ }
114
198
  }
115
199
 
116
200
  // ─── Admin (governance-only) ────────────────────────────────────────
@@ -146,6 +230,90 @@ contract SmartAgentPaymaster is BasePaymaster {
146
230
  emit VerifyingSignerSet(old, newSigner);
147
231
  }
148
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
+
149
317
  // ─── Views ──────────────────────────────────────────────────────────
150
318
 
151
319
  function devMode() external view returns (bool) {
@@ -202,17 +370,13 @@ contract SmartAgentPaymaster is BasePaymaster {
202
370
  bytes32 /*userOpHash*/,
203
371
  uint256 /*maxCost*/
204
372
  ) internal view override returns (bytes memory context, uint256 validationData) {
205
- // H7-C.10 / EXT3-010: gated behind system-wide governance pause.
206
- // Skipped when `governance` is an EOA or non-conforming contract
207
- // (legacy / test deploys); production deploys MUST pass an
208
- // AgenticGovernance address, which the production-deploy preflight
209
- // (`check:production-deploy`) enforces.
210
- if (governance.code.length > 0) {
211
- (bool ok, bytes memory data) = governance.staticcall(
212
- abi.encodeWithSelector(IGovernanceView.isPaused.selector)
213
- );
214
- if (ok && data.length >= 32 && abi.decode(data, (bool))) revert SystemPaused();
215
- }
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();
216
380
 
217
381
  if (_dev) {
218
382
  // Dev mode: accept all. validationData = 0 → "valid sig,
@@ -236,6 +400,8 @@ contract SmartAgentPaymaster is BasePaymaster {
236
400
  // EIP-191 wrap matches what KMS-backed `signMessage({raw})`
237
401
  // produces via the v0.7 reference paymaster convention.
238
402
  bytes32 ethHash = hash.toEthSignedMessageHash();
403
+ // R6.3: third return (sigVersion) intentionally discarded; the explicit `err` + `recovered != verifyingSigner` revert IS the auth.
404
+ // slither-disable-next-line unused-return
239
405
  (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(ethHash, signature);
240
406
  if (err != ECDSA.RecoverError.NoError || recovered != verifyingSigner) {
241
407
  // Returning sigFailed=true (per EntryPoint convention)
@@ -125,11 +125,14 @@ contract UniversalSignatureValidator {
125
125
  ) private pure returns (bool) {
126
126
  if (sig.length != 65) return false;
127
127
  // Try raw hash first.
128
+ // R6.3: third return (sigVersion) intentionally discarded; the `err` + `recovered == signer` is the auth.
129
+ // slither-disable-next-line unused-return
128
130
  (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, sig);
129
131
  if (err == ECDSA.RecoverError.NoError && recovered == signer) return true;
130
132
  // Then try the eth-signed prefix variant — many wallets sign this
131
133
  // form even when the caller passes a raw digest.
132
134
  bytes32 prefixed = hash.toEthSignedMessageHash();
135
+ // slither-disable-next-line unused-return
133
136
  (recovered, err, ) = ECDSA.tryRecover(prefixed, sig);
134
137
  return err == ECDSA.RecoverError.NoError && recovered == signer;
135
138
  }
@@ -86,6 +86,11 @@ contract DelegationManager is IDelegationManager, ReentrancyGuard {
86
86
  "Caveat(address enforcer,bytes terms)"
87
87
  );
88
88
 
89
+ /// @dev Gas cap for the read-only caveat evaluation in `verifyAuthorizationForCall`.
90
+ /// Generous for any pure/view enforcer; bounds the gas a stateful enforcer would
91
+ /// burn before reverting under staticcall, so on-chain callers get a cheap `false`.
92
+ uint256 private constant CAVEAT_VIEW_GAS = 1_000_000;
93
+
89
94
  /// @dev Revoked delegation hashes
90
95
  mapping(bytes32 => bool) private _revoked;
91
96
 
@@ -242,6 +247,171 @@ contract DelegationManager is IDelegationManager, ReentrancyGuard {
242
247
  return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
243
248
  }
244
249
 
250
+ /// @notice View-only verification of a delegation chain. Returns whether
251
+ /// the chain is well-formed (signatures, authority chain, not
252
+ /// revoked) WITHOUT executing it.
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
+ ///
261
+ /// Spec 242 §6 (PD-9): the `attestations` package uses this to verify
262
+ /// bilateral-consent delegations as signed authorization predicates,
263
+ /// rather than redeeming them as cross-account execution. Returning
264
+ /// `(true, "")` means the delegation chain authorizes the named call from
265
+ /// `sender`. Caveat predicates (CalldataHashEnforcer, AllowedTargets,
266
+ /// AllowedMethods, Timestamp) are NOT executed here — callers are expected
267
+ /// to pin those into the delegation's caveats at issuance time, where
268
+ /// they're hash-bound to the delegation signature.
269
+ ///
270
+ /// @param delegations Chain (leaf first; root authority last)
271
+ /// @param sender The address that would be the redeemer
272
+ /// @return ok True if every link verifies
273
+ /// @return reason Empty if ok; otherwise a short rejection reason
274
+ function verifyAuthorization(
275
+ Delegation[] calldata delegations,
276
+ address sender
277
+ ) external view returns (bool ok, string memory reason) {
278
+ if (delegations.length == 0) return (false, "empty-chain");
279
+
280
+ for (uint256 i = 0; i < delegations.length; i++) {
281
+ (bool valid, string memory r) = _validateDelegationView(delegations, i, sender);
282
+ if (!valid) return (false, r);
283
+ }
284
+ return (true, "");
285
+ }
286
+
287
+ /// @notice View verifier for a SPECIFIC call (ADR-0027 / spec 249, RW1-4). Validates the
288
+ /// delegation chain AND evaluates every caveat's `beforeHook` against the exact
289
+ /// `(target, value, data)` — read-only, FAIL-CLOSED.
290
+ /// @dev `verifyAuthorization` checks chain/signature/authority/revocation but NOT the
291
+ /// caveats — too weak to authorize a specific call. This evaluates each caveat via
292
+ /// `staticcall` (the hook is `external`/non-view because stateful enforcers like
293
+ /// Quorum exist). Returns `true` ONLY if the chain validates and EVERY caveat passes
294
+ /// read-only. A caveat that reverts (denied) OR cannot be evaluated read-only (a
295
+ /// stateful enforcer attempting a write under staticcall) yields `false` — so `true`
296
+ /// is a genuine authorization guarantee for this exact call, and `false` means "use
297
+ /// live redemption" (which evaluates stateful caveats properly). The caveat
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
304
+ /// addition — `redeemDelegation` is untouched.
305
+ /// @param delegations Chain (leaf first; root authority last)
306
+ /// @param sender The address that would be the redeemer
307
+ /// @param target The exact target the delegated call would hit
308
+ /// @param value The exact ETH value
309
+ /// @param data The exact calldata
310
+ function verifyAuthorizationForCall(
311
+ Delegation[] calldata delegations,
312
+ address sender,
313
+ address target,
314
+ uint256 value,
315
+ bytes calldata data
316
+ ) external view returns (bool ok, string memory reason) {
317
+ if (delegations.length == 0) return (false, "empty-chain");
318
+
319
+ for (uint256 i = 0; i < delegations.length; i++) {
320
+ (bool valid, string memory r) = _validateDelegationView(delegations, i, sender);
321
+ if (!valid) return (false, r);
322
+
323
+ Delegation calldata d = delegations[i];
324
+ bytes32 dHash = hashDelegation(d);
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");
330
+ // staticcall the same hook redemption would call; success == satisfied read-only.
331
+ // Gas-bounded: a stateful enforcer (which would revert on its SSTORE under
332
+ // staticcall) is capped so an on-chain caller (e.g. RW1-1) sees a cheap, predictable
333
+ // `false`, not an out-of-gas of the whole call. CAVEAT_VIEW_GAS is generous for any
334
+ // pure/view enforcer (timestamp/value/targets/methods are all far below it).
335
+ (bool passed, ) = d.caveats[j].enforcer.staticcall{gas: CAVEAT_VIEW_GAS}(
336
+ abi.encodeWithSelector(
337
+ ICaveatEnforcer.beforeHook.selector,
338
+ d.caveats[j].terms,
339
+ d.caveats[j].args,
340
+ dHash,
341
+ d.delegator,
342
+ sender,
343
+ target,
344
+ value,
345
+ data
346
+ )
347
+ );
348
+ if (!passed) return (false, "caveat-failed");
349
+ }
350
+ }
351
+ return (true, "");
352
+ }
353
+
354
+ /// @dev View-only sibling of `_validateDelegation`. Same checks, no state
355
+ /// writes, no events. Used by `verifyAuthorization`.
356
+ function _validateDelegationView(
357
+ Delegation[] calldata delegations,
358
+ uint256 i,
359
+ address sender
360
+ ) internal view returns (bool ok, string memory reason) {
361
+ Delegation calldata d = delegations[i];
362
+ bytes32 dHash = hashDelegation(d);
363
+
364
+ if (_revoked[dHash]) return (false, "revoked");
365
+
366
+ // Delegate chain
367
+ if (i == 0) {
368
+ if (d.delegate != OPEN_DELEGATION && d.delegate != sender) {
369
+ return (false, "invalid-delegate");
370
+ }
371
+ } else {
372
+ if (d.delegate != OPEN_DELEGATION && d.delegate != delegations[i - 1].delegator) {
373
+ return (false, "invalid-delegate-chain");
374
+ }
375
+ }
376
+
377
+ // Authority chain
378
+ if (d.authority != ROOT_AUTHORITY) {
379
+ if (i + 1 >= delegations.length) return (false, "invalid-authority");
380
+ bytes32 parentHash = hashDelegation(delegations[i + 1]);
381
+ if (d.authority != parentHash) return (false, "invalid-authority-chain");
382
+ }
383
+
384
+ // Signature
385
+ if (!_isValidSignatureBool(d.delegator, dHash, d.signature)) {
386
+ return (false, "invalid-signature");
387
+ }
388
+
389
+ return (true, "");
390
+ }
391
+
392
+ /// @dev Bool-returning sibling of `_validateSignature`. Returns false
393
+ /// instead of reverting, so view-only callers can produce a typed
394
+ /// `(ok, reason)` instead of bubbling a revert.
395
+ function _isValidSignatureBool(
396
+ address signer,
397
+ bytes32 digest,
398
+ bytes calldata signature
399
+ ) internal view returns (bool) {
400
+ // ERC-1271 path for smart accounts
401
+ if (signer.code.length > 0) {
402
+ try IERC1271(signer).isValidSignature(digest, signature) returns (bytes4 result) {
403
+ return result == IERC1271.isValidSignature.selector;
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
408
+ // EOA — tryRecover the eth-signed message hash
409
+ bytes32 ethHash = digest.toEthSignedMessageHash();
410
+ (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(ethHash, signature);
411
+ if (err != ECDSA.RecoverError.NoError) return false;
412
+ return recovered == signer;
413
+ }
414
+
245
415
  // ─── Internal: Validation ──────────────────────────────────────────
246
416
 
247
417
  function _validateDelegation(
@@ -66,4 +66,42 @@ interface IDelegationManager {
66
66
 
67
67
  /// @notice Check if a delegation has been revoked.
68
68
  function isRevoked(bytes32 delegationHash) external view returns (bool);
69
+
70
+ /// @notice View-only verification of a delegation chain. Returns whether
71
+ /// the chain authorizes `sender` to redeem WITHOUT executing it.
72
+ /// Used by trust-substrate contracts (e.g. AttestationRegistry's
73
+ /// bilateral-consent path per spec 242 PD-9) to validate a packed
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).
78
+ /// @param delegations Delegation chain, leaf first
79
+ /// @param sender The address that would be the redeemer
80
+ /// @return ok True if the chain verifies
81
+ /// @return reason Empty if ok; otherwise short rejection reason
82
+ function verifyAuthorization(
83
+ Delegation[] calldata delegations,
84
+ address sender
85
+ ) external view returns (bool ok, string memory reason);
86
+
87
+ /// @notice View-only authorization of a SPECIFIC call (ADR-0027 / spec 249 RW1-4):
88
+ /// validates the chain AND evaluates every caveat's `beforeHook` against the exact
89
+ /// `(target, value, data)` read-only, fail-closed. `true` ONLY if the chain validates
90
+ /// and every caveat passes under `staticcall`; a denied OR non-view-evaluable caveat
91
+ /// yields `false` ("use live redemption"). Lets a registry bind consent to the exact
92
+ /// call inside its own transaction. Caveat evaluation mirrors `redeemDelegation`.
93
+ /// @param delegations Delegation chain, leaf first
94
+ /// @param sender The address that would be the redeemer
95
+ /// @param target The exact target of the delegated call
96
+ /// @param value The exact ETH value
97
+ /// @param data The exact calldata
98
+ /// @return ok True if the chain + every caveat authorize this exact call
99
+ /// @return reason Empty if ok; otherwise short rejection reason
100
+ function verifyAuthorizationForCall(
101
+ Delegation[] calldata delegations,
102
+ address sender,
103
+ address target,
104
+ uint256 value,
105
+ bytes calldata data
106
+ ) external view returns (bool ok, string memory reason);
69
107
  }