@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.
- package/AUDIT.md +21 -1
- package/README.md +36 -12
- package/deployments-base-sepolia.json +35 -1
- package/deployments-registries-base-sepolia.json +1 -0
- package/dist/abi/AgentAccount.json +23 -0
- package/dist/abi/AgentAccountFactory.json +5 -0
- package/dist/abi/AgentNameRegistry.json +5 -0
- package/dist/abi/AgenticGovernance.json +32 -0
- package/dist/abi/AgreementRegistry.json +0 -5
- package/dist/abi/CustodyPolicy.json +11 -0
- package/dist/abi/IPaymentReceiptRegistry.json +50 -0
- package/dist/abi/MockUSDC.json +621 -0
- package/dist/abi/PaymentEnforcer.json +407 -0
- package/dist/abi/PaymentReceiptRegistry.json +182 -0
- package/dist/abi/QuorumEnforcer.json +16 -0
- package/dist/abi/SmartAgentPaymaster.json +209 -0
- package/dist/abi/index.d.ts +4 -0
- package/dist/abi/index.js +4 -0
- package/dist/deployments/base-sepolia.d.ts +3 -0
- package/dist/deployments/base-sepolia.js +31 -28
- package/dist/deployments/index.d.ts +2 -1
- package/dist/deployments/index.js +3 -0
- package/dist/deployments/registries-base-sepolia.d.ts +9 -0
- package/dist/deployments/registries-base-sepolia.js +7 -0
- package/package.json +4 -1
- package/src/AgentAccount.sol +61 -2
- package/src/AgentAccountFactory.sol +32 -7
- package/src/SmartAgentPaymaster.sol +149 -12
- package/src/agency/DelegationManager.sol +17 -2
- package/src/agency/IDelegationManager.sol +3 -0
- package/src/agreement/AgreementRegistry.sol +46 -21
- package/src/attestation/AttestationRegistry.sol +86 -22
- package/src/custody/CustodyPolicy.sol +59 -6
- package/src/enforcers/PaymentEnforcer.sol +194 -0
- package/src/enforcers/QuorumEnforcer.sol +12 -0
- package/src/governance/AgenticGovernance.sol +26 -4
- package/src/libraries/SignatureSlotRecovery.sol +8 -3
- package/src/libraries/WebAuthnLib.sol +13 -0
- package/src/mocks/MockUSDC.sol +152 -0
- package/src/naming/AgentNameRegistry.sol +27 -2
- 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
|
-
|
|
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
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
///
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
89
|
-
|
|
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 —
|
|
157
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
//
|
|
142
|
-
|
|
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
|
-
|
|
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(
|
|
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 +
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|