@agenticprimitives/contracts 0.1.0-alpha.2

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 (84) hide show
  1. package/AUDIT.md +67 -0
  2. package/CLAUDE.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +45 -0
  5. package/deployments-anvil.json +1 -0
  6. package/deployments-base-sepolia.json +1 -0
  7. package/dist/abi/AgentNameAttributeResolver.json +798 -0
  8. package/dist/abi/AgentNamePredicates.json +1 -0
  9. package/dist/abi/AgentNameRegistry.json +826 -0
  10. package/dist/abi/AgentNameUniversalResolver.json +222 -0
  11. package/dist/abi/AgentProfilePredicates.json +1 -0
  12. package/dist/abi/AgentProfileResolver.json +1044 -0
  13. package/dist/abi/AgentRelationship.json +583 -0
  14. package/dist/abi/AgentRelationshipPredicates.json +1 -0
  15. package/dist/abi/AgenticGovernance.json +259 -0
  16. package/dist/abi/AllowedMethodsEnforcer.json +108 -0
  17. package/dist/abi/AllowedTargetsEnforcer.json +103 -0
  18. package/dist/abi/ApprovedHashRegistry.json +114 -0
  19. package/dist/abi/AttributeStorage.json +557 -0
  20. package/dist/abi/CaveatEnforcerBase.json +130 -0
  21. package/dist/abi/GovernanceManaged.json +43 -0
  22. package/dist/abi/IAttributeReader.json +98 -0
  23. package/dist/abi/ICaveatEnforcer.json +98 -0
  24. package/dist/abi/IDelegationManager.json +211 -0
  25. package/dist/abi/IERC7579Module.json +34 -0
  26. package/dist/abi/IERC7579ModuleLifecycle.json +60 -0
  27. package/dist/abi/IGovernanceView.json +34 -0
  28. package/dist/abi/MultiSendCallOnly.json +29 -0
  29. package/dist/abi/MultiSendCallOnlyHarness.json +42 -0
  30. package/dist/abi/OntologyTermRegistry.json +397 -0
  31. package/dist/abi/P256Verifier.json +1 -0
  32. package/dist/abi/PermissionlessSubregistry.json +207 -0
  33. package/dist/abi/RelationshipTypeRegistry.json +455 -0
  34. package/dist/abi/ShapeRegistry.json +627 -0
  35. package/dist/abi/SmartAgentModuleTypes.json +1 -0
  36. package/dist/abi/TimestampEnforcer.json +108 -0
  37. package/dist/abi/ValueEnforcer.json +103 -0
  38. package/dist/abi/WebAuthnLib.json +1 -0
  39. package/dist/abi/index.d.ts +35 -0
  40. package/dist/abi/index.js +35 -0
  41. package/package.json +48 -0
  42. package/spec.md +52 -0
  43. package/src/AgentAccount.sol +1374 -0
  44. package/src/AgentAccountFactory.sol +274 -0
  45. package/src/ApprovedHashRegistry.sol +57 -0
  46. package/src/IAgentAccount.sol +138 -0
  47. package/src/SmartAgentPaymaster.sol +281 -0
  48. package/src/UniversalSignatureValidator.sol +136 -0
  49. package/src/agency/DelegationManager.sol +374 -0
  50. package/src/agency/ICaveatEnforcer.sol +62 -0
  51. package/src/agency/IDelegationManager.sol +69 -0
  52. package/src/custody/CustodyPolicy.sol +892 -0
  53. package/src/custody/IERC7579Module.sol +60 -0
  54. package/src/enforcers/AllowedMethodsEnforcer.AUDIT.md +51 -0
  55. package/src/enforcers/AllowedMethodsEnforcer.sol +48 -0
  56. package/src/enforcers/AllowedTargetsEnforcer.AUDIT.md +49 -0
  57. package/src/enforcers/AllowedTargetsEnforcer.sol +44 -0
  58. package/src/enforcers/CaveatEnforcerBase.sol +19 -0
  59. package/src/enforcers/QuorumEnforcer.AUDIT.md +71 -0
  60. package/src/enforcers/QuorumEnforcer.sol +191 -0
  61. package/src/enforcers/TimestampEnforcer.AUDIT.md +50 -0
  62. package/src/enforcers/TimestampEnforcer.sol +43 -0
  63. package/src/enforcers/ValueEnforcer.AUDIT.md +51 -0
  64. package/src/enforcers/ValueEnforcer.sol +41 -0
  65. package/src/governance/AgenticGovernance.sol +140 -0
  66. package/src/governance/GovernanceManaged.sol +75 -0
  67. package/src/governance/IGovernance.sol +15 -0
  68. package/src/identity/AgentProfilePredicates.sol +40 -0
  69. package/src/identity/AgentProfileResolver.sol +194 -0
  70. package/src/libraries/MultiSendCallOnly.sol +95 -0
  71. package/src/libraries/P256Verifier.sol +47 -0
  72. package/src/libraries/SignatureSlotRecovery.sol +196 -0
  73. package/src/libraries/WebAuthnLib.sol +164 -0
  74. package/src/naming/AgentNameAttributeResolver.sol +95 -0
  75. package/src/naming/AgentNamePredicates.sol +74 -0
  76. package/src/naming/AgentNameRegistry.sol +362 -0
  77. package/src/naming/AgentNameUniversalResolver.sol +210 -0
  78. package/src/naming/PermissionlessSubregistry.sol +98 -0
  79. package/src/ontology/AttributeStorage.sol +289 -0
  80. package/src/ontology/OntologyTermRegistry.sol +146 -0
  81. package/src/ontology/ShapeRegistry.sol +240 -0
  82. package/src/relationships/AgentRelationship.sol +289 -0
  83. package/src/relationships/AgentRelationshipPredicates.sol +44 -0
  84. package/src/relationships/RelationshipTypeRegistry.sol +143 -0
@@ -0,0 +1,892 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {IAgentAccount, AgentAccountRecoveryArgs, AgentAccountRecoveryPasskeyAdd} from "../IAgentAccount.sol";
5
+ import {SignatureSlotRecovery} from "../libraries/SignatureSlotRecovery.sol";
6
+
7
+ /**
8
+ * @title CustodyPolicy
9
+ * @notice ERC-7579 module that owns the schedule / apply / cancel
10
+ * custody-change surface for `AgentAccount` accounts running
11
+ * in any non-`single` mode. Per spec 209 (ERC-7579 module
12
+ * taxonomy) this is the first module extraction; per spec 213
13
+ * (custody-layer carve-out, phase 6g.1) this is renamed from
14
+ * the original `ThresholdValidator` to align with the
15
+ * custody-layer vocabulary firewall.
16
+ *
17
+ * Despite the underlying module type being `EXECUTOR` (id 2),
18
+ * the user-facing semantics are CUSTODY POLICY — it decides
19
+ * who can authorize a custody change (m-of-n approvals from
20
+ * the custody council) and applies the change via
21
+ * `executeFromModule(...)`.
22
+ *
23
+ * State is keyed by account address — one CustodyPolicy
24
+ * instance can serve many accounts. `msg.sender` during
25
+ * install is the account; subsequent custody calls take the
26
+ * account as an explicit first arg so they can be made from
27
+ * any caller (the signature blob is what authorizes).
28
+ *
29
+ * Spec 207 § 5 tier matrix (T1 Read / T2 Write / T3 Value /
30
+ * T4 Admin / T5 Critical / T6 Recovery) is unchanged from the
31
+ * pre-extraction surface. Per spec 213 § 2.2, the enum was
32
+ * renamed from `AdminAction` → `CustodyAction`, the propose /
33
+ * execute / cancel functions to schedule / apply / cancel
34
+ * scheduled change, state vars (threshold → approvalsRequired,
35
+ * timelock → safetyDelay, guardian → trustee, owner → custodian),
36
+ * and EIP-712 typehashes likewise.
37
+ */
38
+ contract CustodyPolicy {
39
+ // ─── ERC-7579 marker constants (mirror of the account-side ids) ──
40
+ uint256 internal constant MODULE_TYPE_EXECUTOR = 2;
41
+
42
+ // ─── CustodyAction enum + structs (moved from AgentAccount) ────────
43
+
44
+ enum CustodyAction {
45
+ AddCustodian, // 0 — T4
46
+ RemoveCustodian, // 1 — T4
47
+ AddPasskeyCredential, // 2 — T4
48
+ RemovePasskeyCredential, // 3 — T4
49
+ AddTrustee, // 4 — T4
50
+ RemoveTrustee, // 5 — T4
51
+ ChangeCustodyMode, // 6 — T4
52
+ ApplySystemUpdate, // 7 — T5
53
+ RotateDelegationManager, // 8 — T5
54
+ RotatePaymaster, // 9 — T5 (stubbed; reverts on execute)
55
+ RotateSessionIssuer, // 10 — T5 (stubbed)
56
+ RotateAllCustodians, // 11 — T4
57
+ ChangeValueCeiling, // 12 — T4
58
+ SetRecoveryApprovals, // 13 — T4
59
+ RecoverAccount, // 14 — T6
60
+ ChangeApprovalsRequired // 15 — T4 (per-tier custody quorum threshold)
61
+ }
62
+
63
+ struct ScheduledChange {
64
+ CustodyAction action;
65
+ bytes args;
66
+ uint64 proposedAt;
67
+ uint64 eta;
68
+ address proposer;
69
+ bool executed;
70
+ bool cancelled;
71
+ }
72
+
73
+ /// @dev Per-account config. One mapping entry per installed-on account.
74
+ struct Config {
75
+ bool installed;
76
+ /// @dev Audit C-11: once an account uninstalls the CustodyPolicy,
77
+ /// reinstall is permanently forbidden — stale trustees /
78
+ /// thresholds / pending changes would otherwise compose with
79
+ /// the next install in unpredictable ways. Accounts that
80
+ /// want a fresh policy state must deploy a fresh account.
81
+ bool permanentlyUninstalled;
82
+ uint8 mode; // 0=single, 1=hybrid, 2=threshold, 3=org
83
+ uint8 recoveryApprovals;
84
+ uint256 t3HighValueCeiling;
85
+ mapping(uint8 => uint8) approvalsRequiredByTier;
86
+ mapping(uint8 => uint32) safetyDelayByTier;
87
+ mapping(address => bool) trustees;
88
+ uint256 trusteeCount;
89
+ uint256 nextChangeId;
90
+ mapping(uint256 => ScheduledChange) pending;
91
+ mapping(uint256 => mapping(address => bool)) proposerCustodians;
92
+ address approvedHashRegistry;
93
+ }
94
+
95
+ mapping(address => Config) internal _configs;
96
+
97
+ // ─── Constants ────────────────────────────────────────────────────
98
+
99
+ // ─── EIP-712 (spec 207 § 15) ─────────────────────────────────────
100
+
101
+ bytes32 internal constant EIP712_DOMAIN_TYPEHASH = keccak256(
102
+ "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
103
+ );
104
+ bytes32 internal constant EIP712_NAME_HASH = keccak256("agenticprimitives.CustodyPolicy");
105
+ bytes32 internal constant EIP712_VERSION_HASH = keccak256("1");
106
+
107
+ bytes32 internal constant SCHEDULE_CUSTODY_CHANGE_TYPEHASH = keccak256(
108
+ "ScheduleCustodyChangeRequest(address account,uint8 action,bytes32 argsHash,uint256 changeId)"
109
+ );
110
+ bytes32 internal constant APPLY_CUSTODY_CHANGE_TYPEHASH = keccak256(
111
+ "ApplyCustodyChangeRequest(address account,uint8 action,bytes32 argsHash,uint256 changeId,uint64 eta)"
112
+ );
113
+ bytes32 internal constant CANCEL_SCHEDULED_CHANGE_TYPEHASH = keccak256(
114
+ "CancelScheduledChangeRequest(address account,uint8 action,bytes32 argsHash,uint256 changeId,uint64 eta)"
115
+ );
116
+
117
+ /// @dev Cached at deploy-time for the deploy-time chainId. If
118
+ /// `block.chainid` differs at call time (chain fork), we
119
+ /// recompute on demand. Mirrors OpenZeppelin's EIP712 base.
120
+ uint256 private immutable _CACHED_CHAIN_ID;
121
+ bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
122
+
123
+ constructor() {
124
+ _CACHED_CHAIN_ID = block.chainid;
125
+ _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator();
126
+ }
127
+
128
+ function _buildDomainSeparator() internal view returns (bytes32) {
129
+ return keccak256(
130
+ abi.encode(
131
+ EIP712_DOMAIN_TYPEHASH,
132
+ EIP712_NAME_HASH,
133
+ EIP712_VERSION_HASH,
134
+ block.chainid,
135
+ address(this)
136
+ )
137
+ );
138
+ }
139
+
140
+ function _domainSeparator() internal view returns (bytes32) {
141
+ if (block.chainid == _CACHED_CHAIN_ID) return _CACHED_DOMAIN_SEPARATOR;
142
+ return _buildDomainSeparator();
143
+ }
144
+
145
+ /// @notice EIP-712 domain separator. Off-chain code can `eth_call` to
146
+ /// confirm what domain wallet sigs are bound to.
147
+ function domainSeparator() external view returns (bytes32) {
148
+ return _domainSeparator();
149
+ }
150
+
151
+ function _hashTypedDataV4(bytes32 structHash) internal view returns (bytes32) {
152
+ return keccak256(abi.encodePacked(bytes2(0x1901), _domainSeparator(), structHash));
153
+ }
154
+
155
+ function _hashProposeRequest(
156
+ address account,
157
+ CustodyAction action,
158
+ bytes memory args,
159
+ uint256 changeId
160
+ ) internal view returns (bytes32) {
161
+ return _hashTypedDataV4(
162
+ keccak256(abi.encode(SCHEDULE_CUSTODY_CHANGE_TYPEHASH, account, uint8(action), keccak256(args), changeId))
163
+ );
164
+ }
165
+
166
+ function _hashExecuteRequest(
167
+ address account,
168
+ CustodyAction action,
169
+ bytes memory args,
170
+ uint256 changeId,
171
+ uint64 eta
172
+ ) internal view returns (bytes32) {
173
+ return _hashTypedDataV4(
174
+ keccak256(abi.encode(APPLY_CUSTODY_CHANGE_TYPEHASH, account, uint8(action), keccak256(args), changeId, eta))
175
+ );
176
+ }
177
+
178
+ function _hashCancelRequest(
179
+ address account,
180
+ CustodyAction action,
181
+ bytes memory args,
182
+ uint256 changeId,
183
+ uint64 eta
184
+ ) internal view returns (bytes32) {
185
+ return _hashTypedDataV4(
186
+ keccak256(abi.encode(CANCEL_SCHEDULED_CHANGE_TYPEHASH, account, uint8(action), keccak256(args), changeId, eta))
187
+ );
188
+ }
189
+
190
+ /// @dev Spec 207 § 8 — first 24h of the T6 timelock is the
191
+ /// primary-owner cancel window.
192
+ uint64 internal constant RECOVERY_PRIMARY_CANCEL_WINDOW = 24 hours;
193
+
194
+ // ─── Events ───────────────────────────────────────────────────────
195
+
196
+ event CustodyPolicyInstalled(address indexed account, uint8 mode, uint8 recoveryApprovals);
197
+ event CustodyPolicyUninstalled(address indexed account);
198
+
199
+ event CustodyChangeScheduled(address indexed account, uint256 indexed changeId, CustodyAction indexed action, uint64 eta, address proposer);
200
+ event CustodyChangeApplied(address indexed account, uint256 indexed changeId);
201
+ event ScheduledChangeCancelled(address indexed account, uint256 indexed changeId);
202
+ event GuardianAdded(address indexed account, address indexed guardian);
203
+ event GuardianRemoved(address indexed account, address indexed guardian);
204
+ event ThresholdChanged(address indexed account, uint8 indexed tier, uint8 oldValue, uint8 newValue);
205
+ event ModeChanged(address indexed account, uint8 oldMode, uint8 newMode);
206
+ event T3CeilingChanged(address indexed account, uint256 oldCeiling, uint256 newCeiling);
207
+ event RecoveryThresholdChanged(address indexed account, uint8 oldValue, uint8 newValue);
208
+ event OwnersRotated(address indexed account, uint256 newOwnerCount);
209
+ /// @notice Audit C-10: per-rotation count of custodians removed.
210
+ event CustodiansRemovedDuringRotation(address indexed account, uint256 removedCount);
211
+ /// @notice Audit C-11: CustodyPolicy was uninstalled; the account
212
+ /// can never reinstall it. Surfaced as a loud event so
213
+ /// operators see the lock applied.
214
+ event CustodyPolicyPermanentlyUninstalled(address indexed account);
215
+ event AccountRecovered(
216
+ address indexed account,
217
+ uint256 ownersAddedCount,
218
+ uint256 ownersRemovedCount,
219
+ uint256 passkeysAddedCount,
220
+ uint256 passkeysRemovedCount
221
+ );
222
+
223
+ // ─── Errors ───────────────────────────────────────────────────────
224
+
225
+ error NotInstalledOn(address account);
226
+ error AlreadyInstalledOn(address account);
227
+ error OnInstallNotByAccount(address caller);
228
+
229
+ error InvalidCustodyAction(uint8 action);
230
+ error InvalidTier(uint8 tier);
231
+ error ProposalNotFound(uint256 changeId);
232
+ error ProposalAlreadyExecuted(uint256 changeId);
233
+ error ProposalAlreadyCancelled(uint256 changeId);
234
+ error ProposalNotReady(uint256 changeId, uint64 eta);
235
+ error AdminInsufficientQuorum(uint256 supplied, uint8 required);
236
+ error AdminDuplicateOrUnsortedSigner(address signer);
237
+ error AdminUnauthorizedSigner(address signer);
238
+ error TrusteeAlreadyExists(address guardian);
239
+ error TrusteeDoesNotExist(address guardian);
240
+ error InvalidMode(uint8 mode_);
241
+ error InvalidThresholdValue(uint8 thr);
242
+ error RecoveryRequiresGuardians();
243
+ error EmptyOwnerSet();
244
+ error CustodyActionNotYetImplemented(uint8 action);
245
+ error CannotDowngradeWithTrustees();
246
+ error TimelockRequiredForTier(uint8 tier);
247
+ error SeparationOfDutiesViolation(address signer);
248
+ error RecoveryRequiresGuardianQuorum();
249
+ error UnauthorizedTrustee(address signer);
250
+ error ZeroAddress();
251
+ /// @dev Audit C-11: reinstall over a previously-uninstalled config
252
+ /// is forbidden — stale mappings would compose unpredictably.
253
+ error ReinstallForbidden(address account);
254
+
255
+ // ─── ERC-7579 lifecycle ───────────────────────────────────────────
256
+
257
+ /**
258
+ * Install-time init data shape:
259
+ * abi.encode(
260
+ * uint8 mode,
261
+ * uint8 recoveryApprovals,
262
+ * address[] trustees,
263
+ * uint8[7] approvalsRequiredByTier, // index 0 unused; T1..T6 use 1..6
264
+ * uint32[7] safetyDelayByTier,
265
+ * uint256 t3HighValueCeiling,
266
+ * address approvedHashRegistry
267
+ * )
268
+ */
269
+ function onInstall(bytes calldata data) external {
270
+ address account = msg.sender;
271
+ Config storage c = _configs[account];
272
+ if (c.installed) revert AlreadyInstalledOn(account);
273
+ // Audit C-11: reinstall over previously-uninstalled state is
274
+ // forbidden. The stale mappings (trustees, thresholds, pending)
275
+ // never zero out — silently re-using them on a fresh install
276
+ // could compose with adversary-chosen new state in pathological
277
+ // ways. Force a new deploy if the operator genuinely needs a
278
+ // fresh policy for this account.
279
+ if (c.permanentlyUninstalled) revert ReinstallForbidden(account);
280
+
281
+ (
282
+ uint8 modeVal,
283
+ uint8 recThr,
284
+ address[] memory trustees,
285
+ uint8[7] memory thresholds,
286
+ uint32[7] memory timelocks,
287
+ uint256 t3Ceiling,
288
+ address approvedHashReg
289
+ ) = abi.decode(data, (uint8, uint8, address[], uint8[7], uint32[7], uint256, address));
290
+
291
+ if (modeVal > 3) revert InvalidMode(modeVal);
292
+
293
+ c.installed = true;
294
+ c.mode = modeVal;
295
+ c.recoveryApprovals = recThr;
296
+ c.t3HighValueCeiling = t3Ceiling;
297
+ c.approvedHashRegistry = approvedHashReg;
298
+
299
+ for (uint8 t = 1; t <= 6; t++) {
300
+ if (thresholds[t] > 0) c.approvalsRequiredByTier[t] = thresholds[t];
301
+ if (timelocks[t] > 0) c.safetyDelayByTier[t] = timelocks[t];
302
+ }
303
+
304
+ for (uint256 i; i < trustees.length; i++) {
305
+ address g = trustees[i];
306
+ if (g == address(0)) revert ZeroAddress();
307
+ if (c.trustees[g]) revert TrusteeAlreadyExists(g);
308
+ c.trustees[g] = true;
309
+ c.trusteeCount += 1;
310
+ }
311
+
312
+ emit CustodyPolicyInstalled(account, modeVal, recThr);
313
+ }
314
+
315
+ function onUninstall(bytes calldata) external {
316
+ address account = msg.sender;
317
+ Config storage c = _configs[account];
318
+ if (!c.installed) revert NotInstalledOn(account);
319
+ c.installed = false;
320
+ // Audit C-11: lock against ever re-installing on this account.
321
+ // The mapping state (trustees, thresholds, proposals) is left
322
+ // in place intentionally — clearing all of it via storage
323
+ // iteration is unbounded gas. The `permanentlyUninstalled`
324
+ // flag prevents the stale state from being composed against
325
+ // a fresh install instead.
326
+ c.permanentlyUninstalled = true;
327
+ emit CustodyPolicyUninstalled(account);
328
+ emit CustodyPolicyPermanentlyUninstalled(account);
329
+ }
330
+
331
+ // ─── Public propose / execute / cancel ──────────────────────────
332
+
333
+ function scheduleCustodyChange(
334
+ address account,
335
+ CustodyAction action,
336
+ bytes calldata args,
337
+ bytes calldata quorumSigs
338
+ ) external returns (uint256 changeId) {
339
+ Config storage c = _configs[account];
340
+ if (!c.installed) revert NotInstalledOn(account);
341
+
342
+ // Audit C-8: tier escalates for ChangeApprovalsRequired based on
343
+ // which tier is being changed + direction of change.
344
+ uint8 tier = _effectiveTierFor(c, action, args);
345
+ uint32 timelock = _safetyDelayValue(c, tier);
346
+
347
+ if ((tier == 5 || tier == 6) && timelock == 0) {
348
+ revert TimelockRequiredForTier(tier);
349
+ }
350
+
351
+ bool isRecovery = (action == CustodyAction.RecoverAccount);
352
+ uint8 reqThreshold;
353
+ if (isRecovery) {
354
+ if (c.trusteeCount == 0 || c.recoveryApprovals == 0) {
355
+ revert RecoveryRequiresGuardianQuorum();
356
+ }
357
+ reqThreshold = c.recoveryApprovals;
358
+ } else {
359
+ reqThreshold = _approvalsValue(c, tier);
360
+ }
361
+
362
+ // H7-C.5 / XCON-002: aggressive block-scoping so `forge coverage`
363
+ // compiles without `--ir-minimum`. The verify + write halves are
364
+ // independent on the stack; we let each release its locals before
365
+ // the next runs.
366
+ uint64 eta;
367
+ changeId = ++c.nextChangeId;
368
+ {
369
+ uint64 nowTs = uint64(block.timestamp);
370
+ eta = uint64(nowTs + timelock);
371
+ c.pending[changeId] = ScheduledChange({
372
+ action: action,
373
+ args: args,
374
+ proposedAt: nowTs,
375
+ eta: eta,
376
+ proposer: msg.sender,
377
+ executed: false,
378
+ cancelled: false
379
+ });
380
+ }
381
+ {
382
+ ScheduleVerifyCtx memory sctx = ScheduleVerifyCtx({
383
+ action: action,
384
+ changeId: changeId,
385
+ reqThreshold: reqThreshold,
386
+ isRecovery: isRecovery
387
+ });
388
+ address[] memory propSigners = _verifyScheduleQuorum(account, c, sctx, args, quorumSigs);
389
+ for (uint256 i; i < propSigners.length; i++) {
390
+ c.proposerCustodians[changeId][propSigners[i]] = true;
391
+ }
392
+ }
393
+
394
+ emit CustodyChangeScheduled(account, changeId, action, eta, msg.sender);
395
+ }
396
+
397
+ function applyCustodyChange(address account, uint256 changeId, bytes calldata quorumSigs) external {
398
+ Config storage c = _configs[account];
399
+ if (!c.installed) revert NotInstalledOn(account);
400
+
401
+ ScheduledChange storage p = c.pending[changeId];
402
+ if (p.eta == 0) revert ProposalNotFound(changeId);
403
+ if (p.executed) revert ProposalAlreadyExecuted(changeId);
404
+ if (p.cancelled) revert ProposalAlreadyCancelled(changeId);
405
+ if (block.timestamp < p.eta) revert ProposalNotReady(changeId, p.eta);
406
+
407
+ bool isRecovery = (p.action == CustodyAction.RecoverAccount);
408
+ uint8 tier = _effectiveTierFor(c, p.action, p.args);
409
+ uint8 reqThreshold = isRecovery ? c.recoveryApprovals : _approvalsValue(c, tier);
410
+ bytes32 payloadHash = _hashExecuteRequest(account, p.action, p.args, changeId, p.eta);
411
+ address[] memory execSigners = _verifyQuorum(account, c, QuorumVerifyArgs({payloadHash: payloadHash, reqThreshold: reqThreshold, guardianMode: isRecovery}), quorumSigs);
412
+
413
+ if (c.mode == 3 && !isRecovery) {
414
+ for (uint256 i; i < execSigners.length; i++) {
415
+ if (c.proposerCustodians[changeId][execSigners[i]]) {
416
+ revert SeparationOfDutiesViolation(execSigners[i]);
417
+ }
418
+ }
419
+ }
420
+
421
+ p.executed = true;
422
+ emit CustodyChangeApplied(account, changeId);
423
+ _applyCustodyChange(account, c, p.action, p.args);
424
+ }
425
+
426
+ function cancelScheduledChange(address account, uint256 changeId, bytes calldata quorumSigs) external {
427
+ Config storage c = _configs[account];
428
+ if (!c.installed) revert NotInstalledOn(account);
429
+
430
+ ScheduledChange storage p = c.pending[changeId];
431
+ if (p.eta == 0) revert ProposalNotFound(changeId);
432
+ if (p.executed) revert ProposalAlreadyExecuted(changeId);
433
+ if (p.cancelled) revert ProposalAlreadyCancelled(changeId);
434
+
435
+ bytes32 payloadHash = _hashCancelRequest(account, p.action, p.args, changeId, p.eta);
436
+
437
+ if (p.action == CustodyAction.RecoverAccount) {
438
+ uint64 cancelWindowEnds = p.proposedAt + RECOVERY_PRIMARY_CANCEL_WINDOW;
439
+ bool inOwnerCancelWindow = block.timestamp < cancelWindowEnds;
440
+ uint8 reqThreshold = inOwnerCancelWindow
441
+ ? _approvalsValue(c, 4)
442
+ : c.recoveryApprovals;
443
+ _verifyQuorum(account, c, QuorumVerifyArgs({payloadHash: payloadHash, reqThreshold: reqThreshold, guardianMode: !inOwnerCancelWindow}), quorumSigs);
444
+ } else {
445
+ uint8 tier = _effectiveTierFor(c, p.action, p.args);
446
+ uint8 reqThreshold = _approvalsValue(c, tier);
447
+ _verifyQuorum(account, c, QuorumVerifyArgs({payloadHash: payloadHash, reqThreshold: reqThreshold, guardianMode: false}), quorumSigs);
448
+ }
449
+
450
+ p.cancelled = true;
451
+ emit ScheduledChangeCancelled(account, changeId);
452
+ }
453
+
454
+ // ─── Views ──────────────────────────────────────────────────────
455
+
456
+ function custodyMode(address account) external view returns (uint8) {
457
+ return _configs[account].mode;
458
+ }
459
+
460
+ function approvalsRequired(address account, uint8 tier) external view returns (uint8) {
461
+ return _approvalsValue(_configs[account], tier);
462
+ }
463
+
464
+ function recoveryApprovals(address account) external view returns (uint8) {
465
+ return _configs[account].recoveryApprovals;
466
+ }
467
+
468
+ function isTrustee(address account, address signer) external view returns (bool) {
469
+ return _configs[account].trustees[signer];
470
+ }
471
+
472
+ function trusteeCount(address account) external view returns (uint256) {
473
+ return _configs[account].trusteeCount;
474
+ }
475
+
476
+ function scheduledChangeCount(address account) external view returns (uint256) {
477
+ return _configs[account].nextChangeId;
478
+ }
479
+
480
+ function t3HighValueCeiling(address account) external view returns (uint256) {
481
+ return _configs[account].t3HighValueCeiling;
482
+ }
483
+
484
+ function safetyDelay(address account, uint8 tier) external view returns (uint32) {
485
+ return _safetyDelayValue(_configs[account], tier);
486
+ }
487
+
488
+ function approvedHashRegistry(address account) external view returns (address) {
489
+ return _configs[account].approvedHashRegistry;
490
+ }
491
+
492
+ function isInstalledOn(address account) external view returns (bool) {
493
+ return _configs[account].installed;
494
+ }
495
+
496
+ function getScheduledChange(address account, uint256 changeId) external view returns (
497
+ CustodyAction action,
498
+ bytes memory args,
499
+ uint64 proposedAt,
500
+ uint64 eta,
501
+ address proposer,
502
+ bool executed,
503
+ bool cancelled
504
+ ) {
505
+ ScheduledChange storage p = _configs[account].pending[changeId];
506
+ return (p.action, p.args, p.proposedAt, p.eta, p.proposer, p.executed, p.cancelled);
507
+ }
508
+
509
+ /// @notice Pure helper exposing the default-threshold matrix from
510
+ /// spec § 5.1 — owners over n=1..N produce per-tier defaults.
511
+ function defaultApprovals(uint8 nCustodians, uint8 tier) external pure returns (uint8) {
512
+ return _defaultApprovals(nCustodians, tier);
513
+ }
514
+
515
+ // ─── Internal helpers ──────────────────────────────────────────
516
+
517
+ function _tierFor(CustodyAction action) internal pure returns (uint8) {
518
+ if (action == CustodyAction.RecoverAccount) return 6;
519
+ if (
520
+ action == CustodyAction.ApplySystemUpdate ||
521
+ action == CustodyAction.RotateDelegationManager ||
522
+ action == CustodyAction.RotatePaymaster ||
523
+ action == CustodyAction.RotateSessionIssuer
524
+ ) return 5;
525
+ return 4;
526
+ }
527
+
528
+ /**
529
+ * @dev Audit C-8: `ChangeApprovalsRequired` must require AT LEAST the
530
+ * tier being modified — otherwise a T4 admin quorum could
531
+ * silently lower the T5 critical threshold to 1, defeating the
532
+ * whole layered-threshold model. Decreases also bump up one
533
+ * tier (require T5 for any reduction) because lowering a
534
+ * threshold is the security-critical direction.
535
+ */
536
+ function _effectiveTierFor(
537
+ Config storage c,
538
+ CustodyAction action,
539
+ bytes memory args
540
+ ) internal view returns (uint8) {
541
+ uint8 base = _tierFor(action);
542
+ if (action != CustodyAction.ChangeApprovalsRequired) return base;
543
+ // Decode (uint8 tier, uint8 newCount). Bound the change tier in
544
+ // [1, 5]; T6 (recovery) routes through SetRecoveryApprovals.
545
+ (uint8 targetTier, uint8 newCount) = abi.decode(args, (uint8, uint8));
546
+ if (targetTier == 0 || targetTier > 5) return base;
547
+ uint8 required = targetTier > base ? targetTier : base;
548
+ // If this would DECREASE the threshold, require at least T5
549
+ // authority (one tier higher than ordinary admin).
550
+ uint8 currentValue = c.approvalsRequiredByTier[targetTier];
551
+ if (currentValue == 0) currentValue = 1; // matches _approvalsValue fallback
552
+ if (newCount < currentValue && required < 5) required = 5;
553
+ return required;
554
+ }
555
+
556
+ function _approvalsValue(Config storage c, uint8 tier) internal view returns (uint8) {
557
+ if (tier == 0 || tier > 6) revert InvalidTier(tier);
558
+ uint8 v = c.approvalsRequiredByTier[tier];
559
+ return v == 0 ? 1 : v;
560
+ }
561
+
562
+ function _safetyDelayValue(Config storage c, uint8 tier) internal view returns (uint32) {
563
+ if (tier == 0 || tier > 6) revert InvalidTier(tier);
564
+ return c.safetyDelayByTier[tier];
565
+ }
566
+
567
+ /// @dev H7-C.5 / XCON-002 — scalar args packed into a memory struct so
568
+ /// `_verifyQuorum`'s callers stay below the via-IR-off stack limit
569
+ /// and `forge coverage` builds without `--ir-minimum`.
570
+ struct QuorumVerifyArgs {
571
+ bytes32 payloadHash;
572
+ uint8 reqThreshold;
573
+ bool guardianMode;
574
+ }
575
+
576
+ /// @dev H7-C.5 / XCON-002 — context struct packed so the verify call
577
+ /// from `scheduleCustodyChange` doesn't blow the stack under
578
+ /// coverage compile.
579
+ struct ScheduleVerifyCtx {
580
+ CustodyAction action;
581
+ uint256 changeId;
582
+ uint8 reqThreshold;
583
+ bool isRecovery;
584
+ }
585
+
586
+ function _verifyScheduleQuorum(
587
+ address account,
588
+ Config storage c,
589
+ ScheduleVerifyCtx memory ctx,
590
+ bytes calldata args,
591
+ bytes calldata quorumSigs
592
+ ) internal view returns (address[] memory) {
593
+ bytes32 payloadHash = _hashProposeRequest(account, ctx.action, args, ctx.changeId);
594
+ QuorumVerifyArgs memory va = QuorumVerifyArgs({
595
+ payloadHash: payloadHash,
596
+ reqThreshold: ctx.reqThreshold,
597
+ guardianMode: ctx.isRecovery
598
+ });
599
+ return _verifyQuorum(account, c, va, quorumSigs);
600
+ }
601
+
602
+ function _verifyQuorum(
603
+ address account,
604
+ Config storage c,
605
+ QuorumVerifyArgs memory vargs,
606
+ bytes calldata signatures
607
+ ) internal view returns (address[] memory signers) {
608
+ if (signatures.length < uint256(vargs.reqThreshold) * 65) {
609
+ revert AdminInsufficientQuorum(signatures.length / 65, vargs.reqThreshold);
610
+ }
611
+ bytes memory sigsMem = signatures;
612
+ address approvedHashReg = c.approvedHashRegistry;
613
+ signers = new address[](vargs.reqThreshold);
614
+ address prev;
615
+ for (uint256 i; i < vargs.reqThreshold; i++) {
616
+ address signer = SignatureSlotRecovery.recoverFromSlot(
617
+ vargs.payloadHash, sigsMem, i, approvedHashReg
618
+ );
619
+ if (signer <= prev) revert AdminDuplicateOrUnsortedSigner(signer);
620
+ prev = signer;
621
+ if (vargs.guardianMode) {
622
+ if (!c.trustees[signer]) revert UnauthorizedTrustee(signer);
623
+ } else {
624
+ if (!IAgentAccount(account).isCustodian(signer)) revert AdminUnauthorizedSigner(signer);
625
+ }
626
+ signers[i] = signer;
627
+ }
628
+ }
629
+
630
+ // ─── Action dispatcher + handlers ──────────────────────────────
631
+
632
+ function _applyCustodyChange(
633
+ address account,
634
+ Config storage c,
635
+ CustodyAction action,
636
+ bytes memory args
637
+ ) internal {
638
+ if (action == CustodyAction.AddCustodian) {
639
+ (address newOwner) = abi.decode(args, (address));
640
+ _execute(account, abi.encodeCall(IAgentAccount.addCustodian, (newOwner)));
641
+ } else if (action == CustodyAction.RemoveCustodian) {
642
+ (address oldOwner) = abi.decode(args, (address));
643
+ _execute(account, abi.encodeCall(IAgentAccount.removeCustodian, (oldOwner)));
644
+ } else if (action == CustodyAction.AddPasskeyCredential) {
645
+ // H7-C.1 / CON-WEBAUTHN-001: rpIdHash now bundled in the action args
646
+ // so credential additions through the custody-policy entrypoint
647
+ // also bind to a specific RP.
648
+ (bytes32 cid, uint256 x, uint256 y, bytes32 rpIdHash) =
649
+ abi.decode(args, (bytes32, uint256, uint256, bytes32));
650
+ _execute(account, abi.encodeWithSignature(
651
+ "addPasskey(bytes32,uint256,uint256,bytes32)",
652
+ cid, x, y, rpIdHash
653
+ ));
654
+ } else if (action == CustodyAction.RemovePasskeyCredential) {
655
+ (bytes32 cid) = abi.decode(args, (bytes32));
656
+ _execute(account, abi.encodeWithSignature("removePasskey(bytes32)", cid));
657
+ } else if (action == CustodyAction.AddTrustee) {
658
+ (address g) = abi.decode(args, (address));
659
+ _applyAddGuardian(account, c, g);
660
+ } else if (action == CustodyAction.RemoveTrustee) {
661
+ (address g) = abi.decode(args, (address));
662
+ _applyRemoveGuardian(account, c, g);
663
+ } else if (action == CustodyAction.ChangeCustodyMode) {
664
+ (uint8 newMode) = abi.decode(args, (uint8));
665
+ _applyChangeMode(account, c, newMode);
666
+ } else if (action == CustodyAction.RotateAllCustodians) {
667
+ // Audit C-10: args shape changed to (addCustodians, removeCustodians).
668
+ // The legacy single-array form only ADDED — a compromised
669
+ // custodian was never actually rotated OUT. Wire-format
670
+ // break is acceptable in pre-alpha; the demo's only
671
+ // current caller (none) was using the legacy form.
672
+ (address[] memory addCustodians, address[] memory removeCustodians) =
673
+ abi.decode(args, (address[], address[]));
674
+ _applyRotateAllOwners(account, addCustodians, removeCustodians);
675
+ } else if (action == CustodyAction.ChangeValueCeiling) {
676
+ (uint256 newCeiling) = abi.decode(args, (uint256));
677
+ _applyChangeT3Ceiling(account, c, newCeiling);
678
+ } else if (action == CustodyAction.SetRecoveryApprovals) {
679
+ (uint8 newThr) = abi.decode(args, (uint8));
680
+ _applySetRecoveryThreshold(account, c, newThr);
681
+ } else if (action == CustodyAction.ChangeApprovalsRequired) {
682
+ (uint8 tier, uint8 newCount) = abi.decode(args, (uint8, uint8));
683
+ _applyChangeApprovalsRequired(account, c, tier, newCount);
684
+ } else if (action == CustodyAction.RecoverAccount) {
685
+ AgentAccountRecoveryArgs memory r = abi.decode(args, (AgentAccountRecoveryArgs));
686
+ _applyRecoverAccount(account, r);
687
+ } else if (action == CustodyAction.ApplySystemUpdate) {
688
+ (address newImpl) = abi.decode(args, (address));
689
+ _execute(account, abi.encodeWithSignature("upgradeToAndCall(address,bytes)", newImpl, ""));
690
+ } else if (action == CustodyAction.RotateDelegationManager) {
691
+ (address newDm) = abi.decode(args, (address));
692
+ _execute(account, abi.encodeWithSignature("setDelegationManager(address)", newDm));
693
+ } else if (
694
+ action == CustodyAction.RotatePaymaster ||
695
+ action == CustodyAction.RotateSessionIssuer
696
+ ) {
697
+ revert CustodyActionNotYetImplemented(uint8(action));
698
+ } else {
699
+ revert InvalidCustodyAction(uint8(action));
700
+ }
701
+ }
702
+
703
+ /// @dev Single entry-point for account self-calls. The account's
704
+ /// `executeFromModule` does the EVM-level call; when target ==
705
+ /// account, msg.sender at the callee is the account itself,
706
+ /// satisfying onlySelf gates on `addCustodian` / `removeCustodian` / etc.
707
+ function _execute(address account, bytes memory data) internal {
708
+ // Use a low-level call so the calldata is the exact ABI-encoded
709
+ // call to executeFromModule, which then bubble-reverts the
710
+ // inner call. Solidity's high-level call generates the same
711
+ // shape but we keep the low-level form for explicitness.
712
+ (bool ok, bytes memory ret) = account.call(
713
+ abi.encodeWithSignature(
714
+ "executeFromModule(address,uint256,bytes)",
715
+ account,
716
+ uint256(0),
717
+ data
718
+ )
719
+ );
720
+ if (!ok) {
721
+ assembly { revert(add(ret, 32), mload(ret)) }
722
+ }
723
+ }
724
+
725
+ function _applyAddGuardian(address account, Config storage c, address g) internal {
726
+ if (g == address(0)) revert ZeroAddress();
727
+ if (c.trustees[g]) revert TrusteeAlreadyExists(g);
728
+ c.trustees[g] = true;
729
+ c.trusteeCount += 1;
730
+ emit GuardianAdded(account, g);
731
+ }
732
+
733
+ function _applyRemoveGuardian(address account, Config storage c, address g) internal {
734
+ if (!c.trustees[g]) revert TrusteeDoesNotExist(g);
735
+ if (c.recoveryApprovals > 0 && c.trusteeCount - 1 < c.recoveryApprovals) {
736
+ revert RecoveryRequiresGuardians();
737
+ }
738
+ c.trustees[g] = false;
739
+ c.trusteeCount -= 1;
740
+ emit GuardianRemoved(account, g);
741
+ }
742
+
743
+ function _applyChangeMode(address account, Config storage c, uint8 newMode) internal {
744
+ if (newMode > 3) revert InvalidMode(newMode);
745
+ uint8 oldMode = c.mode;
746
+ if (newMode == 0 && c.trusteeCount > 0) revert CannotDowngradeWithTrustees();
747
+ c.mode = newMode;
748
+ emit ModeChanged(account, oldMode, newMode);
749
+ }
750
+
751
+ /**
752
+ * @notice Add new custodians AND remove old ones in one ceremony.
753
+ * @dev Audit C-10: the previous shape only added — a "rotate" that
754
+ * didn't rotate. New shape is exact-replacement-friendly: pass
755
+ * the additions in `addCustodians` and the removals in
756
+ * `removeCustodians`. Validates the final custodian count
757
+ * stays ≥ 1 (the on-chain `removeCustodian` will revert with
758
+ * CannotRemoveLastCustodian if we'd zero it, but we double-check
759
+ * here for explicitness).
760
+ */
761
+ function _applyRotateAllOwners(
762
+ address account,
763
+ address[] memory addCustodians,
764
+ address[] memory removeCustodians
765
+ ) internal {
766
+ if (addCustodians.length == 0 && removeCustodians.length == 0) {
767
+ revert EmptyOwnerSet();
768
+ }
769
+ uint256 added;
770
+ for (uint256 i; i < addCustodians.length; i++) {
771
+ address o = addCustodians[i];
772
+ if (o == address(0)) revert ZeroAddress();
773
+ if (!IAgentAccount(account).isCustodian(o)) {
774
+ _execute(account, abi.encodeCall(IAgentAccount.addCustodian, (o)));
775
+ added++;
776
+ }
777
+ }
778
+ // Remove AFTER add so we never transiently dip below count=1 in
779
+ // the "rotate sole custodian to a new sole custodian" case.
780
+ uint256 removed;
781
+ for (uint256 i; i < removeCustodians.length; i++) {
782
+ address o = removeCustodians[i];
783
+ if (o == address(0)) revert ZeroAddress();
784
+ if (IAgentAccount(account).isCustodian(o)) {
785
+ _execute(account, abi.encodeCall(IAgentAccount.removeCustodian, (o)));
786
+ removed++;
787
+ }
788
+ }
789
+ emit OwnersRotated(account, added);
790
+ // Note: OwnersRotated only carries `added` for ABI compat. A
791
+ // future revision should add a `removed` field; that's a
792
+ // wire-format break we'll batch with the next event-schema rev.
793
+ if (removed > 0) emit CustodiansRemovedDuringRotation(account, removed);
794
+ }
795
+
796
+ function _applyChangeT3Ceiling(address account, Config storage c, uint256 newCeiling) internal {
797
+ uint256 oldCeiling = c.t3HighValueCeiling;
798
+ c.t3HighValueCeiling = newCeiling;
799
+ emit T3CeilingChanged(account, oldCeiling, newCeiling);
800
+ }
801
+
802
+ function _applySetRecoveryThreshold(address account, Config storage c, uint8 newThr) internal {
803
+ // Audit C-9: a `SetRecoveryApprovals(0)` previously disabled
804
+ // recovery entirely under a T4 admin quorum — quiet enough to
805
+ // be missed by reviewers, catastrophic if a key was lost
806
+ // afterwards. Refuse zero here; explicit disable-recovery
807
+ // semantics should live behind a separate DisableRecovery
808
+ // action gated at T5 + timelock (not yet wired).
809
+ if (newThr == 0) revert InvalidThresholdValue(newThr);
810
+ if (c.trusteeCount < newThr) revert RecoveryRequiresGuardians();
811
+ if (newThr > c.trusteeCount) revert InvalidThresholdValue(newThr);
812
+ uint8 oldThr = c.recoveryApprovals;
813
+ c.recoveryApprovals = newThr;
814
+ emit RecoveryThresholdChanged(account, oldThr, newThr);
815
+ }
816
+
817
+ /// @dev Per-tier custody-quorum threshold mutator. Tier 6 (recovery)
818
+ /// lives in `recoveryApprovals` instead, so it routes through
819
+ /// `SetRecoveryApprovals`; this surface covers T1..T5. The new
820
+ /// count must be ≥ 1 and ≤ the account's current custodianCount.
821
+ function _applyChangeApprovalsRequired(
822
+ address account,
823
+ Config storage c,
824
+ uint8 tier,
825
+ uint8 newCount
826
+ ) internal {
827
+ if (tier == 0 || tier > 5) revert InvalidTier(tier);
828
+ if (newCount == 0) revert InvalidThresholdValue(newCount);
829
+ uint256 n = IAgentAccount(account).custodianCount();
830
+ if (uint256(newCount) > n) revert InvalidThresholdValue(newCount);
831
+ uint8 oldValue = c.approvalsRequiredByTier[tier];
832
+ if (oldValue == 0) oldValue = 1; // mirror _approvalsValue fallback
833
+ c.approvalsRequiredByTier[tier] = newCount;
834
+ emit ThresholdChanged(account, tier, oldValue, newCount);
835
+ }
836
+
837
+ function _applyRecoverAccount(address account, AgentAccountRecoveryArgs memory r) internal {
838
+ uint256 addedOwners;
839
+ for (uint256 i; i < r.addOwners.length; i++) {
840
+ address o = r.addOwners[i];
841
+ if (o == address(0)) revert ZeroAddress();
842
+ if (!IAgentAccount(account).isCustodian(o)) {
843
+ _execute(account, abi.encodeCall(IAgentAccount.addCustodian, (o)));
844
+ addedOwners++;
845
+ }
846
+ }
847
+
848
+ uint256 addedPasskeys;
849
+ for (uint256 i; i < r.addPasskeys.length; i++) {
850
+ AgentAccountRecoveryPasskeyAdd memory pk = r.addPasskeys[i];
851
+ // H7-C.1 / CON-WEBAUTHN-001: addPasskey now requires rpIdHash.
852
+ _execute(account, abi.encodeWithSignature(
853
+ "addPasskey(bytes32,uint256,uint256,bytes32)",
854
+ pk.credentialIdDigest, pk.x, pk.y, pk.rpIdHash
855
+ ));
856
+ addedPasskeys++;
857
+ }
858
+
859
+ uint256 removedOwners;
860
+ for (uint256 i; i < r.removeOwners.length; i++) {
861
+ address o = r.removeOwners[i];
862
+ if (IAgentAccount(account).isCustodian(o)) {
863
+ _execute(account, abi.encodeCall(IAgentAccount.removeCustodian, (o)));
864
+ removedOwners++;
865
+ }
866
+ }
867
+
868
+ uint256 removedPasskeys;
869
+ for (uint256 i; i < r.removePasskeyCredentialIdDigests.length; i++) {
870
+ bytes32 cid = r.removePasskeyCredentialIdDigests[i];
871
+ _execute(account, abi.encodeWithSignature("removePasskey(bytes32)", cid));
872
+ removedPasskeys++;
873
+ }
874
+
875
+ emit AccountRecovered(account, addedOwners, removedOwners, addedPasskeys, removedPasskeys);
876
+ }
877
+
878
+ /// @dev Spec § 5.1 default-threshold matrix.
879
+ function _defaultApprovals(uint8 nCustodians, uint8 tier) internal pure returns (uint8) {
880
+ if (nCustodians == 0) return 0;
881
+ if (tier == 4) {
882
+ if (nCustodians <= 3) return nCustodians;
883
+ if (nCustodians <= 6) return nCustodians - 1;
884
+ return nCustodians - 2;
885
+ }
886
+ if (tier == 5) {
887
+ if (nCustodians <= 5) return nCustodians;
888
+ return nCustodians - 1;
889
+ }
890
+ return 0;
891
+ }
892
+ }