diff --git a/packages/state-transition/src/block/processBuilderDepositRequest.ts b/packages/state-transition/src/block/processBuilderDepositRequest.ts index 1f1a89586b62..ca6afaccc8d9 100644 --- a/packages/state-transition/src/block/processBuilderDepositRequest.ts +++ b/packages/state-transition/src/block/processBuilderDepositRequest.ts @@ -33,11 +33,13 @@ export function processBuilderDepositRequest( const builder = state.builders.get(builderIndex); - // Increase balance by deposit amount - builder.balance += amount; - - // If exited, reset the withdrawable epoch - if (builder.withdrawableEpoch !== FAR_FUTURE_EPOCH) { + // If the builder has exited and been fully swept (balance drained to 0), reset the + // withdrawable epoch so this top-up becomes withdrawable again. Must run before the + // balance increase, since the reset is gated on the current balance being 0. + if (builder.withdrawableEpoch !== FAR_FUTURE_EPOCH && builder.balance === 0) { builder.withdrawableEpoch = computeEpochAtSlot(state.slot) + state.config.MIN_BUILDER_WITHDRAWABILITY_DELAY; } + + // Increase balance by deposit amount + builder.balance += amount; } diff --git a/packages/state-transition/test/unit/block/processBuilderDepositRequest.test.ts b/packages/state-transition/test/unit/block/processBuilderDepositRequest.test.ts index 1d70914fad28..ba5366305ebe 100644 --- a/packages/state-transition/test/unit/block/processBuilderDepositRequest.test.ts +++ b/packages/state-transition/test/unit/block/processBuilderDepositRequest.test.ts @@ -146,19 +146,19 @@ describe("processBuilderDepositRequest", () => { expect(builder.version).toBe(BUILDER_WITHDRAWAL_PREFIX); }); - it("resets the withdrawable epoch when topping up an exited builder", () => { + it("resets the withdrawable epoch when topping up an exited, fully-swept builder", () => { const slot = SLOTS_PER_EPOCH * 2; const state = buildGloasState(slot); const pubkey = Uint8Array.from({length: 48}, (_, i) => i + 1); const executionAddress = Uint8Array.from({length: 20}, (_, i) => i + 1); - // Exited builder: finite withdrawableEpoch + // Exited and fully swept builder: finite withdrawableEpoch, zero balance state.builders.push( ssz.gloas.Builder.toViewDU({ pubkey, version: BUILDER_WITHDRAWAL_PREFIX, executionAddress, - balance: 1_000_000_000, + balance: 0, depositEpoch: 0, withdrawableEpoch: 1, }) @@ -169,8 +169,36 @@ describe("processBuilderDepositRequest", () => { processBuilderDepositRequest(state, request); const builder = state.builders.get(0); - expect(builder.balance).toBe(2_000_000_000); + expect(builder.balance).toBe(1_000_000_000); const currentEpoch = Math.floor(slot / SLOTS_PER_EPOCH); expect(builder.withdrawableEpoch).toBe(currentEpoch + state.config.MIN_BUILDER_WITHDRAWABILITY_DELAY); }); + + it("does not reset the withdrawable epoch when topping up an exited builder with nonzero balance", () => { + const slot = SLOTS_PER_EPOCH * 2; + const state = buildGloasState(slot); + const pubkey = Uint8Array.from({length: 48}, (_, i) => i + 1); + const executionAddress = Uint8Array.from({length: 20}, (_, i) => i + 1); + + // Exited builder that has NOT been swept: finite withdrawableEpoch, nonzero balance. + // Per spec, the reset is gated on balance == 0, so the withdrawableEpoch must be preserved. + state.builders.push( + ssz.gloas.Builder.toViewDU({ + pubkey, + version: BUILDER_WITHDRAWAL_PREFIX, + executionAddress, + balance: 1_000_000_000, + depositEpoch: 0, + withdrawableEpoch: 1, + }) + ); + + const request = makeBuilderDepositRequest({pubkey, executionAddress, amount: 1_000_000_000}); + + processBuilderDepositRequest(state, request); + + const builder = state.builders.get(0); + expect(builder.balance).toBe(2_000_000_000); + expect(builder.withdrawableEpoch).toBe(1); + }); });