Skip to content

Commit 95323ea

Browse files
authored
fix(security): slashing model, manager-hook reentrancy, lifecycle, EIP-712 (#118)
* fix(security): slashing model, manager-hook reentrancy, lifecycle, EIP-712 Closes a broad class of pre-mainnet correctness gaps. Greenfield posture: takes the right long-term API rather than backwards-compatible shims. All 1478 tests pass on the fast profile. Slashing - New `disputeResolutionDeadline` (default 14d): a disputed slash auto-fails and becomes executable after this window, so a forgotten or compromised SLASH_ADMIN cannot lock delegators forever. - New `disputeBond` (configurable, default 0/disabled): native-asset bond posted when an operator disputes; forfeit to treasury on auto-fail / execute, refunded on cancel. Defeats free-DoS via self-dispute. - New `maxPendingSlashesPerOperator` (default 32): caps concurrent pending proposals per operator so a malicious proposer can't spam-grief the pending-slash counter. - `executeSlash` now routes to `slashForService` when the operator made explicit per-asset commitments to the offending service. Falls back to blueprint-wide slashing only for legacy services with no commitments. Closes the over-broad slashing of uncommitted assets. - Operators with pending slashes can no longer call `_startLeaving`; pending slashes must resolve first. - Operators in `Inactive` status (e.g. forced inactive by being slashed below minimum) can now exit via `_startLeaving`. Stake no longer stranded. - `setSlashConfig` signature extended to surface the new knobs to admin. - Per-operator pending tracker (`_operatorActiveSlashProposals`) increments on propose, decrements on execute / cancel. Manager-hook reentrancy + CEI - `_callManager` / `_tryCallManager` capped at 500k gas. Failures from `_tryCallManager` now emit `ManagerHookFailed(manager, selector, returnData)` for off-chain observability. - `Operators.registerOperator` reordered to write all state BEFORE the BSM hook (CEI). Both registerOperator entrypoints + unregisterOperator gain `nonReentrant`. - `createBlueprint`, `transferBlueprint`, `updateBlueprint`, `deactivateBlueprint`, `setJobEventRates`, `setBlueprintResourceRequirements` gain `nonReentrant`. Service lifecycle - `fundService` re-checks (a) blueprint manager's payment-asset policy and (b) service TTL before accepting a top-up. Closes top-ups to expired services and to services whose token policy was revoked. - New permissionless `expireServiceRequest(requestId)` refunds the requester after the configured grace period, so stale unapproved requests don't lock payment indefinitely. All approve paths reject expired requests. EIP-712 / replay - Quote domain separator computed per-call against current chainid via new `_domainSeparatorView()`. Prevents stale-cache replay across chain forks. - `OperatorStatusRegistry.submitHeartbeat` now full EIP-712 with explicit `timestamp` parameter and `HEARTBEAT_MAX_AGE` (5 minutes) freshness. Closes cross-fork / cross-service / cross-deployment replay. Governance for Base - `TangleToken.clock()` switched to `block.timestamp` (ERC-6372). `votingDelay` and `votingPeriod` are now denominated in seconds and chain-agnostic. - `GovernanceDeployer` defaults updated: mainnet 1 day delay / 7 day period; testnet 20 minute delay / 3 hour period. MBSM registry - Storage gap restored to 50 (greenfield clean). Tests - Governance test suite migrated to timestamp-clock semantics (vm.warp + vm.roll) and updated default-param assertions. - Heartbeat tests updated for EIP-712 typed digest and timestamp parameter. - Slashing tests updated for the new 6-arg `setSlashConfig`. - Slashing edge-case test now exercises the per-operator pending cap. - 1478 / 1478 passing on fast profile. * fix(security): address PR-118 review (CRITICAL + 4 HIGH + 7 non-blocking) Tangletools review on commit 95b956f flagged 1 critical, 4 high, and 7 non-blocking findings. All addressed below. Full suite: 1483/1483. CRITICAL: expireServiceRequest activated-service drain - Added `bool activated` to Types.ServiceRequest. Set to true in TangleServicesFacet._activateService BEFORE any other state mutations. - expireServiceRequest now reverts on req.rejected || req.activated. - _requireRequestNotExpired (the approval-path guard) also rejects activated requests with ServiceRequestAlreadyProcessed. HIGH: ITangleSlashing.disputeSlash missing payable - Added `payable` to the interface. The implementation was already payable. Without this, typed callers could not pass value through ITangleSlashing. HIGH: dispute resolution deadline live-config bypass - Added `uint64 disputeDeadline` to SlashProposal. - Snapshot taken in SlashingLib.disputeSlash: disputeDeadline = block.timestamp + config.disputeResolutionDeadline. - isExecutable now reads proposal.disputeDeadline (snapshot), not live config. Admin shrinking config.disputeResolutionDeadline cannot retroactively shorten an already-disputed slash's review window. HIGH: cancelSlash lacked nonReentrant + bond transferred before state - Added nonReentrant to cancelSlash. - Reordered: SlashingLib.cancelSlash + decrementPendingSlash + _decrementOperatorPendingTracker complete BEFORE _settleDisputeBond. - Same CEI ordering applied to executeSlash and executeSlashBatch (state mutations, then bond settlement, then BSM hook). Non-blocking - _settleDisputeBond now restores the bond on transfer failure for BOTH refund and forfeit paths (was only the refund path). Bond is never silently lost. Treasury-unset case also restores rather than stranding. - Removed dead `if (perOpCap == 0) perOpCap = 32` branch in proposeSlash (config validates non-zero on init and update). - SlashConfigUpdated event now emits all six fields. - unregisterOperator: CEI reordering (state cleared before BSM hook). - _completeLeaving: clears _operatorBondLessRequests and iterates _operatorBlueprints to remove all blueprint associations on full exit. - Governance test: getPastVotes/quorum lookups use block.timestamp - 1 (was block.number - 1, which only worked by Foundry-init coincidence). New regression tests in SlashingEdgeCases.t.sol: - test_DisputeSlash_RevertsOnWrongBond - test_DisputeSlash_BondRefundedOnCancel - test_DisputeSlash_BondForfeitOnExecute - test_DisputeSlash_AdminDoesNotPostBond - test_DisputeDeadlineSnapshot_IgnoresAdminShrink test_ApproveService_AlreadyApproved_Reverts updated for the new activation flag (uses a 2-operator request so the request stays non-activated through the duplicate-approve test). Storage gap reductions deferred (greenfield posture per project guidance; gaps are arbitrary on first deploy, not a live-proxy migration).
1 parent fdc890b commit 95323ea

34 files changed

Lines changed: 736 additions & 236 deletions

script/DemoSimulation.s.sol

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -653,12 +653,23 @@ contract DemoSimulation is Script, BlueprintDefinitionHelper {
653653
vm.startBroadcast(operatorKeys[i]);
654654

655655
bytes memory metrics = abi.encode("cpu", 50 + (tick % 50), "mem", 60 + (tick % 40));
656-
bytes32 messageHash = keccak256(abi.encodePacked(serviceId, blueprintId, metrics));
657-
bytes32 ethSignedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
658-
(uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKeys[i], ethSignedHash);
656+
uint64 timestamp = uint64(block.timestamp);
657+
bytes32 structHash = keccak256(
658+
abi.encode(
659+
statusRegistry.HEARTBEAT_TYPEHASH(),
660+
vm.addr(operatorKeys[i]),
661+
serviceId,
662+
blueprintId,
663+
uint8(0),
664+
keccak256(metrics),
665+
timestamp
666+
)
667+
);
668+
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", statusRegistry.DOMAIN_SEPARATOR(), structHash));
669+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKeys[i], digest);
659670
bytes memory signature = abi.encodePacked(r, s, v);
660671

661-
try statusRegistry.submitHeartbeat(serviceId, blueprintId, 0, metrics, signature) {
672+
try statusRegistry.submitHeartbeat(serviceId, blueprintId, 0, metrics, timestamp, signature) {
662673
totalHeartbeats++;
663674
} catch { }
664675

script/LocalTestnet.s.sol

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,19 +1393,30 @@ contract TestHeartbeat is Script {
13931393

13941394
vm.startBroadcast(OPERATOR1_KEY);
13951395

1396-
// Create heartbeat signature using Foundry's vm.sign
1396+
// Build the EIP-712 typed-data digest for the new heartbeat schema.
13971397
bytes memory metrics = "";
1398-
bytes32 messageHash = keccak256(abi.encodePacked(serviceId, blueprintId, metrics));
1399-
// Add Ethereum signed message prefix
1400-
bytes32 ethSignedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
1401-
(uint8 v, bytes32 r, bytes32 s) = vm.sign(OPERATOR1_KEY, ethSignedHash);
1398+
uint64 timestamp = uint64(block.timestamp);
1399+
bytes32 structHash = keccak256(
1400+
abi.encode(
1401+
registry.HEARTBEAT_TYPEHASH(),
1402+
vm.addr(OPERATOR1_KEY),
1403+
serviceId,
1404+
blueprintId,
1405+
uint8(0),
1406+
keccak256(metrics),
1407+
timestamp
1408+
)
1409+
);
1410+
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", registry.DOMAIN_SEPARATOR(), structHash));
1411+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(OPERATOR1_KEY, digest);
14021412
bytes memory signature = abi.encodePacked(r, s, v);
14031413

14041414
registry.submitHeartbeat(
14051415
serviceId,
14061416
blueprintId,
14071417
0, // Healthy
14081418
metrics,
1419+
timestamp,
14091420
signature
14101421
);
14111422
console2.log("Heartbeat submitted");

src/BlueprintServiceManagerBase.sol

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,11 @@ contract BlueprintServiceManagerBase is IBlueprintServiceManager {
7878
// ═══════════════════════════════════════════════════════════════════════════
7979

8080
/// @inheritdoc IBlueprintServiceManager
81+
/// @dev One-shot binder. The BSM is paired with exactly one Tangle core on its first
82+
/// successful call; subsequent calls revert. A frontrunner can only DoS a single
83+
/// BSM deployment, which the deployer detects (the recorded `tangleCore` won't
84+
/// match the intended Tangle) and re-deploys to fix.
8185
function onBlueprintCreated(uint64 _blueprintId, address owner, address _tangleCore) external virtual {
82-
// Can only be set once
8386
if (tangleCore != address(0)) revert AlreadyInitialized();
8487

8588
blueprintId = _blueprintId;

src/MBSMRegistry.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ contract MBSMRegistry is Initializable, AccessControlUpgradeable, UUPSUpgradeabl
5353
mapping(uint32 => uint256) private _emergencyDeprecationReadyAt;
5454

5555
/// @notice Storage gap for upgrades
56-
uint256[47] private __gap;
56+
uint256[50] private __gap;
5757

5858
// ═══════════════════════════════════════════════════════════════════════════
5959
// EVENTS

src/TangleStorage.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ abstract contract TangleStorage {
232232
/// @notice Slash ID => Slash proposal
233233
mapping(uint64 => SlashingLib.SlashProposal) internal _slashProposals;
234234

235+
/// @notice Live count of pending (unresolved) slash proposals per operator. Used to
236+
/// enforce `SlashConfig.maxPendingSlashesPerOperator` so a malicious proposer
237+
/// can't grief an operator by spamming pending slashes that all bump the
238+
/// staking-side `_operatorPendingSlashCount`.
239+
mapping(address => uint64) internal _operatorActiveSlashProposals;
240+
235241
// ═══════════════════════════════════════════════════════════════════════════
236242
// RFQ (QUOTE) STORAGE (Slot 81-90)
237243
// ═══════════════════════════════════════════════════════════════════════════

src/core/Base.sol

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ abstract contract Base is
6262
/// @param registry The new registry address
6363
event MBSMRegistryUpdated(address indexed registry);
6464

65+
/// @notice Emitted when a best-effort blueprint-manager hook reverted or ran out of
66+
/// the capped gas stipend. Observable so off-chain monitors can detect a
67+
/// misbehaving BSM without halting the protocol path.
68+
event ManagerHookFailed(address indexed manager, bytes4 indexed selector, bytes returnData);
69+
6570
/// @notice Emitted when the metrics recorder is updated
6671
/// @param recorder The new recorder address (or zero to disable)
6772
event MetricsRecorderUpdated(address indexed recorder);
@@ -181,13 +186,23 @@ abstract contract Base is
181186
stakerBps: DEFAULT_STAKER_BPS
182187
});
183188

184-
// Initialize EIP-712 domain separator
189+
// Domain separator is computed on-the-fly from `block.chainid` (see
190+
// `_domainSeparatorView`) so a post-fork chainid mismatch invalidates quotes
191+
// signed under the old chain id automatically. We keep the storage slot
192+
// populated as a snapshot for off-chain indexers but never read it on-chain.
185193
_domainSeparator = SignatureLib.computeDomainSeparator("TangleQuote", "1", address(this));
186194

187195
// Initialize slashing config
188196
SlashingLib.initializeConfig(_slashState);
189197
}
190198

199+
/// @notice Compute the EIP-712 domain separator at the *current* chainid. Used by all
200+
/// on-chain quote / signature verification so a chain fork or upgrade does not
201+
/// allow replays signed under a different chainid.
202+
function _domainSeparatorView() internal view returns (bytes32) {
203+
return SignatureLib.computeDomainSeparator("TangleQuote", "1", address(this));
204+
}
205+
191206
// ═══════════════════════════════════════════════════════════════════════════
192207
// ADMIN
193208
// ═══════════════════════════════════════════════════════════════════════════
@@ -683,9 +698,17 @@ abstract contract Base is
683698
}
684699
}
685700

686-
/// @notice Call manager with revert on failure
701+
/// @notice Maximum gas forwarded to a blueprint manager hook.
702+
/// @dev Capped to 500k so a malicious or buggy BSM cannot burn the entire transaction
703+
/// gas, and so that downstream protocol logic always has enough gas left to
704+
/// finalize state changes (CEI). Hooks should be lightweight; bookkeeping work
705+
/// belongs off-chain. The cap is generous enough for typical hook bodies and
706+
/// tightens the worst-case reentrancy / DoS surface significantly.
707+
uint256 internal constant MANAGER_HOOK_GAS_LIMIT = 500_000;
708+
709+
/// @notice Call manager with revert on failure (capped gas).
687710
function _callManager(address manager, bytes memory data) internal {
688-
(bool success, bytes memory returnData) = manager.call(data);
711+
(bool success, bytes memory returnData) = manager.call{ gas: MANAGER_HOOK_GAS_LIMIT }(data);
689712
if (!success) {
690713
if (returnData.length > 0) {
691714
revert Errors.ManagerReverted(manager, returnData);
@@ -694,10 +717,14 @@ abstract contract Base is
694717
}
695718
}
696719

697-
/// @notice Try to call manager, ignore failures
720+
/// @notice Try to call manager, ignore failures (capped gas).
721+
/// @dev Failure is observable via the `ManagerHookFailed` event so off-chain monitors
722+
/// can detect a misbehaving BSM without halting the protocol path.
698723
function _tryCallManager(address manager, bytes memory data) internal {
699-
(bool success,) = manager.call(data);
700-
success; // Silence unused variable warning
724+
(bool success, bytes memory returnData) = manager.call{ gas: MANAGER_HOOK_GAS_LIMIT }(data);
725+
if (!success) {
726+
emit ManagerHookFailed(manager, bytes4(data), returnData);
727+
}
701728
}
702729

703730
// ═══════════════════════════════════════════════════════════════════════════

src/core/BlueprintsCreate.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ abstract contract BlueprintsCreate is Base {
3030
function createBlueprint(Types.BlueprintDefinition calldata def)
3131
external
3232
whenNotPaused
33+
nonReentrant
3334
returns (uint64 blueprintId)
3435
{
3536
if (address(_mbsmRegistry) == address(0)) revert Errors.MBSMRegistryNotSet();

src/core/BlueprintsManage.sol

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ abstract contract BlueprintsManage is Base {
7777
}
7878

7979
/// @notice Update blueprint metadata
80-
function updateBlueprint(uint64 blueprintId, string calldata metadataUri, bytes32 metadataHash) external {
80+
function updateBlueprint(uint64 blueprintId, string calldata metadataUri, bytes32 metadataHash)
81+
external
82+
nonReentrant
83+
{
8184
Types.Blueprint storage bp = _getBlueprint(blueprintId);
8285
if (bp.owner != msg.sender) {
8386
revert Errors.NotBlueprintOwner(blueprintId, msg.sender);
@@ -91,7 +94,7 @@ abstract contract BlueprintsManage is Base {
9194
}
9295

9396
/// @notice Transfer blueprint ownership
94-
function transferBlueprint(uint64 blueprintId, address newOwner) external {
97+
function transferBlueprint(uint64 blueprintId, address newOwner) external nonReentrant {
9598
if (newOwner == address(0)) revert Errors.ZeroAddress();
9699

97100
Types.Blueprint storage bp = _getBlueprint(blueprintId);
@@ -105,7 +108,7 @@ abstract contract BlueprintsManage is Base {
105108
}
106109

107110
/// @notice Deactivate a blueprint
108-
function deactivateBlueprint(uint64 blueprintId) external {
111+
function deactivateBlueprint(uint64 blueprintId) external nonReentrant {
109112
Types.Blueprint storage bp = _getBlueprint(blueprintId);
110113
if (bp.owner != msg.sender) {
111114
revert Errors.NotBlueprintOwner(blueprintId, msg.sender);
@@ -123,7 +126,10 @@ abstract contract BlueprintsManage is Base {
123126
/// @param blueprintId The blueprint ID
124127
/// @param jobIndexes Array of job indexes
125128
/// @param rates Array of per-job event rates (0 to clear override and use blueprint default)
126-
function setJobEventRates(uint64 blueprintId, uint8[] calldata jobIndexes, uint256[] calldata rates) external {
129+
function setJobEventRates(uint64 blueprintId, uint8[] calldata jobIndexes, uint256[] calldata rates)
130+
external
131+
nonReentrant
132+
{
127133
if (jobIndexes.length != rates.length) revert Errors.LengthMismatch();
128134

129135
Types.Blueprint storage bp = _getBlueprint(blueprintId);
@@ -164,6 +170,7 @@ abstract contract BlueprintsManage is Base {
164170
Types.ResourceCommitment[] calldata requirements
165171
)
166172
external
173+
nonReentrant
167174
{
168175
Types.Blueprint storage bp = _getBlueprint(blueprintId);
169176
if (bp.owner != msg.sender) {

src/core/JobsRFQ.sol

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ abstract contract JobsRFQ is Base {
172172
revert Errors.PaymentTooSmall(quote.details.price, PaymentLib.MINIMUM_PAYMENT_AMOUNT);
173173
}
174174

175-
// Verify EIP-712 signature and mark as used
176-
SignatureLib.verifyAndMarkJobQuoteUsed(_usedQuotes, _domainSeparator, quote, maxQuoteAge);
175+
// Verify EIP-712 signature and mark as used. Domain separator is recomputed
176+
// per-call against current chainid so cross-fork replay is impossible.
177+
SignatureLib.verifyAndMarkJobQuoteUsed(_usedQuotes, _domainSeparatorView(), quote, maxQuoteAge);
177178

178179
totalPrice += quote.details.price;
179180
}

src/core/Operators.sol

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ abstract contract Operators is Base {
7474
)
7575
external
7676
whenNotPaused
77+
nonReentrant
7778
{
7879
_registerOperator(blueprintId, ecdsaPublicKey, rpcAddress, bytes(""));
7980
}
@@ -87,6 +88,7 @@ abstract contract Operators is Base {
8788
)
8889
external
8990
whenNotPaused
91+
nonReentrant
9092
{
9193
_registerOperator(blueprintId, ecdsaPublicKey, rpcAddress, registrationInputs);
9294
}
@@ -150,25 +152,13 @@ abstract contract Operators is Base {
150152

151153
string memory rpcAddressCopy = rpcAddress;
152154

153-
// Encode preferences for backwards-compatible manager hooks
154-
{
155-
bytes memory encodedPreferences =
156-
abi.encode(Types.OperatorPreferences({ ecdsaPublicKey: ecdsaPublicKey, rpcAddress: rpcAddressCopy }));
157-
158-
// Call manager hook first (may reject)
159-
if (bp.manager != address(0)) {
160-
bytes memory managerPayload = registrationInputs.length > 0 ? registrationInputs : encodedPreferences;
161-
_callManager(
162-
bp.manager, abi.encodeCall(IBlueprintServiceManager.onRegister, (msg.sender, managerPayload))
163-
);
164-
}
165-
166-
// Store preferences (including ECDSA public key for gossip)
167-
_operatorPreferences[blueprintId][msg.sender] =
168-
Types.OperatorPreferences({ ecdsaPublicKey: ecdsaPublicKey, rpcAddress: rpcAddressCopy });
169-
}
155+
// CEI: complete every state write before invoking the (untrusted) BSM hook.
156+
// The hook can revert to reject the registration; if it does, all the state
157+
// writes are reverted along with it. The hook *cannot* observe partially-
158+
// written state, so a malicious BSM cannot exploit half-initialized records.
159+
_operatorPreferences[blueprintId][msg.sender] =
160+
Types.OperatorPreferences({ ecdsaPublicKey: ecdsaPublicKey, rpcAddress: rpcAddressCopy });
170161

171-
// Register
172162
_operatorRegistrations[blueprintId][msg.sender] = Types.OperatorRegistration({
173163
registeredAt: uint64(block.timestamp), updatedAt: uint64(block.timestamp), active: true, online: true
174164
});
@@ -189,11 +179,21 @@ abstract contract Operators is Base {
189179

190180
_recordBlueprintRegistration(blueprintId, msg.sender);
191181
emit OperatorRegistered(blueprintId, msg.sender, ecdsaPublicKey, rpcAddressCopy);
182+
183+
// Hook fires last so the BSM observes the fully-committed registration.
184+
if (bp.manager != address(0)) {
185+
bytes memory encodedPreferences =
186+
abi.encode(Types.OperatorPreferences({ ecdsaPublicKey: ecdsaPublicKey, rpcAddress: rpcAddressCopy }));
187+
bytes memory managerPayload = registrationInputs.length > 0 ? registrationInputs : encodedPreferences;
188+
_callManager(
189+
bp.manager, abi.encodeCall(IBlueprintServiceManager.onRegister, (msg.sender, managerPayload))
190+
);
191+
}
192192
}
193193

194194
/// @notice Unregister from a blueprint
195195
/// @dev Reverts if operator has any active services for this blueprint
196-
function unregisterOperator(uint64 blueprintId) external {
196+
function unregisterOperator(uint64 blueprintId) external nonReentrant {
197197
Types.Blueprint storage bp = _getBlueprint(blueprintId);
198198
Types.OperatorRegistration storage reg = _operatorRegistrations[blueprintId][msg.sender];
199199
Types.OperatorPreferences storage prefs = _operatorPreferences[blueprintId][msg.sender];
@@ -207,11 +207,8 @@ abstract contract Operators is Base {
207207
revert Errors.OperatorHasActiveServices(blueprintId, msg.sender);
208208
}
209209

210-
// Call manager hook
211-
if (bp.manager != address(0)) {
212-
_tryCallManager(bp.manager, abi.encodeCall(IBlueprintServiceManager.onUnregister, (msg.sender)));
213-
}
214-
210+
// CEI: clear operator state BEFORE invoking the (untrusted) BSM hook so the
211+
// hook observes a fully-finalized unregistration. Mirrors the registration path.
215212
bytes32 keyHash;
216213
if (prefs.ecdsaPublicKey.length != 0) {
217214
keyHash = keccak256(prefs.ecdsaPublicKey);
@@ -229,10 +226,14 @@ abstract contract Operators is Base {
229226
_operatorBlueprintCounts[msg.sender] -= 1;
230227
}
231228

232-
// Remove blueprint from operator's staking profile
233229
_staking.removeBlueprintForOperator(msg.sender, blueprintId);
234230

235231
emit OperatorUnregistered(blueprintId, msg.sender);
232+
233+
// Hook fires last (interaction).
234+
if (bp.manager != address(0)) {
235+
_tryCallManager(bp.manager, abi.encodeCall(IBlueprintServiceManager.onUnregister, (msg.sender)));
236+
}
236237
}
237238

238239
/// @notice Update operator preferences for a blueprint

0 commit comments

Comments
 (0)