diff --git a/common/changes/@subsquid/evm-normalization/alert-fix-4mtxPN-selfdestruct-to_2026-07-23-13-16.json b/common/changes/@subsquid/evm-normalization/alert-fix-4mtxPN-selfdestruct-to_2026-07-23-13-16.json new file mode 100644 index 000000000..f90ffe22c --- /dev/null +++ b/common/changes/@subsquid/evm-normalization/alert-fix-4mtxPN-selfdestruct-to_2026-07-23-13-16.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@subsquid/evm-normalization", + "comment": "Tolerate SELFDESTRUCT debug frames that omit `to` by falling back to the self-destructing account", + "type": "patch" + } + ], + "packageName": "@subsquid/evm-normalization" +} diff --git a/evm/evm-normalization/src/mapping.test.ts b/evm/evm-normalization/src/mapping.test.ts new file mode 100644 index 000000000..3841b5e47 --- /dev/null +++ b/evm/evm-normalization/src/mapping.test.ts @@ -0,0 +1,79 @@ +import {describe, expect, it} from 'vitest' +import {mapRawBlock} from './mapping' + + +// Minimal raw block carrying a single SELFDESTRUCT debug frame. The header/tx +// fields are only what `mapRawBlock` touches on the trace path. +function selfdestructBlock(frame: Record): any { + return { + number: '0x1', + hash: '0xblock', + parentHash: '0x0', + timestamp: '0x0', + transactionsRoot: '0x', + receiptsRoot: '0x', + stateRoot: '0x', + logsBloom: '0x', + sha3Uncles: '0x', + extraData: '0x', + miner: '0x', + size: '0x0', + gasLimit: '0x0', + gasUsed: '0x0', + transactions: [ + { + transactionIndex: '0x0', + hash: '0xtx', + nonce: '0x0', + from: '0xe22a1e72591acb61ec32a9a1d2a1d0818c2f53e0', + gas: '0x0', + debugFrame_: {result: frame}, + }, + ], + } +} + + +describe('mapDebugFrame SELFDESTRUCT', () => { + // Regression: a self-referential SELFDESTRUCT where callTracer omits `to` + // used to crash the ingest with `assertNotNull(frame.to)`. + it('tolerates a missing `to` by falling back to the account itself', () => { + let block = mapRawBlock( + selfdestructBlock({ + type: 'SELFDESTRUCT', + from: '0xe22a1e72591acb61ec32a9a1d2a1d0818c2f53e0', + gas: '0x0', + gasUsed: '0x0', + input: '0x', + value: '0x0', + }), + {withTraces: true} + ) + + expect(block.traces).toHaveLength(1) + let trace = block.traces![0] + expect(trace.type).toBe('selfdestruct') + expect((trace.action as any).refundAddress).toBe( + '0xe22a1e72591acb61ec32a9a1d2a1d0818c2f53e0' + ) + }) + + it('keeps an explicit `to` as the refund address', () => { + let block = mapRawBlock( + selfdestructBlock({ + type: 'SELFDESTRUCT', + from: '0xe22a1e72591acb61ec32a9a1d2a1d0818c2f53e0', + to: '0x1111111111111111111111111111111111111111', + gas: '0x0', + gasUsed: '0x0', + input: '0x', + value: '0x0', + }), + {withTraces: true} + ) + + expect((block.traces![0].action as any).refundAddress).toBe( + '0x1111111111111111111111111111111111111111' + ) + }) +}) diff --git a/evm/evm-normalization/src/mapping.ts b/evm/evm-normalization/src/mapping.ts index 015a60653..c72066cf8 100644 --- a/evm/evm-normalization/src/mapping.ts +++ b/evm/evm-normalization/src/mapping.ts @@ -139,7 +139,9 @@ function* mapDebugFrame( type: 'selfdestruct', action: { address: frame.from.toLowerCase(), - refundAddress: assertNotNull(frame.to).toLowerCase(), + // callTracer omits `to` on a self-referential SELFDESTRUCT; + // the beneficiary is then the account itself. + refundAddress: (frame.to ?? frame.from).toLowerCase(), balance: frame.value ?? undefined } }