Fixed wrong assumption about SelfDestruct having only at max one withdraw balance change

It appears a self destruct can really lead to 3 balance changes, 2 of which are withdraw of the money if the contract being self destructed and the self destruct beneficiary are the same.

We thought it was a bug in the Geth tracing API but it appears it's not and we must actually correctly handle that when fixing up balance changes. So we now support the case where there is 2 withdraws, in this case, the old Firehose 2.x was only ever keeping the last one, so we can simply filter it out of the changes to retain backward compatibility.
This commit is contained in:
Matthieu Vachon 2025-02-21 16:49:38 -05:00
parent bbcf323c96
commit d444c54548
2 changed files with 86 additions and 40 deletions

View file

@ -246,10 +246,9 @@ func (s *hookedStateDB) SelfDestruct6780(address common.Address) (uint256.Int, b
prev, changed := s.inner.SelfDestruct6780(address) prev, changed := s.inner.SelfDestruct6780(address)
// No balance change hook for EIP-6780 as the instruction handler does it already if s.hooks.OnBalanceChange != nil && changed && !prev.IsZero() {
// if s.hooks.OnBalanceChange != nil && changed && !prev.IsZero() { s.hooks.OnBalanceChange(address, prev.ToBig(), new(big.Int), tracing.BalanceDecreaseSelfdestruct)
// s.hooks.OnBalanceChange(address, prev.ToBig(), new(big.Int), tracing.BalanceDecreaseSelfdestruct) }
// }
if s.hooks.OnCodeChange != nil && changed && len(prevCode) > 0 { if s.hooks.OnCodeChange != nil && changed && len(prevCode) > 0 {
s.hooks.OnCodeChange(address, prevCodeHash, prevCode, types.EmptyCodeHash, nil) s.hooks.OnCodeChange(address, prevCodeHash, prevCode, types.EmptyCodeHash, nil)

View file

@ -895,7 +895,7 @@ func (f *Firehose) OnCallEnter(depth int, typ byte, from common.Address, to comm
// So if we are in compatibility mode and the block is Cancun, we must reorder the balance changes // So if we are in compatibility mode and the block is Cancun, we must reorder the balance changes
// to match the Firehose 2.3 behavior. // to match the Firehose 2.3 behavior.
if *f.applyBackwardCompatibility && f.blockRules.IsCancun { if *f.applyBackwardCompatibility && f.blockRules.IsCancun {
f.maybeReorderSelfDestructBalanceChanges() f.fixSelfDestructBalanceChanges()
} }
firehoseDebug("ignoring OnCallEnter for SELFDESTRUCT opcode, not recorded as a call") firehoseDebug("ignoring OnCallEnter for SELFDESTRUCT opcode, not recorded as a call")
@ -914,57 +914,104 @@ func (f *Firehose) OnCallEnter(depth int, typ byte, from common.Address, to comm
f.callStart(computeCallSource(depth), callType, from, to, input, gas, value) f.callStart(computeCallSource(depth), callType, from, to, input, gas, value)
} }
func (f *Firehose) maybeReorderSelfDestructBalanceChanges() { func (f *Firehose) fixSelfDestructBalanceChanges() {
type indexedBalanceChange struct {
index int
ordinal uint64
change *pbeth.BalanceChange
}
f.ensureInCall() f.ensureInCall()
activeCall := f.callStack.Peek() activeCall := f.callStack.Peek()
var increaseBalanceChange indexedBalanceChange if len(activeCall.BalanceChanges) == 0 {
var decreaseBalanceChange indexedBalanceChange return
}
for index, change := range activeCall.BalanceChanges { // It's possible in the new tracing API to get 3 balance changes for a self destruct if
switch change.Reason { // the self destruct beneficiary is the same as the contract and if the contract was
case pbeth.BalanceChange_REASON_SUICIDE_WITHDRAW: // created and destructed in the same transaction.
if decreaseBalanceChange.change != nil { //
f.panicInvalidState(fmt.Sprintf("more than one balance change with reason REASON_SUICIDE_WITHDRAW found in call #%d, this is not expected", activeCall.Index), 0) // In this case, we get first a decrease from contract to 0, then an increase from 0 to beneficiary
} // (which is the contract) and finally a decrease from contract to 0 again.
//
// In the Firehose 2.3 model, this wasn't recorded properly. The first decrease was always ignored, and
// only the second one was recorded.
decreaseBalanceChange.index = index withdrawIndices := make([]int, 0, 2)
decreaseBalanceChange.ordinal = change.Ordinal refundIndices := make([]int, 0, 1)
decreaseBalanceChange.change = change for i, change := range activeCall.BalanceChanges {
if change.Reason == pbeth.BalanceChange_REASON_SUICIDE_WITHDRAW {
case pbeth.BalanceChange_REASON_SUICIDE_REFUND: withdrawIndices = append(withdrawIndices, i)
if increaseBalanceChange.change != nil { } else if change.Reason == pbeth.BalanceChange_REASON_SUICIDE_REFUND {
f.panicInvalidState(fmt.Sprintf("more than one balance change with reason REASON_SUICIDE_REFUND found in call #%d, this is not expected", activeCall.Index), 0) refundIndices = append(refundIndices, i)
}
increaseBalanceChange.index = index
increaseBalanceChange.ordinal = change.Ordinal
increaseBalanceChange.change = change
} }
} }
// Nothing to do if there is only one side // No suicide balance change found, nothing to do
if decreaseBalanceChange.change == nil || increaseBalanceChange.change == nil { if len(withdrawIndices) == 0 && len(refundIndices) == 0 {
return return
} }
// Both side cannot be 0 (due to above condition), if only one side is 0, there is also
// nothing todo.
if len(withdrawIndices) == 0 || len(refundIndices) == 0 {
return
}
if len(refundIndices) == 1 && len(withdrawIndices) == 1 {
f.invertWithdrawAndRefundBalanceChange(activeCall, withdrawIndices[0], refundIndices[0])
return
}
if len(refundIndices) == 1 && len(withdrawIndices) == 2 {
f.removeFirstWithdrawBalanceChange(activeCall, withdrawIndices[1])
return
}
f.panicInvalidState(fmt.Sprintf("invalid state when fixing self destruct balance changes found in call #%d, withdraw indices %v and refund indices %v not matching one of the expected case(s)", activeCall.Index, withdrawIndices, refundIndices), 0)
}
func (f *Firehose) invertWithdrawAndRefundBalanceChange(activeCall *pbeth.Call, withdrawIndex int, refundIndex int) {
// Nothing to do if they are already ordered according to Firehose 2.3 rules // Nothing to do if they are already ordered according to Firehose 2.3 rules
if decreaseBalanceChange.index > increaseBalanceChange.index { if withdrawIndex > refundIndex {
return return
} }
// Otherwise, invert them and their ordinal // Otherwise, invert them to fit Firehose 2.3 rules
decreaseBalanceChange.change.Ordinal = increaseBalanceChange.ordinal changes := activeCall.BalanceChanges
increaseBalanceChange.change.Ordinal = decreaseBalanceChange.ordinal withdrawOrdinal := changes[withdrawIndex].Ordinal
refundOrdinal := changes[refundIndex].Ordinal
activeCall.BalanceChanges[decreaseBalanceChange.index] = increaseBalanceChange.change changes[withdrawIndex].Ordinal = refundOrdinal
activeCall.BalanceChanges[increaseBalanceChange.index] = decreaseBalanceChange.change changes[refundIndex].Ordinal = withdrawOrdinal
withdrawChange := changes[withdrawIndex]
changes[withdrawIndex] = changes[refundIndex]
changes[refundIndex] = withdrawChange
return
}
func (f *Firehose) removeFirstWithdrawBalanceChange(activeCall *pbeth.Call, lastWithdrawIndex int) {
finalChanges := make([]*pbeth.BalanceChange, 0, len(activeCall.BalanceChanges)-1)
for i, change := range activeCall.BalanceChanges {
switch change.Reason {
case pbeth.BalanceChange_REASON_SUICIDE_WITHDRAW:
if i != lastWithdrawIndex {
// Skip all except the last withdraw change.
continue
}
// We remove the first one, this one must be shifted by one
change.Ordinal -= 1
case pbeth.BalanceChange_REASON_SUICIDE_REFUND:
// We remove the first one withdraw, which always happens before the refund,
// this one must be shifted by one
change.Ordinal -= 1
}
finalChanges = append(finalChanges, change)
}
// We remove one change, we must adjust the overall block ordinal to fit
f.blockOrdinal.value -= 1
activeCall.BalanceChanges = finalChanges
} }
// OnCallExit is called after the call finishes to finalize the tracing. // OnCallExit is called after the call finishes to finalize the tracing.