diff --git a/common/changes/@subsquid/evm-normalization/alert-fix-YgI6h3-optimism-deposit-nonce_2026-07-22-07-02.json b/common/changes/@subsquid/evm-normalization/alert-fix-YgI6h3-optimism-deposit-nonce_2026-07-22-07-02.json new file mode 100644 index 000000000..611b4fd90 --- /dev/null +++ b/common/changes/@subsquid/evm-normalization/alert-fix-YgI6h3-optimism-deposit-nonce_2026-07-22-07-02.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@subsquid/evm-normalization", + "comment": "Treat normalized transaction `nonce` as optional so OP-stack deposit txs (type 0x7e) that omit it no longer fail mapping", + "type": "patch" + } + ], + "packageName": "@subsquid/evm-normalization" +} diff --git a/common/changes/@subsquid/evm-rpc/alert-fix-YgI6h3-optimism-deposit-nonce_2026-07-22-07-02.json b/common/changes/@subsquid/evm-rpc/alert-fix-YgI6h3-optimism-deposit-nonce_2026-07-22-07-02.json new file mode 100644 index 000000000..54b90178b --- /dev/null +++ b/common/changes/@subsquid/evm-rpc/alert-fix-YgI6h3-optimism-deposit-nonce_2026-07-22-07-02.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@subsquid/evm-rpc", + "comment": "Treat transaction `nonce` as optional so OP-stack deposit txs (type 0x7e) that omit it (e.g. from Alchemy) no longer fail validation", + "type": "patch" + } + ], + "packageName": "@subsquid/evm-rpc" +} diff --git a/evm/evm-normalization/src/data.ts b/evm/evm-normalization/src/data.ts index bc9af57e7..d1a734f0d 100644 --- a/evm/evm-normalization/src/data.ts +++ b/evm/evm-normalization/src/data.ts @@ -172,7 +172,8 @@ export interface TempoFeePayerSignature { export interface Transaction { transactionIndex: number, hash: Bytes32, - nonce: number, + // Optional for OP-stack deposit (0x7e) transactions, which some providers omit `nonce` for + nonce?: number, from: Bytes20, to?: Bytes20, // Optional for Tempo 0x76 transactions which use batched `calls` instead of `input` diff --git a/evm/evm-normalization/src/mapping.ts b/evm/evm-normalization/src/mapping.ts index 015a60653..65f48f7e0 100644 --- a/evm/evm-normalization/src/mapping.ts +++ b/evm/evm-normalization/src/mapping.ts @@ -555,7 +555,7 @@ function mapTransaction(src: rpc.Transaction, receipt?: rpc.Receipt): Transactio return { transactionIndex: qty2Int(src.transactionIndex), hash: src.hash, - nonce: qty2Int(src.nonce), + nonce: src.nonce != null ? qty2Int(src.nonce) : undefined, from: src.from.toLowerCase(), to: src.to ? src.to.toLowerCase() : undefined, input: src.input ?? undefined, diff --git a/evm/evm-rpc/src/rpc-data.test.ts b/evm/evm-rpc/src/rpc-data.test.ts new file mode 100644 index 000000000..dfa37e175 --- /dev/null +++ b/evm/evm-rpc/src/rpc-data.test.ts @@ -0,0 +1,42 @@ +import {describe, it, expect} from 'vitest' +import {cast} from '@subsquid/util-internal-validation' +import {Transaction} from './rpc-data' + +// Real optimism-mainnet block 0x93570f5 tx[0] as returned by Alchemy: an +// OP-stack deposit transaction (type 0x7e). Alchemy omits the `nonce` field +// for these system transactions (Dwellir/geth include it), so the validator +// must treat `nonce` as optional or it crashes the dumper on every OP block. +const ALCHEMY_DEPOSIT_TX = { + type: '0x7e', + sourceHash: '0x74e8e903225d29359d3e2b53682e28da2e415872533659255f28951e4e1c3af1', + from: '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001', + to: '0x4200000000000000000000000000000000000015', + mint: '0x0', + value: '0x0', + gas: '0xf4240', + input: '0x3db6be2b0000146b000f79c50000000000000003000000006a5ebb53000000000186480000000000000000000000000000000000000000000000000000000000045488f6000000000000000000000000000000000000000000000000000000000039ef561076949313403c81113fe7b7de83f0e1686e0df1afbfd2112469530562d0bb8e0000000000000000000000006887246668a3b87f54deb3b94ba47a6f63f329850000000000000000000000000190', + hash: '0x0ba6943635ddd8ea164cd7945dd35f69a2ca6dab7b58bdf23ca1512c30d950ad', + r: '0x0', + s: '0x0', + yParity: '0x0', + v: '0x0', + blockHash: '0x0607985081d5cd537cd4ab5fb7159cf6d1e92d5009c6cb03a617d4240ee03b4d', + blockNumber: '0x93570f5', + transactionIndex: '0x0', + blockTimestamp: '0x6a5ebba3', + gasPrice: '0x0', + // note: no `nonce` +} + +describe('Transaction validation', () => { + it('accepts an OP-stack deposit tx (0x7e) that omits nonce', () => { + const tx = cast(Transaction, ALCHEMY_DEPOSIT_TX) + expect(tx.type).toBe(0x7e) + expect(tx.nonce).toBeUndefined() + }) + + it('still parses nonce when present', () => { + const tx = cast(Transaction, {...ALCHEMY_DEPOSIT_TX, nonce: '0x2efae9e'}) + expect(tx.nonce).toBe(0x2efae9e) + }) +}) diff --git a/evm/evm-rpc/src/rpc-data.ts b/evm/evm-rpc/src/rpc-data.ts index 7ab653296..d38832853 100644 --- a/evm/evm-rpc/src/rpc-data.ts +++ b/evm/evm-rpc/src/rpc-data.ts @@ -258,7 +258,9 @@ export const Transaction = object({ input: option(BYTES), maxFeePerGas: option(QTY), maxPriorityFeePerGas: option(QTY), - nonce: SMALL_QTY, + // Optional: OP-stack deposit transactions (type 0x7e) may omit `nonce` + // entirely (e.g. Alchemy), so it can't be required for every tx type. + nonce: option(SMALL_QTY), v: option(QTY), r: option(BYTES), s: option(BYTES), diff --git a/evm/evm-rpc/src/rpc.ts b/evm/evm-rpc/src/rpc.ts index 0048ef5b6..00ec9f0e8 100644 --- a/evm/evm-rpc/src/rpc.ts +++ b/evm/evm-rpc/src/rpc.ts @@ -528,7 +528,7 @@ export class Rpc { // Group all txs by (sender, nonce) so we can detect multi-tx-per-nonce cases. let txsBySenderNonce = new Map() for (let tx of transactions) { - let key = `${tx.from}:${qty2Int(tx.nonce)}` + let key = `${tx.from}:${qty2Int(assertNotNull(tx.nonce, 'tx.nonce is missing'))}` let list = txsBySenderNonce.get(key) if (list == null) { list = [] @@ -540,7 +540,7 @@ export class Rpc { let phantomHashes = new Set() for (let candidate of candidates) { let nonceAfter = nonceAfterBySender.get(candidate.from)! - let txNonce = qty2Int(candidate.nonce) + let txNonce = qty2Int(assertNotNull(candidate.nonce, 'tx.nonce is missing')) if (txNonce >= nonceAfter) { // Nonce not consumed at this block — definitely phantom. diff --git a/evm/evm-rpc/src/verification.ts b/evm/evm-rpc/src/verification.ts index 6589b9ece..459beb6ce 100644 --- a/evm/evm-rpc/src/verification.ts +++ b/evm/evm-rpc/src/verification.ts @@ -169,7 +169,7 @@ function encodeTransaction(tx: Transaction): Buffer { if (tx.type == '0x0') { return Buffer.from( RLP.encode([ - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.gasPrice, 'tx.gasPrice is missing')), BigInt(tx.gas), tx.to ? decodeHex(tx.to) : Buffer.alloc(0), @@ -183,7 +183,7 @@ function encodeTransaction(tx: Transaction): Buffer { } else if (tx.type == '0x1') { let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.gasPrice, 'tx.gasPrice is missing')), BigInt(tx.gas), tx.to ? decodeHex(tx.to) : Buffer.alloc(0), @@ -198,7 +198,7 @@ function encodeTransaction(tx: Transaction): Buffer { } else if (tx.type == '0x2') { let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas), @@ -215,7 +215,7 @@ function encodeTransaction(tx: Transaction): Buffer { // https://eips.ethereum.org/EIPS/eip-4844 let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas), @@ -234,7 +234,7 @@ function encodeTransaction(tx: Transaction): Buffer { // https://eips.ethereum.org/EIPS/eip-7702 let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas), @@ -263,7 +263,7 @@ function encodeTransaction(tx: Transaction): Buffer { let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), decodeHex(tx.from), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(tx.gasPrice ?? 0), BigInt(tx.gas), tx.to ? decodeHex(tx.to) : Buffer.alloc(0), @@ -288,7 +288,7 @@ function encodeTransaction(tx: Transaction): Buffer { // https://github.com/OffchainLabs/go-ethereum/blob/7503143fd13f73e46a966ea2c42a058af96f7fcf/core/types/arb_types.go#L161 let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), decodeHex(tx.from), BigInt(tx.gasPrice ?? 0), BigInt(tx.gas), @@ -331,7 +331,7 @@ function encodeTransaction(tx: Transaction): Buffer { // EIP-1559 base fields + nonceKey and timeoutTimestamp appended after the signature let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas), @@ -584,7 +584,7 @@ function encodeTempoTransactionFields(tx: Transaction): any[] { assertNotNull(tx.calls, 'tx.calls is missing for 0x76 tx').map(encodeTempoCall), decodeAccessList(tx.accessList ?? []), BigInt(assertNotNull(tx.nonceKey, 'tx.nonceKey is missing for 0x76 tx')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), // valid_before: u64 when present, 0x80 (empty string) when null tx.validBefore != null ? BigInt(tx.validBefore) : Buffer.alloc(0), // valid_after: u64 when present, 0x80 (empty string) when null @@ -636,7 +636,7 @@ function encodeTempoTransactionFieldsForSigning(tx: Transaction): any[] { assertNotNull(tx.calls, 'tx.calls is missing for 0x76 tx').map(encodeTempoCall), decodeAccessList(tx.accessList ?? []), BigInt(assertNotNull(tx.nonceKey, 'tx.nonceKey is missing for 0x76 tx')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), tx.validBefore != null ? BigInt(tx.validBefore) : Buffer.alloc(0), tx.validAfter != null ? BigInt(tx.validAfter) : Buffer.alloc(0), // fee_token: skipped when fee_payer_signature is present @@ -803,7 +803,7 @@ export function isBloomSuperset(superset: string, subset: string): boolean { function serializeTransaction(tx: Transaction): Uint8Array | undefined { if (tx.type == '0x0') { let fields = [ - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(tx.gasPrice ?? 0), BigInt(tx.gas), tx.to ? decodeHex(tx.to) : Buffer.alloc(0), @@ -820,7 +820,7 @@ function serializeTransaction(tx: Transaction): Uint8Array | undefined { } else if (tx.type == '0x1') { let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(tx.gasPrice ?? 0), BigInt(tx.gas), tx.to ? decodeHex(tx.to) : Buffer.alloc(0), @@ -832,7 +832,7 @@ function serializeTransaction(tx: Transaction): Uint8Array | undefined { } else if (tx.type == '0x2') { let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas), @@ -845,7 +845,7 @@ function serializeTransaction(tx: Transaction): Uint8Array | undefined { } else if (tx.type == '0x3') { let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas), @@ -860,7 +860,7 @@ function serializeTransaction(tx: Transaction): Uint8Array | undefined { } else if (tx.type == '0x4') { let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas), @@ -893,7 +893,7 @@ function serializeTransaction(tx: Transaction): Uint8Array | undefined { // Stable v1.4.0 custom transaction type — signing payload (no signature) let payload = RLP.encode([ BigInt(assertNotNull(tx.chainId, 'tx.chainId is missing')), - BigInt(tx.nonce), + BigInt(assertNotNull(tx.nonce, 'tx.nonce is missing')), BigInt(assertNotNull(tx.maxPriorityFeePerGas, 'tx.maxPriorityFeePerGas is missing')), BigInt(assertNotNull(tx.maxFeePerGas, 'tx.maxFeePerGas is missing')), BigInt(tx.gas),