diff --git a/clients/feeder/feeder.go b/clients/feeder/feeder.go index dba2fd7f82..c529b3b190 100644 --- a/clients/feeder/feeder.go +++ b/clients/feeder/feeder.go @@ -58,6 +58,11 @@ type Reader interface { blockIdentifier string, knownTransactionCount uint64, ) (starknet.PreConfirmedUpdate, error) + PreConfirmedBlockLatest( + ctx context.Context, + blockIdentifier string, + knownTransactionCount uint64, + ) (starknet.PreConfirmedUpdate, uint64, error) PublicKey(ctx context.Context) (*felt.Felt, error) Signature(ctx context.Context, blockID string) (*starknet.Signature, error) StateUpdate(ctx context.Context, blockID string) (*starknet.StateUpdate, error) @@ -382,6 +387,45 @@ func (c *Client) PreConfirmedBlockWithIdentifier( blockIdentifier string, knownTransactionCount uint64, ) (starknet.PreConfirmedUpdate, error) { + preConfirmedEnvelope, err := c.fetchPreConfirmedUpdate( + ctx, + blockNumber, + blockIdentifier, + knownTransactionCount, + ) + if err != nil { + return nil, err + } + return preConfirmedEnvelope.Update, nil +} + +// PreConfirmedBlockLatest fetches the highest pre_confirmed block the server +// currently exposes. The response carries its block_number so the caller can +// discover the pre_confirmed tip without tracking the height itself. +// Pass an empty identifier and zero txCount for a full reply. +func (c *Client) PreConfirmedBlockLatest( + ctx context.Context, + blockIdentifier string, + knownTransactionCount uint64, +) (starknet.PreConfirmedUpdate, uint64, error) { + preConfirmedEnvelope, err := c.fetchPreConfirmedUpdate( + ctx, + "latest", + blockIdentifier, + knownTransactionCount, + ) + if err != nil { + return nil, 0, err + } + return preConfirmedEnvelope.Update, preConfirmedEnvelope.BlockNumber, nil +} + +func (c *Client) fetchPreConfirmedUpdate( + ctx context.Context, + blockNumber string, + blockIdentifier string, + knownTransactionCount uint64, +) (*starknet.PreConfirmedUpdateEnvelope, error) { if blockIdentifier == "" { blockIdentifier = PreConfirmedBlankIdentifier } @@ -390,12 +434,7 @@ func (c *Client) PreConfirmedBlockWithIdentifier( "blockIdentifier": blockIdentifier, "knownTransactionCount": strconv.FormatUint(knownTransactionCount, 10), }) - - env, err := doRequest[starknet.PreConfirmedUpdateEnvelope](ctx, c, queryURL) - if err != nil { - return nil, err - } - return env.Update, nil + return doRequest[starknet.PreConfirmedUpdateEnvelope](ctx, c, queryURL) } // Deprecated: Transaction calls the get_transaction endpoint which returns diff --git a/clients/feeder/feeder_test.go b/clients/feeder/feeder_test.go index 89b0f4aa37..11fab9e351 100644 --- a/clients/feeder/feeder_test.go +++ b/clients/feeder/feeder_test.go @@ -1321,3 +1321,37 @@ func TestFeederValidation(t *testing.T) { ) }) } + +func TestPreConfirmedBlockLatest(t *testing.T) { + client := feeder.NewTestClient(t, &networks.Sepolia) + + t.Run("blank identifier returns the full block for a new round", func(t *testing.T) { + update, blockNumber, err := client.PreConfirmedBlockLatest(t.Context(), "", 0) + require.NoError(t, err) + assert.Equal(t, uint64(10936237), blockNumber) + + full, ok := update.(starknet.PreConfirmedBlock) + require.True(t, ok, "expected PreConfirmedBlock, got %T", update) + assert.Equal(t, "0x1cbe25d9", full.BlockIdentifier) + assert.Equal(t, "PRE_CONFIRMED", full.Status) + assert.Equal(t, "0.14.2", full.Version) + assert.Len(t, full.Transactions, 3) + }) + + t.Run("matching identifier and txn count returns the appended delta", func(t *testing.T) { + update, blockNumber, err := client.PreConfirmedBlockLatest(t.Context(), "0x1cbe25d9", 3) + require.NoError(t, err) + assert.Equal(t, uint64(10936237), blockNumber) + + delta, ok := update.(starknet.PreConfirmedDeltaUpdate) + require.True(t, ok, "expected PreConfirmedDeltaUpdate, got %T", update) + assert.Equal(t, "0x1cbe25d9", delta.BlockIdentifier) + assert.Len(t, delta.Transactions, 1) + }) + + t.Run("matching identifier with no appended txns returns no change", func(t *testing.T) { + update, _, err := client.PreConfirmedBlockLatest(t.Context(), "0x1cbe25d9", 4) + require.NoError(t, err) + assert.IsType(t, starknet.PreConfirmedNoChange{}, update) + }) +} diff --git a/clients/feeder/test_feeder.go b/clients/feeder/test_feeder.go index 67171368ae..d42509dd8d 100644 --- a/clients/feeder/test_feeder.go +++ b/clients/feeder/test_feeder.go @@ -156,12 +156,15 @@ func resolveDirAndQueryArg(t testing.TB, path string, queryMap url.Values) (stri queryArg = "blockHash" case strings.HasSuffix(path, "get_preconfirmed_block"): - if _, ok := queryMap["blockIdentifier"]; ok { - return "pre_confirmed_delta", blockNumberArg, nil + // A blank identifier (re)syncs from preconfirmed//full; + // any other identifier selects the exact response fixture at + // preconfirmed///. + blockNumber := queryMap.Get(blockNumberArg) + if identifier := queryMap.Get("blockIdentifier"); identifier != PreConfirmedBlankIdentifier { + return filepath.Join("preconfirmed", blockNumber, identifier), "knownTransactionCount", nil } - - dir = "pre_confirmed" - queryArg = blockNumberArg + queryMap["preconfirmedFile"] = []string{"full"} + return filepath.Join("preconfirmed", blockNumber), "preconfirmedFile", nil default: err = errors.New("unknown endpoint") diff --git a/clients/feeder/testdata/sepolia-integration/pre_confirmed_delta/11251800.json b/clients/feeder/testdata/sepolia-integration/preconfirmed/11251800/full.json similarity index 100% rename from clients/feeder/testdata/sepolia-integration/pre_confirmed_delta/11251800.json rename to clients/feeder/testdata/sepolia-integration/preconfirmed/11251800/full.json diff --git a/clients/feeder/testdata/sepolia-integration/pre_confirmed_delta/11252240.json b/clients/feeder/testdata/sepolia-integration/preconfirmed/11252240/full.json similarity index 100% rename from clients/feeder/testdata/sepolia-integration/pre_confirmed_delta/11252240.json rename to clients/feeder/testdata/sepolia-integration/preconfirmed/11252240/full.json diff --git a/clients/feeder/testdata/sepolia/preconfirmed/1781802365/0x1857317c/2.json b/clients/feeder/testdata/sepolia/preconfirmed/1781802365/0x1857317c/2.json new file mode 100644 index 0000000000..1cb751ea9f --- /dev/null +++ b/clients/feeder/testdata/sepolia/preconfirmed/1781802365/0x1857317c/2.json @@ -0,0 +1,215 @@ +{ + "transactions": [ + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x1", + "0x35df8ef86553a190f19be56e763442a31f277828039f00b97ac4afdc4ad9cf3", + "0x2fd9126ee011f3a837cea02e32ae4ee73342d827e216998e5616bab88d8b7ea", + "0x1", + "0x2fd9126ee011f3a837cea02e32ae4ee73342d827e216998e5616bab88d8b7ea" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb10", + "signature": [ + "0x694e21e4cd18f496b21fd9ec325400c58c2282bcf1a00cb641bf29255210c88", + "0x2a278ea699bad88bd38cd1e7b758229ddf440081248ee31f8b46cb6ac3d007e" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x433b1e9ce02a32d8788f630fbcabf9ae4904a913c4e3803077392f37d2b14fd", + "version": "0x3" + }, + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x2", + "0x35df8ef86553a190f19be56e763442a31f277828039f00b97ac4afdc4ad9cf3", + "0x27c3334165536f239cfd400ed956eabff55fc60de4fb56728b6a4f6b87db01c", + "0x3", + "0x30a26db8c8e2ca8c5bac843188bd538ace0f09176a519cecf5beb2475204fad", + "0xb17d8a2731ba7ca1816631e6be14f0fc1b8390422d649fa27f0fbb0c91eea8", + "0x0", + "0x35df8ef86553a190f19be56e763442a31f277828039f00b97ac4afdc4ad9cf3", + "0x17da35ce4ed77e22e3b9149fd965dba57351a6c29f588a7d245e208d073e4c1", + "0x0" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb11", + "signature": [ + "0x684ccb78ae4ac3ba5c5f8cbd0623d43b3ebe29e833df9a6955e16705f1d8a4", + "0x1a7feb5abc778a74194bb62f2fa7676c5cffefcaf1177189c336d22c1bb71da" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x518b61ae3d0a3c783d96714d42a67b411db77156c0328c56cc7fcba2672d6b", + "version": "0x3" + } + ], + "transaction_receipts": [ + { + "transaction_index": 2, + "transaction_hash": "0x433b1e9ce02a32d8788f630fbcabf9ae4904a913c4e3803077392f37d2b14fd", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x1717098adb2b80", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4885, + "builtin_instance_counter": { + "range_check_builtin": 158, + "poseidon_builtin": 18, + "pedersen_builtin": 4 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 795835 + } + }, + "actual_fee": "0x1717098adb2b80", + "execution_status": "SUCCEEDED" + }, + { + "transaction_index": 3, + "transaction_hash": "0x518b61ae3d0a3c783d96714d42a67b411db77156c0328c56cc7fcba2672d6b", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x202fd9fdf3b4880", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4912, + "builtin_instance_counter": { + "range_check_builtin": 158, + "poseidon_builtin": 20, + "pedersen_builtin": 4 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 17889384 + } + }, + "actual_fee": "0x202fd9fdf3b4880", + "execution_status": "SUCCEEDED" + } + ], + "transaction_state_diffs": [ + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bd8e02f99f7fb4fd" + }, + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116b05ea4128f31e48f" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb11" + }, + "replaced_classes": [] + }, + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bf9100997ebafd7d" + }, + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116ae5ba672aff69c0f" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb12" + }, + "replaced_classes": [] + } + ], + "block_identifier": "0x1857317c", + "changed": true +} diff --git a/clients/feeder/testdata/sepolia/preconfirmed/1781802365/0x1857317c/4.json b/clients/feeder/testdata/sepolia/preconfirmed/1781802365/0x1857317c/4.json new file mode 100644 index 0000000000..5939b6f75b --- /dev/null +++ b/clients/feeder/testdata/sepolia/preconfirmed/1781802365/0x1857317c/4.json @@ -0,0 +1,3 @@ +{ + "changed": false +} diff --git a/clients/feeder/testdata/sepolia/preconfirmed/1781802365/full.json b/clients/feeder/testdata/sepolia/preconfirmed/1781802365/full.json new file mode 100644 index 0000000000..87ebedf579 --- /dev/null +++ b/clients/feeder/testdata/sepolia/preconfirmed/1781802365/full.json @@ -0,0 +1,271 @@ +{ + "status": "PRE_CONFIRMED", + "starknet_version": "0.14.2", + "l1_da_mode": "BLOB", + "l1_gas_price": { + "price_in_fri": "0x5cc81f21abb5", + "price_in_wei": "0x79a68be8" + }, + "l1_data_gas_price": { + "price_in_fri": "0x6063b99ec1", + "price_in_wei": "0x7e6187" + }, + "l2_gas_price": { + "price_in_fri": "0x1dcd65000", + "price_in_wei": "0x27134" + }, + "timestamp": 1781802365, + "sequencer_address": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "transactions": [ + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x1", + "0x30a26db8c8e2ca8c5bac843188bd538ace0f09176a519cecf5beb2475204fad", + "0x1136789e1c76159d9b9eca06fcef05bdcf77f5d51bd4d9e09f2bc8d7520d8e6", + "0x2", + "0xc5310a2e04412e3eca8f82c4a23a4908", + "0xa923adf3bb0d6ada1361acba53796d30" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb0e", + "signature": [ + "0x54fb2e17df2a6ade45ef06e6c45a957821f422d29d2ae2edf5f863a25012e33", + "0x6cb8e5cf81d4b6863a730185cb59d82b495904a23c454ff4235e211a893782b" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x34b76d35cf15aa9d3ca00d7f85277af20235ee9fa2af93102698b3d5b765fbf", + "version": "0x3" + }, + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x1", + "0x30a26db8c8e2ca8c5bac843188bd538ace0f09176a519cecf5beb2475204fad", + "0x2468d193cd15b621b24c2a602b8dbcfa5eaa14f88416c40c09d7fd12592cb4b", + "0x0" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb0f", + "signature": [ + "0x61d7c8a58c86db181cffea34a4e3015db7bcc450ce981eac2cf6ad39e65ec13", + "0x6757fc2bd0c49cde68a909e4197776d783b5d890957f313630210ae7c392a40" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x3c2c7b8200756e7ce2a6ce36629c8d8c0c36af10fc0819f51b1efeab0695faf", + "version": "0x3" + } + ], + "transaction_receipts": [ + { + "transaction_index": 0, + "transaction_hash": "0x34b76d35cf15aa9d3ca00d7f85277af20235ee9fa2af93102698b3d5b765fbf", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x21722a87548980", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4890, + "builtin_instance_counter": { + "range_check_builtin": 158, + "pedersen_builtin": 4, + "poseidon_builtin": 18 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 1155705 + } + }, + "actual_fee": "0x21722a87548980", + "execution_status": "SUCCEEDED" + }, + { + "transaction_index": 1, + "transaction_hash": "0x3c2c7b8200756e7ce2a6ce36629c8d8c0c36af10fc0819f51b1efeab0695faf", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x169a0739ff3280", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4879, + "builtin_instance_counter": { + "poseidon_builtin": 17, + "range_check_builtin": 158, + "pedersen_builtin": 4 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 778866 + } + }, + "actual_fee": "0x169a0739ff3280", + "execution_status": "SUCCEEDED" + } + ], + "transaction_state_diffs": [ + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116b08c5523540c428f" + }, + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bd6051e8daa556fd" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb0f" + }, + "replaced_classes": [] + }, + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bd76ebf014a4897d" + }, + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116b075bb1c1a0d100f" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb10" + }, + "replaced_classes": [] + }, + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bd8e02f99f7fb4fd" + }, + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116b05ea4128f31e48f" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb11" + }, + "replaced_classes": [] + }, + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bf9100997ebafd7d" + }, + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116ae5ba672aff69c0f" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb12" + }, + "replaced_classes": [] + } + ], + "block_identifier": "0x1857317c", + "changed": true +} diff --git a/clients/feeder/testdata/sepolia/preconfirmed/latest/0x1cbe25d9/3.json b/clients/feeder/testdata/sepolia/preconfirmed/latest/0x1cbe25d9/3.json new file mode 100644 index 0000000000..b780f684db --- /dev/null +++ b/clients/feeder/testdata/sepolia/preconfirmed/latest/0x1cbe25d9/3.json @@ -0,0 +1,115 @@ +{ + "transactions": [ + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x2", + "0x30a26db8c8e2ca8c5bac843188bd538ace0f09176a519cecf5beb2475204fad", + "0x3d3da80997f8be5d16e9ae7ee6a4b5f7191d60765a1a6c219ab74269c85cf97", + "0x0", + "0x35df8ef86553a190f19be56e763442a31f277828039f00b97ac4afdc4ad9cf3", + "0x1a8e87e9d2008fcd3ce423ae5219c21e49be18d05d72825feb7e2bb687ba35c", + "0x2", + "0xe4ca5c77326105056cfd368a4ae0120d", + "0x2b62d4cef0b2e30ae57021bbb6f529d" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb15", + "signature": [ + "0x2dda857036e68453baa0e65f9eb2d2eaefb09a7ef0c6c54f284bb26851eb6bf", + "0x10fc085926a2531c31465223641f9204e9bc17d53d07cda71b6c98a19e56053" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x6482f228e979a247a7ec3081c6b88c506cbd9f3cb052a4dcf435a4627ec2366", + "version": "0x3" + } + ], + "transaction_receipts": [ + { + "transaction_index": 3, + "transaction_hash": "0x6482f228e979a247a7ec3081c6b88c506cbd9f3cb052a4dcf435a4627ec2366", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x24ed9b2875d780", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4907, + "builtin_instance_counter": { + "pedersen_builtin": 4, + "poseidon_builtin": 20, + "range_check_builtin": 158 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 1276711 + } + }, + "actual_fee": "0x24ed9b2875d780", + "execution_status": "SUCCEEDED" + } + ], + "transaction_state_diffs": [ + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116addad078ba476d0f" + }, + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1c011d693746a2c7d" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb16" + }, + "replaced_classes": [] + } + ], + "block_number": 10936237, + "block_identifier": "0x1cbe25d9", + "changed": true +} diff --git a/clients/feeder/testdata/sepolia/preconfirmed/latest/0x1cbe25d9/4.json b/clients/feeder/testdata/sepolia/preconfirmed/latest/0x1cbe25d9/4.json new file mode 100644 index 0000000000..5939b6f75b --- /dev/null +++ b/clients/feeder/testdata/sepolia/preconfirmed/latest/0x1cbe25d9/4.json @@ -0,0 +1,3 @@ +{ + "changed": false +} diff --git a/clients/feeder/testdata/sepolia/preconfirmed/latest/full.json b/clients/feeder/testdata/sepolia/preconfirmed/latest/full.json new file mode 100644 index 0000000000..03183ac6b7 --- /dev/null +++ b/clients/feeder/testdata/sepolia/preconfirmed/latest/full.json @@ -0,0 +1,333 @@ +{ + "status": "PRE_CONFIRMED", + "starknet_version": "0.14.2", + "l1_da_mode": "BLOB", + "l1_gas_price": { + "price_in_fri": "0x5cc81f21abb5", + "price_in_wei": "0x79a68be8" + }, + "l1_data_gas_price": { + "price_in_fri": "0x6063b99ec1", + "price_in_wei": "0x7e6187" + }, + "l2_gas_price": { + "price_in_fri": "0x1dcd65000", + "price_in_wei": "0x27134" + }, + "timestamp": 1781802368, + "sequencer_address": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "transactions": [ + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x2", + "0x30a26db8c8e2ca8c5bac843188bd538ace0f09176a519cecf5beb2475204fad", + "0x2468d193cd15b621b24c2a602b8dbcfa5eaa14f88416c40c09d7fd12592cb4b", + "0x0", + "0x30a26db8c8e2ca8c5bac843188bd538ace0f09176a519cecf5beb2475204fad", + "0x1a8e87e9d2008fcd3ce423ae5219c21e49be18d05d72825feb7e2bb687ba35c", + "0x2", + "0x2728c6ab0e195031d25dbf4da896bf68", + "0xaa23d2916d9ff86a7b5ede3da066dc5f" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb12", + "signature": [ + "0x31ca8e151aea2d99b8522a7236e1857c7d0465dd19bb9867517fd1f6b8cc2e8", + "0x4aa38d515eb557857367d8566b021463f1abb32dca5902dc73c9cfb588cdef4" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x6b7292ceff441a6a9044fa9f96da51a53d7f2cfe0373b932d4562e7cd45e9cf", + "version": "0x3" + }, + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x1", + "0x35df8ef86553a190f19be56e763442a31f277828039f00b97ac4afdc4ad9cf3", + "0x2fd9126ee011f3a837cea02e32ae4ee73342d827e216998e5616bab88d8b7ea", + "0x1", + "0x2fd9126ee011f3a837cea02e32ae4ee73342d827e216998e5616bab88d8b7ea" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb13", + "signature": [ + "0x1dab7262474b9178aa1d1484851d6acf3db4c7db59c776100fe753019701305", + "0x649d915d442f78cf01e7dc870af998ed9d28d2e33c3ab04b2671452eb0c4ad9" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x2e91061194f1566f5fdba60f0c6d7819cbcfec945c6de23b7d60e77cd052534", + "version": "0x3" + }, + { + "type": "INVOKE_FUNCTION", + "resource_bounds": { + "L1_GAS": { + "max_amount": "0x11170", + "max_price_per_unit": "0x8d79883d20000" + }, + "L2_GAS": { + "max_amount": "0x5f5e100", + "max_price_per_unit": "0xba43b7400" + }, + "L1_DATA_GAS": { + "max_amount": "0x2710", + "max_price_per_unit": "0x62448724953354" + } + }, + "tip": "0x5f5e100", + "calldata": [ + "0x1", + "0x30a26db8c8e2ca8c5bac843188bd538ace0f09176a519cecf5beb2475204fad", + "0x1a8e87e9d2008fcd3ce423ae5219c21e49be18d05d72825feb7e2bb687ba35c", + "0x2", + "0x1958302ac8ac4fd97c8f4ae80d60318d", + "0x8cce58de90b9285f47062fc2a3f8f4b9" + ], + "sender_address": "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "nonce": "0x95eb14", + "signature": [ + "0x7800ed48eebfbb4aac422222d2c31984a42c2c3200cf7d9d0867b84a822e6c2", + "0x63d3a2dbf5e5b8dbc27065809c78e9954aaa1b330bc6ca551546948d043182d" + ], + "nonce_data_availability_mode": 0, + "fee_data_availability_mode": 0, + "paymaster_data": [], + "account_deployment_data": [], + "proof_facts": [], + "transaction_hash": "0x191d2a07d1e0c378d4bac370a98decec4109f3290e5baabc3f41f97293bba38", + "version": "0x3" + } + ], + "transaction_receipts": [ + { + "transaction_index": 0, + "transaction_hash": "0x6b7292ceff441a6a9044fa9f96da51a53d7f2cfe0373b932d4562e7cd45e9cf", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x245a0296170480", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4907, + "builtin_instance_counter": { + "range_check_builtin": 158, + "pedersen_builtin": 4, + "poseidon_builtin": 20 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 1256676 + } + }, + "actual_fee": "0x245a0296170480", + "execution_status": "SUCCEEDED" + }, + { + "transaction_index": 1, + "transaction_hash": "0x2e91061194f1566f5fdba60f0c6d7819cbcfec945c6de23b7d60e77cd052534", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x1717098adb2b80", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4885, + "builtin_instance_counter": { + "poseidon_builtin": 18, + "pedersen_builtin": 4, + "range_check_builtin": 158 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 795835 + } + }, + "actual_fee": "0x1717098adb2b80", + "execution_status": "SUCCEEDED" + }, + { + "transaction_index": 2, + "transaction_hash": "0x191d2a07d1e0c378d4bac370a98decec4109f3290e5baabc3f41f97293bba38", + "l2_to_l1_messages": [], + "events": [ + { + "from_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "keys": [ + "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9", + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5", + "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8" + ], + "data": [ + "0x207752ac472780", + "0x0" + ] + } + ], + "execution_resources": { + "n_steps": 4890, + "builtin_instance_counter": { + "poseidon_builtin": 18, + "range_check_builtin": 158, + "pedersen_builtin": 4 + }, + "n_memory_holes": 0, + "data_availability": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 0 + }, + "total_gas_consumed": { + "l1_gas": 0, + "l1_data_gas": 128, + "l2_gas": 1121655 + } + }, + "actual_fee": "0x207752ac472780", + "execution_status": "SUCCEEDED" + } + ], + "transaction_state_diffs": [ + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116ae374c7019df978f" + }, + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bfb55a9c14d201fd" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb13" + }, + "replaced_classes": [] + }, + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bfcc71a59fad2d7d" + }, + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116ae2035668f046c0f" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb14" + }, + "replaced_classes": [] + }, + { + "storage_diffs": { + "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": [ + { + "key": "0x47146b3357200298e01a27f2994f8b4ab20c6df9b07a31923bf8769cb3e6449", + "value": "0x116adffbe13e2bd448f" + }, + { + "key": "0x5496768776e3db30053404f18067d81a6e06f5a2b0de326e21298fd9d569a9a", + "value": "0x39ab1bfece8f84bf454fd" + } + ] + }, + "deployed_contracts": [], + "declared_classes": [], + "migrated_compiled_classes": [], + "old_declared_contracts": [], + "nonces": { + "0x27125dc293a66e3df8784c51ed07c7011cf02f5fe53de3163ae78cbab7e80f5": "0x95eb15" + }, + "replaced_classes": [] + } + ], + "block_number": 10936237, + "block_identifier": "0x1cbe25d9", + "changed": true +} diff --git a/mocks/mock_feeder.go b/mocks/mock_feeder.go index 2be0e99e06..022e13b766 100644 --- a/mocks/mock_feeder.go +++ b/mocks/mock_feeder.go @@ -132,6 +132,22 @@ func (mr *MockFeederReaderMockRecorder) FeeTokenAddresses(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FeeTokenAddresses", reflect.TypeOf((*MockFeederReader)(nil).FeeTokenAddresses), ctx) } +// PreConfirmedBlockLatest mocks base method. +func (m *MockFeederReader) PreConfirmedBlockLatest(ctx context.Context, blockIdentifier string, knownTransactionCount uint64) (starknet.PreConfirmedUpdate, uint64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PreConfirmedBlockLatest", ctx, blockIdentifier, knownTransactionCount) + ret0, _ := ret[0].(starknet.PreConfirmedUpdate) + ret1, _ := ret[1].(uint64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PreConfirmedBlockLatest indicates an expected call of PreConfirmedBlockLatest. +func (mr *MockFeederReaderMockRecorder) PreConfirmedBlockLatest(ctx, blockIdentifier, knownTransactionCount any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreConfirmedBlockLatest", reflect.TypeOf((*MockFeederReader)(nil).PreConfirmedBlockLatest), ctx, blockIdentifier, knownTransactionCount) +} + // PreConfirmedBlockWithIdentifier mocks base method. func (m *MockFeederReader) PreConfirmedBlockWithIdentifier(ctx context.Context, blockNumber, blockIdentifier string, knownTransactionCount uint64) (starknet.PreConfirmedUpdate, error) { m.ctrl.T.Helper() diff --git a/mocks/mock_starknetdata.go b/mocks/mock_starknetdata.go index 259d18bc90..48bb1bc6a1 100644 --- a/mocks/mock_starknetdata.go +++ b/mocks/mock_starknetdata.go @@ -133,6 +133,22 @@ func (mr *MockStarknetDataMockRecorder) PreConfirmedBlockByNumber(ctx, blockNumb return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreConfirmedBlockByNumber", reflect.TypeOf((*MockStarknetData)(nil).PreConfirmedBlockByNumber), ctx, blockNumber, blockIdentifier, knownTransactionCount) } +// PreConfirmedBlockLatest mocks base method. +func (m *MockStarknetData) PreConfirmedBlockLatest(ctx context.Context, blockIdentifier string, knownTransactionCount uint64) (starknet.PreConfirmedUpdate, uint64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PreConfirmedBlockLatest", ctx, blockIdentifier, knownTransactionCount) + ret0, _ := ret[0].(starknet.PreConfirmedUpdate) + ret1, _ := ret[1].(uint64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PreConfirmedBlockLatest indicates an expected call of PreConfirmedBlockLatest. +func (mr *MockStarknetDataMockRecorder) PreConfirmedBlockLatest(ctx, blockIdentifier, knownTransactionCount any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreConfirmedBlockLatest", reflect.TypeOf((*MockStarknetData)(nil).PreConfirmedBlockLatest), ctx, blockIdentifier, knownTransactionCount) +} + // StateUpdate mocks base method. func (m *MockStarknetData) StateUpdate(ctx context.Context, blockNumber uint64) (*core.StateUpdate, error) { m.ctrl.T.Helper() diff --git a/starknet/pre_confirmed_update.go b/starknet/pre_confirmed_update.go index af293eab50..a5734606f6 100644 --- a/starknet/pre_confirmed_update.go +++ b/starknet/pre_confirmed_update.go @@ -140,14 +140,20 @@ var ( // - "changed": false → NoChange // - "changed": true + "timestamp" → Full (new round) // - "changed": true, no "timestamp" → Delta +// +// BlockNumber is set when the response carries a top-level "block_number" +// (the "latest" endpoint includes it; the explicit-number endpoint does not, +// since the caller already knows the requested number). type PreConfirmedUpdateEnvelope struct { - Update PreConfirmedUpdate + Update PreConfirmedUpdate + BlockNumber uint64 } func (e *PreConfirmedUpdateEnvelope) UnmarshalJSON(data []byte) error { var peek struct { - Changed *bool `json:"changed"` - Timestamp *uint64 `json:"timestamp"` + Changed *bool `json:"changed"` + Timestamp *uint64 `json:"timestamp"` + BlockNumber *uint64 `json:"block_number"` } if err := json.Unmarshal(data, &peek); err != nil { return err @@ -155,6 +161,9 @@ func (e *PreConfirmedUpdateEnvelope) UnmarshalJSON(data []byte) error { if peek.Changed == nil { return errors.New("pre_confirmed update: missing required \"changed\" field") } + if peek.BlockNumber != nil { + e.BlockNumber = *peek.BlockNumber + } switch { case !*peek.Changed: diff --git a/starknet/pre_confirmed_update_test.go b/starknet/pre_confirmed_update_test.go index 2b7358547d..f0fe9d41b4 100644 --- a/starknet/pre_confirmed_update_test.go +++ b/starknet/pre_confirmed_update_test.go @@ -22,146 +22,137 @@ func loadFeederTestdata(t *testing.T, relPath string) []byte { } func TestPreConfirmedUpdateEnvelope_UnmarshalJSON(t *testing.T) { - t.Run("changed absent returns error", func(t *testing.T) { - var env starknet.PreConfirmedUpdateEnvelope - require.Error(t, json.Unmarshal([]byte(`{}`), &env)) - }) + // The latest tag carries block_number on the wire (the caller is + // discovering the tip), so the envelope unwraps it into BlockNumber. + t.Run("latest tag response", func(t *testing.T) { + t.Run("Full decodes as a new round carrying block_number", func(t *testing.T) { + raw := loadFeederTestdata(t, "sepolia/preconfirmed/latest/full.json") + + var env starknet.PreConfirmedUpdateEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + require.Equal(t, uint64(10936237), env.BlockNumber, "latest endpoint carries block_number") + + full, ok := env.Update.(starknet.PreConfirmedBlock) + require.True(t, ok, "expected PreConfirmedBlock, got %T", env.Update) + require.Equal(t, "0x1cbe25d9", full.BlockIdentifier) + require.Equal(t, "PRE_CONFIRMED", full.Status) + require.NotZero(t, full.Timestamp) + require.NotNil(t, full.SequencerAddress) + require.NotNil(t, full.L1GasPrice) + }) + + t.Run("Delta decodes carrying block_number", func(t *testing.T) { + // Appended-delta poll: changed=true, no timestamp, carrying the + // transactions/receipts/state-diffs appended since the known tx count. + raw := loadFeederTestdata(t, "sepolia/preconfirmed/latest/0x1cbe25d9/3.json") + + var env starknet.PreConfirmedUpdateEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + require.Equal(t, uint64(10936237), env.BlockNumber, "latest endpoint carries block_number") + + delta, ok := env.Update.(starknet.PreConfirmedDeltaUpdate) + require.True(t, ok, "expected PreConfirmedDeltaUpdate, got %T", env.Update) + require.Equal(t, "0x1cbe25d9", delta.BlockIdentifier) + require.Len(t, delta.Transactions, 1) + require.Len(t, delta.Receipts, 1) + require.Len(t, delta.TransactionStateDiffs, 1) + // The appended tx/receipt decoded into real, non-nil wire values. + require.NotNil(t, delta.Transactions[0].Hash) + require.NotNil(t, delta.Receipts[0].TransactionHash) + }) - t.Run("changed=true with timestamp decodes as Full (new round)", func(t *testing.T) { - // Real new-endpoint Full response: "changed": true with timestamp. - raw := loadFeederTestdata(t, "sepolia-integration/pre_confirmed_delta/11252240.json") + t.Run("NoChange decodes when nothing was appended", func(t *testing.T) { + raw := loadFeederTestdata(t, "sepolia/preconfirmed/latest/0x1cbe25d9/4.json") - var env starknet.PreConfirmedUpdateEnvelope - require.NoError(t, json.Unmarshal(raw, &env)) + var env starknet.PreConfirmedUpdateEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) - full, ok := env.Update.(starknet.PreConfirmedBlock) - require.True(t, ok, "expected PreConfirmedBlock, got %T", env.Update) - require.NotEmpty(t, full.BlockIdentifier, "new-round Full must carry an identifier") - require.Equal(t, "PRE_CONFIRMED", full.Status) - require.NotZero(t, full.Timestamp) - require.NotNil(t, full.SequencerAddress) - require.NotNil(t, full.L1GasPrice) + _, ok := env.Update.(starknet.PreConfirmedNoChange) + require.True(t, ok, "expected PreConfirmedNoChange, got %T", env.Update) + }) }) - // NOTE: NoChange and Delta-without-timestamp paths still use inline JSON - // because no real-wire fixtures for them exist in clients/feeder/testdata. - // If/when fixtures are added (e.g. sepolia-integration/pre_confirmed_delta - // captures of an actual no-change / appended-delta poll), these should - // move to loadFeederTestdata. - - t.Run("changed=false decodes as NoChange", func(t *testing.T) { - raw := []byte(`{"changed": false}`) + // The explicit-number endpoint omits block_number (the caller already knows + // the height), so the envelope leaves BlockNumber at zero. + t.Run("explicit number response", func(t *testing.T) { + t.Run("Full decodes as a new round omitting block_number", func(t *testing.T) { + raw := loadFeederTestdata(t, "sepolia/preconfirmed/1781802365/full.json") + + var env starknet.PreConfirmedUpdateEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + require.Zero(t, env.BlockNumber, "numbered endpoint omits block_number") + + full, ok := env.Update.(starknet.PreConfirmedBlock) + require.True(t, ok, "expected PreConfirmedBlock, got %T", env.Update) + require.Equal(t, "0x1857317c", full.BlockIdentifier) + require.Equal(t, "PRE_CONFIRMED", full.Status) + require.NotZero(t, full.Timestamp) + }) - var env starknet.PreConfirmedUpdateEnvelope - require.NoError(t, json.Unmarshal(raw, &env)) + t.Run("Delta decodes omitting block_number", func(t *testing.T) { + raw := loadFeederTestdata(t, "sepolia/preconfirmed/1781802365/0x1857317c/2.json") - _, ok := env.Update.(starknet.PreConfirmedNoChange) - require.True(t, ok, "expected PreConfirmedNoChange, got %T", env.Update) - }) + var env starknet.PreConfirmedUpdateEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + require.Zero(t, env.BlockNumber, "numbered endpoint omits block_number") - t.Run("changed=false ignores additional fields", func(t *testing.T) { - // A NoChange response is identified purely by changed=false; any - // additional fields must be ignored. - raw := []byte(`{ - "changed": false, - "block_identifier": "ignored", - "transactions": [{"transaction_hash": "0x1"}] - }`) + delta, ok := env.Update.(starknet.PreConfirmedDeltaUpdate) + require.True(t, ok, "expected PreConfirmedDeltaUpdate, got %T", env.Update) + require.Equal(t, "0x1857317c", delta.BlockIdentifier) + }) - var env starknet.PreConfirmedUpdateEnvelope - require.NoError(t, json.Unmarshal(raw, &env)) + t.Run("NoChange decodes when nothing was appended", func(t *testing.T) { + raw := loadFeederTestdata(t, "sepolia/preconfirmed/1781802365/0x1857317c/4.json") - _, ok := env.Update.(starknet.PreConfirmedNoChange) - require.True(t, ok, "expected PreConfirmedNoChange, got %T", env.Update) - }) + var env starknet.PreConfirmedUpdateEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) - t.Run("changed=true without timestamp decodes as Delta", func(t *testing.T) { - raw := []byte(`{ - "changed": true, - "block_identifier": "abc123", - "transactions": [], - "transaction_receipts": [], - "transaction_state_diffs": [] - }`) - - var env starknet.PreConfirmedUpdateEnvelope - require.NoError(t, json.Unmarshal(raw, &env)) - - delta, ok := env.Update.(starknet.PreConfirmedDeltaUpdate) - require.True(t, ok, "expected PreConfirmedDeltaUpdate, got %T", env.Update) - require.Equal(t, "abc123", delta.BlockIdentifier) - require.Empty(t, delta.Transactions) + _, ok := env.Update.(starknet.PreConfirmedNoChange) + require.True(t, ok, "expected PreConfirmedNoChange, got %T", env.Update) + }) }) - t.Run("Delta carries appended txs and receipts (carved from real Full)", func(t *testing.T) { - // We don't have a real Delta wire fixture. To get the tx/receipt/state-diff - // payloads onto the real wire path anyway, splice them at the JSON-bytes - // level: read a real Full, lift the txs/receipts/state-diffs arrays - // out as json.RawMessage, take their tail, and re-emit inside a Delta - // envelope (changed=true, block_identifier, no timestamp). The actual - // wire-shape of each tx/receipt/state-diff is the server's, only the - // Delta-specific wrapper is synthesised. - fullRaw := loadFeederTestdata(t, "sepolia-integration/pre_confirmed_delta/11252240.json") - - var fullFields map[string]json.RawMessage - require.NoError(t, json.Unmarshal(fullRaw, &fullFields)) - - var blockIdentifier string - require.NoError(t, json.Unmarshal(fullFields["block_identifier"], &blockIdentifier)) - - var txs, receipts, stateDiffs []json.RawMessage - require.NoError(t, json.Unmarshal(fullFields["transactions"], &txs)) - require.NoError(t, json.Unmarshal(fullFields["transaction_receipts"], &receipts)) - require.NoError(t, json.Unmarshal(fullFields["transaction_state_diffs"], &stateDiffs)) - require.Equal(t, len(txs), len(receipts)) - require.Equal(t, len(txs), len(stateDiffs)) - require.GreaterOrEqual(t, len(txs), 2, - "fixture must have >=2 txs to carve a non-trivial Delta") - - // Take the last 2 entries as the "appended" Delta tail. - k := len(txs) - 2 - deltaJSON, err := json.Marshal(map[string]any{ - "changed": true, - "block_identifier": blockIdentifier, - "transactions": txs[k:], - "transaction_receipts": receipts[k:], - "transaction_state_diffs": stateDiffs[k:], + // Variant discrimination and malformed payloads are endpoint-independent. + t.Run("discrimination and malformed payloads", func(t *testing.T) { + t.Run("changed absent returns error", func(t *testing.T) { + var env starknet.PreConfirmedUpdateEnvelope + require.Error(t, json.Unmarshal([]byte(`{}`), &env)) }) - require.NoError(t, err) - var env starknet.PreConfirmedUpdateEnvelope - require.NoError(t, json.Unmarshal(deltaJSON, &env)) - - delta, ok := env.Update.(starknet.PreConfirmedDeltaUpdate) - require.True(t, ok, "expected PreConfirmedDeltaUpdate, got %T", env.Update) - require.Equal(t, blockIdentifier, delta.BlockIdentifier) - require.Len(t, delta.Transactions, 2) - require.Len(t, delta.Receipts, 2) - require.Len(t, delta.TransactionStateDiffs, 2) - // Each carved tx decoded into a real, non-nil Transaction. - require.NotNil(t, delta.Transactions[0].Hash) - require.NotNil(t, delta.Transactions[1].Hash) - require.NotNil(t, delta.Receipts[0].TransactionHash) - require.NotNil(t, delta.Receipts[1].TransactionHash) - }) + t.Run("changed=false ignores additional fields", func(t *testing.T) { + // A NoChange response is identified purely by changed=false; any + // additional fields must be ignored. + raw := []byte(`{ + "changed": false, + "block_identifier": "ignored", + "transactions": [{"transaction_hash": "0x1"}] + }`) - t.Run("invalid JSON returns error", func(t *testing.T) { - var env starknet.PreConfirmedUpdateEnvelope - require.Error(t, json.Unmarshal([]byte(`{`), &env)) - }) + var env starknet.PreConfirmedUpdateEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) - t.Run("invalid Full payload returns error", func(t *testing.T) { - // `timestamp` must be a uint64; passing an object surfaces the inner - // decode error rather than being silently dropped. - raw := []byte(`{"timestamp": {}}`) - var env starknet.PreConfirmedUpdateEnvelope - require.Error(t, json.Unmarshal(raw, &env)) - }) + _, ok := env.Update.(starknet.PreConfirmedNoChange) + require.True(t, ok, "expected PreConfirmedNoChange, got %T", env.Update) + }) - t.Run("invalid Delta payload returns error", func(t *testing.T) { - raw := []byte(`{"changed": true, "block_identifier": 7}`) - var env starknet.PreConfirmedUpdateEnvelope - require.Error(t, json.Unmarshal(raw, &env)) + t.Run("invalid JSON returns error", func(t *testing.T) { + var env starknet.PreConfirmedUpdateEnvelope + require.Error(t, json.Unmarshal([]byte(`{`), &env)) + }) + + t.Run("invalid Full payload returns error", func(t *testing.T) { + // `timestamp` must be a uint64; passing an object surfaces the inner + // decode error rather than being silently dropped. + raw := []byte(`{"changed": true, "timestamp": {}}`) + var env starknet.PreConfirmedUpdateEnvelope + require.Error(t, json.Unmarshal(raw, &env)) + }) + + t.Run("invalid Delta payload returns error", func(t *testing.T) { + raw := []byte(`{"changed": true, "block_identifier": 7}`) + var env starknet.PreConfirmedUpdateEnvelope + require.Error(t, json.Unmarshal(raw, &env)) + }) }) } diff --git a/starknetdata/feeder/feeder.go b/starknetdata/feeder/feeder.go index 13727bcae6..853138ad44 100644 --- a/starknetdata/feeder/feeder.go +++ b/starknetdata/feeder/feeder.go @@ -217,3 +217,14 @@ func (f *Feeder) PreConfirmedBlockByNumber( knownTransactionCount, ) } + +// PreConfirmedBlockLatest fetches whichever pre_confirmed block the sequencer is +// currently exposing as latest. +// The returned block number is the height the response describes. +func (f *Feeder) PreConfirmedBlockLatest( + ctx context.Context, + blockIdentifier string, + knownTransactionCount uint64, +) (starknet.PreConfirmedUpdate, uint64, error) { + return f.client.PreConfirmedBlockLatest(ctx, blockIdentifier, knownTransactionCount) +} diff --git a/starknetdata/starknetdata.go b/starknetdata/starknetdata.go index 14f1d3ca5e..60dd066556 100644 --- a/starknetdata/starknetdata.go +++ b/starknetdata/starknetdata.go @@ -29,4 +29,9 @@ type StarknetData interface { blockIdentifier string, knownTransactionCount uint64, ) (starknet.PreConfirmedUpdate, error) + PreConfirmedBlockLatest( + ctx context.Context, + blockIdentifier string, + knownTransactionCount uint64, + ) (starknet.PreConfirmedUpdate, uint64, error) } diff --git a/sync/data_source.go b/sync/data_source.go index 9f1ae8310f..1c1adc55d9 100644 --- a/sync/data_source.go +++ b/sync/data_source.go @@ -30,6 +30,11 @@ type DataSource interface { blockIdentifier string, knownTransactionCount uint64, ) (starknet.PreConfirmedUpdate, error) + PreConfirmedBlockLatest( + ctx context.Context, + blockIdentifier string, + knownTransactionCount uint64, + ) (starknet.PreConfirmedUpdate, uint64, error) } type feederGatewayDataSource struct { @@ -157,3 +162,11 @@ func (f *feederGatewayDataSource) PreConfirmedBlockByNumber( knownTransactionCount, ) } + +func (f *feederGatewayDataSource) PreConfirmedBlockLatest( + ctx context.Context, + blockIdentifier string, + knownTransactionCount uint64, +) (starknet.PreConfirmedUpdate, uint64, error) { + return f.starknetData.PreConfirmedBlockLatest(ctx, blockIdentifier, knownTransactionCount) +} diff --git a/sync/preconfirmed/chain_storage.go b/sync/preconfirmed/chain_storage.go new file mode 100644 index 0000000000..d4f049c57a --- /dev/null +++ b/sync/preconfirmed/chain_storage.go @@ -0,0 +1,579 @@ +package preconfirmed + +import ( + "errors" + "fmt" + "iter" + "sync/atomic" + + "github.com/NethermindEth/juno/adapters/sn2core" + "github.com/NethermindEth/juno/blockchain" + "github.com/NethermindEth/juno/clients/feeder" + "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/core/pending" + "github.com/NethermindEth/juno/starknet" +) + +// ErrBaseTxCountMismatch is returned when a Delta update's baseTxCount hint +// doesn't match the targeted slot's current tx count. Defensive against any +// non-poller writer (or future race) that could drift the slot between the +// wire send and the storage apply. +var ErrBaseTxCountMismatch = errors.New("pre_confirmed base transaction count mismatch") + +// node is one entry in the chain's immutable linked list, pointing back +// toward older blocks via parent. Nodes are never mutated in place — every +// storage write produces fresh nodes for the affected slot and everything +// newer than it, so concurrent readers walking a prior snapshot see a stable +// graph. Popped nodes become unreferenced and GC-collectable. +type node struct { + preconfirmed *pending.PreConfirmed + parent *node +} + +// ChainReader is an immutable snapshot of a contiguous run of pre-confirmed +// blocks, ordered newest-first via parent pointers. Iteration must respect Length +// — head-aligned views (see ChainStorage.SnapshotForHead) may stop +// before the underlying linked list's nil terminator. +type ChainReader struct { + head *node + length int +} + +// NewChain builds a ChainReader from non-nil pre-confirmed entries given in +// oldest-first order with contiguous block numbers. No args returns the +// zero-value ChainReader. +func NewChain(entries ...*pending.PreConfirmed) (ChainReader, error) { + if len(entries) == 0 { + return ChainReader{}, nil + } + var head *node + for i, pc := range entries { + if pc == nil { + return ChainReader{}, fmt.Errorf("building pre_confirmed chain: entry %d is nil", i) + } + if i > 0 && pc.Block.Number != entries[i-1].Block.Number+1 { + return ChainReader{}, fmt.Errorf( + "building pre_confirmed chain: non-contiguous block numbers at index %d (%d after %d)", + i, pc.Block.Number, entries[i-1].Block.Number, + ) + } + head = &node{preconfirmed: pc, parent: head} + } + return ChainReader{head: head, length: len(entries)}, nil +} + +// Length is the number of entries in this chain view. +func (c *ChainReader) Length() int { + return c.length +} + +// Head returns the most recent pre-confirmed in the view, or nil if empty. +func (c *ChainReader) Head() *pending.PreConfirmed { + if c.length == 0 { + return nil + } + return c.head.preconfirmed +} + +// NewestFirst yields entries from the most recent down to head+1, bounded by Length. +func (c *ChainReader) NewestFirst() iter.Seq[*pending.PreConfirmed] { + return func(yield func(*pending.PreConfirmed) bool) { + n := c.head + for i := 0; i < c.length && n != nil; i++ { + if !yield(n.preconfirmed) { + return + } + n = n.parent + } + } +} + +// OldestFirst yields entries from head+1 up to the most recent, bounded by Length. +func (c *ChainReader) OldestFirst() iter.Seq[*pending.PreConfirmed] { + return func(yield func(*pending.PreConfirmed) bool) { + walkOldestFirst(c.head, c.length, yield) + } +} + +// TransactionByHash scans every chain entry's Block.Transactions. +// +// Returns [pending.ErrTransactionNotFound] when missing. +func (c *ChainReader) TransactionByHash(hash *felt.Felt) (core.Transaction, error) { + if c.length == 0 { + return nil, pending.ErrTransactionNotFound + } + + for entry := range c.NewestFirst() { + for _, tx := range entry.Block.Transactions { + if tx.Hash().Equal(hash) { + return tx, nil + } + } + } + + return nil, pending.ErrTransactionNotFound +} + +// ReceiptByHash scans every chain entry's Block.Receipts. Returns the receipt +// and the number of the block it lives in. ErrTransactionReceiptNotFound when +// missing. +func (c *ChainReader) ReceiptByHash( + hash *felt.Felt, +) (*core.TransactionReceipt, uint64, error) { + if c.length == 0 { + return nil, 0, pending.ErrTransactionReceiptNotFound + } + for entry := range c.NewestFirst() { + for _, receipt := range entry.Block.Receipts { + if receipt.TransactionHash.Equal(hash) { + return receipt, entry.Block.Number, nil + } + } + } + return nil, 0, pending.ErrTransactionReceiptNotFound +} + +// PreConfirmedStateAt returns the chain's view of state at blockNumber. The chain +// owns base resolution: it opens the canonical state immediately below its +// own bottom (derived from tip - length + 1. +// +// Returns [pending.ErrPreConfirmedNotFound] if blockNumber falls outside the chain. +func (c *ChainReader) PreConfirmedStateAt( + blockNumber uint64, + bcReader blockchain.Reader, +) (core.StateReader, blockchain.StateCloser, error) { + if c.length == 0 { + return nil, nil, pending.ErrPreConfirmedNotFound + } + bottom := c.head.preconfirmed.Block.Number - uint64(c.length-1) + if blockNumber < bottom || blockNumber > c.head.preconfirmed.Block.Number { + return nil, nil, pending.ErrPreConfirmedNotFound + } + base, closer, err := c.baseState(bcReader) + if err != nil { + return nil, nil, err + } + stateDiff := core.EmptyStateDiff() + for entry := range c.OldestFirst() { + stateDiff.Merge(entry.StateUpdate.StateDiff) + if entry.Block.Number == blockNumber { + break + } + } + return pending.NewState(&stateDiff, nil, base, blockNumber), closer, nil +} + +// PreConfirmedStateBeforeIndexAt returns the chain's view of state immediately +// before transaction `index` at blockNumber. See PreConfirmedStateAt for the base- +// resolution contract; here the chain additionally layers the target slot's +// per-transaction diffs up to (but not including) `index`. Returns +// [pending.ErrPreConfirmedNotFound] if blockNumber isn't in the chain, or +// [pending.ErrTransactionIndexOutOfBounds] if `index` exceeds the target's +// transaction count. +func (c *ChainReader) PreConfirmedStateBeforeIndexAt( + blockNumber uint64, + index uint, + bcReader blockchain.Reader, +) (core.StateReader, blockchain.StateCloser, error) { + if c.length == 0 { + return nil, nil, pending.ErrPreConfirmedNotFound + } + bottom := c.head.preconfirmed.Block.Number - uint64(c.length-1) + if blockNumber < bottom || blockNumber > c.head.preconfirmed.Block.Number { + return nil, nil, pending.ErrPreConfirmedNotFound + } + stateDiff := core.EmptyStateDiff() + var target *pending.PreConfirmed + for entry := range c.OldestFirst() { + if entry.Block.Number == blockNumber { + target = entry + break + } + stateDiff.Merge(entry.StateUpdate.StateDiff) + } + if target == nil { + return nil, nil, pending.ErrPreConfirmedNotFound + } + if index > uint(len(target.Block.Transactions)) { + return nil, nil, pending.ErrTransactionIndexOutOfBounds + } + base, closer, err := c.baseState(bcReader) + if err != nil { + return nil, nil, err + } + for _, txStateDiff := range target.TransactionStateDiffs[:index] { + stateDiff.Merge(txStateDiff) + } + return pending.NewState(&stateDiff, nil, base, blockNumber), closer, nil +} + +// baseState opens the canonical state immediately below the chain's bottom. +// Caller must hold a non-empty chain (length>0 verified upstream by the +// public methods). +func (c *ChainReader) baseState( + bcReader blockchain.Reader, +) (core.StateReader, blockchain.StateCloser, error) { + bottom := c.head.preconfirmed.Block.Number - uint64(c.length-1) + if bottom == 0 { + return bcReader.StateAtBlockHash(&felt.Zero) + } + return bcReader.StateAtBlockNumber(bottom - 1) +} + +// walkOldestFirst recurses to the bottom of the chain and yields entries on +// the way back up, producing oldest-first iteration order without +// materialising a slice. remaining bounds the depth so head-aligned views +// stop short of the underlying linked list's nil terminator (relevant after +// SnapshotForHead trims entries at or below the canonical head). Returns false +// when the yield callback aborts iteration. Depth equals the caller's Length. +func walkOldestFirst( + n *node, + remaining int, + yield func(*pending.PreConfirmed) bool, +) bool { + if n == nil || remaining == 0 { + return true + } + if !walkOldestFirst(n.parent, remaining-1, yield) { + return false + } + return yield(n.preconfirmed) +} + +// ChainStorage holds a contiguous run of pre-confirmed blocks above the +// canonical head. Readers obtain a head-aligned view via SnapshotForHead. +// Single writer (polling loop) with many concurrent readers; reads are +// lock-free via atomic.Pointer. +type ChainStorage struct { + inner atomic.Pointer[ChainReader] +} + +func NewChainStorage() *ChainStorage { + return &ChainStorage{} +} + +// SnapshotForHead returns a head-aligned view of the pre-confirmed chain: its +// conceptual bottom is head.Number+1 (or 0 at genesis with head == nil), so +// entries at or below the canonical head are excluded via the reported length. +// If the stored chain briefly extends below head+1 (AdvanceTo hasn't run yet +// against this head advance), the view reports a shorter length, excluding the +// now-committed entries. +// +// The view is uncapped: it exposes every stored entry from head+1 up to the +// stored tip, even when the chain runs more than core.BlockHashLag ahead of the +// canonical head. Reading pre-confirmed state at such a far-ahead block can fail +// at execution time, when a get_block_hash lookup falls outside the available +// window; that failure is intentionally left to the execution layer rather than +// masked by truncating the view here. +// +// Returns the zero-value ChainReader (length 0) if the chain does not cover +// head+1 (head advanced past the stored tip, or the chain's bottom sits above +// head+1); callers should branch on Length(). +func (s *ChainStorage) SnapshotForHead(head *core.Header) ChainReader { + c := s.inner.Load() + if c == nil || c.length == 0 { + return ChainReader{} + } + wantBottom := headPlusOne(head) + storedTip := c.head.preconfirmed.Block.Number + if wantBottom > storedTip { + return ChainReader{} + } + storedBottom := storedTip - uint64(c.length-1) + if wantBottom < storedBottom { + return ChainReader{} + } + want := int(storedTip - wantBottom + 1) + if want == c.length { + return *c + } + return ChainReader{head: c.head, length: want} +} + +// ApplyUpdate atomically evolves the stored chain from a wire-side update. +// blockNumber is the height targeted by the update; baseTxCount is the +// knownTransactionCount the poller sent (consulted only for the Delta case +// as a defensive race-check against the targeted slot). head MUST be the +// canonical chain head, or nil at genesis. Returns the affected entry, or +// nil when the update was a no-op (NoChange, preserved, rejected at cap, etc.). +// +// On CAS failure the chain changed between Load and CompareAndSwap; we return +// an error instead of retrying. +func (s *ChainStorage) ApplyUpdate( + update starknet.PreConfirmedUpdate, + blockNumber uint64, + baseTxCount uint64, + head *core.Header, +) (*pending.PreConfirmed, error) { + if _, ok := update.(starknet.PreConfirmedNoChange); ok { + return nil, nil + } + current := s.inner.Load() + newChain, affected, err := computeUpdate(current, update, blockNumber, baseTxCount, head) + if err != nil { + return nil, err + } + if newChain == nil { + return nil, nil + } + if !s.inner.CompareAndSwap(current, newChain) { + return nil, errors.New("chain changed between load and store") + } + return affected, nil +} + +// AdvanceTo realigns the chain to a new canonical head. Three outcomes: +// +// - wantBottom == bottom: chain already aligned, no-op. +// - wantBottom > mostRecent (head advanced past everything we stored) OR +// wantBottom < bottom (head reverted below us — every entry's parent now +// references a discarded block): drop the whole chain. The next poll +// bootstraps fresh against the new head. +// - bottom < wantBottom <= mostRecent: rebuild from the new bottom up so the +// surviving nodes nil-terminate cleanly and the dropped tail is GC-able. +// +// Pre-pop readers retain their *ChainReader and walk the original (still +// intact) nodes; the new chain references only fresh nodes. +// +// Single-writer: like ApplyUpdate, this assumes the pre_confirmed poller +// goroutine is the only writer. +func (s *ChainStorage) AdvanceTo(head *core.Header) bool { + current := s.inner.Load() + if current == nil || current.length == 0 { + return false + } + mostRecent := current.head.preconfirmed.Block.Number + bottom := mostRecent - uint64(current.length-1) + wantBottom := headPlusOne(head) + if wantBottom == bottom { + return false + } + if wantBottom < bottom || wantBottom > mostRecent { + return s.inner.CompareAndSwap(current, nil) + } + drop := int(wantBottom - bottom) + keep := current.length - drop + newChain := &ChainReader{head: rebuild(current.head, keep), length: keep} + return s.inner.CompareAndSwap(current, newChain) +} + +// rebuild walks `keep` levels down from n via parent pointers, then on the +// way back up builds fresh nodes so the bottom-most has parent==nil. The +// original nodes stay reachable for any concurrent walkers of the old chain +// pointer; once those release, the dropped tail (below the new bottom) +// becomes unreachable and GC-collectable. +func rebuild(n *node, keep int) *node { + if keep == 0 || n == nil { + return nil + } + child := rebuild(n.parent, keep-1) + return &node{preconfirmed: n.preconfirmed, parent: child} +} + +// computeUpdate is the pure dispatcher that turns a wire-side update into a +// new chain. Four mutually-exclusive cases: +// +// - empty chain → bootstrapChain (only PreConfirmedBlock accepted) +// - blockNumber > mostRecent + 1 → gap above tip, reject (no-op return) +// - blockNumber == mostRecent + 1 → appendMostRecent (extension by one) +// - blockNumber within [bottom, mostRecent] → replaceSlot (in-chain mutation) +// +// Below-bottom updates and unalignment with head are rejected here too — +// callers must AdvanceTo first to align the chain to a fresh head. +// +// Returns (newChain, affected, err): newChain==nil means "no-op, leave the +// store as-is"; err is reserved for invariant violations (e.g. bottom +// drifted from head, delta baseTxCount mismatch). +func computeUpdate( + current *ChainReader, + update starknet.PreConfirmedUpdate, + blockNumber uint64, + baseTxCount uint64, + head *core.Header, +) (*ChainReader, *pending.PreConfirmed, error) { + if current == nil || current.length == 0 { + block, ok := update.(starknet.PreConfirmedBlock) + if !ok { + return nil, nil, fmt.Errorf("bootstrap rejected: want PreConfirmedBlock, got %T", update) + } + return bootstrapChain(&block, blockNumber, head) + } + + mostRecent := current.head.preconfirmed.Block.Number + bottom := mostRecent - uint64(current.length-1) + + if !validBottomForHead(bottom, head) { + return nil, nil, fmt.Errorf("chain bottom %d not aligned with head", bottom) + } + + // Gap above tip — should never happen under a well-behaved poller, which + // backfills intermediate heights before applying the latest. Surface as + // an error so the bug isn't masked as a silent no-op. + if blockNumber > mostRecent+1 { + return nil, nil, fmt.Errorf( + "gap above tip: block %d > mostRecent+1 %d", blockNumber, mostRecent+1, + ) + } + + // Append as new most recent. Only PreConfirmedBlock makes sense at a + // brand-new slot; a Delta would have nothing to merge into. + if blockNumber == mostRecent+1 { + block, ok := update.(starknet.PreConfirmedBlock) + if !ok { + return nil, nil, fmt.Errorf( + "append rejected at slot %d: want PreConfirmedBlock, got %T", blockNumber, update, + ) + } + return extend(current, &block, blockNumber) + } + + // In-chain update — locate the target slot. blockNumber below bottom means + // the apply target is at or below the canonical head (bottom == head+1), + // i.e. already committed; the caller is asking us to write into the past. + if blockNumber < bottom { + return nil, nil, fmt.Errorf("apply target %d below bottom %d", blockNumber, bottom) + } + return replaceSlot(current, update, blockNumber, baseTxCount) +} + +// bootstrapChain handles the first entry case (empty storage). The caller +// (computeUpdate) has already narrowed the update variant to PreConfirmedBlock. +// validBottomForHead is the precondition: blockNumber must equal head.Number+1 +// (or 0 at genesis). Returns the new length-1 chain plus the adapted entry. +func bootstrapChain( + block *starknet.PreConfirmedBlock, + blockNumber uint64, + head *core.Header, +) (*ChainReader, *pending.PreConfirmed, error) { + if !validBottomForHead(blockNumber, head) { + if head == nil { + return nil, nil, fmt.Errorf("bootstrap block %d invalid at genesis (want 0)", blockNumber) + } + return nil, nil, fmt.Errorf("bootstrap block %d invalid for head %d (want %d)", + blockNumber, head.Number, head.Number+1) + } + next, err := sn2core.AdaptPreConfirmedBlock(block, blockNumber) + if err != nil { + return nil, nil, err + } + if err := core.CheckBlockVersion(next.Block.ProtocolVersion); err != nil { + return nil, nil, err + } + newNode := &node{preconfirmed: &next, parent: nil} + return &ChainReader{head: newNode, length: 1}, &next, nil +} + +// extend grows the chain by one when the incoming block's blockNumber equals +// mostRecent+1. +func extend( + current *ChainReader, + block *starknet.PreConfirmedBlock, + blockNumber uint64, +) (*ChainReader, *pending.PreConfirmed, error) { + next, err := sn2core.AdaptPreConfirmedBlock(block, blockNumber) + if err != nil { + return nil, nil, err + } + if err := core.CheckBlockVersion(next.Block.ProtocolVersion); err != nil { + return nil, nil, err + } + newNode := &node{preconfirmed: &next, parent: current.head} + return &ChainReader{head: newNode, length: current.length + 1}, &next, nil +} + +// replaceSlot locates the in-chain slot at blockNumber and mutates it, +// dispatching by update variant: +// +// - PreConfirmedBlock — new round or richer same-round content; shouldPreserveSlot +// decides whether to keep the existing entry or swap in the incoming one. +// A non-tip replacement also truncates every node above the replaced slot. +// - PreConfirmedDeltaUpdate — merges appended txs into the existing slot; +// baseTxCount must match the slot's current tx count or ErrBaseTxCountMismatch +// is returned (defensive race-check). +// +// Returns (nil, nil, nil) when shouldPreserveSlot says "keep the existing slot, +// no broadcast needed." Caller must have already validated that blockNumber +// falls within [bottom, mostRecent]. +func replaceSlot( + current *ChainReader, + update starknet.PreConfirmedUpdate, + blockNumber uint64, + baseTxCount uint64, +) (*ChainReader, *pending.PreConfirmed, error) { + depthFromHead := int(current.head.preconfirmed.Block.Number - blockNumber) + target := current.head + for range depthFromHead { + target = target.parent + } + switch u := update.(type) { + case starknet.PreConfirmedBlock: + next, err := sn2core.AdaptPreConfirmedBlock(&u, blockNumber) + if err != nil { + return nil, nil, err + } + if err := core.CheckBlockVersion(next.Block.ProtocolVersion); err != nil { + return nil, nil, err + } + if shouldPreserveSlot(target.preconfirmed, &next) { + return nil, nil, nil + } + newNode := &node{preconfirmed: &next, parent: target.parent} + return &ChainReader{ + head: newNode, + length: current.length - depthFromHead, + }, &next, nil + + case starknet.PreConfirmedDeltaUpdate: + // Delta updates can only target the chain tip + if depthFromHead != 0 { + return nil, nil, fmt.Errorf("delta at non-tip slot %d (depth %d)", blockNumber, depthFromHead) + } + if uint64(len(target.preconfirmed.Block.Transactions)) != baseTxCount { + return nil, nil, ErrBaseTxCountMismatch + } + next, err := sn2core.AdaptPreConfirmedWithDelta(target.preconfirmed, &u) + if err != nil { + return nil, nil, err + } + newNode := &node{preconfirmed: &next, parent: target.parent} + return &ChainReader{ + head: newNode, + length: current.length, + }, &next, nil + } + return nil, nil, fmt.Errorf("unknown PreConfirmedUpdate variant %T", update) +} + +// headPlusOne returns the first pre-confirmed slot above the canonical head: +// head.Number+1, or 0 when head is nil (i.e. before the genesis block has +// been ingested). +func headPlusOne(head *core.Header) uint64 { + if head == nil { + return 0 + } + return head.Number + 1 +} + +// validBottomForHead is the storage's core alignment check: the chain's +// bottom slot must equal head+1, otherwise the stored entries reference +// blocks that are either already committed (head moved past them) or floating +// without a parent. Callers compute bottom independently and pass it in. +func validBottomForHead(bottom uint64, head *core.Header) bool { + return bottom == headPlusOne(head) +} + +// shouldPreserveSlot keeps the existing slot when the incoming pre-confirmed is +// at the same identifier with no extra transactions, or carries the blank +// placeholder identifier. A different real identifier (new round) or a richer +// same-identifier block replaces. +func shouldPreserveSlot(existing, incoming *pending.PreConfirmed) bool { + if incoming.BlockIdentifier != existing.BlockIdentifier && + incoming.BlockIdentifier != feeder.PreConfirmedBlankIdentifier { + return false + } + if incoming.Block.TransactionCount > existing.Block.TransactionCount { + return false + } + return true +} diff --git a/sync/preconfirmed/chain_storage_test.go b/sync/preconfirmed/chain_storage_test.go new file mode 100644 index 0000000000..b27eda1719 --- /dev/null +++ b/sync/preconfirmed/chain_storage_test.go @@ -0,0 +1,1471 @@ +package preconfirmed_test + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/NethermindEth/juno/clients/feeder" + "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/core/pending" + "github.com/NethermindEth/juno/mocks" + "github.com/NethermindEth/juno/starknet" + "github.com/NethermindEth/juno/sync/preconfirmed" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// ---- helpers -------------------------------------------------------------- + +func headAt(n uint64) *core.Header { return &core.Header{Number: n} } + +// roundID is the per-slot identifier convention used across the storage +// tests: slot N's block carries identifier "round-N", so each slot in a +// chain is structurally distinguishable from every other (and a slot-mixup +// bug would surface as an identifier mismatch in assertChain). +func roundID(slot uint64) string { return fmt.Sprintf("round-%d", slot) } + +// chainBlockNumbers collects block numbers in oldest-first order. Used where +// the test cares about ordering / contiguity rather than per-slot identity +// (concurrent reader, iterator direction). +func chainBlockNumbers(c *preconfirmed.ChainReader) []uint64 { + if c == nil { + return nil + } + out := make([]uint64, 0, c.Length()) + for pc := range c.OldestFirst() { + out = append(out, pc.Block.Number) + } + return out +} + +// applyBlock constructs a synthetic PreConfirmedBlock, applies it to storage, +// and returns it so callers can hand it to entry() for assertChain. +func applyBlock( + t *testing.T, + s *preconfirmed.ChainStorage, + identifier string, + txCount int, + number uint64, + head *core.Header, +) starknet.PreConfirmedBlock { + t.Helper() + block := makeTestPreConfirmedBlock(identifier, txCount) + _, err := s.ApplyUpdate(block, number, 0, head) + require.NoError(t, err) + return block +} + +// rangeEntries returns `entry(from+i, &blocks[i])` for each block, suitable +// for spread-passing to assertChain. +func rangeEntries(from uint64, blocks []starknet.PreConfirmedBlock) []expectedEntry { + out := make([]expectedEntry, len(blocks)) + for i := range blocks { + out[i] = entry(from+uint64(i), &blocks[i]) + } + return out +} + +// ---- TestChainStorageApplyUpdate ------------------------------------------ + +func TestChainStorageApplyUpdate(t *testing.T) { + t.Run("bootstrap: rejects Delta on empty chain", testApplyUpdateBootstrapRejectsDelta) + t.Run("bootstrap: NoChange on empty is a no-op", testApplyUpdateBootstrapNoChangeNoop) + t.Run("bootstrap: rejected at wrong height", testApplyUpdateBootstrapWrongHeight) + t.Run("bootstrap: accepted at head+1", testApplyUpdateBootstrapAtHeadPlusOne) + t.Run("bootstrap: accepted at genesis when head is nil", testApplyUpdateBootstrapAtGenesis) + t.Run( + "bootstrap: rejected at non-zero height when nil head", + testApplyUpdateBootstrapNonzeroAtNilHead, + ) + t.Run("extend: gap above tip is rejected", testApplyUpdateExtendGapRejected) + t.Run("extend: non-Block update at brand-new slot rejected", testApplyUpdateExtendNonBlockRejected) + t.Run( + "replace-tip: same identity non-richer is preserved", + testApplyUpdateReplaceTipNonRicherPreserved, + ) + t.Run( + "replace-tip: same identifier richer block replaces", + testApplyUpdateReplaceTipRicherReplaces, + ) + t.Run("replace-tip: new round at most recent replaces", testApplyUpdateReplaceTipNewRound) + t.Run("replace-tip: blank identifier never overrides a real round", + testApplyUpdateReplaceTipBlankIgnored) + t.Run("reorg: new round at non-tip truncates above", testApplyUpdateReorgNonTipTruncates) + t.Run("reorg: new round at bottom slot truncates above", testApplyUpdateReorgBottomTruncates) + t.Run("reorg: re-extend after reorg rebuilds the chain", testApplyUpdateReorgReExtend) + t.Run("reorg: sequential reorgs at depths each truncate above", testApplyUpdateReorgSequential) + t.Run("reorg: pre-reorg snapshot walks the old chain", testApplyUpdateReorgPreSnapshotIntact) + t.Run("replace-tip: delta swaps tip with a fresh node carrying merged txs", + testApplyUpdateDeltaAtTip) + t.Run("delta: at non-tip is rejected", testApplyUpdateDeltaAtNonTipRejected) + t.Run("delta: wrong baseTxCount returns mismatch err", testApplyUpdateDeltaWrongBaseTxCount) + t.Run( + "delta: mismatched identifier is rejected by adapter", + testApplyUpdateDeltaIdentifierMismatch, + ) + t.Run("apply below chain bottom is rejected", testApplyUpdateBelowBottomRejected) +} + +func testApplyUpdateBootstrapRejectsDelta(t *testing.T) { + head := headAt(100) + s := preconfirmed.NewChainStorage() + pc, err := s.ApplyUpdate(starknet.PreConfirmedDeltaUpdate{}, 101, 0, head) + require.Error(t, err) + require.Contains(t, err.Error(), "bootstrap rejected") + require.Nil(t, pc) + view := s.SnapshotForHead(head) + require.Zero(t, view.Length()) +} + +func testApplyUpdateBootstrapNoChangeNoop(t *testing.T) { + head := headAt(100) + s := preconfirmed.NewChainStorage() + pc, err := s.ApplyUpdate(starknet.PreConfirmedNoChange{}, 101, 0, head) + require.NoError(t, err) + require.Nil(t, pc) + view := s.SnapshotForHead(head) + require.Zero(t, view.Length()) +} + +func testApplyUpdateBootstrapWrongHeight(t *testing.T) { + head := headAt(100) + s := preconfirmed.NewChainStorage() + _, err := s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(103), 0), 103, 0, head) + require.Error(t, err) + view := s.SnapshotForHead(head) + require.Zero(t, view.Length()) +} + +func testApplyUpdateBootstrapAtHeadPlusOne(t *testing.T) { + head := headAt(100) + s := preconfirmed.NewChainStorage() + b := applyBlock(t, s, roundID(101), 1, 101, head) + view := s.SnapshotForHead(head) + assertChain(t, &view, entry(101, &b)) +} + +func testApplyUpdateBootstrapAtGenesis(t *testing.T) { + s := preconfirmed.NewChainStorage() + b := applyBlock(t, s, roundID(0), 0, 0, nil) + view := s.SnapshotForHead(nil) + assertChain(t, &view, entry(0, &b)) +} + +func testApplyUpdateBootstrapNonzeroAtNilHead(t *testing.T) { + s := preconfirmed.NewChainStorage() + _, err := s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(1), 0), 1, 0, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "genesis") + view := s.SnapshotForHead(nil) + require.Zero(t, view.Length()) +} + +func testApplyUpdateExtendGapRejected(t *testing.T) { + head := headAt(100) + s := preconfirmed.NewChainStorage() + b101 := applyBlock(t, s, roundID(101), 0, 101, head) + b102 := applyBlock(t, s, roundID(102), 0, 102, head) + before := s.SnapshotForHead(head) + + // Skip slot 103, attempt to apply at 104. + _, err := s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(104), 0), 104, 0, head) + require.Error(t, err) + require.Contains(t, err.Error(), "gap above tip") + after := s.SnapshotForHead(head) + require.Same(t, before.Head(), after.Head(), "chain pointer must be unchanged on error") + assertChain(t, &after, entry(101, &b101), entry(102, &b102)) +} + +func testApplyUpdateExtendNonBlockRejected(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + seed := applyBlock(t, s, roundID(1), 0, 1, head) + before := s.SnapshotForHead(head) + + // A Delta at brand-new slot 2 is rejected before identifier validation — + // only PreConfirmedBlock is valid at a new tip. + delta := makeTestDelta(roundID(2), 1) + _, err := s.ApplyUpdate(delta, 2, 0, head) + require.Error(t, err) + require.Contains(t, err.Error(), "append rejected") + after := s.SnapshotForHead(head) + require.Same(t, before.Head(), after.Head()) + assertChain(t, &after, entry(1, &seed)) +} + +func testApplyUpdateReplaceTipNonRicherPreserved(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + b1 := applyBlock(t, s, roundID(1), 1, 1, head) + b2 := applyBlock(t, s, roundID(2), 1, 2, head) + before := s.SnapshotForHead(head) + + // Re-apply at slot 2 with matching identifier and same tx count → preserved. + applyBlock(t, s, roundID(2), 1, 2, head) + after := s.SnapshotForHead(head) + require.Same(t, before.Head(), after.Head()) + assertChain(t, &after, entry(1, &b1), entry(2, &b2)) +} + +func testApplyUpdateReplaceTipRicherReplaces(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + b1 := applyBlock(t, s, roundID(1), 1, 1, head) + applyBlock(t, s, roundID(2), 1, 2, head) + before := s.SnapshotForHead(head) + + // Re-apply at slot 2 with matching identifier but more txs → replaces. + bRicher := applyBlock(t, s, roundID(2), 3, 2, head) + after := s.SnapshotForHead(head) + require.NotSame(t, before.Head(), after.Head()) + assertChain(t, &after, entry(1, &b1), entry(2, &bRicher)) +} + +func testApplyUpdateReplaceTipNewRound(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + b1 := applyBlock(t, s, roundID(1), 1, 1, head) + applyBlock(t, s, roundID(2), 1, 2, head) + before := s.SnapshotForHead(head) + + // Different identifier at slot 2 → new round replaces. + bNew := applyBlock(t, s, "round-2-alt", 0, 2, head) + after := s.SnapshotForHead(head) + require.NotSame(t, before.Head(), after.Head()) + assertChain(t, &after, entry(1, &b1), entry(2, &bNew)) +} + +func testApplyUpdateReplaceTipBlankIgnored(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + b1 := applyBlock(t, s, roundID(1), 1, 1, head) + b2 := applyBlock(t, s, roundID(2), 1, 2, head) + before := s.SnapshotForHead(head) + + applyBlock(t, s, feeder.PreConfirmedBlankIdentifier, 0, 2, head) + after := s.SnapshotForHead(head) + require.Same(t, before.Head(), after.Head()) + assertChain(t, &after, entry(1, &b1), entry(2, &b2)) +} + +func testApplyUpdateReorgNonTipTruncates(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + b1 := applyBlock(t, s, roundID(1), 0, 1, head) + applyBlock(t, s, roundID(2), 0, 2, head) + applyBlock(t, s, roundID(3), 0, 3, head) + + // New round at non-tip slot 2 → slot 3 is truncated. + bReorg := applyBlock(t, s, "round-2-alt", 5, 2, head) + view := s.SnapshotForHead(head) + assertChain(t, &view, entry(1, &b1), entry(2, &bReorg)) +} + +func testApplyUpdateReorgBottomTruncates(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= 4; n++ { + applyBlock(t, s, roundID(n), 0, n, head) + } + + // Reorg at slot 1 (bottom) with a new round — everything above truncated. + bReorg := applyBlock(t, s, "round-1-alt", 2, 1, head) + view := s.SnapshotForHead(head) + assertChain(t, &view, entry(1, &bReorg)) +} + +func testApplyUpdateReorgReExtend(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + b1 := applyBlock(t, s, roundID(1), 0, 1, head) + applyBlock(t, s, roundID(2), 0, 2, head) // will be replaced + applyBlock(t, s, roundID(3), 0, 3, head) // will be truncated + + // Reorg at slot 2. + b2Alt := applyBlock(t, s, "round-2-alt", 1, 2, head) + view := s.SnapshotForHead(head) + assertChain(t, &view, entry(1, &b1), entry(2, &b2Alt)) + + // Re-extend slots 3 and 4 with new rounds. + b3Alt := applyBlock(t, s, "round-3-alt", 0, 3, head) + b4Alt := applyBlock(t, s, "round-4-alt", 0, 4, head) + view2 := s.SnapshotForHead(head) + assertChain(t, &view2, + entry(1, &b1), + entry(2, &b2Alt), + entry(3, &b3Alt), + entry(4, &b4Alt), + ) +} + +func testApplyUpdateReorgSequential(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + b1 := applyBlock(t, s, roundID(1), 0, 1, head) + b2 := applyBlock(t, s, roundID(2), 0, 2, head) + b3 := applyBlock(t, s, roundID(3), 0, 3, head) + applyBlock(t, s, roundID(4), 0, 4, head) + applyBlock(t, s, roundID(5), 0, 5, head) + + // First reorg at slot 4. + b4Alt := applyBlock(t, s, "round-4-alt", 0, 4, head) + view := s.SnapshotForHead(head) + assertChain(t, &view, + entry(1, &b1), + entry(2, &b2), + entry(3, &b3), + entry(4, &b4Alt), + ) + + // Re-extend slot 5 with a new round. + b5Alt := applyBlock(t, s, "round-5-alt", 0, 5, head) + view2 := s.SnapshotForHead(head) + assertChain(t, &view2, + entry(1, &b1), + entry(2, &b2), + entry(3, &b3), + entry(4, &b4Alt), + entry(5, &b5Alt), + ) + + // Second reorg at slot 3 — truncates everything above. + b3Alt := applyBlock(t, s, "round-3-alt", 0, 3, head) + view3 := s.SnapshotForHead(head) + assertChain(t, &view3, + entry(1, &b1), + entry(2, &b2), + entry(3, &b3Alt), + ) +} + +func testApplyUpdateReorgPreSnapshotIntact(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + original := make([]starknet.PreConfirmedBlock, 4) + for i := range original { + n := uint64(i + 1) + original[i] = applyBlock(t, s, roundID(n), 0, n, head) + } + pre := s.SnapshotForHead(head) + + // Reorg at slot 2 — slots 3 and 4 truncated in the live chain. + b2Alt := applyBlock(t, s, "round-2-alt", 0, 2, head) + view := s.SnapshotForHead(head) + assertChain(t, &view, entry(1, &original[0]), entry(2, &b2Alt)) + + // Pre-reorg snapshot must still walk the original four-entry chain. + assertChain(t, &pre, rangeEntries(1, original)...) +} + +func testApplyUpdateDeltaAtTip(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + // Delta and seed MUST share an identifier for the merge to be valid. + const round = "round-1" + seed := applyBlock(t, s, round, 2, 1, head) + + delta := makeTestDelta(round, 3) + _, err := s.ApplyUpdate(delta, 1, 2, head) + require.NoError(t, err) + + // 2 base + 3 appended via delta. + view := s.SnapshotForHead(head) + assertChain(t, &view, entry(1, &seed, &delta)) +} + +func testApplyUpdateDeltaAtNonTipRejected(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + const slot1Round = "round-1" + applyBlock(t, s, slot1Round, 1, 1, head) + applyBlock(t, s, roundID(2), 0, 2, head) + applyBlock(t, s, roundID(3), 0, 3, head) + before := s.SnapshotForHead(head) + + delta := makeTestDelta(slot1Round, 2) + _, err := s.ApplyUpdate(delta, 1, 1, head) + require.Error(t, err) + require.Contains(t, err.Error(), "non-tip") + after := s.SnapshotForHead(head) + require.Same(t, before.Head(), after.Head(), "rejected delta must not mutate storage") +} + +func testApplyUpdateDeltaWrongBaseTxCount(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + const round = "round-1" + seed := applyBlock(t, s, round, 2, 1, head) + before := s.SnapshotForHead(head) + + // Matching identifier — failure is purely from the baseTxCount race-check. + delta := makeTestDelta(round, 1) + _, err := s.ApplyUpdate(delta, 1, 99, head) + require.ErrorIs(t, err, preconfirmed.ErrBaseTxCountMismatch) + after := s.SnapshotForHead(head) + require.Same(t, before.Head(), after.Head()) + assertChain(t, &after, entry(1, &seed)) +} + +func testApplyUpdateDeltaIdentifierMismatch(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + seed := applyBlock(t, s, "round-1", 1, 1, head) + before := s.SnapshotForHead(head) + + delta := makeTestDelta("round-1-different", 1) + _, err := s.ApplyUpdate(delta, 1, 1, head) + require.Error(t, err) + after := s.SnapshotForHead(head) + require.Same(t, before.Head(), after.Head()) + assertChain(t, &after, entry(1, &seed)) +} + +func testApplyUpdateBelowBottomRejected(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(1), 0, 1, head) + b2 := applyBlock(t, s, roundID(2), 0, 2, head) + // Advance head past slot 1; chain bottom is now 2. + s.AdvanceTo(headAt(1)) + before := s.SnapshotForHead(headAt(1)) + + // Identifier is irrelevant here — the below-bottom check fires first. + delta := makeTestDelta(roundID(1), 1) + _, err := s.ApplyUpdate(delta, 1, 0, headAt(1)) + require.Error(t, err, "apply target below bottom must surface as an error") + after := s.SnapshotForHead(headAt(1)) + require.Same(t, before.Head(), after.Head()) + assertChain(t, &after, entry(2, &b2)) +} + +// ---- TestChainStorageAdvanceTo -------------------------------------------- + +func TestChainStorageAdvanceTo(t *testing.T) { + t.Run("partial drop preserves pre-advance snapshot", testAdvanceToPartialDrop) + t.Run("full drop clears the chain", testAdvanceToFullDrop) + t.Run( + "canonical reorg clears chain and recovers on next poll", + testAdvanceToReorgClearsAndRecovers, + ) + t.Run("nil head with bootstrapped chain is a no-op", testAdvanceToNilHeadBootstrapped) +} + +// testAdvanceToNilHeadBootstrapped mirrors the poller's genesis tick: head is +// nil because blockchain.HeadsHeader returned ErrKeyNotFound, but a prior tick +// already bootstrapped block 0. AdvanceTo(nil) must treat nil as "head below +// every slot" and leave the chain intact. +func testAdvanceToNilHeadBootstrapped(t *testing.T) { + s := preconfirmed.NewChainStorage() + b := applyBlock(t, s, roundID(0), 0, 0, nil) + before := s.SnapshotForHead(nil) + + require.NotPanics(t, func() { s.AdvanceTo(nil) }) + + after := s.SnapshotForHead(nil) + require.Same(t, before.Head(), after.Head()) + assertChain(t, &after, entry(0, &b)) +} + +func testAdvanceToPartialDrop(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + blocks := make([]starknet.PreConfirmedBlock, 5) + for i := range blocks { + n := uint64(i + 1) + blocks[i] = applyBlock(t, s, roundID(n), 0, n, head) + } + preAdvance := s.SnapshotForHead(head) + all := rangeEntries(1, blocks) + assertChain(t, &preAdvance, all...) + + // Head advances by 2 — blocks 1 and 2 are now committed. + s.AdvanceTo(headAt(2)) + + view := s.SnapshotForHead(headAt(2)) + assertChain(t, &view, rangeEntries(3, blocks[2:])...) + // Pre-advance snapshot is untouched (rebuild, not mutation). + assertChain(t, &preAdvance, all...) +} + +func testAdvanceToFullDrop(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= 3; n++ { + applyBlock(t, s, roundID(n), 0, n, head) + } + s.AdvanceTo(headAt(10)) + view := s.SnapshotForHead(headAt(10)) + require.Zero(t, view.Length()) +} + +// testAdvanceToReorgClearsAndRecovers simulates a canonical head reorg: the +// chain was built on top of head=5 (entries at slots 6,7), then the canonical +// head reverts to 3. Every stored entry's parent now references a discarded +// block, so AdvanceTo must drop the whole chain. SnapshotForHead at the new +// head reports empty (callers see no pre_confirmed), and the next poll +// (applying a fresh block at the new head+1) bootstraps cleanly. +func testAdvanceToReorgClearsAndRecovers(t *testing.T) { + oldHead := headAt(5) + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(6), 0, 6, oldHead) + applyBlock(t, s, roundID(7), 0, 7, oldHead) + preReorg := s.SnapshotForHead(oldHead) + require.Equal(t, 2, preReorg.Length()) + + // Reorg: canonical head reverts from 5 to 3. + newHead := headAt(3) + s.AdvanceTo(newHead) + + // Storage cleared; readers see nothing for the new head. + cleared := s.SnapshotForHead(newHead) + require.Zero(t, cleared.Length()) + view := s.SnapshotForHead(newHead) + require.Zero(t, view.Length()) + require.Nil(t, view.Head()) + + // Next poll lands a fresh pre_confirmed at the new head+1; chain recovers. + b4 := applyBlock(t, s, roundID(4), 0, 4, newHead) + recovered := s.SnapshotForHead(newHead) + assertChain(t, &recovered, entry(4, &b4)) +} + +// ---- TestChainStorageSnapshotForHead -------------------------------------- + +func TestChainStorageSnapshotForHead(t *testing.T) { + t.Run("empty storage returns zero-value view", testSnapshotForHeadEmpty) + t.Run("trims view when storage is briefly stale", testSnapshotForHeadStaleTrim) + t.Run("zero-value when head+1 is above most recent", testSnapshotForHeadEmptyAboveTip) +} + +func testSnapshotForHeadEmpty(t *testing.T) { + s := preconfirmed.NewChainStorage() + view := s.SnapshotForHead(headAt(100)) + require.Zero(t, view.Length()) + require.Nil(t, view.Head()) +} + +func testSnapshotForHeadStaleTrim(t *testing.T) { + // Bootstrap under head=0 → chain bottom=1. Then a reader passes head=2 + // before AdvanceTo has run. + storageHead := headAt(0) + s := preconfirmed.NewChainStorage() + blocks := make([]starknet.PreConfirmedBlock, 5) + for i := range blocks { + n := uint64(i + 1) + blocks[i] = applyBlock(t, s, roundID(n), 0, n, storageHead) + } + + stale := s.SnapshotForHead(headAt(2)) + assertChain(t, &stale, rangeEntries(3, blocks[2:])...) + + // Stored chain is unchanged; only the view's length was trimmed. + full := s.SnapshotForHead(storageHead) + assertChain(t, &full, rangeEntries(1, blocks)...) +} + +func testSnapshotForHeadEmptyAboveTip(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= 5; n++ { + applyBlock(t, s, roundID(n), 0, n, head) + } + view := s.SnapshotForHead(headAt(99)) + require.Zero(t, view.Length()) +} + +// ---- TestChainStorageSnapshot --------------------------------------------- + +func TestChainStorageSnapshot(t *testing.T) { + t.Run("empty returns zero-value view", testSnapshotEmpty) + t.Run("survives subsequent updates", testSnapshotSurvivesUpdates) +} + +func testSnapshotEmpty(t *testing.T) { + s := preconfirmed.NewChainStorage() + view := s.SnapshotForHead(headAt(0)) + require.Zero(t, view.Length()) +} + +func testSnapshotSurvivesUpdates(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + blocks := make([]starknet.PreConfirmedBlock, 4) + for i := range blocks { + n := uint64(i + 1) + blocks[i] = applyBlock(t, s, roundID(n), 0, n, head) + } + view := s.SnapshotForHead(head) + + // Drive subsequent updates: append at slot 5, richer-replace at slot 5 + // (matching identifier required for richer-replace), tail-pop. + applyBlock(t, s, roundID(5), 0, 5, head) + applyBlock(t, s, roundID(5), 7, 5, head) + s.AdvanceTo(headAt(2)) + + // The snapshot taken pre-mutation still walks the original chain at + // the original (pre-richer) tx counts. + assertChain(t, &view, rangeEntries(1, blocks)...) +} + +// ---- TestChainReader ------------------------------------------------------ + +func TestChainReader(t *testing.T) { + t.Run("NewestFirst yields newest-to-oldest", testChainReaderNewestFirstOrder) + t.Run("OldestFirst yields oldest-to-newest", testChainReaderOldestFirstOrder) + t.Run("NewestFirst early-exit stops walking", testChainReaderNewestFirstEarlyExit) + t.Run("OldestFirst early-exit stops walking", testChainReaderOldestFirstEarlyExit) + t.Run("iterators are alloc-free", testChainReaderIteratorsAllocFree) + t.Run("PreConfirmedStateAt composes diffs through target block", + testChainReaderPreConfirmedStateAtComposes) + t.Run("PreConfirmedStateAt rejects blockNumber outside chain", + testChainReaderPreConfirmedStateAtOutOfRange) + t.Run("PreConfirmedStateBeforeIndexAt walks tx diffs of slot", + testChainReaderPreConfirmedStateBeforeIndexAtTraversesTxs) + t.Run("PreConfirmedStateBeforeIndexAt rejects index past tx count", + testChainReaderPreConfirmedStateBeforeIndexAtBadIndex) + t.Run("PreConfirmedStateBeforeIndexAt rejects block outside chain", + testChainReaderPreConfirmedStateBeforeIndexAtBlockOutOfRange) + t.Run("PreConfirmedStateAt resolves base at chain bottom minus one", + testChainReaderPreConfirmedStateAtBaseAlignsWithBottom) + t.Run("PreConfirmedStateAt at genesis resolves base via zero hash", + testChainReaderPreConfirmedStateAtBaseAtGenesis) + t.Run("PreConfirmedStateAt surfaces bcReader error from base lookup", + testChainReaderPreConfirmedStateAtBaseError) + t.Run("TransactionByHash finds tx in any chain entry", + testChainReaderTransactionByHashAcrossChain) + t.Run("TransactionByHash returns not-found on miss", + testChainReaderTransactionByHashMissing) + t.Run("ReceiptByHash finds receipt and reports owning block", + testChainReaderReceiptByHashAcrossChain) + t.Run("ReceiptByHash returns not-found on miss", + testChainReaderReceiptByHashMissing) + t.Run("NewChain with single entry produces a length-1 reader", + testChainReaderNewChainSingleEntry) + t.Run("NewChain with multiple entries orders newest-first", + testChainReaderNewChainMultiEntry) + t.Run("NewChain errors on nil entry or non-contiguous numbers", + testChainReaderNewChainInvalid) +} + +func chainReaderFixture(t *testing.T, count int) *preconfirmed.ChainReader { + t.Helper() + head := headAt(0) + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= uint64(count); n++ { + applyBlock(t, s, roundID(n), 0, n, head) + } + v := s.SnapshotForHead(head) + return &v +} + +func testChainReaderNewestFirstOrder(t *testing.T) { + c := chainReaderFixture(t, 4) + var got []uint64 + for pc := range c.NewestFirst() { + got = append(got, pc.Block.Number) + } + require.Equal(t, []uint64{4, 3, 2, 1}, got) +} + +func testChainReaderOldestFirstOrder(t *testing.T) { + c := chainReaderFixture(t, 4) + require.Equal(t, []uint64{1, 2, 3, 4}, chainBlockNumbers(c)) +} + +func testChainReaderNewestFirstEarlyExit(t *testing.T) { + c := chainReaderFixture(t, 5) + var got []uint64 + for pc := range c.NewestFirst() { + got = append(got, pc.Block.Number) + if len(got) == 2 { + break + } + } + require.Equal(t, []uint64{5, 4}, got) +} + +func testChainReaderOldestFirstEarlyExit(t *testing.T) { + c := chainReaderFixture(t, 5) + var got []uint64 + for pc := range c.OldestFirst() { + got = append(got, pc.Block.Number) + if len(got) == 2 { + break + } + } + require.Equal(t, []uint64{1, 2}, got) +} + +func testChainReaderIteratorsAllocFree(t *testing.T) { + c := chainReaderFixture(t, 5) + sink := uint64(0) + + newestAllocs := testing.AllocsPerRun(50, func() { + for pc := range c.NewestFirst() { + sink += pc.Block.Number + } + }) + require.Equal(t, 0.0, newestAllocs, "NewestFirst must be alloc-free") + + oldestAllocs := testing.AllocsPerRun(50, func() { + for pc := range c.OldestFirst() { + sink += pc.Block.Number + } + }) + require.Equal(t, 0.0, oldestAllocs, "OldestFirst must be alloc-free") + _ = sink +} + +// storageWrite is a single (key, value) write under a fixed contract, +// emitted as exactly one tx. Lets callers interleave shared keys (testing +// last-write-wins / prefix walks) with unshared keys (testing that unrelated +// state from lower slots survives the merge). +type storageWrite struct { + key felt.Felt + value uint64 +} + +// applyBlockWithStorageWrites applies a block where each write becomes its +// own tx. The block-level merged StateDiff resolves to the last value per +// key (last-write-wins), while the preserved TransactionStateDiffs let +// PreConfirmedStateBeforeIndexAt walk through intermediate values. +func applyBlockWithStorageWrites( + t *testing.T, + s *preconfirmed.ChainStorage, + identifier string, + contract *felt.Felt, + writes []storageWrite, + number uint64, + head *core.Header, +) { + t.Helper() + txCount := len(writes) + txs := make([]starknet.Transaction, txCount) + receipts := make([]*starknet.TransactionReceipt, txCount) + stateDiffs := make([]*starknet.StateDiff, txCount) + for i, w := range writes { + hash := new(felt.Felt).SetUint64(number*1000 + uint64(i)) + emptySlice := []*felt.Felt{} + txs[i] = starknet.Transaction{ + Hash: hash, + Type: starknet.TxnInvoke, + Version: &felt.One, + CallData: &emptySlice, + Signature: &emptySlice, + } + receipts[i] = &starknet.TransactionReceipt{TransactionHash: hash} + key := w.key + value := felt.NewFromUint64[felt.Felt](w.value) + stateDiffs[i] = &starknet.StateDiff{ + StorageDiffs: map[string][]struct { + Key *felt.Felt `json:"key"` + Value *felt.Felt `json:"value"` + }{ + contract.String(): {{Key: &key, Value: value}}, + }, + } + } + block := starknet.PreConfirmedBlock{ + BlockIdentifier: identifier, + Transactions: txs, + Receipts: receipts, + TransactionStateDiffs: stateDiffs, + Status: "PRE_CONFIRMED", + Timestamp: uint64(time.Now().Unix()), + Version: core.Ver0_14_0.String(), + SequencerAddress: feltOne, + L1GasPrice: &starknet.GasPrice{PriceInWei: feltOne, PriceInFri: feltOne}, + L2GasPrice: &starknet.GasPrice{PriceInWei: feltOne, PriceInFri: feltOne}, + L1DAMode: starknet.Blob, + L1DataGasPrice: &starknet.GasPrice{PriceInWei: feltOne, PriceInFri: feltOne}, + } + _, err := s.ApplyUpdate(block, number, 0, head) + require.NoError(t, err) +} + +func testChainReaderPreConfirmedStateAtComposes(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + contract := felt.FromUint64[felt.Felt](0xC0) + keyShared := felt.FromUint64[felt.Felt](0x1) + keyOnlyInSlot1 := felt.FromUint64[felt.Felt](0xA) + + // keyShared is touched in every slot → tests last-write-wins merging. + // keyOnlyInSlot1 is touched once at the bottom → tests that values from + // lower slots that no upper slot rewrites are preserved through the merge. + applyBlockWithStorageWrites( + t, + s, + roundID(1), + &contract, + []storageWrite{{keyShared, 11}, {keyShared, 12}, {keyOnlyInSlot1, 100}}, + 1, + head, + ) + applyBlockWithStorageWrites( + t, + s, + roundID(2), + &contract, + []storageWrite{{keyShared, 21}, {keyShared, 22}}, + 2, + head, + ) + applyBlockWithStorageWrites( + t, + s, + roundID(3), + &contract, + []storageWrite{{keyShared, 31}, {keyShared, 32}}, + 3, + head, + ) + view := s.SnapshotForHead(head) + + cases := []struct { + blockNumber uint64 + want uint64 + }{ + {1, 12}, + {2, 22}, + {3, 32}, + } + + ctrl := gomock.NewController(t) + bc := mocks.NewMockReader(ctrl) + baseReader := mocks.NewMockStateReader(ctrl) + bc.EXPECT().StateAtBlockNumber(uint64(0)). + Return(baseReader, func() error { return nil }, nil). + Times(len(cases)) + for _, tc := range cases { + state, closer, err := view.PreConfirmedStateAt(tc.blockNumber, bc) + require.NoError(t, err) + + gotShared, err := state.ContractStorage(&contract, &keyShared) + require.NoError(t, err) + require.Equal(t, felt.FromUint64[felt.Felt](tc.want), gotShared, + "PreConfirmedStateAt(%d) keyShared should resolve to %d", tc.blockNumber, tc.want) + + // keyOnlyInSlot1 survives every merge — no upper slot rewrites it. + gotPreserved, err := state.ContractStorage(&contract, &keyOnlyInSlot1) + require.NoError(t, err) + require.Equal(t, felt.FromUint64[felt.Felt](100), gotPreserved, + "PreConfirmedStateAt(%d) keyOnlyInSlot1 must survive the merge", tc.blockNumber) + require.NoError(t, closer()) + } +} + +func testChainReaderPreConfirmedStateAtOutOfRange(t *testing.T) { + // The two non-empty subtests trip the bounds check before baseState is + // opened, so passing a nil bcReader is safe and proves the early-return order. + t.Run("empty chain", func(t *testing.T) { + // Empty storage yields a zero-value view; callers branch on Length. + s := preconfirmed.NewChainStorage() + view := s.SnapshotForHead(headAt(0)) + require.Zero(t, view.Length()) + }) + + t.Run("above tip", func(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(1), 0, 1, head) + view := s.SnapshotForHead(head) + _, _, err := view.PreConfirmedStateAt(99, nil) + require.ErrorIs(t, err, pending.ErrPreConfirmedNotFound) + }) + + t.Run("below chain bottom", func(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(1), 0, 1, head) + applyBlock(t, s, roundID(2), 0, 2, head) + s.AdvanceTo(headAt(1)) // chain bottom is now slot 2. + view := s.SnapshotForHead(headAt(1)) + _, _, err := view.PreConfirmedStateAt(1, nil) + require.ErrorIs(t, err, pending.ErrPreConfirmedNotFound) + }) +} + +func testChainReaderPreConfirmedStateBeforeIndexAtTraversesTxs(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + contract := felt.FromUint64[felt.Felt](0xC0) + key := felt.FromUint64[felt.Felt](0x1) + + // keyShared is touched in every slot — exercises last-write-wins merging + // across slots AND prefix walks within a slot. + // keyOnlyInSlot1 is touched once at the bottom — exercises "values from + // lower slots that no upper slot rewrites survive every merge." + keyOnlyInSlot1 := felt.FromUint64[felt.Felt](0xA) + // Slot 1: 2 txs to keyShared (11, 12), 1 tx to keyOnlyInSlot1 (100). + // Slot 2: 3 txs to keyShared (21, 22, 23) — block diff = 23 for keyShared. + // Slot 3: 2 txs to keyShared (31, 32) — block diff = 32 for keyShared. + applyBlockWithStorageWrites( + t, + s, + roundID(1), + &contract, + []storageWrite{{key, 11}, {key, 12}, {keyOnlyInSlot1, 100}}, + 1, + head, + ) + applyBlockWithStorageWrites( + t, + s, + roundID(2), + &contract, + []storageWrite{{key, 21}, {key, 22}, {key, 23}}, + 2, + head, + ) + applyBlockWithStorageWrites( + t, + s, + roundID(3), + &contract, + []storageWrite{{key, 31}, {key, 32}}, + 3, + head, + ) + view := s.SnapshotForHead(head) + + // Mixed target slots: tip (3) and middle (2). Middle-slot queries verify + // slot 3 is *excluded* — i.e. the OldestFirst walk breaks at the target + // rather than merging the full chain. + cases := []struct { + blockNumber uint64 + index uint + want uint64 + }{ + // Middle slot — slot 1's full diff is the merge base, slot 3 must not leak in. + {2, 0, 12}, // no slot-2 txs applied → falls through to slot 1's full diff + {2, 1, 21}, // slot 2's tx[0] + {2, 2, 22}, // slot 2's tx[0..1] + {2, 3, 23}, // slot 2's tx[0..2] — equivalent to PreConfirmedStateAt(2) + // Tip — slot 1 and slot 2's full diffs are merged first, then slot 3 prefixes. + {3, 0, 23}, // no slot-3 txs applied → falls through to slot 2's full diff + {3, 1, 31}, + {3, 2, 32}, + } + // Chain bottom is slot 1 (head=0); base resolves via StateAtBlockNumber(0) + // once per case. nil StateReader is fine — every queried key lives in the + // chain's diff, so pending.State never consults the base. + ctrl := gomock.NewController(t) + bc := mocks.NewMockReader(ctrl) + bc.EXPECT().StateAtBlockNumber(uint64(0)). + Return(nil, func() error { return nil }, nil). + Times(len(cases)) + for _, tc := range cases { + state, closer, err := view.PreConfirmedStateBeforeIndexAt(tc.blockNumber, tc.index, bc) + require.NoError(t, err) + + gotShared, err := state.ContractStorage(&contract, &key) + require.NoError(t, err) + require.Equal(t, felt.FromUint64[felt.Felt](tc.want), gotShared, + "PreConfirmedStateBeforeIndexAt(%d, %d) keyShared should resolve to %d", + tc.blockNumber, tc.index, tc.want) + + // keyOnlyInSlot1 must survive every merge — no upper slot rewrites it. + gotPreserved, err := state.ContractStorage(&contract, &keyOnlyInSlot1) + require.NoError(t, err) + require.Equal(t, felt.FromUint64[felt.Felt](100), gotPreserved, + "PreConfirmedStateBeforeIndexAt(%d, %d) keyOnlyInSlot1 must survive the merge", + tc.blockNumber, tc.index) + require.NoError(t, closer()) + } +} + +func testChainReaderPreConfirmedStateBeforeIndexAtBadIndex(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + contract := felt.FromUint64[felt.Felt](0xC0) + key := felt.FromUint64[felt.Felt](0x1) + + applyBlockWithStorageWrites( + t, + s, + roundID(1), + &contract, + []storageWrite{{key, 11}, {key, 12}}, + 1, + head, + ) + view := s.SnapshotForHead(head) + + // Slot 1 has 2 transactions; index 3 is past the end. The index check + // runs before baseState, so a nil bcReader is safe here. + _, _, err := view.PreConfirmedStateBeforeIndexAt(1, 3, nil) + require.ErrorIs(t, err, pending.ErrTransactionIndexOutOfBounds) +} + +func testChainReaderPreConfirmedStateBeforeIndexAtBlockOutOfRange(t *testing.T) { + // The non-empty subtest trips the bounds check before baseState is opened. + t.Run("empty chain", func(t *testing.T) { + // Empty storage yields a zero-value view; callers branch on Length. + s := preconfirmed.NewChainStorage() + view := s.SnapshotForHead(headAt(0)) + require.Zero(t, view.Length()) + }) + + t.Run("above tip", func(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(1), 0, 1, head) + view := s.SnapshotForHead(head) + _, _, err := view.PreConfirmedStateBeforeIndexAt(99, 0, nil) + require.ErrorIs(t, err, pending.ErrPreConfirmedNotFound) + }) +} + +// testChainReaderPreConfirmedStateAtBaseAlignsWithBottom is the regression test +// for the head-vs-snapshot race: even with a 3-entry chain whose canonical +// head sits multiple slots below the tip, the base lookup must hit exactly +// `chain.bottom - 1` and never the live head — otherwise base diffs would +// overlap with chain entries. +func testChainReaderPreConfirmedStateAtBaseAlignsWithBottom(t *testing.T) { + head := headAt(4) + s := preconfirmed.NewChainStorage() + for n := uint64(5); n <= 7; n++ { + applyBlock(t, s, roundID(n), 0, n, head) + } + view := s.SnapshotForHead(head) + require.Equal(t, 3, view.Length()) + + // bottom = 7 - (3-1) = 5; base must resolve at block 4. + bc := mocks.NewMockReader(gomock.NewController(t)) + bc.EXPECT().StateAtBlockNumber(uint64(4)). + Return(nil, func() error { return nil }, nil) + + _, closer, err := view.PreConfirmedStateAt(7, bc) + require.NoError(t, err) + require.NoError(t, closer()) +} + +// testChainReaderPreConfirmedStateAtBaseAtGenesis exercises the bottom==0 branch: +// a single-entry chain at slot 0 has no canonical block below it, so the +// base resolves via the zero hash rather than StateAtBlockNumber. +func testChainReaderPreConfirmedStateAtBaseAtGenesis(t *testing.T) { + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(0), 0, 0, nil) + view := s.SnapshotForHead(nil) + + bc := mocks.NewMockReader(gomock.NewController(t)) + bc.EXPECT().StateAtBlockHash(&felt.Zero). + Return(nil, func() error { return nil }, nil) + + _, closer, err := view.PreConfirmedStateAt(0, bc) + require.NoError(t, err) + require.NoError(t, closer()) +} + +// testChainReaderPreConfirmedStateAtBaseError verifies that a bcReader failure +// (e.g. base block pruned) is surfaced verbatim — no swallowing, no closer +// returned that the caller might invoke against a half-opened state. +func testChainReaderPreConfirmedStateAtBaseError(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(1), 0, 1, head) + view := s.SnapshotForHead(head) + + wantErr := errors.New("base pruned") + bc := mocks.NewMockReader(gomock.NewController(t)) + bc.EXPECT().StateAtBlockNumber(uint64(0)). + Return(nil, nil, wantErr) + + state, closer, err := view.PreConfirmedStateAt(1, bc) + require.ErrorIs(t, err, wantErr) + require.Nil(t, state) + require.Nil(t, closer) +} + +// emptyStateDiffPtr returns a fresh empty StateDiff value as a pointer. +func emptyStateDiffPtr() *core.StateDiff { + sd := core.EmptyStateDiff() + return &sd +} + +// txChainFixture builds a 3-block chain where every transaction is uniquely +// hashed via applyBlockWithStorageWrites's `number*1000 + index` scheme. +// Block 1 carries txs 1000,1001,1002; block 2 carries 2000,2001; block 3 (tip) +// carries 3000,3001,3002. +func txChainFixture(t *testing.T) *preconfirmed.ChainReader { + t.Helper() + head := headAt(0) + s := preconfirmed.NewChainStorage() + contract := felt.FromUint64[felt.Felt](0xC0) + applyBlockWithStorageWrites(t, s, roundID(1), &contract, + []storageWrite{ + {felt.FromUint64[felt.Felt](1), 1}, + {felt.FromUint64[felt.Felt](2), 2}, + {felt.FromUint64[felt.Felt](3), 3}, + }, + 1, head) + applyBlockWithStorageWrites(t, s, roundID(2), &contract, + []storageWrite{{felt.FromUint64[felt.Felt](4), 4}, {felt.FromUint64[felt.Felt](5), 5}}, + 2, head) + applyBlockWithStorageWrites(t, s, roundID(3), &contract, + []storageWrite{ + {felt.FromUint64[felt.Felt](6), 6}, + {felt.FromUint64[felt.Felt](7), 7}, + {felt.FromUint64[felt.Felt](8), 8}, + }, + 3, head) + v := s.SnapshotForHead(head) + return &v +} + +func testChainReaderTransactionByHashAcrossChain(t *testing.T) { + c := txChainFixture(t) + + cases := []struct { + name string + hash uint64 + }{ + {"bottom block", 1000}, + {"middle block", 2001}, + {"tip block", 3002}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hash := felt.NewFromUint64[felt.Felt](tc.hash) + tx, err := c.TransactionByHash(hash) + require.NoError(t, err) + require.NotNil(t, tx) + require.True(t, tx.Hash().Equal(hash)) + }) + } +} + +func testChainReaderTransactionByHashMissing(t *testing.T) { + t.Run("empty chain", func(t *testing.T) { + // Empty storage yields a zero-value view; callers branch on Length. + s := preconfirmed.NewChainStorage() + view := s.SnapshotForHead(headAt(0)) + require.Zero(t, view.Length()) + }) + + t.Run("unknown hash", func(t *testing.T) { + c := txChainFixture(t) + _, err := c.TransactionByHash(felt.NewFromUint64[felt.Felt](999_999)) + require.ErrorIs(t, err, pending.ErrTransactionNotFound) + }) +} + +func testChainReaderReceiptByHashAcrossChain(t *testing.T) { + c := txChainFixture(t) + + cases := []struct { + name string + hash uint64 + wantBlockNo uint64 + }{ + {"bottom block", 1002, 1}, + {"middle block", 2000, 2}, + {"tip block", 3001, 3}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hash := felt.NewFromUint64[felt.Felt](tc.hash) + receipt, blockNumber, err := c.ReceiptByHash(hash) + require.NoError(t, err) + require.NotNil(t, receipt) + require.True(t, receipt.TransactionHash.Equal(hash)) + require.Equal(t, tc.wantBlockNo, blockNumber) + }) + } +} + +func testChainReaderReceiptByHashMissing(t *testing.T) { + t.Run("empty chain", func(t *testing.T) { + // Empty storage yields a zero-value view; callers branch on Length. + s := preconfirmed.NewChainStorage() + view := s.SnapshotForHead(headAt(0)) + require.Zero(t, view.Length()) + }) + + t.Run("unknown hash", func(t *testing.T) { + c := txChainFixture(t) + _, _, err := c.ReceiptByHash(felt.NewFromUint64[felt.Felt](999_999)) + require.ErrorIs(t, err, pending.ErrTransactionReceiptNotFound) + }) +} + +func testChainReaderNewChainSingleEntry(t *testing.T) { + t.Run("no args produces empty reader", func(t *testing.T) { + c, err := preconfirmed.NewChain() + require.NoError(t, err) + require.Equal(t, 0, c.Length()) + require.Nil(t, c.Head()) + }) + + t.Run("non-nil produces length-1 reader pointing at entry", func(t *testing.T) { + pc := &pending.PreConfirmed{ + Block: &core.Block{Header: &core.Header{Number: 42}}, + StateUpdate: &core.StateUpdate{StateDiff: emptyStateDiffPtr()}, + } + c, err := preconfirmed.NewChain(pc) + require.NoError(t, err) + require.Equal(t, 1, c.Length()) + require.Same(t, pc, c.Head()) + }) +} + +func newChainEntry(n uint64) *pending.PreConfirmed { + return &pending.PreConfirmed{ + Block: &core.Block{Header: &core.Header{Number: n}}, + StateUpdate: &core.StateUpdate{StateDiff: emptyStateDiffPtr()}, + } +} + +func testChainReaderNewChainMultiEntry(t *testing.T) { + // Entries are given oldest-first; the reader exposes them newest-first with + // the highest block number as Head. + c, err := preconfirmed.NewChain(newChainEntry(5), newChainEntry(6), newChainEntry(7)) + require.NoError(t, err) + require.Equal(t, 3, c.Length()) + require.Equal(t, uint64(7), c.Head().Block.Number) + + var newest []uint64 + for pc := range c.NewestFirst() { + newest = append(newest, pc.Block.Number) + } + require.Equal(t, []uint64{7, 6, 5}, newest) + + require.Equal(t, []uint64{5, 6, 7}, chainBlockNumbers(&c)) +} + +func testChainReaderNewChainInvalid(t *testing.T) { + t.Run("nil entry returns error", func(t *testing.T) { + _, err := preconfirmed.NewChain(newChainEntry(5), nil) + require.ErrorContains(t, err, "entry 1 is nil") + }) + + t.Run("non-contiguous block numbers return error", func(t *testing.T) { + _, err := preconfirmed.NewChain(newChainEntry(5), newChainEntry(7)) + require.ErrorContains(t, err, "non-contiguous block numbers at index 1 (7 after 5)") + }) +} + +// ---- TestChainStoragePinnedSnapshotImmutability --------------------------- + +// TestChainStoragePinnedSnapshotImmutability asserts the core immutability +// invariant of the storage's linked-list design: a snapshot captured at time T +// keeps yielding the same block sequence (and the same per-entry content) +// regardless of any subsequent writer path. Each subtest pins a fresh +// snapshot, exercises one write path against the live storage, and asserts +// the pinned view is unaffected via assertChain (number + identifier + tx +// count, so delta-style content drift is caught alongside structural drift). +func TestChainStoragePinnedSnapshotImmutability(t *testing.T) { + t.Run("extend", testPinnedSnapshotImmuneToExtend) + t.Run("replace tip", testPinnedSnapshotImmuneToReplaceTip) + t.Run("delta at tip", testPinnedSnapshotImmuneToDelta) + t.Run("advance", testPinnedSnapshotImmuneToAdvance) +} + +// pinChain seeds storage with 5 contiguous blocks above head=0 and +// returns (storage, pinnedSnapshot, the seed blocks). +func pinChain(t *testing.T) ( + *preconfirmed.ChainStorage, *preconfirmed.ChainReader, []starknet.PreConfirmedBlock, +) { + t.Helper() + head := headAt(0) + s := preconfirmed.NewChainStorage() + blocks := make([]starknet.PreConfirmedBlock, 5) + for i := range blocks { + n := uint64(i + 1) + blocks[i] = applyBlock(t, s, roundID(n), 0, n, head) + } + pinned := s.SnapshotForHead(head) + return s, &pinned, blocks +} + +func testPinnedSnapshotImmuneToExtend(t *testing.T) { + s, pinned, blocks := pinChain(t) + head := headAt(0) + for n := uint64(6); n <= 20; n++ { + _, err := s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(n), 0), n, 0, head) + require.NoError(t, err) + } + assertChain(t, pinned, rangeEntries(1, blocks)...) +} + +func testPinnedSnapshotImmuneToReplaceTip(t *testing.T) { + s, pinned, blocks := pinChain(t) + head := headAt(0) + // Richer-replace must share the existing tip's identifier (roundID(5)). + for txCount := 1; txCount <= 10; txCount++ { + _, err := s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(5), txCount), 5, 0, head) + require.NoError(t, err) + } + assertChain(t, pinned, rangeEntries(1, blocks)...) +} + +func testPinnedSnapshotImmuneToDelta(t *testing.T) { + s, pinned, blocks := pinChain(t) + // Delta merges txs into the tip; baseTxCount must match the slot's current + // tx count. After each apply the tip's count grows, so the next delta's + // baseTxCount must follow. + tipTxCount := uint64(0) + for _, add := range []int{3, 2, 4} { + _, err := s.ApplyUpdate(makeTestDelta(roundID(5), add), 5, tipTxCount, headAt(0)) + require.NoError(t, err) + tipTxCount += uint64(add) + } + assertChain(t, pinned, rangeEntries(1, blocks)...) +} + +func testPinnedSnapshotImmuneToAdvance(t *testing.T) { + s, pinned, blocks := pinChain(t) + // Walk the head forward through every slot — partial trims, then full clear. + for h := uint64(1); h <= 6; h++ { + s.AdvanceTo(headAt(h)) + } + assertChain(t, pinned, rangeEntries(1, blocks)...) +} + +// ---- TestChainStorageAllocations ------------------------------------------ + +// Pre_confirmed RPC handlers walk a snapshot per request, so the storage's +// read fast paths are expected lock-free and allocation-free. The trim/rebuild +// paths do allocate — these tests pin the exact cost (1 ChainReader for a +// view rebuild, 1 ChainReader + keep nodes for AdvanceTo) so a regression +// that walks the whole chain or wraps the reader in a defensive copy shows up. +func TestChainStorageAllocations(t *testing.T) { + t.Run("SnapshotForHead cached path is alloc-free", testAllocsSnapshotCached) + t.Run("SnapshotForHead view-trim is alloc-free", testAllocsSnapshotTrim) + t.Run("AdvanceTo when head hasn't moved is alloc-free", testAllocsAdvanceNoOp) + t.Run("AdvanceTo trim allocates 1 ChainReader + keep nodes", testAllocsAdvanceTrim) + t.Run("ApplyUpdate NoChange is alloc-free", testAllocsApplyNoChange) + t.Run("ApplyUpdate Delta cost is stable", testAllocsApplyDelta) + t.Run("ApplyUpdate full-block extend cost is stable", testAllocsApplyExtend) +} + +// testAllocsSnapshotCached pins the fast path where the reader's head aligns +// with storage's bottom, so the view length equals the stored length and +// SnapshotForHead returns the stored ChainReader without rebuilding. +func testAllocsSnapshotCached(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= 5; n++ { + applyBlock(t, s, roundID(n), 0, n, head) + } + + allocs := testing.AllocsPerRun(100, func() { + _ = s.SnapshotForHead(head) + }) + require.Zero(t, allocs) +} + +// testAllocsSnapshotTrim pins the view-trim path: the reader's head sits above +// the stored chain's bottom (storage briefly stale before AdvanceTo runs), so +// the view is shorter than the stored chain and can't reuse the stored pointer. +// Value-returning SnapshotForHead constructs the trimmed ChainReader in the +// return slot — no heap allocation. +func testAllocsSnapshotTrim(t *testing.T) { + storageHead := headAt(0) + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= 5; n++ { + applyBlock(t, s, roundID(n), 0, n, storageHead) + } + allocs := testing.AllocsPerRun(100, func() { + _ = s.SnapshotForHead(headAt(2)) // bottom below head+1 → trimmed view + }) + require.Zero(t, allocs) +} + +func testAllocsAdvanceNoOp(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= 5; n++ { + applyBlock(t, s, roundID(n), 0, n, head) + } + + // head still at 0, chain bottom at 1 → wantBottom == bottom → early return, + // no rebuild. This pins the per-tick cost when the canonical head hasn't + // moved past the chain bottom (the common steady-state poller tick). + allocs := testing.AllocsPerRun(100, func() { + s.AdvanceTo(head) + }) + require.Zero(t, allocs) +} + +// testAllocsAdvanceTrim measures the rebuild cost by subtracting a baseline +// (build-only) from a full run (build + trim). AllocsPerRun amortises a +// deterministic function exactly, so the diff isolates AdvanceTo's +// contribution: 1 ChainReader + `keep` fresh nodes from rebuild(). +func testAllocsAdvanceTrim(t *testing.T) { + head := headAt(0) + const chainLen, headAfter = 5, 3 + const keep = chainLen - headAfter // blocks headAfter+1 .. chainLen survive + build := func() *preconfirmed.ChainStorage { + s := preconfirmed.NewChainStorage() + for n := uint64(1); n <= chainLen; n++ { + _, _ = s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(n), 0), n, 0, head) + } + return s + } + baseline := testing.AllocsPerRun(50, func() { _ = build() }) + withTrim := testing.AllocsPerRun(50, func() { + s := build() + s.AdvanceTo(headAt(headAfter)) + }) + require.InDelta(t, float64(keep+1), withTrim-baseline, 0.5) +} + +// testAllocsApplyNoChange pins the NoChange short-circuit at the top of +// ApplyUpdate. Every poller tick where the sequencer hasn't moved lands here, +// so a regression that started doing real work on NoChange would be a hot-path +// allocation per tick. +func testAllocsApplyNoChange(t *testing.T) { + head := headAt(0) + s := preconfirmed.NewChainStorage() + applyBlock(t, s, roundID(1), 0, 1, head) + noChange := starknet.PreConfirmedNoChange{} + + allocs := testing.AllocsPerRun(100, func() { + _, _ = s.ApplyUpdate(noChange, 1, 0, head) + }) + require.Zero(t, allocs) +} + +// testAllocsApplyDelta and testAllocsApplyExtend pin the apply cost via +// build/with-apply subtraction. The constants below capture the total cost +// (sn2core adapter + storage's own node + ChainReader + escaped pending.PreConfirmed) +// observed on Go 1.24/Opus-test infra; if either changes the test breaks loud +// so the dev makes a conscious bump rather than absorbing a silent regression. +func testAllocsApplyDelta(t *testing.T) { + head := headAt(0) + const expectedDeltaCost = 29 + build := func() *preconfirmed.ChainStorage { + s := preconfirmed.NewChainStorage() + _, _ = s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(1), 0), 1, 0, head) + return s + } + delta := makeTestDelta(roundID(1), 1) + baseline := testing.AllocsPerRun(50, func() { _ = build() }) + withApply := testing.AllocsPerRun(50, func() { + s := build() + _, _ = s.ApplyUpdate(delta, 1, 0, head) + }) + require.InDelta(t, float64(expectedDeltaCost), withApply-baseline, 0.5) +} + +func testAllocsApplyExtend(t *testing.T) { + head := headAt(0) + const expectedExtendCost = 22 + build := func() *preconfirmed.ChainStorage { + s := preconfirmed.NewChainStorage() + _, _ = s.ApplyUpdate(makeTestPreConfirmedBlock(roundID(1), 0), 1, 0, head) + return s + } + extendBlock := makeTestPreConfirmedBlock(roundID(2), 0) + baseline := testing.AllocsPerRun(50, func() { _ = build() }) + withApply := testing.AllocsPerRun(50, func() { + s := build() + _, _ = s.ApplyUpdate(extendBlock, 2, 0, head) + }) + require.InDelta(t, float64(expectedExtendCost), withApply-baseline, 0.5) +} diff --git a/sync/preconfirmed/poller.go b/sync/preconfirmed/poller.go new file mode 100644 index 0000000000..66d537b393 --- /dev/null +++ b/sync/preconfirmed/poller.go @@ -0,0 +1,214 @@ +package preconfirmed + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/NethermindEth/juno/blockchain" + "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/core/pending" + "github.com/NethermindEth/juno/db" + "github.com/NethermindEth/juno/feed" + "github.com/NethermindEth/juno/starknet" + "github.com/NethermindEth/juno/utils/log" + "go.uber.org/zap" +) + +// DataSource is the narrow surface the Poller needs from the wire side. Any +// type implementing both methods (e.g. sync.DataSource) satisfies it. +type DataSource interface { + PreConfirmedBlockLatest( + ctx context.Context, + identifier string, + txCount uint64, + ) (starknet.PreConfirmedUpdate, uint64, error) + PreConfirmedBlockByNumber( + ctx context.Context, + blockNumber uint64, + identifier string, + txCount uint64, + ) (starknet.PreConfirmedUpdate, error) +} + +// Poller drives the pre_confirmed chain from a single goroutine. +// +// One tick reads as: poll the server's latest pre_confirmed, backfill any gap +// below it, then insert the latest. backfill is a no-op when there's no gap; +// otherwise it finalises the current mostRecent (re-polls its number to capture +// the last delta before the sequencer moved past it) and then walks +// explicit-number polls up to latest-1. Same-height polls (latest matches our +// mostRecent) skip backfill and land in insert as delta / preserve / replace. +type Poller struct { + dataSource DataSource + storage *ChainStorage + blockchain *blockchain.Blockchain + out *feed.Feed[*pending.PreConfirmed] + highestBlockHeader *atomic.Pointer[core.Header] + interval time.Duration + logger log.StructuredLogger +} + +func NewPoller( + dataSource DataSource, + storage *ChainStorage, + bc *blockchain.Blockchain, + out *feed.Feed[*pending.PreConfirmed], + highestBlockHeader *atomic.Pointer[core.Header], + interval time.Duration, + logger log.StructuredLogger, +) *Poller { + return &Poller{ + dataSource: dataSource, + storage: storage, + blockchain: bc, + out: out, + highestBlockHeader: highestBlockHeader, + interval: interval, + logger: logger, + } +} + +func (p *Poller) Run(ctx context.Context) { + if p.interval == 0 { + p.logger.Info("Pre-confirmed block polling is disabled") + return + } + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := p.tick(ctx); err != nil { + p.logger.Warn("pre_confirmed tick failed", zap.Error(err)) + } + } + } +} + +func (p *Poller) tick(ctx context.Context) error { + head, err := p.blockchain.HeadsHeader() + if err != nil { + if !errors.Is(err, db.ErrKeyNotFound) { + return fmt.Errorf("reading heads header: %w", err) + } + head = nil + } + p.storage.AdvanceTo(head) + if !p.atTip(head) { + return nil + } + + chain := p.storage.SnapshotForHead(head) + var ( + mostRecent *pending.PreConfirmed + identifier string + txCount uint64 + ) + fromBlock := headPlusOne(head) + + if chain.Length() > 0 { + if mostRecent = chain.Head(); mostRecent != nil { + fromBlock = mostRecent.Block.Number + identifier = mostRecent.BlockIdentifier + txCount = uint64(len(mostRecent.Block.Transactions)) + } + } + + update, blockNumber, err := p.dataSource.PreConfirmedBlockLatest(ctx, identifier, txCount) + if err != nil { + return fmt.Errorf("polling pre_confirmed latest: %w", err) + } + + // NoChange and Delta both imply the server's identifier matched ours, + // which means the same block (block_identifier is per-block-round). + // Delta carries block_number on the wire; NoChange may omit it. Falling + // back to fromBlock is only required for NoChange, but we keep Delta in + // the switch as a defensive guard in case the wire ever omits it. + switch update.(type) { + case starknet.PreConfirmedNoChange, starknet.PreConfirmedDeltaUpdate: + blockNumber = fromBlock + } + + if blockNumber > fromBlock { + if err := p.backfill(ctx, head, fromBlock, identifier, txCount, blockNumber); err != nil { + return err + } + } + + // txCount is mostRecent's tx count; it's only semantically valid as + // baseTxCount when blockNumber == mostRecent.Block.Number (Delta replay + // onto the same block we already had). On a forward jump the server saw + // an identifier mismatch and returned a Full update, whose ApplyUpdate + // path ignores baseTxCount — so the stale value is harmless under current + // semantics. Revisit if ApplyUpdate grows a branch that reads baseTxCount + // for non-Delta updates. + return p.apply(update, blockNumber, txCount, head) +} + +// backfill polls fromBlock with the given delta hints (identifier+txCount) to +// capture the final view of that block, then walks fromBlock+1..endExclusive-1 +// with blank hints. The caller is responsible for deciding when backfill is +// needed; backfill itself performs no gap check. +func (p *Poller) backfill( + ctx context.Context, + head *core.Header, + fromBlock uint64, + identifier string, + txCount uint64, + endExclusive uint64, +) error { + update, err := p.dataSource.PreConfirmedBlockByNumber(ctx, fromBlock, identifier, txCount) + if err != nil { + return fmt.Errorf("polling pre_confirmed by number %d: %w", fromBlock, err) + } + if err := p.apply(update, fromBlock, txCount, head); err != nil { + return fmt.Errorf("applying pre_confirmed at %d: %w", fromBlock, err) + } + for n := fromBlock + 1; n < endExclusive; n++ { + update, err := p.dataSource.PreConfirmedBlockByNumber(ctx, n, "", 0) + if err != nil { + return fmt.Errorf("polling pre_confirmed by number %d: %w", n, err) + } + if err := p.apply(update, n, 0, head); err != nil { + return fmt.Errorf("applying pre_confirmed at %d: %w", n, err) + } + } + return nil +} + +// apply writes the update to storage and publishes the affected entry. +// Returns an error on apply failure so callers can abort mid-fill. +func (p *Poller) apply( + update starknet.PreConfirmedUpdate, + blockNumber uint64, + baseTxCount uint64, + head *core.Header, +) error { + applied, err := p.storage.ApplyUpdate(update, blockNumber, baseTxCount, head) + if err != nil { + return fmt.Errorf("applying pre_confirmed update at %d: %w", blockNumber, err) + } + if applied != nil { + p.out.Send(applied) + } + + return nil +} + +func (p *Poller) atTip(head *core.Header) bool { + highest := p.highestBlockHeader.Load() + if highest == nil { + return false + } + headNum := uint64(0) + if head != nil { + headNum = head.Number + } + return highest.Number <= headNum +} diff --git a/sync/preconfirmed/poller_test.go b/sync/preconfirmed/poller_test.go new file mode 100644 index 0000000000..a2787f3f5b --- /dev/null +++ b/sync/preconfirmed/poller_test.go @@ -0,0 +1,786 @@ +package preconfirmed_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/NethermindEth/juno/blockchain" + "github.com/NethermindEth/juno/blockchain/networks" + "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/core/pending" + "github.com/NethermindEth/juno/db" + "github.com/NethermindEth/juno/db/memory" + "github.com/NethermindEth/juno/feed" + "github.com/NethermindEth/juno/mocks" + "github.com/NethermindEth/juno/starknet" + "github.com/NethermindEth/juno/sync/preconfirmed" + "github.com/NethermindEth/juno/utils/log" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +var feltOne = &felt.One + +const tickInterval = 100 * time.Millisecond + +// makeTestPreConfirmedBlock returns a starknet.PreConfirmedBlock carrying +// `txCount` synthesised invoke transactions with matching receipts and per-tx +// state diffs. +func makeTestPreConfirmedBlock(identifier string, txCount int) starknet.PreConfirmedBlock { + txs := make([]starknet.Transaction, txCount) + receipts := make([]*starknet.TransactionReceipt, txCount) + stateDiffs := make([]*starknet.StateDiff, txCount) + for i := range txCount { + hash := felt.NewFromUint64[felt.Felt](uint64(i + 1)) + emptySlice := []*felt.Felt{} + txs[i] = starknet.Transaction{ + Hash: hash, + Type: starknet.TxnInvoke, + Version: &felt.One, + CallData: &emptySlice, + Signature: &emptySlice, + } + receipts[i] = &starknet.TransactionReceipt{TransactionHash: hash} + stateDiffs[i] = &starknet.StateDiff{ + StorageDiffs: map[string][]struct { + Key *felt.Felt `json:"key"` + Value *felt.Felt `json:"value"` + }{ + hash.String(): {{ + Key: felt.NewFromUint64[felt.Felt](uint64(i + 1)), + Value: felt.NewFromUint64[felt.Felt](uint64(i + 1)), + }}, + }, + } + } + return starknet.PreConfirmedBlock{ + BlockIdentifier: identifier, + Transactions: txs, + Receipts: receipts, + TransactionStateDiffs: stateDiffs, + Status: "PRE_CONFIRMED", + Timestamp: uint64(time.Now().Unix()), + Version: core.Ver0_14_0.String(), + SequencerAddress: feltOne, + L1GasPrice: &starknet.GasPrice{PriceInWei: feltOne, PriceInFri: feltOne}, + L2GasPrice: &starknet.GasPrice{PriceInWei: feltOne, PriceInFri: feltOne}, + L1DAMode: starknet.Blob, + L1DataGasPrice: &starknet.GasPrice{PriceInWei: feltOne, PriceInFri: feltOne}, + } +} + +// makeTestDelta returns a PreConfirmedDeltaUpdate that appends `addedCount` +// transactions/receipts/state diffs under the given block identifier. +func makeTestDelta(identifier string, addedCount int) starknet.PreConfirmedDeltaUpdate { + txs := make([]starknet.Transaction, addedCount) + receipts := make([]*starknet.TransactionReceipt, addedCount) + stateDiffs := make([]*starknet.StateDiff, addedCount) + for i := range addedCount { + hash := new(felt.Felt).SetUint64(uint64(100 + i)) + emptySlice := []*felt.Felt{} + txs[i] = starknet.Transaction{ + Hash: hash, + Type: starknet.TxnInvoke, + Version: new(felt.Felt).SetUint64(1), + CallData: &emptySlice, + Signature: &emptySlice, + } + receipts[i] = &starknet.TransactionReceipt{TransactionHash: hash} + stateDiffs[i] = &starknet.StateDiff{} + } + return starknet.PreConfirmedDeltaUpdate{ + BlockIdentifier: identifier, + Transactions: txs, + Receipts: receipts, + TransactionStateDiffs: stateDiffs, + } +} + +type chainFixture struct { + bc *blockchain.Blockchain + db db.KeyValueStore + head *core.Header +} + +// newChainFixture seeds the underlying db with a synthetic header at Number=0 +// and chain height = 0. +func newChainFixture(t *testing.T) *chainFixture { + t.Helper() + testDB := memory.New() + bc := blockchain.New(testDB, &networks.Sepolia) + + header := &core.Header{ + Number: 0, + Hash: new(felt.Felt).SetUint64(1), + ParentHash: &felt.Zero, + } + require.NoError(t, core.WriteBlockHeaderByNumber(testDB, header)) + require.NoError(t, core.WriteChainHeight(testDB, 0)) + + return &chainFixture{bc: bc, db: testDB, head: header} +} + +// advanceHead writes a synthetic header at head.Number+1 to the db and bumps +// chain height. Returns the new head. +func (f *chainFixture) advanceHead(t *testing.T) *core.Header { + t.Helper() + next := &core.Header{ + Number: f.head.Number + 1, + Hash: new(felt.Felt).SetUint64(f.head.Number + 2), + ParentHash: f.head.Hash, + } + require.NoError(t, core.WriteBlockHeaderByNumber(f.db, next)) + require.NoError(t, core.WriteChainHeight(f.db, next.Number)) + f.head = next + return next +} + +type harness struct { + poller *preconfirmed.Poller + storage *preconfirmed.ChainStorage + head *core.Header + highest *atomic.Pointer[core.Header] + sub *feed.Subscription[*pending.PreConfirmed] +} + +// wirePoller creates the in-memory bits (storage, feed, atomic header, poller) +// against an already-constructed blockchain and DataSource. +func wirePoller( + t *testing.T, + bc *blockchain.Blockchain, + head *core.Header, + ds preconfirmed.DataSource, +) harness { + t.Helper() + storage := preconfirmed.NewChainStorage() + out := feed.New[*pending.PreConfirmed]() + sub := out.SubscribeKeepLast() + t.Cleanup(sub.Unsubscribe) + + highest := &atomic.Pointer[core.Header]{} + highest.Store(head) + + p := preconfirmed.NewPoller(ds, storage, bc, out, highest, tickInterval, log.NewNopZapLogger()) + return harness{ + poller: p, + storage: storage, + head: head, + highest: highest, + sub: sub, + } +} + +// expectedEntry describes one slot of an expected chain snapshot. Used by +// assertChain to pin down the full storage state after a tick. +type expectedEntry struct { + number uint64 + identifier string + txCount int +} + +// entry derives an expectedEntry from the wire-side block whose application +// produced the stored slot, plus any deltas merged on top of it. Identifier +// is taken from the seed (deltas preserve identifier); tx count is the sum. +// Pointer args avoid copying the ~168-byte PreConfirmedBlock value. +func entry( + number uint64, + block *starknet.PreConfirmedBlock, + deltas ...*starknet.PreConfirmedDeltaUpdate, +) expectedEntry { + txCount := len(block.Transactions) + for _, d := range deltas { + txCount += len(d.Transactions) + } + return expectedEntry{number, block.BlockIdentifier, txCount} +} + +// assertChain pins down a snapshot's full contents in oldest-first order. +// Catches off-by-one length mistakes, identifier preservation/replacement, +// and tx-count merges that a head-only check would silently miss. +func assertChain(t *testing.T, snap *preconfirmed.ChainReader, want ...expectedEntry) { + t.Helper() + require.NotNil(t, snap, "snapshot must be non-nil") + require.Equal(t, len(want), snap.Length(), "chain length") + i := 0 + for pc := range snap.OldestFirst() { + require.Equal(t, want[i].number, pc.Block.Number, "entry %d block number", i) + require.Equal(t, want[i].identifier, pc.BlockIdentifier, "entry %d identifier", i) + require.Equal(t, want[i].txCount, len(pc.Block.Transactions), "entry %d tx count", i) + i++ + } +} + +// Empty storage, sequencer at head+1: tick polls latest once and bootstraps +// the chain — no backfill needed. +func TestPollerColdBootstrapNoGap(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + block1 := makeTestPreConfirmedBlock("r0", 1) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "", uint64(0)). + Return(block1, uint64(1), nil) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + go h.poller.Run(t.Context()) + synctest.Wait() + time.Sleep(tickInterval) + synctest.Wait() + + view := h.storage.SnapshotForHead(h.head) + assertChain(t, &view, entry(1, &block1)) + }) +} + +// Empty storage, sequencer several blocks ahead of head: backfill walks the +// intermediate heights with blank hints before applying the latest. +func TestPollerColdBootstrapWithGap(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + block1 := makeTestPreConfirmedBlock("r1", 0) + block2 := makeTestPreConfirmedBlock("r2", 0) + block3 := makeTestPreConfirmedBlock("r3", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + gomock.InOrder( + ds.EXPECT(). + PreConfirmedBlockLatest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(block3, uint64(3), nil), + ds.EXPECT(). + PreConfirmedBlockByNumber(gomock.Any(), uint64(1), "", uint64(0)). + Return(block1, nil), + ds.EXPECT(). + PreConfirmedBlockByNumber(gomock.Any(), uint64(2), "", uint64(0)). + Return(block2, nil), + ) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + view := h.storage.SnapshotForHead(h.head) + assertChain(t, &view, + entry(1, &block1), + entry(2, &block2), + entry(3, &block3), + ) + }) +} + +// mostRecent already at target height and server returns NoChange: tick is a +// pure no-op — no backfill, no apply, storage pointer unchanged. +func TestPollerSameHeightNoBackfill(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed := makeTestPreConfirmedBlock("r0", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "r0", uint64(0)). + Return(starknet.PreConfirmedNoChange{}, uint64(1), nil) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + _, err := h.storage.ApplyUpdate(seed, h.head.Number+1, 0, h.head) + require.NoError(t, err) + before := h.storage.SnapshotForHead(h.head) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + after := h.storage.SnapshotForHead(h.head) + require.Same(t, before.Head(), after.Head(), + "NoChange must leave the chain pointer unchanged") + assertChain(t, &after, entry(1, &seed)) + }) +} + +// mostRecent at target height and server returns a Delta enriching that +// block: no backfill, apply merges the delta into the same slot, the stored +// chain still has length 1 but with the appended transactions visible. +func TestPollerSameHeightDeltaAppliesToSlot(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed := makeTestPreConfirmedBlock("r0", 0) + delta := makeTestDelta("r0", 2) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + // Delta hint matches mostRecent: identifier="r0", txCount=0. + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "r0", uint64(0)). + Return(delta, uint64(1), nil) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + _, err := h.storage.ApplyUpdate(seed, h.head.Number+1, 0, h.head) + require.NoError(t, err) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + // Delta preserves seed.identifier and appends its own txs to seed's. + view := h.storage.SnapshotForHead(h.head) + assertChain(t, &view, entry(1, &seed, &delta)) + }) +} + +// Sequencer advanced by exactly one: backfill re-polls mostRecent's own +// height with its identifier+txCount hints, and the server replies with a +// Delta carrying the final txs the sequencer appended before publishing the +// next block. The delta merges into the existing slot (identifier preserved, +// txs summed), then the new most recent is applied. +func TestPollerForwardJumpFinalisesMostRecent(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed := makeTestPreConfirmedBlock("r0", 0) + finaliseDelta := makeTestDelta("r0", 3) // 3 final txs appended at block 1 + block2 := makeTestPreConfirmedBlock("r1", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + gomock.InOrder( + ds.EXPECT(). + PreConfirmedBlockLatest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(block2, uint64(2), nil), + // Finalise poll: server replies with a Delta keyed off the stored + // identifier+txCount; carries any txs appended since. + ds.EXPECT(). + PreConfirmedBlockByNumber(gomock.Any(), uint64(1), "r0", uint64(0)). + Return(finaliseDelta, nil), + ) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + _, err := h.storage.ApplyUpdate(seed, h.head.Number+1, 0, h.head) + require.NoError(t, err) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + view := h.storage.SnapshotForHead(h.head) + assertChain(t, &view, + entry(1, &seed, &finaliseDelta), // seed + delta merged at block 1 + entry(2, &block2), + ) + }) +} + +// Sequencer jumped multiple blocks ahead: backfill finalises mostRecent and +// then walks every intermediate height with blank hints before applying the +// new most recent. +func TestPollerLargeJumpWalksGap(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed := makeTestPreConfirmedBlock("r0", 0) + finaliseReply := makeTestPreConfirmedBlock("r0", 0) // same content → slot preserved + block2 := makeTestPreConfirmedBlock("r2", 0) + block3 := makeTestPreConfirmedBlock("r3", 0) + block4 := makeTestPreConfirmedBlock("r4", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + gomock.InOrder( + ds.EXPECT(). + PreConfirmedBlockLatest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(block4, uint64(4), nil), + // Finalise mostRecent (1) with delta hints. + ds.EXPECT(). + PreConfirmedBlockByNumber(gomock.Any(), uint64(1), "r0", uint64(0)). + Return(finaliseReply, nil), + // Walk intermediate blocks 2, 3 with blank hints. + ds.EXPECT(). + PreConfirmedBlockByNumber(gomock.Any(), uint64(2), "", uint64(0)). + Return(block2, nil), + ds.EXPECT(). + PreConfirmedBlockByNumber(gomock.Any(), uint64(3), "", uint64(0)). + Return(block3, nil), + ) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + _, err := h.storage.ApplyUpdate(seed, h.head.Number+1, 0, h.head) + require.NoError(t, err) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + view := h.storage.SnapshotForHead(h.head) + assertChain(t, &view, + entry(1, &seed), // identifier preserved by finalise + entry(2, &block2), + entry(3, &block3), + entry(4, &block4), + ) + }) +} + +// highestBlockHeader sits above head (canonical sync is still catching up): +// the atTip gate short-circuits the tick before any wire call. +func TestPollerNotAtTipSkipsAllWork(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + // No expectations set: any wire call fails the test. + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + h.highest.Store(&core.Header{Number: h.head.Number + 5}) // not at tip + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + view := h.storage.SnapshotForHead(h.head) + require.Zero(t, view.Length()) + }) +} + +// PreConfirmedBlockLatest errors: tick aborts immediately, no backfill or apply, +// storage stays empty for the next tick to retry from scratch. +func TestPollerLatestErrorSkipsApply(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, uint64(0), errors.New("wire boom")) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + view := h.storage.SnapshotForHead(h.head) + require.Zero(t, view.Length(), "latest error must not produce any storage state") + }) +} + +// backfill's per-block poll errors mid-gap: tick aborts before the final apply +// at target, so storage remains empty (next tick reconciles). +func TestPollerBackfillErrorSkipsApply(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + latestReply := makeTestPreConfirmedBlock("r3", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + gomock.InOrder( + ds.EXPECT(). + PreConfirmedBlockLatest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(latestReply, uint64(3), nil), + ds.EXPECT(). + PreConfirmedBlockByNumber(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, errors.New("backfill boom")), + ) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + view := h.storage.SnapshotForHead(h.head) + require.Zero(t, view.Length(), "tick aborts before any apply when backfill errors") + }) +} + +// Across three consecutive ticks the sequencer advances one block at a time: +// each tick finalises the prior mostRecent and lands a new most recent, so the +// chain grows monotonically from 1 to 3 entries. +func TestPollerMultiTickExtendsChain(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + block1 := makeTestPreConfirmedBlock("r1", 0) + block2 := makeTestPreConfirmedBlock("r2", 0) + block3 := makeTestPreConfirmedBlock("r3", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + // Script every wire call across the three ticks in chronological order. + // Finalise polls return the same block already at that slot so + // shouldPreserveSlot keeps the entry instead of replacing it. + gomock.InOrder( + // Tick 1: cold bootstrap. + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "", uint64(0)). + Return(block1, uint64(1), nil), + // Tick 2: sequencer advanced; latest + finalise of block 1. + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "r1", uint64(0)). + Return(block2, uint64(2), nil), + ds.EXPECT().PreConfirmedBlockByNumber(gomock.Any(), uint64(1), "r1", uint64(0)). + Return(block1, nil), + // Tick 3: advanced again; latest + finalise of block 2. + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "r2", uint64(0)). + Return(block3, uint64(3), nil), + ds.EXPECT().PreConfirmedBlockByNumber(gomock.Any(), uint64(2), "r2", uint64(0)). + Return(block2, nil), + ) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + go h.poller.Run(t.Context()) + synctest.Wait() + + // Tick 1. + time.Sleep(tickInterval) + synctest.Wait() + view1 := h.storage.SnapshotForHead(h.head) + assertChain(t, &view1, entry(1, &block1)) + + // Tick 2. + time.Sleep(tickInterval) + synctest.Wait() + view2 := h.storage.SnapshotForHead(h.head) + assertChain(t, &view2, + entry(1, &block1), + entry(2, &block2), + ) + + // Tick 3. + time.Sleep(tickInterval) + synctest.Wait() + view3 := h.storage.SnapshotForHead(h.head) + assertChain(t, &view3, + entry(1, &block1), + entry(2, &block2), + entry(3, &block3), + ) + }) +} + +// Canonical head advances past one of the stored pre_confirmed entries: +// AdvanceTo at the top of tick drops the now-committed entry from the chain, +// leaving only entries that are still above the new head. +func TestPollerHeadAdvancesDropsCommittedEntries(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed1 := makeTestPreConfirmedBlock("r1", 0) // will be dropped after head advances past it + seed2 := makeTestPreConfirmedBlock("r2", 0) + + var latestBlockNumber atomic.Uint64 + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(context.Context, string, uint64) (starknet.PreConfirmedUpdate, uint64, error) { + return starknet.PreConfirmedNoChange{}, latestBlockNumber.Load(), nil + }) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + _, err := h.storage.ApplyUpdate(seed1, h.head.Number+1, 0, h.head) + require.NoError(t, err) + _, err = h.storage.ApplyUpdate(seed2, h.head.Number+2, 0, h.head) + require.NoError(t, err) + before := h.storage.SnapshotForHead(h.head) + require.Equal(t, 2, before.Length()) + + // Canonical head advances by one — block head+1 is now committed. + newHead := fx.advanceHead(t) + h.highest.Store(newHead) + latestBlockNumber.Store(newHead.Number + 1) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + // seed1 committed → dropped; only seed2 remains at the new head+1. + view := h.storage.SnapshotForHead(newHead) + assertChain(t, &view, entry(newHead.Number+1, &seed2)) + }) +} + +// Sequencer rewinds: PreConfirmedBlockLatest reports a height BELOW the chain's +// current most recent with a new identifier (a new round started at a lower +// slot). Tick must not backfill (target < fromBlock); apply hits the in-chain +// replace path which truncates everything above the replaced slot and +// installs the new block there. +func TestPollerReorgLowerHeightDifferentIdentifier(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed1 := makeTestPreConfirmedBlock("r1", 0) + seed2 := makeTestPreConfirmedBlock("r2", 0) + seed3 := makeTestPreConfirmedBlock("r3", 0) + // New round arrives at block 2 with a different identifier — anything + // above (block 3) must be dropped. + replacement := makeTestPreConfirmedBlock("rZ", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + // Hint matches the most recent (block 3, "r3", 0 txs) the poller would carry. + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "r3", uint64(0)). + Return(replacement, uint64(2), nil) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + _, err := h.storage.ApplyUpdate(seed1, h.head.Number+1, 0, h.head) + require.NoError(t, err) + _, err = h.storage.ApplyUpdate(seed2, h.head.Number+2, 0, h.head) + require.NoError(t, err) + _, err = h.storage.ApplyUpdate(seed3, h.head.Number+3, 0, h.head) + require.NoError(t, err) + before := h.storage.SnapshotForHead(h.head) + require.Equal(t, 3, before.Length()) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + // Block 2 swapped to the new identifier; block 3 truncated. + view := h.storage.SnapshotForHead(h.head) + assertChain(t, &view, + entry(1, &seed1), + entry(2, &replacement), + ) + }) +} + +func TestPollerReorgSameHeightDifferentIdentifier(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed1 := makeTestPreConfirmedBlock("r1", 0) + seed2 := makeTestPreConfirmedBlock("r2", 0) + seed3 := makeTestPreConfirmedBlock("r3", 0) + // New round at the same slot — different identifier replaces the most recent. + replacement := makeTestPreConfirmedBlock("rZ", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "r3", uint64(0)). + Return(replacement, uint64(3), nil) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + _, err := h.storage.ApplyUpdate(seed1, h.head.Number+1, 0, h.head) + require.NoError(t, err) + _, err = h.storage.ApplyUpdate(seed2, h.head.Number+2, 0, h.head) + require.NoError(t, err) + _, err = h.storage.ApplyUpdate(seed3, h.head.Number+3, 0, h.head) + require.NoError(t, err) + before := h.storage.SnapshotForHead(h.head) + require.Equal(t, 3, before.Length()) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + // Deepest slot replaced; lower entries and length untouched. + view := h.storage.SnapshotForHead(h.head) + assertChain(t, &view, + entry(1, &seed1), + entry(2, &seed2), + entry(3, &replacement), + ) + }) +} + +// Successful apply must publish the affected entry on the broadcast. +func TestPollerBroadcastsOnApply(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + block1 := makeTestPreConfirmedBlock("r0", 1) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "", uint64(0)). + Return(block1, uint64(1), nil) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + select { + case pc := <-h.sub.Recv(): + require.NotNil(t, pc) + require.Equal(t, uint64(1), pc.Block.Number) + require.Equal(t, "r0", pc.BlockIdentifier) + default: + t.Fatal("expected a pre_confirmed broadcast on successful apply") + } + }) +} + +// NoChange must not publish anything +func TestPollerSilentOnNoChange(t *testing.T) { + t.Parallel() + fx := newChainFixture(t) + + seed := makeTestPreConfirmedBlock("r0", 0) + + ctrl := gomock.NewController(t) + ds := mocks.NewMockStarknetData(ctrl) + ds.EXPECT().PreConfirmedBlockLatest(gomock.Any(), "r0", uint64(0)). + Return(starknet.PreConfirmedNoChange{}, uint64(1), nil) + + synctest.Test(t, func(t *testing.T) { + h := wirePoller(t, fx.bc, fx.head, ds) + // Seed directly through storage; this does NOT publish (only the + // poller's apply wrapper does Send), so the feed starts clean. + _, err := h.storage.ApplyUpdate(seed, h.head.Number+1, 0, h.head) + require.NoError(t, err) + + go h.poller.Run(t.Context()) + synctest.Wait() + + time.Sleep(tickInterval) + synctest.Wait() + + select { + case pc := <-h.sub.Recv(): + t.Fatalf("did not expect a broadcast on NoChange, got %+v", pc) + default: + } + }) +} diff --git a/sync/reorg_test.go b/sync/reorg_test.go index 8465fcf796..b9e31705ab 100644 --- a/sync/reorg_test.go +++ b/sync/reorg_test.go @@ -76,6 +76,14 @@ func (t *testBlockDataSource) PreConfirmedBlockByNumber( return nil, errors.New("not implemented") } +func (t *testBlockDataSource) PreConfirmedBlockLatest( + ctx context.Context, + blockIdentifier string, + knownTransactionCount uint64, +) (starknet.PreConfirmedUpdate, uint64, error) { + return nil, 0, errors.New("not implemented") +} + func (t *testBlockDataSource) setBlocks(blocks []sync.CommittedBlock) { (*atomic.Value)(t).Store(blocks) }