From f25bbf1bff8a88f587265db05b4e13114141ff83 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 21 May 2026 10:26:54 -0700 Subject: [PATCH 1/6] add convert tests for spork version 8 --- state/convert_test.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/state/convert_test.go b/state/convert_test.go index d78218f..ed595ec 100644 --- a/state/convert_test.go +++ b/state/convert_test.go @@ -49,6 +49,13 @@ var Mainnet26_SporkVersion7 = sporkTemplate{ Version: 7, } +var Mainnet28_SporkVersion8 = sporkTemplate{ + AccessNodes: []access.NodeConfig{{Address: "access-001.mainnet28.nodes.onflow.org:9000"}}, + Chain: config.Chain{Network: "mainnet"}, + RootBlock: 150_000_000, + Version: 8, +} + func TestVerifyBlockHash(t *testing.T) { t.Run("mainnet24 spork version 6", func(t *testing.T) { ctx := context.Background() @@ -60,6 +67,11 @@ func TestVerifyBlockHash(t *testing.T) { spork := Mainnet26_SporkVersion7.create(ctx) VerifyBlocksForSpork(t, ctx, spork, 125_000_001, 125_000_011) }) + t.Run("mainnet28 spork version 8", func(t *testing.T) { + ctx := context.Background() + spork := Mainnet28_SporkVersion8.create(ctx) + VerifyBlocksForSpork(t, ctx, spork, 150_000_001, 150_000_011) + }) } func VerifyBlocksForSpork(t *testing.T, ctx context.Context, spork *config.Spork, startHeight uint64, endHeight uint64) { @@ -89,6 +101,11 @@ func TestVerifyExecutionResultHash(t *testing.T) { spork := Mainnet26_SporkVersion7.create(ctx) VerifyExecutionResultsForSpork(t, ctx, spork, 125_000_001, 125_000_011) }) + t.Run("mainnet28 spork version 8", func(t *testing.T) { + ctx := context.Background() + spork := Mainnet28_SporkVersion8.create(ctx) + VerifyExecutionResultsForSpork(t, ctx, spork, 150_000_001, 150_000_011) + }) } // VerifyExecutionResultsForSpork verifies that execution result IDs in block seals match those computed @@ -128,16 +145,22 @@ func VerifyExecutionResultsForSpork(t *testing.T, ctx context.Context, spork *co } func TestDeriveEventsHash(t *testing.T) { - t.Run("mainnet24 / spork version 6", func(t *testing.T) { + t.Run("mainnet24 spork version 6", func(t *testing.T) { ctx := context.Background() spork := Mainnet24_SporkVersion6.create(ctx) VerifyEventsHashForSpork(t, ctx, spork, 65264620, 65264630) }) - t.Run("mainnet26 / spork version 7", func(t *testing.T) { + t.Run("mainnet26 spork version 7", func(t *testing.T) { ctx := context.Background() spork := Mainnet26_SporkVersion7.create(ctx) VerifyEventsHashForSpork(t, ctx, spork, 125_000_001, 125_000_011) }) + t.Run("mainnet28 spork version 8", func(t *testing.T) { + ctx := context.Background() + spork := Mainnet28_SporkVersion8.create(ctx) + VerifyEventsHashForSpork(t, ctx, spork, 150_000_001, 150_000_011) + }) + } func VerifyEventsHashForSpork(t *testing.T, ctx context.Context, spork *config.Spork, startHeight uint64, endHeight uint64) { From 61963695f0649edb35796fabc03020c88369ea49 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 21 May 2026 10:30:12 -0700 Subject: [PATCH 2/6] don't add new derived account when the account creation fails --- Makefile | 6 +++--- script/README.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 169fc42..92b8fdc 100644 --- a/Makefile +++ b/Makefile @@ -68,9 +68,9 @@ create-originator-derived-account: ROOT_ORIGINATOR_PRIVATE_KEY=$$(grep '$(ORIGINATOR_NAME)' $(ACCOUNT_KEYS_FILENAME) | cut -d ',' -f4 ); \ ROOT_ORIGINATOR_ADDRESS=$$(grep '$(ORIGINATOR_NAME)' $(ACCOUNT_KEYS_FILENAME) | cut -d ',' -f5); \ echo "Originator address: $$ROOT_ORIGINATOR_ADDRESS"; \ - TX_HASH=$$(python3 rosetta_handler.py rosetta-create-derived-account $(ROSETTA_HOST_URL) $$ROOT_ORIGINATOR_ADDRESS $$ROOT_ORIGINATOR_PUBLIC_KEY $$ROOT_ORIGINATOR_PRIVATE_KEY $$NEW_ACCOUNT_PUBLIC_ROSETTA_KEY); \ - ADDRESS=$$(flow transactions get $$TX_HASH -f $(FLOW_JSON) -n $(ROSETTA_ENV) -o json | jq -r '.events[] | select(.type == "flow.AccountCreated") | .values.value.fields[] | select(.name == "address") | .value.value'); \ - echo "TX_HASH: $$TX_HASH , ADDRESS: $$ADDRESS"; \ + TX_HASH=$$(python3 rosetta_handler.py rosetta-create-derived-account $(ROSETTA_HOST_URL) $$ROOT_ORIGINATOR_ADDRESS $$ROOT_ORIGINATOR_PUBLIC_KEY $$ROOT_ORIGINATOR_PRIVATE_KEY $$NEW_ACCOUNT_PUBLIC_ROSETTA_KEY) && \ + ADDRESS=$$(flow transactions get $$TX_HASH -f $(FLOW_JSON) -n $(ROSETTA_ENV) -o json | jq -r '.events[] | select(.type == "flow.AccountCreated") | .values.value.fields[] | select(.name == "address") | .value.value') && \ + echo "TX_HASH: $$TX_HASH , ADDRESS: $$ADDRESS" && \ echo "$(NEW_ACCOUNT_NAME),$$NEW_ACCOUNT_PUBLIC_FLOW_KEY,$$NEW_ACCOUNT_PUBLIC_ROSETTA_KEY,$$NEW_ACCOUNT_PRIVATE_KEY,$$ADDRESS" >> $(ACCOUNT_KEYS_FILENAME); .PHONY: rosetta-transfer-funds diff --git a/script/README.md b/script/README.md index c44f8f5..9982115 100644 --- a/script/README.md +++ b/script/README.md @@ -162,7 +162,7 @@ restarting it. This is required to ensure that the indexer knows which the origi add to`${ROSETTA_ENV}.json`). ```bash -NEW_ACCOUNT_NAME=derived-account-1 ORIGINATOR_NAME=root-originator-1 make rosetta-create-sub-account +NEW_ACCOUNT_NAME=derived-account-1 ORIGINATOR_NAME=root-originator-1 make create-originator-derived-account ``` Rosetta should emit logs similar to the following after some time. The `WARN` log can be ignored From 343cca21a4b508f8bf28851929224a10ad91573f Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 21 May 2026 12:44:17 -0700 Subject: [PATCH 3/6] clarify storage key prefix usage in indexdb --- indexdb/indexdb.go | 83 ++++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/indexdb/indexdb.go b/indexdb/indexdb.go index 2b1a3d4..55562f5 100644 --- a/indexdb/indexdb.go +++ b/indexdb/indexdb.go @@ -25,6 +25,14 @@ var ( ErrBlockNotIndexed = errors.New("indexdb: block not indexed") ) +var ( + accountPrefix = []byte("a") + blockPrefix = []byte("b") + hash2HeightPrefix = []byte("c") + height2HashPrefix = []byte("d") + isProxyPrefix = []byte("p") +) + // NOTE(tav): We store the blockchain data within Badger using the following // key/value structure: // @@ -80,10 +88,9 @@ func (s *Store) Accounts() (map[[8]byte]bool, error) { err := s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) defer it.Close() - prefix := []byte("a") - it.Seek(prefix) + it.Seek(accountPrefix) for { - if !it.ValidForPrefix(prefix) { + if !it.ValidForPrefix(accountPrefix) { break } key := it.Item().Key() @@ -94,10 +101,9 @@ func (s *Store) Accounts() (map[[8]byte]bool, error) { } it = txn.NewIterator(badger.IteratorOptions{}) defer it.Close() - prefix = []byte("p") - it.Seek(prefix) + it.Seek(isProxyPrefix) for { - if !it.ValidForPrefix(prefix) { + if !it.ValidForPrefix(isProxyPrefix) { break } key := it.Item().Key() @@ -127,10 +133,9 @@ func (s *Store) AccountsInfo() (map[string]*AccountInfo, error) { err := s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) defer it.Close() - prefix := []byte("a") - it.Seek(prefix) + it.Seek(accountPrefix) for { - if !it.ValidForPrefix(prefix) { + if !it.ValidForPrefix(accountPrefix) { break } item := it.Item() @@ -163,10 +168,9 @@ func (s *Store) AccountsInfo() (map[string]*AccountInfo, error) { err = s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) defer it.Close() - prefix := []byte("p") - it.Seek(prefix) + it.Seek(isProxyPrefix) for { - if !it.ValidForPrefix(prefix) { + if !it.ValidForPrefix(isProxyPrefix) { break } key := it.Item().Key() @@ -308,7 +312,7 @@ func (s *Store) Genesis() *model.BlockMeta { // given height. func (s *Store) HasBalance(acct []byte, height uint64) (bool, error) { key := make([]byte, 1+8+8) - key[0] = 'a' + key[0] = 'a' // accountPrefix copy(key[1:9], acct) binary.BigEndian.PutUint64(key[9:], height) ok := false @@ -337,7 +341,7 @@ func (s *Store) HashForHeight(height uint64) ([]byte, error) { var hash []byte heightEnc := make([]byte, 8) binary.BigEndian.PutUint64(heightEnc, height) - key := append([]byte("d"), heightEnc...) + key := append(height2HashPrefix, heightEnc...) err := s.db.View(func(txn *badger.Txn) error { item, err := txn.Get(key) if err != nil { @@ -363,7 +367,7 @@ func (s *Store) HashForHeight(height uint64) ([]byte, error) { // HeightForHash returns the block height for the given hash. func (s *Store) HeightForHash(hash []byte) (uint64, error) { height := uint64(0) - key := append([]byte("c"), hash...) + key := append(hash2HeightPrefix, hash...) err := s.db.View(func(txn *badger.Txn) error { item, err := txn.Get(key) if err != nil { @@ -423,7 +427,7 @@ func (s *Store) Index(ctx context.Context, height uint64, hash []byte, block *mo } if len(op.ProxyPublicKey) > 0 { key := make([]byte, 17) - key[0] = 'p' + key[0] = 'p' // isProxyPrefix copy(key[1:], op.Account) binary.BigEndian.PutUint64(key[9:], height) proxyAccts = append(proxyAccts, key) @@ -440,7 +444,7 @@ func (s *Store) Index(ctx context.Context, height uint64, hash []byte, block *mo updates := make([]accountUpdate, len(accts)) for acct, diff := range accts { key := make([]byte, 1+8+8) - key[0] = 'a' + key[0] = 'a' // accountPrefix copy(key[1:], acct) copy(key[9:], hval) updates[i] = accountUpdate{ @@ -449,13 +453,13 @@ func (s *Store) Index(ctx context.Context, height uint64, hash []byte, block *mo } i++ } - blockKey := append([]byte("b"), hval...) + blockKey := append(blockPrefix, hval...) blockValue, err := proto.Marshal(block) if err != nil { log.Fatalf("Failed to encode model.IndexedBlock: %s", err) } - hash2heightKey := append([]byte("c"), hash...) - height2hashKey := append([]byte("d"), hval...) + hash2heightKey := append(hash2HeightPrefix, hash...) + height2hashKey := append(height2HashPrefix, hval...) latest = &model.BlockMeta{ Hash: hash, Height: height, @@ -576,10 +580,9 @@ func (s *Store) PurgeProxyAccounts() { err := s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) defer it.Close() - prefix := []byte("p") - it.Seek(prefix) + it.Seek(isProxyPrefix) for { - if !it.ValidForPrefix(prefix) { + if !it.ValidForPrefix(isProxyPrefix) { break } key := it.Item().KeyCopy(nil) @@ -595,7 +598,7 @@ func (s *Store) PurgeProxyAccounts() { err = s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) defer it.Close() - prefix := []byte("a") + prefix := accountPrefix it.Seek(prefix) for { if !it.ValidForPrefix(prefix) { @@ -640,7 +643,7 @@ func (s *Store) ResetTo(base uint64) error { delKeys := [][]byte{} err := s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) - prefix := []byte("a") + prefix := accountPrefix it.Seek(prefix) for { if !it.ValidForPrefix(prefix) { @@ -662,7 +665,7 @@ func (s *Store) ResetTo(base uint64) error { } err = s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) - prefix := []byte("p") + prefix := isProxyPrefix it.Seek(prefix) for { if !it.ValidForPrefix(prefix) { @@ -685,7 +688,7 @@ func (s *Store) ResetTo(base uint64) error { last := uint64(0) err = s.db.View(func(txn *badger.Txn) error { it := txn.NewIterator(badger.IteratorOptions{}) - prefix := []byte("d") + prefix := height2HashPrefix it.Seek(prefix) for { if !it.ValidForPrefix(prefix) { @@ -695,21 +698,21 @@ func (s *Store) ResetTo(base uint64) error { key := item.Key() height := binary.BigEndian.Uint64(key[1:]) if height > base { - key = item.KeyCopy(nil) - delKeys = append(delKeys, key) - key = make([]byte, 9) - key[0] = 'b' - binary.BigEndian.PutUint64(key[1:], height) - delKeys = append(delKeys, key) + height2HashKey := item.KeyCopy(nil) + delKeys = append(delKeys, height2HashKey) + blockKey := make([]byte, 9) + blockKey[0] = 'b' // blockPrefix + binary.BigEndian.PutUint64(blockKey[1:], height) + delKeys = append(delKeys, blockKey) hash, err := item.ValueCopy(nil) if err != nil { it.Close() return err } - key = make([]byte, len(hash)+1) - key[0] = 'c' - copy(key[1:], hash) - delKeys = append(delKeys, key) + hash2HeightKey := make([]byte, len(hash)+1) + hash2HeightKey[0] = 'c' // hash2HeightPrefix + copy(hash2HeightKey[1:], hash) + delKeys = append(delKeys, hash2HeightKey) } else { last = height } @@ -775,15 +778,15 @@ func (s *Store) SetGenesis(val *model.BlockMeta) error { } hval := make([]byte, 8) binary.BigEndian.PutUint64(hval, val.Height) - blockKey := append([]byte("b"), hval...) + blockKey := append(blockPrefix, hval...) blockValue, err := proto.Marshal(&model.IndexedBlock{ Timestamp: val.Timestamp, }) if err != nil { log.Fatalf("Failed to encode model.IndexedBlock: %s", err) } - hash2heightKey := append([]byte("c"), val.Hash...) - height2hashKey := append([]byte("d"), hval...) + hash2heightKey := append(hash2HeightPrefix, val.Hash...) + height2hashKey := append(height2HashPrefix, hval...) err = s.db.Update(func(txn *badger.Txn) error { if err := txn.Set([]byte("genesis"), genesis); err != nil { return err From a0027e378ddd750b575b3736044a24a58ac8f014 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 18 Jun 2026 16:47:37 -0700 Subject: [PATCH 4/6] update localnet.json --- localnet.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localnet.json b/localnet.json index 33d9c68..92c30da 100644 --- a/localnet.json +++ b/localnet.json @@ -27,7 +27,7 @@ } ], "root_block": 0, - "version": 7 + "version": 8 } } } From 4231635c61da4ea436b9411834cfc6db5f25024b Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 18 Jun 2026 18:12:10 -0700 Subject: [PATCH 5/6] add automated localnet compatibility test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes the script/README.md localnet validation procedure as a Go integration test behind the `localnet` build tag, runnable via `make localnet-test`. It bootstraps and funds an originator, starts the server against a running localnet, waits for the indexer to reach tip, then creates a derived account and transfers funds through the construction API — asserting via the REST API that the server indexes the network and observes the transfer. Funding renders basic-transfer.cdc through script.Compile (the template is invalid Cadence as-is, so `make fund-accounts` never worked), and the indexer fails fast on block-ID mismatches so a stale spork version or a flow-go incompatibility surfaces immediately. Verified against a flow-go-master localnet. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 7 + localnettest/doc.go | 17 ++ localnettest/localnet_test.go | 487 ++++++++++++++++++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 localnettest/doc.go create mode 100644 localnettest/localnet_test.go diff --git a/Makefile b/Makefile index 92b8fdc..2fb07ba 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,13 @@ go-test: go test -v github.com/onflow/rosetta/state/... go test -v github.com/onflow/rosetta/script/... +# End-to-end localnet compatibility test (script/README.md). Requires a flow-go +# localnet up at 127.0.0.1:4001, the flow CLI, jq, and python3 with click + +# requests; skips cleanly if any are absent. Build-tagged out of go-test. +.PHONY: localnet-test +localnet-test: + go test -tags localnet -v -timeout 20m github.com/onflow/rosetta/localnettest/... + .PHONY: gen-originator-account gen-originator-account: KEYS=$$(go run ./cmd/genkey/genkey.go -csv); \ diff --git a/localnettest/doc.go b/localnettest/doc.go new file mode 100644 index 0000000..6ee1602 --- /dev/null +++ b/localnettest/doc.go @@ -0,0 +1,17 @@ +// Package localnettest holds an end-to-end localnet compatibility test for the +// Rosetta server, automating the validation procedure in script/README.md. +// +// The test is guarded by the `localnet` build tag and is excluded from +// `make go-test`; run it with `make localnet-test`. It requires a flow-go +// localnet running at 127.0.0.1:4001 (see flow-go integration/localnet, built +// at the flow-go version under test), plus the flow CLI, jq, and python3 with +// `click` and `requests`. It skips cleanly when any of those are unavailable. +// +// It bootstraps and funds an originator, starts ./server against localnet, +// waits for the indexer to reach tip, then creates a derived account and +// transfers funds through Rosetta's construction API — asserting via the +// Rosetta REST API that the server indexes the network without a block-ID +// mismatch and observes the transfer. A failure here means the current Rosetta +// build is incompatible with the localnet's flow-go version (or its spork +// config is stale), which is exactly the signal the upgrade procedure needs. +package localnettest diff --git a/localnettest/localnet_test.go b/localnettest/localnet_test.go new file mode 100644 index 0000000..584b657 --- /dev/null +++ b/localnettest/localnet_test.go @@ -0,0 +1,487 @@ +//go:build localnet + +package localnettest + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/coinbase/rosetta-sdk-go/types" + + "github.com/onflow/rosetta/config" + "github.com/onflow/rosetta/script" +) + +const ( + // localnetAccessNode is where flow-go's integration/localnet exposes its + // access node; Rosetta's localnet.json points its access nodes here too. + localnetAccessNode = "127.0.0.1:4001" + + blockchain = "flow" + originatorName = "root-originator-1" + derivedName = "derived-account-1" + transferAmount = "50" +) + +// indexerFatalErrors are server log lines meaning the indexer is wedged and will +// never reach tip — most often a flow-go version mismatch between Rosetta's +// compiled-in flow-go and the running network (a changed header encoding yields +// a different block ID than Rosetta recomputes) or a stale spork `version` in +// the config. Spotting them lets the test fail fast with the cause. See +// script/README.md "Troubleshooting". +var indexerFatalErrors = []string{ + "Mismatching block ID from header", + "Unexpected parent hash", +} + +// TestLocalnetCompat validates that this Rosetta build stays compatible with the +// Flow network version running on a local flow-go localnet, following the +// emulator/localnet procedure in script/README.md. It drives Rosetta's own +// tooling (Makefile targets, script.Compile, the construction API) and asserts +// against the Rosetta REST API. +// +// It does not bring up localnet — that is a Docker network started from flow-go +// integration/localnet at the flow-go version under test (see script/README.md). +// Whoever runs `make localnet-test` is responsible for having it up first; the +// test skips when localnet or the required tooling is absent. +func TestLocalnetCompat(t *testing.T) { + requireTools(t, "make", "go", "flow", "jq", "python3") + requirePythonModules(t, "click", "requests") + requireLocalnet(t) + + root := repoRoot(t) + cfg := readLocalnetConfig(t, root) + base := fmt.Sprintf("http://127.0.0.1:%d", cfg.Port) + + t.Log("resetting localnet state for a fresh bootstrap") + resetState(t, root) + + t.Log("building the Rosetta server with its current dependencies") + run(t, root, nil, "make", "go-build") + + t.Logf("bootstrapping originator %q", originatorName) + run(t, root, []string{"ACCOUNT_NAME=" + originatorName}, "make", "gen-originator-account") + + t.Log("funding originator(s) with a rendered transfer transaction") + fundOriginators(t, root, cfg) + + t.Log("starting the Rosetta server against localnet") + srv := startServer(t, root) + + t.Log("waiting for the indexer to reach tip") + waitForSynced(t, srv, base, cfg.Network, 5*time.Minute) + + t.Logf("creating derived account %q via Rosetta", derivedName) + run(t, root, []string{ + "NEW_ACCOUNT_NAME=" + derivedName, + "ORIGINATOR_NAME=" + originatorName, + }, "make", "create-originator-derived-account") + + recipient := accountAddress(t, root, derivedName) + before := waitForBalance(t, srv, base, cfg.Network, recipient, func(uint64) bool { return true }, 2*time.Minute) + t.Logf("derived account %s indexed with balance %d", recipient, before) + + t.Logf("transferring %s FLOW from %q to %q via Rosetta", transferAmount, originatorName, derivedName) + run(t, root, []string{ + "RECIPIENT_NAME=" + derivedName, + "PAYER_NAME=" + originatorName, + "AMOUNT=" + transferAmount, + }, "make", "rosetta-transfer-funds") + + t.Log("waiting for the transfer to be indexed (recipient balance increases)") + after := waitForBalance(t, srv, base, cfg.Network, recipient, func(v uint64) bool { return v > before }, 3*time.Minute) + t.Logf("recipient balance %d -> %d: Rosetta indexed the transfer — compatibility confirmed", before, after) +} + +// localnetConfig is the subset of localnet.json this test needs. +type localnetConfig struct { + Port uint16 `json:"port"` + Network string `json:"network"` + Contracts *config.Contracts `json:"contracts"` +} + +func readLocalnetConfig(t *testing.T, root string) localnetConfig { + t.Helper() + raw, err := os.ReadFile(filepath.Join(root, "localnet.json")) + if err != nil { + t.Fatalf("read localnet.json: %v", err) + } + var cfg localnetConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parse localnet.json: %v", err) + } + if cfg.Port == 0 || cfg.Network == "" || cfg.Contracts == nil { + t.Fatalf("localnet.json missing port/network/contracts: %+v", cfg) + } + return cfg +} + +// resetState clears state that would break a fresh localnet bootstrap: the +// committed originators (whose addresses don't exist on a new localnet and +// trigger balance-mismatch errors), the account-keys CSV that fund-accounts +// iterates, and the indexer data dir (whose cached blocks cause the "Unexpected +// parent hash" startup error documented in script/README.md). The spork +// `version` is preserved. +func resetState(t *testing.T, root string) { + t.Helper() + + cfgPath := filepath.Join(root, "localnet.json") + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read %s: %v", cfgPath, err) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parse %s: %v", cfgPath, err) + } + cfg["originators"] = []string{} + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("encode %s: %v", cfgPath, err) + } + if err := os.WriteFile(cfgPath, append(out, '\n'), 0o600); err != nil { + t.Fatalf("write %s: %v", cfgPath, err) + } + + if err := os.WriteFile(filepath.Join(root, "account-keys.csv"), nil, 0o600); err != nil { + t.Fatalf("truncate account-keys.csv: %v", err) + } + if err := os.RemoveAll(filepath.Join(root, "data")); err != nil { + t.Fatalf("remove data dir: %v", err) + } +} + +// fundOriginators funds every account in account-keys.csv from the localnet +// service account, replacing `make fund-accounts`. +// +// `make fund-accounts` feeds script/cadence/transactions/basic-transfer.cdc +// straight to the flow CLI, but that file is a template rendered at runtime +// (see Server.compileScripts) — `import FlowToken from 0x{{.Contracts.FlowToken}}` +// is invalid Cadence on its own. We render it the same way the server does, with +// script.Compile and the localnet contract addresses, then send it. +func fundOriginators(t *testing.T, root string, cfg localnetConfig) { + t.Helper() + + code := script.Compile("basic_transfer", script.BasicTransfer, &config.Chain{Contracts: cfg.Contracts}) + txPath := filepath.Join(t.TempDir(), "fund-transfer.cdc") + if err := os.WriteFile(txPath, code, 0o600); err != nil { + t.Fatalf("write rendered funding tx: %v", err) + } + + for _, addr := range accountAddresses(t, root) { + run(t, root, nil, "flow", "transactions", "send", txPath, addr, "100.0", + "-n", "localnet", "-f", "script/flow.json", "--signer", "localnet-service-account") + } +} + +// accountAddresses returns the address column of every account-keys.csv row. +func accountAddresses(t *testing.T, root string) []string { + t.Helper() + rows := accountKeyRows(t, root) + if len(rows) == 0 { + t.Fatal("no accounts found in account-keys.csv") + } + addrs := make([]string, 0, len(rows)) + for _, fields := range rows { + addrs = append(addrs, fields[len(fields)-1]) + } + return addrs +} + +// accountAddress returns the address for the named account-keys.csv row. +func accountAddress(t *testing.T, root, name string) string { + t.Helper() + for _, fields := range accountKeyRows(t, root) { + if fields[0] == name { + return fields[len(fields)-1] + } + } + t.Fatalf("account %q not found in account-keys.csv", name) + return "" +} + +// accountKeyRows parses account-keys.csv into trimmed field slices. Rows are: +// name, flow pub key, rosetta pub key, flow priv key, address (0x-prefixed for +// originators, bare for derived accounts). +func accountKeyRows(t *testing.T, root string) [][]string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(root, "account-keys.csv")) + if err != nil { + t.Fatalf("read account-keys.csv: %v", err) + } + var rows [][]string + for line := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") { + if line == "" { + continue + } + fields := strings.Split(line, ",") + if len(fields) < 5 { + t.Fatalf("unexpected account-keys.csv row: %q", line) + } + for i := range fields { + fields[i] = strings.TrimSpace(fields[i]) + } + rows = append(rows, fields) + } + return rows +} + +// --- Rosetta REST polling --- + +func networkID(network string) *types.NetworkIdentifier { + return &types.NetworkIdentifier{Blockchain: blockchain, Network: network} +} + +// waitForSynced blocks until /network/status reports the indexer is synced to +// tip, failing fast if the server exits or logs a fatal indexer error. +func waitForSynced(t *testing.T, srv *server, base, network string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + srv.checkAlive(t, "waiting for the indexer to reach tip") + var resp types.NetworkStatusResponse + if srv.post(t, base+"/network/status", &types.NetworkRequest{NetworkIdentifier: networkID(network)}, &resp) { + if resp.SyncStatus != nil && resp.SyncStatus.Synced != nil && *resp.SyncStatus.Synced { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("timed out after %s waiting for the indexer to sync; last server output:\n%s", timeout, srv.out.tail(4000)) + } + time.Sleep(2 * time.Second) + } +} + +// waitForBalance polls /account/balance for addr until pred holds on the balance +// value, returning it. Used both to confirm an account is indexed (pred always +// true) and to confirm a transfer landed (balance increased). +func waitForBalance(t *testing.T, srv *server, base, network, addr string, pred func(uint64) bool, timeout time.Duration) uint64 { + t.Helper() + // /account/balance requires a 0x-prefixed address (see Server.getAccount). + // account-keys.csv stores originators 0x-prefixed but derived accounts bare, + // so normalize to always carry the prefix. + address := "0x" + strings.TrimPrefix(addr, "0x") + deadline := time.Now().Add(timeout) + var last uint64 + haveLast := false + for { + srv.checkAlive(t, "querying /account/balance") + var resp types.AccountBalanceResponse + ok := srv.post(t, base+"/account/balance", &types.AccountBalanceRequest{ + NetworkIdentifier: networkID(network), + AccountIdentifier: &types.AccountIdentifier{Address: address}, + }, &resp) + if ok && len(resp.Balances) > 0 { + v, err := strconv.ParseUint(resp.Balances[0].Value, 10, 64) + if err != nil { + t.Fatalf("parse balance %q for %s: %v", resp.Balances[0].Value, address, err) + } + last, haveLast = v, true + if pred(v) { + return v + } + } + if time.Now().After(deadline) { + t.Fatalf("timed out after %s waiting for balance of %s (last seen: %d, observed=%t); last server output:\n%s", + timeout, address, last, haveLast, srv.out.tail(4000)) + } + time.Sleep(2 * time.Second) + } +} + +// --- subprocess management --- + +type server struct { + cmd *exec.Cmd + out *syncBuffer + done chan struct{} +} + +func startServer(t *testing.T, root string) *server { + t.Helper() + cmd := exec.Command("./server", "localnet.json") + cmd.Dir = root + out := &syncBuffer{} + cmd.Stdout = out + cmd.Stderr = out + + t.Logf("$ (cd %s && ./server localnet.json)", root) + if err := cmd.Start(); err != nil { + t.Fatalf("start rosetta server: %v", err) + } + srv := &server{cmd: cmd, out: out, done: make(chan struct{})} + go func() { + _ = cmd.Wait() + close(srv.done) + }() + t.Cleanup(srv.stop) + return srv +} + +func (s *server) stop() { + if s.cmd.Process == nil { + return + } + _ = s.cmd.Process.Signal(syscall.SIGTERM) + select { + case <-s.done: + case <-time.After(10 * time.Second): + _ = s.cmd.Process.Kill() + } +} + +func (s *server) exited() bool { + select { + case <-s.done: + return true + default: + return false + } +} + +// checkAlive fails the test if the server has exited or logged a fatal indexer +// error, so incompatibilities surface in seconds instead of at the timeout. +func (s *server) checkAlive(t *testing.T, doing string) { + t.Helper() + if line, ok := firstFatalError(s.out.String()); ok { + t.Fatalf("rosetta indexer hit a fatal error while %s "+ + "(likely a flow-go version mismatch or stale spork version):\n%s", doing, line) + } + if s.exited() { + t.Fatalf("rosetta server exited while %s; last output:\n%s", doing, s.out.tail(4000)) + } +} + +// post sends req as JSON to url and decodes a 200 response into resp. It returns +// false (for the caller to retry) on a connection error, non-200 status, or +// decode failure — all expected while the server is still starting or syncing. +func (s *server) post(t *testing.T, url string, req, resp any) bool { + t.Helper() + body, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + httpResp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + return false + } + defer httpResp.Body.Close() + data, err := io.ReadAll(httpResp.Body) + if err != nil || httpResp.StatusCode != http.StatusOK { + return false + } + return json.Unmarshal(data, resp) == nil +} + +func firstFatalError(out string) (string, bool) { + for line := range strings.SplitSeq(out, "\n") { + for _, marker := range indexerFatalErrors { + if strings.Contains(line, marker) { + return strings.TrimSpace(line), true + } + } + } + return "", false +} + +// syncBuffer is a goroutine-safe buffer collecting the server's combined output. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func (b *syncBuffer) tail(n int) string { + s := b.String() + if len(s) > n { + return s[len(s)-n:] + } + return s +} + +// --- environment helpers --- + +func repoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + return root +} + +func run(t *testing.T, dir string, env []string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + t.Logf("$ (cd %s && %s %s)", dir, name, strings.Join(args, " ")) + err := cmd.Run() + if out := strings.TrimSpace(buf.String()); out != "" { + t.Logf("output:\n%s", out) + } + if err != nil { + t.Fatalf("%s %s failed: %v", name, strings.Join(args, " "), err) + } +} + +func requireTools(t *testing.T, tools ...string) { + t.Helper() + var missing []string + for _, tool := range tools { + if _, err := exec.LookPath(tool); err != nil { + missing = append(missing, tool) + } + } + if len(missing) > 0 { + t.Skipf("required tool(s) not on PATH: %s", strings.Join(missing, ", ")) + } +} + +func requirePythonModules(t *testing.T, mods ...string) { + t.Helper() + for _, mod := range mods { + if err := exec.Command("python3", "-c", "import "+mod).Run(); err != nil { + t.Skipf("python3 module %q not importable (pip install %s): %v", mod, mod, err) + } + } +} + +func requireLocalnet(t *testing.T) { + t.Helper() + conn, err := net.DialTimeout("tcp", localnetAccessNode, 2*time.Second) + if err != nil { + t.Skipf("no localnet access node at %s (start flow-go integration/localnet first): %v", localnetAccessNode, err) + } + _ = conn.Close() +} From ddac19bb4ed6a8368451648c9fb9b2b1516cee4f Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Wed, 15 Jul 2026 09:54:12 -0700 Subject: [PATCH 6/6] remove benchnet override Opens the possibility of running Rosetta against the flow emulator. Localnet should be preferred over benchnet anyway. --- state/convert.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/state/convert.go b/state/convert.go index 4875214..43222f7 100644 --- a/state/convert.go +++ b/state/convert.go @@ -641,9 +641,6 @@ func toSignatureSlice(v [][]byte) []crypto.Signature { func verifyBlockHash(spork *config.Spork, hash []byte, height uint64, hdr *entities.BlockHeader, block *entities.Block) bool { chainID := flow.ChainID("flow-" + spork.Chain.Network) - if spork.Chain.Network == "emulator" { - chainID = flow.ChainID("flow-benchnet") - } var lastViewTC *flow.TimeoutCertificate if hdr.LastViewTc != nil {