From be230a984b4b995227d1d3dc1a38e24eee05f4f7 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 13 Oct 2025 14:38:19 -0700 Subject: [PATCH 01/28] Update Flow Emulator Forking Commands --- README.md | 14 +++++----- cmd/emulator/start/start.go | 51 +++++++++++++++++++++++++---------- docs/overview.md | 14 +++++----- server/server.go | 53 ++++++++++++++++++++++++++++++++----- storage/remote/store.go | 29 +++++++++++++++++--- 5 files changed, 120 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 6c7d3f51..5955b53d 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ values. | `--host` | `FLOW_HOST` | ` ` | Host to listen on for emulator GRPC/REST/Admin servers (default: All Interfaces) | | `--chain-id` | `FLOW_CHAINID` | `emulator` | Chain to simulate, if 'mainnet' or 'testnet' values are used, you will be able to run transactions against that network and a local fork will be created. Valid values are: 'emulator', 'testnet', 'mainnet' | | `--redis-url` | `FLOW_REDIS_URL` | '' | Redis-server URL for persisting redis storage backend ( `redis://[[username:]password@]host[:port][/database]` ) | -| `--start-block-height` | `FLOW_STARTBLOCKHEIGHT` | `0` | Start block height to use when starting the network using 'testnet' or 'mainnet' as the chain-id | -| `--rpc-host` | `FLOW_RPCHOST` | '' | RPC host (access node) to query for previous state when starting the network using 'testnet' or 'mainnet' as the chain-id | +| `--fork-url` | `FLOW_FORKURL` | '' | gRPC access node address (`host:port`) to fork from | +| `--fork-block-number` | `FLOW_FORKBLOCKNUMBER` | `0` | Block number/height to pin the fork (defaults to latest sealed) | | `--legacy-upgrade` | `FLOW_LEGACYUPGRADE` | `false` | Enable upgrading of legacy contracts | | `--computation-reporting` | `FLOW_COMPUTATIONREPORTING` | `false` | Enable computation reporting for Cadence scripts & transactions | | `--checkpoint-dir` | `FLOW_CHECKPOINTDIR` | '' | Checkpoint directory to load the emulator state from, if starting the emulator from a checkpoint | @@ -155,8 +155,7 @@ Post Data: height={block height} ``` Note: it is only possible to roll back state to a height that was previously executed by the emulator. -To roll back to a past block height when using a forked Mainnet or Testnet network, use the -`--start-block-height` flag. +To pin the starting block height when using a fork, use the `--fork-block-number` flag. ## Managing emulator state It's possible to manage emulator state by using the admin API. You can at any point @@ -269,15 +268,14 @@ you must specify the network name for the chain ID flag and the RPC host to connect to. ``` -flow emulator --chain-id mainnet --rpc-host access.mainnet.nodes.onflow.org:9000 -flow emulator --chain-id mainnet --rpc-host access.devnet.nodes.onflow.org:9000 +flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 +flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 --fork-block-number 12345 ``` Please note, that the actual execution on the real network may differ depending on the exact state when the transaction is executed. -By default, the forked network will start from the latest sealed block when the emulator -is started. You can specify a different starting block height by using the `--start-block-height` flag. +By default, the forked network will start from the latest sealed block when the emulator is started. You can specify a different starting block height by using the `--fork-block-number` flag. You can also store all of your changes and cached registers to a persistent db by using the `--persist` flag, along with the other SQLite settings. diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index e25faef4..ad034b0a 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -75,14 +75,17 @@ type Config struct { SqliteURL string `default:"" flag:"sqlite-url" info:"sqlite db URL for persisting sqlite storage backend "` CoverageReportingEnabled bool `default:"false" flag:"coverage-reporting" info:"enable Cadence code coverage reporting"` LegacyContractUpgradeEnabled bool `default:"false" flag:"legacy-upgrade" info:"enable Cadence legacy contract upgrade"` - StartBlockHeight uint64 `default:"0" flag:"start-block-height" info:"block height to start the emulator at. only valid when forking Mainnet or Testnet"` - RPCHost string `default:"" flag:"rpc-host" info:"rpc host to query when forking Mainnet or Testnet"` - CheckpointPath string `default:"" flag:"checkpoint-dir" info:"checkpoint directory to load the emulator state from"` - StateHash string `default:"" flag:"state-hash" info:"state hash of the checkpoint to load the emulator state from"` - ComputationReportingEnabled bool `default:"false" flag:"computation-reporting" info:"enable Cadence computation reporting"` - ScheduledTransactionsEnabled bool `default:"true" flag:"scheduled-transactions" info:"enable Cadence scheduled transactions"` - SetupEVMEnabled bool `default:"true" flag:"setup-evm" info:"enable EVM setup for the emulator, this will deploy the EVM contracts"` - SetupVMBridgeEnabled bool `default:"true" flag:"setup-vm-bridge" info:"enable VM Bridge setup for the emulator, this will deploy the VM Bridge contracts"` + ForkURL string `default:"" flag:"fork-url" info:"gRPC access node address (host:port) to fork from"` + ForkBlockNumber uint64 `default:"0" flag:"fork-block-number" info:"block number/height to pin fork; defaults to latest sealed"` + // Deprecated hidden aliases + StartBlockHeight uint64 `default:"0" flag:"start-block-height" info:"(deprecated) use --fork-block-number"` + RPCHost string `default:"" flag:"rpc-host" info:"(deprecated) use --fork-url"` + CheckpointPath string `default:"" flag:"checkpoint-dir" info:"checkpoint directory to load the emulator state from"` + StateHash string `default:"" flag:"state-hash" info:"state hash of the checkpoint to load the emulator state from"` + ComputationReportingEnabled bool `default:"false" flag:"computation-reporting" info:"enable Cadence computation reporting"` + ScheduledTransactionsEnabled bool `default:"true" flag:"scheduled-transactions" info:"enable Cadence scheduled transactions"` + SetupEVMEnabled bool `default:"true" flag:"setup-evm" info:"enable EVM setup for the emulator, this will deploy the EVM contracts"` + SetupVMBridgeEnabled bool `default:"true" flag:"setup-vm-bridge" info:"enable VM Bridge setup for the emulator, this will deploy the VM Bridge contracts"` } const EnvPrefix = "FLOW" @@ -147,12 +150,26 @@ func Cmd(config StartConfig) *cobra.Command { Exit(1, err.Error()) } - if conf.StartBlockHeight > 0 && flowChainID != flowgo.Mainnet && flowChainID != flowgo.Testnet { - Exit(1, "❗ --start-block-height is only valid when forking Mainnet or Testnet") + // Deprecation shims: map old flags to new and warn + if conf.RPCHost != "" && conf.ForkURL == "" { + logger.Warn().Msg("❗ --rpc-host is deprecated; use --fork-url") + conf.ForkURL = conf.RPCHost + } + if conf.StartBlockHeight > 0 && conf.ForkBlockNumber == 0 { + logger.Warn().Msg("❗ --start-block-height is deprecated; use --fork-block-number") + conf.ForkBlockNumber = conf.StartBlockHeight } - if (flowChainID == flowgo.Mainnet || flowChainID == flowgo.Testnet) && conf.RPCHost == "" { - Exit(1, "❗ --rpc-host must be provided when forking Mainnet or Testnet") + // If forking, ignore provided chain-id and detect from remote later in server + if conf.ForkURL != "" { + if conf.ForkBlockNumber == 0 { + // default to latest sealed handled in remote store + } + } else { + // Non-fork mode cannot accept deprecated fork-only flags + if conf.StartBlockHeight > 0 || conf.ForkBlockNumber > 0 { + Exit(1, "❗ --fork-block-number requires --fork-url") + } } serviceAddress := flowsdk.ServiceAddress(flowsdk.ChainID(flowChainID)) @@ -217,8 +234,8 @@ func Cmd(config StartConfig) *cobra.Command { ContractRemovalEnabled: conf.ContractRemovalEnabled, SqliteURL: conf.SqliteURL, CoverageReportingEnabled: conf.CoverageReportingEnabled, - StartBlockHeight: conf.StartBlockHeight, - RPCHost: conf.RPCHost, + ForkURL: conf.ForkURL, + ForkBlockNumber: conf.ForkBlockNumber, CheckpointPath: conf.CheckpointPath, StateHash: conf.StateHash, ComputationReportingEnabled: conf.ComputationReportingEnabled, @@ -241,6 +258,12 @@ func Cmd(config StartConfig) *cobra.Command { initConfig(cmd) + // Hide and deprecate legacy flags while keeping them functional + _ = cmd.PersistentFlags().MarkHidden("rpc-host") + _ = cmd.PersistentFlags().MarkDeprecated("rpc-host", "use --fork-url") + _ = cmd.PersistentFlags().MarkHidden("start-block-height") + _ = cmd.PersistentFlags().MarkDeprecated("start-block-height", "use --fork-block-number") + return cmd } diff --git a/docs/overview.md b/docs/overview.md index 547a66f1..fcc7a61c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -73,8 +73,8 @@ values. | `--host` | `FLOW_HOST` | ` ` | Host to listen on for emulator GRPC/REST/Admin servers (default: all interfaces) | | `--chain-id` | `FLOW_CHAINID` | `emulator` | Chain to emulate for address generation. Valid values are: 'emulator', 'testnet', 'mainnet' | | `--redis-url` | `FLOW_REDIS_URL` | '' | Redis-server URL for persisting redis storage backend ( `redis://[[username:]password@]host[:port][/database]` ) | -| `--start-block-height` | `FLOW_STARTBLOCKHEIGHT` | `0` | Start block height to use when starting the network using 'testnet' or 'mainnet' as the chain-id | -| `--rpc-host` | `FLOW_RPCHOST` | '' | RPC host (access node) to query for previous state when starting the network using 'testnet' or 'mainnet' as the chain-id | +| `--fork-url` | `FLOW_FORKURL` | '' | gRPC access node address (`host:port`) to fork from | +| `--fork-block-number` | `FLOW_FORKBLOCKNUMBER` | `0` | Block number/height to pin the fork (defaults to latest sealed) | ## Running the emulator with the Flow CLI @@ -149,8 +149,7 @@ Post Data: height={block height} ``` Note: it is only possible to roll back state to a height that was previously executed by the emulator. -To roll back to a past block height when using a forked Mainnet or Testnet network, use the -`--start-block-height` flag. +To pin the starting block height when using a fork, use the `--fork-block-number` flag. ## Managing emulator state @@ -246,14 +245,13 @@ you must specify the network name for the chain ID flag as well as the RPC host to connect to. ``` -flow emulator --chain-id mainnet --rpc-host access-008.mainnet24.nodes.onflow.org:9000 -flow emulator --chain-id mainnet --rpc-host access-002.devnet49.nodes.onflow.org:9000 +flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 +flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 --fork-block-number 12345 ``` Please note, the actual execution on the real network may differ depending on the exact state when the transaction is executed. -By default, the forked network will start from the latest sealed block when the emulator -is started. You can specify a different starting block height by using the `--start-block-height` flag. +By default, the forked network will start from the latest sealed block when the emulator is started. You can specify a different starting block height by using the `--fork-block-number` flag. You can also store all of your changes and cached registers to a persistent db by using the `--persist` flag, along with the other sqlite settings. diff --git a/server/server.go b/server/server.go index e30ba4cb..428b67db 100644 --- a/server/server.go +++ b/server/server.go @@ -19,6 +19,7 @@ package server import ( + "context" _ "embed" "fmt" "net/http" @@ -44,6 +45,9 @@ import ( "github.com/onflow/flow-emulator/storage/remote" "github.com/onflow/flow-emulator/storage/sqlite" "github.com/onflow/flow-emulator/storage/util" + flowaccess "github.com/onflow/flow/protobuf/go/flow/access" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" ) // EmulatorServer is a local server that runs a Flow Emulator instance. @@ -137,10 +141,10 @@ type Config struct { SqliteURL string // CoverageReportingEnabled enables/disables Cadence code coverage reporting. CoverageReportingEnabled bool - // RPCHost is the address of the access node to use when using a forked network. - RPCHost string - // StartBlockHeight is the height at which to start the emulator. - StartBlockHeight uint64 + // ForkURL is the gRPC access node address to fork from (host:port). + ForkURL string + // ForkBlockNumber is the height at which to start the emulator when forking. + ForkBlockNumber uint64 // CheckpointPath is the path to the checkpoint folder to use when starting the network on top of existing state. // StateHash should be provided as well. CheckpointPath string @@ -245,6 +249,35 @@ func NewEmulatorServer(logger *zerolog.Logger, conf *Config) *EmulatorServer { return server } +func parseFlowChainID(id string) (flowgo.ChainID, error) { + switch id { + case flowgo.Mainnet.String(): + return flowgo.Mainnet, nil + case flowgo.Testnet.String(): + return flowgo.Testnet, nil + case flowgo.Emulator.String(): + return flowgo.Emulator, nil + default: + return "", fmt.Errorf("unknown chain id: %s", id) + } +} + +// detectRemoteChainID connects to the remote access node and fetches network parameters to obtain the chain ID. +func detectRemoteChainID(url string) (flowgo.ChainID, error) { + // Expect raw host:port + conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return "", err + } + defer conn.Close() + client := flowaccess.NewAccessAPIClient(conn) + resp, err := client.GetNetworkParameters(context.Background(), &flowaccess.GetNetworkParametersRequest{}) + if err != nil { + return "", err + } + return parseFlowChainID(resp.ChainId) +} + // Listen starts listening for incoming connections. // // After this non-blocking function executes we can treat the @@ -383,7 +416,7 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto } } - if conf.ChainID == flowgo.Testnet || conf.ChainID == flowgo.Mainnet { + if conf.ForkURL != "" { // TODO: any reason redis shouldn't work? baseProvider, ok := storageProvider.(*sqlite.Store) if !ok { @@ -391,13 +424,19 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto } provider, err := remote.New(baseProvider, logger, - remote.WithRPCHost(conf.RPCHost, conf.ChainID), - remote.WithStartBlockHeight(conf.StartBlockHeight), + remote.WithForkURL(conf.ForkURL), + remote.WithForkBlockNumber(conf.ForkBlockNumber), ) if err != nil { return nil, err } storageProvider = provider + + // detect and override chain ID from remote parameters (no dependency on store internals) + // TODO: do not mutate conf here; derive chain ID immutably during setup + if parsed, err := detectRemoteChainID(conf.ForkURL); err == nil { + conf.ChainID = parsed + } } if conf.Snapshot { diff --git a/storage/remote/store.go b/storage/remote/store.go index be74773c..ec6b52a1 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -21,6 +21,7 @@ package remote import ( "context" "fmt" + "strings" "github.com/onflow/flow-go/engine/common/rpc/convert" "github.com/onflow/flow-go/fvm/errors" @@ -53,21 +54,38 @@ type Store struct { type Option func(*Store) -// WithRPCHost sets access/observer node host. +// WithForkURL configures the remote access/observer node gRPC endpoint. +// Expects raw host:port with no scheme. +func WithForkURL(url string) Option { + return func(store *Store) { + // enforce raw host:port only + if strings.Contains(url, "://") { + // keep as-is; the New() will error when dialing + } + store.host = url + } +} + +// WithRPCHost sets access/observer node host. Deprecated: use WithForkURL. func WithRPCHost(host string, chainID flowgo.ChainID) Option { return func(store *Store) { + // Keep legacy behavior: set host and (optionally) chainID for validation. store.host = host store.chainID = chainID } } // WithStartBlockHeight sets the start height for the store. -func WithStartBlockHeight(height uint64) Option { +// WithForkBlockNumber sets the pinned fork block/height. +func WithForkBlockNumber(height uint64) Option { return func(store *Store) { store.forkHeight = height } } +// WithStartBlockHeight is deprecated: use WithForkBlockNumber. +func WithStartBlockHeight(height uint64) Option { return WithForkBlockNumber(height) } + // WithClient can set an rpc host client // // This is mostly use for testing. @@ -115,8 +133,11 @@ func New(provider *sqlite.Store, logger *zerolog.Logger, options ...Option) (*St return nil, fmt.Errorf("could not get network parameters: %w", err) } - if params.ChainId != store.chainID.String() { - return nil, fmt.Errorf("chain ID of rpc host does not match chain ID provided in config: %s != %s", params.ChainId, store.chainID) + // If a chainID was provided (legacy path), validate it matches the remote. If not provided, skip. + if store.chainID != "" { + if params.ChainId != store.chainID.String() { + return nil, fmt.Errorf("chain ID of rpc host does not match chain ID provided in config: %s != %s", params.ChainId, store.chainID) + } } if err := store.initializeStartBlock(context.Background()); err != nil { From 42df5c6f6538a5f7373dc83469c32b5b6ea5692b Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 13 Oct 2025 17:17:39 -0700 Subject: [PATCH 02/28] Add integraton test --- server/fork_integration_test.go | 152 ++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 server/fork_integration_test.go diff --git a/server/fork_integration_test.go b/server/fork_integration_test.go new file mode 100644 index 00000000..c10fec66 --- /dev/null +++ b/server/fork_integration_test.go @@ -0,0 +1,152 @@ +package server + +import ( + "context" + "testing" + + "github.com/onflow/flow-emulator/convert" + flowsdk "github.com/onflow/flow-go-sdk" + flowgo "github.com/onflow/flow-go/model/flow" + flowaccess "github.com/onflow/flow/protobuf/go/flow/access" + "github.com/rs/zerolog" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// TestForkingAgainstTestnet exercises the forking path by wiring a remote store +// We do not test Mainnet as at the time of writing Mainnet is not compatibible +// with the latest upstream Forte upgrade available in the latest emulator releases. +func TestForkingAgainstTestnet(t *testing.T) { + logger := zerolog.Nop() + + // Get remote latest sealed height to pin fork + conn, err := grpc.NewClient("access.testnet.nodes.onflow.org:9000", grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("dial remote: %v", err) + } + defer conn.Close() + remote := flowaccess.NewAccessAPIClient(conn) + rh, err := remote.GetLatestBlockHeader(context.Background(), &flowaccess.GetLatestBlockHeaderRequest{IsSealed: true}) + if err != nil { + t.Fatalf("get remote header: %v", err) + } + remoteHeight := rh.Block.Height + + cfg := &Config{ + // Do not start listeners; NewEmulatorServer only configures components. + DBPath: "", + Persist: false, + Snapshot: false, + SkipTransactionValidation: true, + ChainID: flowgo.Testnet, // will be overridden by detectRemoteChainID + ForkURL: "access.testnet.nodes.onflow.org:9000", + ForkBlockNumber: remoteHeight, + } + + srv := NewEmulatorServer(&logger, cfg) + if srv == nil { + t.Fatal("NewEmulatorServer returned nil") + } + + if cfg.ChainID != flowgo.Testnet { + t.Fatalf("expected ChainID to be Testnet after fork detection, got %q", cfg.ChainID) + } + + // Create an initial local block so we have a valid reference block ID in the forked store + if _, _, err := srv.Emulator().ExecuteAndCommitBlock(); err != nil { + t.Fatalf("prime local block: %v", err) + } + + // Submit a minimal transaction against the forked emulator to ensure tx processing works + latest, err := srv.Emulator().GetLatestBlock() + if err != nil { + t.Fatalf("get latest block: %v", err) + } + // Allow emulator height to be equal to or one greater than remote (if remote advanced by one between queries) + if latest.Height != remoteHeight+1 { + t.Fatalf("fork height mismatch: emulator %d not in {remote, remote+1} where remote=%d", latest.Height, remoteHeight) + } + sk := srv.Emulator().ServiceKey() + // Write an Int into account storage and publish a capability to read it later + writeScript := []byte(` + transaction { + prepare(acct: auth(Storage, Capabilities) &Account) { + acct.storage.save(42, to: /storage/foo) + let cap = acct.capabilities.storage.issue<&Int>(/storage/foo) + acct.capabilities.publish(cap, at: /public/foo) + } + } + `) + tx := flowsdk.NewTransaction(). + SetScript(writeScript). + SetReferenceBlockID(flowsdk.Identifier(latest.ID())). + SetProposalKey(flowsdk.Address(sk.Address), sk.Index, sk.SequenceNumber). + SetPayer(flowsdk.Address(sk.Address)). + AddAuthorizer(flowsdk.Address(sk.Address)) + + signer, err := sk.Signer() + if err != nil { + t.Fatalf("signer: %v", err) + } + if err := tx.SignEnvelope(flowsdk.Address(sk.Address), sk.Index, signer); err != nil { + t.Fatalf("sign envelope: %v", err) + } + if err := srv.Emulator().AddTransaction(*convert.SDKTransactionToFlow(*tx)); err != nil { + t.Fatalf("add tx: %v", err) + } + if _, results, err := srv.Emulator().ExecuteAndCommitBlock(); err != nil { + t.Fatalf("execute block: %v", err) + } else { + if len(results) != 1 { + t.Fatalf("expected 1 tx result, got %d", len(results)) + } + r := results[0] + if !r.Succeeded() { + t.Fatalf("tx failed: %v", r.Error) + } + } + + // Read back in a second transaction using the same authorizer and assert via logs + readTxCode := []byte(` + transaction { + prepare(acct: auth(Storage) &Account) { + let ok = acct.storage.borrow<&Int>(from: /storage/foo) != nil + log(ok) + } + } + `) + latest2, err := srv.Emulator().GetLatestBlock() + if err != nil { + t.Fatalf("get latest block for read tx: %v", err) + } + readTx := flowsdk.NewTransaction(). + SetScript(readTxCode). + SetReferenceBlockID(flowsdk.Identifier(latest2.ID())). + SetProposalKey(flowsdk.Address(sk.Address), sk.Index, sk.SequenceNumber). + SetPayer(flowsdk.Address(sk.Address)). + AddAuthorizer(flowsdk.Address(sk.Address)) + if err := readTx.SignEnvelope(flowsdk.Address(sk.Address), sk.Index, signer); err != nil { + t.Fatalf("sign read envelope: %v", err) + } + if err := srv.Emulator().AddTransaction(*convert.SDKTransactionToFlow(*readTx)); err != nil { + t.Fatalf("add read tx: %v", err) + } + if _, readResults, err := srv.Emulator().ExecuteAndCommitBlock(); err != nil { + t.Fatalf("execute read block: %v", err) + } else { + if len(readResults) != 1 || !readResults[0].Succeeded() { + t.Fatalf("read tx failed: %v", readResults[0].Error) + } + logs := readResults[0].Logs + found := false + for _, l := range logs { + if l == "true" { + found = true + break + } + } + if !found { + t.Fatalf("expected log containing true, got: %v", logs) + } + } +} From 98caa17f05e16af1b132cc69b3ae927a7a578687 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 14 Oct 2025 03:01:11 -0700 Subject: [PATCH 03/28] Add pub key deduplication shim --- storage/remote/store.go | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/storage/remote/store.go b/storage/remote/store.go index ec6b52a1..f6cbf4b2 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -20,6 +20,7 @@ package remote import ( "context" + "encoding/hex" "fmt" "strings" @@ -50,6 +51,8 @@ type Store struct { chainID flowgo.ChainID forkHeight uint64 logger *zerolog.Logger + // applyKeyDeduplication enables a compatibility shim for pre-migration networks (e.g. mainnet) + applyKeyDeduplication bool } type Option func(*Store) @@ -140,6 +143,16 @@ func New(provider *sqlite.Store, logger *zerolog.Logger, options ...Option) (*St } } + // Record remote chain ID if not already set via options + if store.chainID == "" { + store.chainID = flowgo.ChainID(params.ChainId) + } + + // Enable account key deduplication shim for mainnet (pre-migration) + if store.chainID == flowgo.Mainnet { + store.applyKeyDeduplication = true + } + if err := store.initializeStartBlock(context.Background()); err != nil { return nil, err } @@ -260,6 +273,9 @@ func (s *Store) LedgerByHeight( ctx context.Context, blockHeight uint64, ) (snapshot.StorageSnapshot, error) { + // Track seen account public key encodings per owner to suppress duplicates (apk_* registers) + seenAPKByOwner := make(map[string]map[string]bool) + return snapshot.NewReadFuncStorageSnapshot(func(id flowgo.RegisterID) (flowgo.RegisterValue, error) { // create a copy so updating it doesn't affect future calls lookupHeight := blockHeight @@ -297,6 +313,21 @@ func (s *Store) LedgerByHeight( if response != nil && len(response.Values) > 0 { value = response.Values[0] + + // Apply account key deduplication shim for pre-migration networks + if s.applyKeyDeduplication && isAPKRegister(id.Key) && len(value) > 0 { + owner := id.Owner + if _, ok := seenAPKByOwner[owner]; !ok { + seenAPKByOwner[owner] = make(map[string]bool) + } + valHex := hex.EncodeToString(value) + if seenAPKByOwner[owner][valHex] { + // suppress duplicate key value for this owner + value = []byte{} + } else { + seenAPKByOwner[owner][valHex] = true + } + } } // cache the value for future use @@ -314,6 +345,24 @@ func (s *Store) LedgerByHeight( }), nil } +// isAPKRegister returns true if the register key appears to be an account public key slot (apk_). +// The key may be stored as raw text ("apk_0") or hex-encoded with a leading '#' ("#61706b5f30"). +func isAPKRegister(key string) bool { + // Plain text + if strings.HasPrefix(key, "apk_") { + return true + } + // Hex-encoded with leading '#' + if strings.HasPrefix(key, "#") { + hexPart := key[1:] + if b, err := hex.DecodeString(hexPart); err == nil { + s := string(b) + return strings.HasPrefix(s, "apk_") + } + } + return false +} + func (s *Store) Stop() { _ = s.grpcConn.Close() } From 785c6ab943d3b1672d4c4aadc69fa3ec58b96477 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 14 Oct 2025 20:13:42 -0700 Subject: [PATCH 04/28] Fix compat shim --- storage/remote/store.go | 382 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 350 insertions(+), 32 deletions(-) diff --git a/storage/remote/store.go b/storage/remote/store.go index f6cbf4b2..7ce941b2 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -22,9 +22,11 @@ import ( "context" "encoding/hex" "fmt" + "strconv" "strings" "github.com/onflow/flow-go/engine/common/rpc/convert" + "github.com/onflow/flow-go/fvm/environment" "github.com/onflow/flow-go/fvm/errors" "github.com/onflow/flow-go/fvm/storage/snapshot" flowgo "github.com/onflow/flow-go/model/flow" @@ -51,7 +53,9 @@ type Store struct { chainID flowgo.ChainID forkHeight uint64 logger *zerolog.Logger - // applyKeyDeduplication enables a compatibility shim for pre-migration networks (e.g. mainnet) + // COMPATIBILITY SHIM: Account Key Deduplication Migration + // TODO: Remove after Flow release - this shim provides backward compatibility + // for pre-migration networks by synthesizing migrated registers from legacy data applyKeyDeduplication bool } @@ -148,10 +152,9 @@ func New(provider *sqlite.Store, logger *zerolog.Logger, options ...Option) (*St store.chainID = flowgo.ChainID(params.ChainId) } - // Enable account key deduplication shim for mainnet (pre-migration) - if store.chainID == flowgo.Mainnet { - store.applyKeyDeduplication = true - } + // COMPATIBILITY SHIM: Enable for all networks + // TODO: Remove after Forte release - provides backward compatibility + store.applyKeyDeduplication = true if err := store.initializeStartBlock(context.Background()); err != nil { return nil, err @@ -273,9 +276,6 @@ func (s *Store) LedgerByHeight( ctx context.Context, blockHeight uint64, ) (snapshot.StorageSnapshot, error) { - // Track seen account public key encodings per owner to suppress duplicates (apk_* registers) - seenAPKByOwner := make(map[string]map[string]bool) - return snapshot.NewReadFuncStorageSnapshot(func(id flowgo.RegisterID) (flowgo.RegisterValue, error) { // create a copy so updating it doesn't affect future calls lookupHeight := blockHeight @@ -313,19 +313,56 @@ func (s *Store) LedgerByHeight( if response != nil && len(response.Values) > 0 { value = response.Values[0] + } - // Apply account key deduplication shim for pre-migration networks - if s.applyKeyDeduplication && isAPKRegister(id.Key) && len(value) > 0 { - owner := id.Owner - if _, ok := seenAPKByOwner[owner]; !ok { - seenAPKByOwner[owner] = make(map[string]bool) + // COMPATIBILITY SHIM: Account Key Deduplication Migration + // TODO: Remove after Flow release - synthesizes migrated registers from legacy data + if s.applyKeyDeduplication { + normalizedKey := normalizeKey(id.Key) + + // COMPATIBILITY SHIM: Synthesize migrated registers from legacy data + // TODO: Remove after Flow release - these functions provide backward compatibility + if isAPK0Key(normalizedKey) && len(value) == 0 { + // Fallback apk_0 -> public_key_0 + legacy, err := s.fetchRemoteRegister(ctx, id.Owner, "public_key_0", lookupHeight) + if err != nil { + return nil, err + } + if len(legacy) > 0 { + value = legacy } - valHex := hex.EncodeToString(value) - if seenAPKByOwner[owner][valHex] { - // suppress duplicate key value for this owner - value = []byte{} - } else { - seenAPKByOwner[owner][valHex] = true + } else if isPKBKey(normalizedKey) && len(value) == 0 { + // Synthesize pk_b from individual public_key_* registers + batchIdx, ok := parsePKBBatchIndex(normalizedKey) + if ok { + synthesized, err := s.synthesizeBatchPublicKeys(ctx, id.Owner, batchIdx, lookupHeight) + if err != nil { + return nil, err + } + if len(synthesized) > 0 { + value = synthesized + } + } + } else if isSNKey(normalizedKey) && len(value) == 0 { + // Synthesize sn_ from public_key_ sequence numbers + keyIdx, ok := parseSNKeyIndex(normalizedKey) + if ok { + synthesized, err := s.synthesizeSequenceNumber(ctx, id.Owner, keyIdx, lookupHeight) + if err != nil { + return nil, err + } + if len(synthesized) > 0 { + value = synthesized + } + } + } else if isAccountStatusKey(normalizedKey) { + // Synthesize account status v4 with key metadata from legacy registers + synthesized, err := s.synthesizeAccountStatusV4(ctx, id.Owner, lookupHeight) + if err != nil { + return nil, err + } + if len(synthesized) > 0 { + value = synthesized } } } @@ -344,25 +381,306 @@ func (s *Store) LedgerByHeight( return value, nil }), nil } +func (s *Store) Stop() { + _ = s.grpcConn.Close() +} -// isAPKRegister returns true if the register key appears to be an account public key slot (apk_). -// The key may be stored as raw text ("apk_0") or hex-encoded with a leading '#' ("#61706b5f30"). -func isAPKRegister(key string) bool { - // Plain text - if strings.HasPrefix(key, "apk_") { - return true - } - // Hex-encoded with leading '#' +// COMPATIBILITY SHIM: Helper functions for account key deduplication migration +// TODO: Remove after Flow release - these functions provide backward compatibility + +// normalizeKey decodes a hex-prefixed ("#") key into its string form; otherwise returns the key unchanged. +func normalizeKey(key string) string { if strings.HasPrefix(key, "#") { hexPart := key[1:] if b, err := hex.DecodeString(hexPart); err == nil { - s := string(b) - return strings.HasPrefix(s, "apk_") + return string(b) } } - return false + return key } -func (s *Store) Stop() { - _ = s.grpcConn.Close() +func isAPK0Key(key string) bool { + return key == flowgo.AccountPublicKey0RegisterKey +} + +func isPKBKey(key string) bool { + return strings.HasPrefix(key, flowgo.BatchPublicKeyRegisterKeyPrefix) +} + +func isSNKey(key string) bool { + return strings.HasPrefix(key, flowgo.SequenceNumberRegisterKeyPrefix) +} + +func isAccountStatusKey(key string) bool { + return key == flowgo.AccountStatusKey +} + +func parsePKBBatchIndex(key string) (uint32, bool) { + if !isPKBKey(key) { + return 0, false + } + suffix := strings.TrimPrefix(key, flowgo.BatchPublicKeyRegisterKeyPrefix) + n, err := strconv.ParseUint(suffix, 10, 32) + if err != nil { + return 0, false + } + return uint32(n), true +} + +func parseSNKeyIndex(key string) (uint32, bool) { + if !isSNKey(key) { + return 0, false + } + suffix := strings.TrimPrefix(key, flowgo.SequenceNumberRegisterKeyPrefix) + n, err := strconv.ParseUint(suffix, 10, 32) + if err != nil { + return 0, false + } + return uint32(n), true +} + +// fetchRemoteRegister fetches a single register value from the remote at a fixed height. +func (s *Store) fetchRemoteRegister(ctx context.Context, owner string, key string, height uint64) ([]byte, error) { + registerID := convert.RegisterIDToMessage(flowgo.RegisterID{Key: key, Owner: owner}) + response, err := s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ + BlockHeight: height, + RegisterIds: []*entities.RegisterID{registerID}, + }) + if err != nil { + if status.Code(err) == codes.NotFound { + return nil, nil + } + return nil, err + } + if response != nil && len(response.Values) > 0 { + return response.Values[0], nil + } + return nil, nil +} + +// COMPATIBILITY SHIM: Batch public key synthesis +// TODO: Remove after Flow release - builds batch public key payloads from individual public_key_* registers +func (s *Store) synthesizeBatchPublicKeys(ctx context.Context, owner string, batchIndex uint32, height uint64) ([]byte, error) { + // Load account status to get key count + statusBytes, err := s.fetchRemoteRegister(ctx, owner, flowgo.AccountStatusKey, height) + if err != nil { + return nil, err + } + if len(statusBytes) == 0 { + return nil, nil + } + status, err := environment.AccountStatusFromBytes(statusBytes) + if err != nil { + return nil, fmt.Errorf("could not parse account status: %w", err) + } + count := status.AccountPublicKeyCount() + if count == 0 { + return nil, nil + } + + const max = environment.MaxPublicKeyCountInBatch + start := batchIndex * max + // storedKeyIndex range is [start, min(start+max-1, count-1)] + if start >= count { + return nil, nil + } + end := start + max - 1 + if end > count-1 { + end = count - 1 + } + + batch := make([]byte, 0, 1+(end-start+1)*8) // rough capacity + + // Batch 0 reserves index 0 as nil placeholder to align indices + if batchIndex == 0 { + batch = append(batch, 0x00) + } + + for i := start; i <= end; i++ { + if i == 0 { + // stored key 0 is apk_0 and not included in batch payload (nil placeholder already added) + continue + } + legacyKey := fmt.Sprintf("public_key_%d", i) + legacyVal, err := s.fetchRemoteRegister(ctx, owner, legacyKey, height) + if err != nil { + return nil, err + } + if len(legacyVal) == 0 { + // keep index alignment with zero-length entry + batch = append(batch, 0x00) + continue + } + + // Decode legacy account public key to extract public material + decoded, err := flowgo.DecodeAccountPublicKey(legacyVal, uint32(i)) + if err != nil { + // cannot decode -> keep alignment with zero-length entry + batch = append(batch, 0x00) + continue + } + stored := flowgo.StoredPublicKey{ + PublicKey: decoded.PublicKey, + SignAlgo: decoded.SignAlgo, + HashAlgo: decoded.HashAlgo, + } + enc, err := flowgo.EncodeStoredPublicKey(stored) + if err != nil { + batch = append(batch, 0x00) + continue + } + if len(enc) > 255 { + // out of spec for batch encoding; skip with placeholder + batch = append(batch, 0x00) + continue + } + batch = append(batch, byte(len(enc))) + batch = append(batch, enc...) + } + + return batch, nil +} + +// COMPATIBILITY SHIM: Sequence number synthesis +// TODO: Remove after Flow release - builds sequence number registers from legacy public_key_* registers +func (s *Store) synthesizeSequenceNumber(ctx context.Context, owner string, keyIndex uint32, height uint64) ([]byte, error) { + if keyIndex == 0 { + // key 0 sequence number lives in apk_0 + return nil, nil + } + legacyKey := fmt.Sprintf("public_key_%d", keyIndex) + legacyVal, err := s.fetchRemoteRegister(ctx, owner, legacyKey, height) + if err != nil { + return nil, err + } + if len(legacyVal) == 0 { + return nil, nil + } + decoded, err := flowgo.DecodeAccountPublicKey(legacyVal, keyIndex) + if err != nil { + return nil, nil + } + if decoded.SeqNumber == 0 { + return nil, nil + } + enc, err := flowgo.EncodeSequenceNumber(decoded.SeqNumber) + if err != nil { + return nil, nil + } + return enc, nil +} + +// COMPATIBILITY SHIM: Account status v4 synthesis +// TODO: Remove after Flow release - synthesizes v4 account status with key metadata from legacy registers +func (s *Store) synthesizeAccountStatusV4(ctx context.Context, owner string, height uint64) ([]byte, error) { + // Load existing account status (v3) + statusBytes, err := s.fetchRemoteRegister(ctx, owner, flowgo.AccountStatusKey, height) + if err != nil { + return nil, err + } + if len(statusBytes) == 0 { + return nil, nil + } + + status, err := environment.AccountStatusFromBytes(statusBytes) + if err != nil { + return nil, fmt.Errorf("could not parse account status: %w", err) + } + + count := status.AccountPublicKeyCount() + if count <= 1 { + // No key metadata needed for accounts with 0-1 keys + return statusBytes, nil + } + + // Build key metadata from legacy registers + keyMetadata, err := s.buildKeyMetadataFromLegacy(ctx, owner, count, height) + if err != nil { + return nil, fmt.Errorf("could not build key metadata: %w", err) + } + + // Create new status with key metadata by parsing the original bytes and appending metadata + // The AccountStatus struct has unexported fields, so we work with the byte representation + originalBytes := status.ToBytes() + + // Set account status v4 flag (0x40 = accountStatusV4WithNoDeduplicationFlag) + if len(originalBytes) > 0 { + originalBytes[0] = 0x40 + } + + // Append key metadata to the original account status bytes + newBytes := make([]byte, len(originalBytes)+len(keyMetadata)) + copy(newBytes, originalBytes) + copy(newBytes[len(originalBytes):], keyMetadata) + + return newBytes, nil +} + +// COMPATIBILITY SHIM: Key metadata construction +// TODO: Remove after Flow release - builds key metadata from legacy public_key_* registers +func (s *Store) buildKeyMetadataFromLegacy(ctx context.Context, owner string, count uint32, height uint64) ([]byte, error) { + // For pre-migration networks, we build minimal key metadata: + // - Weight and revoked status for keys 1..count-1 (RLE encoded) + // - No deduplication mappings (storedKeyIndex == keyIndex) + // - No digests (not needed for basic functionality) + + if count <= 1 { + return nil, nil + } + + // Build weight and revoked status for keys 1..count-1 + var weightAndRevoked []byte + + for i := uint32(1); i < count; i++ { + legacyKey := fmt.Sprintf("public_key_%d", i) + legacyVal, err := s.fetchRemoteRegister(ctx, owner, legacyKey, height) + if err != nil { + return nil, err + } + if len(legacyVal) == 0 { + // Default values for missing keys + weightAndRevoked = append(weightAndRevoked, 0, 0) // weight=0, revoked=false + continue + } + + decoded, err := flowgo.DecodeAccountPublicKey(legacyVal, i) + if err != nil { + // Default values for unparseable keys + weightAndRevoked = append(weightAndRevoked, 0, 0) + continue + } + + // Encode weight and revoked status (RLE format) + weight := uint16(decoded.Weight) + if weight > 1000 { + weight = 1000 // clamp to max + } + + revokedFlag := uint16(0) + if decoded.Revoked { + revokedFlag = 0x8000 // high bit set + } + + weightAndRevoked = append(weightAndRevoked, byte(weight>>8), byte(weight&0xFF)) + weightAndRevoked = append(weightAndRevoked, byte(revokedFlag>>8), byte(revokedFlag&0xFF)) + } + + // Build minimal key metadata: + // - Length-prefixed weight and revoked status + // - Start index for digests (0, no digests) + // - Length-prefixed empty digests + + metadata := make([]byte, 0, 4+len(weightAndRevoked)+4+4) + + // Length-prefixed weight and revoked status + metadata = append(metadata, byte(len(weightAndRevoked)>>24), byte(len(weightAndRevoked)>>16), byte(len(weightAndRevoked)>>8), byte(len(weightAndRevoked))) + metadata = append(metadata, weightAndRevoked...) + + // Start index for digests (0) + metadata = append(metadata, 0, 0, 0, 0) + + // Length-prefixed empty digests + metadata = append(metadata, 0, 0, 0, 0) + + return metadata, nil } From 84ef55e1cdda70f01fe193702eaa4c91f7b6a9c4 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 14 Oct 2025 23:37:39 -0700 Subject: [PATCH 05/28] Optimize RPC requests --- go.sum | 1 + storage/remote/store.go | 85 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/go.sum b/go.sum index 7111e191..c5b036af 100644 --- a/go.sum +++ b/go.sum @@ -504,6 +504,7 @@ github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= diff --git a/storage/remote/store.go b/storage/remote/store.go index 7ce941b2..72d368ba 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" + lru "github.com/hashicorp/golang-lru/v2" "github.com/onflow/flow-go/engine/common/rpc/convert" "github.com/onflow/flow-go/fvm/environment" "github.com/onflow/flow-go/fvm/errors" @@ -276,7 +277,18 @@ func (s *Store) LedgerByHeight( ctx context.Context, blockHeight uint64, ) (snapshot.StorageSnapshot, error) { + // Create a snapshot with LRU cache to avoid duplicate RPC calls within the same snapshot + // LRU cache with max 1000 entries to prevent memory bloat + cache, err := lru.New[string, flowgo.RegisterValue](1000) + if err != nil { + return nil, fmt.Errorf("failed to create LRU cache: %w", err) + } + return snapshot.NewReadFuncStorageSnapshot(func(id flowgo.RegisterID) (flowgo.RegisterValue, error) { + // Check LRU cache first to avoid duplicate RPC calls within this snapshot + if cachedValue, exists := cache.Get(id.String()); exists { + return cachedValue, nil + } // create a copy so updating it doesn't affect future calls lookupHeight := blockHeight @@ -378,6 +390,8 @@ func (s *Store) LedgerByHeight( return nil, fmt.Errorf("could not cache ledger value: %w", err) } + // Cache in LRU cache for this snapshot to avoid duplicate RPC calls + cache.Add(id.String(), value) return value, nil }), nil } @@ -458,6 +472,40 @@ func (s *Store) fetchRemoteRegister(ctx context.Context, owner string, key strin return nil, nil } +// COMPATIBILITY SHIM: Batch register fetching to reduce RPC calls +// TODO: Remove after Flow release - batches multiple register lookups into single RPC call +func (s *Store) fetchRemoteRegisters(ctx context.Context, owner string, keys []string, height uint64) (map[string][]byte, error) { + if len(keys) == 0 { + return make(map[string][]byte), nil + } + + registerIDs := make([]*entities.RegisterID, len(keys)) + for i, key := range keys { + registerIDs[i] = convert.RegisterIDToMessage(flowgo.RegisterID{Key: key, Owner: owner}) + } + + response, err := s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ + BlockHeight: height, + RegisterIds: registerIDs, + }) + if err != nil { + if status.Code(err) == codes.NotFound { + return make(map[string][]byte), nil + } + return nil, err + } + + result := make(map[string][]byte) + if response != nil && len(response.Values) > 0 { + for i, key := range keys { + if i < len(response.Values) && len(response.Values[i]) > 0 { + result[key] = response.Values[i] + } + } + } + return result, nil +} + // COMPATIBILITY SHIM: Batch public key synthesis // TODO: Remove after Flow release - builds batch public key payloads from individual public_key_* registers func (s *Store) synthesizeBatchPublicKeys(ctx context.Context, owner string, batchIndex uint32, height uint64) ([]byte, error) { @@ -496,16 +544,28 @@ func (s *Store) synthesizeBatchPublicKeys(ctx context.Context, owner string, bat batch = append(batch, 0x00) } + // Batch fetch all legacy public key registers for this batch to reduce RPC calls + legacyKeys := make([]string, 0, end-start+1) + for i := start; i <= end; i++ { + if i == 0 { + continue // skip key 0 + } + legacyKeys = append(legacyKeys, fmt.Sprintf("public_key_%d", i)) + } + + legacyValues, err := s.fetchRemoteRegisters(ctx, owner, legacyKeys, height) + if err != nil { + return nil, err + } + for i := start; i <= end; i++ { if i == 0 { // stored key 0 is apk_0 and not included in batch payload (nil placeholder already added) continue } legacyKey := fmt.Sprintf("public_key_%d", i) - legacyVal, err := s.fetchRemoteRegister(ctx, owner, legacyKey, height) - if err != nil { - return nil, err - } + legacyVal := legacyValues[legacyKey] + if len(legacyVal) == 0 { // keep index alignment with zero-length entry batch = append(batch, 0x00) @@ -628,15 +688,24 @@ func (s *Store) buildKeyMetadataFromLegacy(ctx context.Context, owner string, co return nil, nil } + // Batch fetch all legacy public key registers to reduce RPC calls + legacyKeys := make([]string, count-1) + for i := uint32(1); i < count; i++ { + legacyKeys[i-1] = fmt.Sprintf("public_key_%d", i) + } + + legacyValues, err := s.fetchRemoteRegisters(ctx, owner, legacyKeys, height) + if err != nil { + return nil, err + } + // Build weight and revoked status for keys 1..count-1 var weightAndRevoked []byte for i := uint32(1); i < count; i++ { legacyKey := fmt.Sprintf("public_key_%d", i) - legacyVal, err := s.fetchRemoteRegister(ctx, owner, legacyKey, height) - if err != nil { - return nil, err - } + legacyVal := legacyValues[legacyKey] + if len(legacyVal) == 0 { // Default values for missing keys weightAndRevoked = append(weightAndRevoked, 0, 0) // weight=0, revoked=false From 7c850076be2a731442d0a24c566fd21edd5cc498 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 14 Oct 2025 23:37:43 -0700 Subject: [PATCH 06/28] Add Mainnet Test --- server/fork_integration_test.go | 142 +++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/server/fork_integration_test.go b/server/fork_integration_test.go index c10fec66..3ff3951e 100644 --- a/server/fork_integration_test.go +++ b/server/fork_integration_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "strings" "testing" "github.com/onflow/flow-emulator/convert" @@ -14,8 +15,6 @@ import ( ) // TestForkingAgainstTestnet exercises the forking path by wiring a remote store -// We do not test Mainnet as at the time of writing Mainnet is not compatibible -// with the latest upstream Forte upgrade available in the latest emulator releases. func TestForkingAgainstTestnet(t *testing.T) { logger := zerolog.Nop() @@ -150,3 +149,142 @@ func TestForkingAgainstTestnet(t *testing.T) { } } } + +// TestForkingAgainstMainnet exercises the forking path with mainnet and tests the account key deduplication shim +func TestForkingAgainstMainnet(t *testing.T) { + logger := zerolog.Nop() + + // Get remote latest sealed height to pin fork + conn, err := grpc.NewClient("access.mainnet.nodes.onflow.org:9000", grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("dial remote: %v", err) + } + defer conn.Close() + remote := flowaccess.NewAccessAPIClient(conn) + rh, err := remote.GetLatestBlockHeader(context.Background(), &flowaccess.GetLatestBlockHeaderRequest{IsSealed: true}) + if err != nil { + t.Fatalf("get remote header: %v", err) + } + remoteHeight := rh.Block.Height + + cfg := &Config{ + // Do not start listeners; NewEmulatorServer only configures components. + DBPath: "", + Persist: false, + Snapshot: false, + SkipTransactionValidation: true, + ChainID: flowgo.Mainnet, // will be overridden by detectRemoteChainID + ForkURL: "access.mainnet.nodes.onflow.org:9000", + ForkBlockNumber: remoteHeight, + } + + srv := NewEmulatorServer(&logger, cfg) + if srv == nil { + t.Fatal("NewEmulatorServer returned nil") + } + + if cfg.ChainID != flowgo.Mainnet { + t.Fatalf("expected ChainID to be Mainnet after fork detection, got %q", cfg.ChainID) + } + + // Create an initial local block so we have a valid reference block ID in the forked store + if _, _, err := srv.Emulator().ExecuteAndCommitBlock(); err != nil { + t.Fatalf("prime local block: %v", err) + } + + // Test account key retrieval for a known mainnet account with multiple keys + // This tests the account key deduplication shim + testAccountScript := []byte(` + transaction { + prepare(acct: auth(Storage) &Account) { + // Test getting account keys for a known mainnet account + let account = getAccount(0xe467b9dd11fa00df) + + // Test accessing specific key indices + let key0 = account.keys.get(keyIndex: 0) + if key0 != nil { + log("Key 0 weight: ".concat(key0!.weight.toString())) + if key0!.isRevoked { + log("Key 0 is revoked") + } else { + log("Key 0 is not revoked") + } + } + + let key1 = account.keys.get(keyIndex: 1) + if key1 != nil { + log("Key 1 weight: ".concat(key1!.weight.toString())) + if key1!.isRevoked { + log("Key 1 is revoked") + } else { + log("Key 1 is not revoked") + } + } + + // Test that we can access keys without errors + log("Account key access test completed") + } + } + `) + + latest, err := srv.Emulator().GetLatestBlock() + if err != nil { + t.Fatalf("get latest block: %v", err) + } + // Allow emulator height to be equal to or one greater than remote (if remote advanced by one between queries) + if latest.Height != remoteHeight+1 { + t.Fatalf("fork height mismatch: emulator %d not in {remote, remote+1} where remote=%d", latest.Height, remoteHeight) + } + sk := srv.Emulator().ServiceKey() + + tx := flowsdk.NewTransaction(). + SetScript(testAccountScript). + SetReferenceBlockID(flowsdk.Identifier(latest.ID())). + SetProposalKey(flowsdk.Address(sk.Address), sk.Index, sk.SequenceNumber). + SetPayer(flowsdk.Address(sk.Address)). + AddAuthorizer(flowsdk.Address(sk.Address)) + + signer, err := sk.Signer() + if err != nil { + t.Fatalf("signer: %v", err) + } + if err := tx.SignEnvelope(flowsdk.Address(sk.Address), sk.Index, signer); err != nil { + t.Fatalf("sign envelope: %v", err) + } + if err := srv.Emulator().AddTransaction(*convert.SDKTransactionToFlow(*tx)); err != nil { + t.Fatalf("add tx: %v", err) + } + if _, results, err := srv.Emulator().ExecuteAndCommitBlock(); err != nil { + t.Fatalf("execute block: %v", err) + } else { + if len(results) != 1 { + t.Fatalf("expected 1 tx result, got %d", len(results)) + } + r := results[0] + if !r.Succeeded() { + t.Fatalf("tx failed: %v", r.Error) + } + + // Check that we got meaningful logs about the account keys + logs := r.Logs + hasKeyWeight := false + hasCompletion := false + for _, log := range logs { + if strings.Contains(log, "weight:") { + hasKeyWeight = true + } + if strings.Contains(log, "Account key access test completed") { + hasCompletion = true + } + } + + if !hasKeyWeight { + t.Fatalf("expected log with key weight, got: %v", logs) + } + if !hasCompletion { + t.Fatalf("expected completion log, got: %v", logs) + } + + t.Logf("Account key test successful. Logs: %v", logs) + } +} From f1fe23ee283a9b6ca659adcacb65f2e0c38a2bc9 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 00:04:50 -0700 Subject: [PATCH 07/28] Add retries for remote store & optimize requests --- go.mod | 2 +- go.sum | 1 - storage/remote/store.go | 225 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 216 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index b0c0bdff..c5775d4c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/go-dap v0.11.0 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/improbable-eng/grpc-web v0.15.0 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/onflow/cadence v1.7.1 @@ -93,7 +94,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect github.com/holiman/uint256 v1.3.2 // indirect diff --git a/go.sum b/go.sum index c5b036af..7111e191 100644 --- a/go.sum +++ b/go.sum @@ -504,7 +504,6 @@ github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= diff --git a/storage/remote/store.go b/storage/remote/store.go index 72d368ba..4f1ada97 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -22,8 +22,12 @@ import ( "context" "encoding/hex" "fmt" + "math" + "math/rand" "strconv" "strings" + "sync" + "time" lru "github.com/hashicorp/golang-lru/v2" "github.com/onflow/flow-go/engine/common/rpc/convert" @@ -45,6 +49,171 @@ import ( "github.com/onflow/flow-emulator/types" ) +// Retry and circuit breaker configuration +const ( + maxRetries = 5 + baseDelay = 100 * time.Millisecond + maxDelay = 30 * time.Second + jitterFactor = 0.1 + circuitTimeout = 30 * time.Second // Circuit breaker timeout +) + +// isRateLimitError checks if the error is a rate limiting error +func isRateLimitError(err error) bool { + if err == nil { + return false + } + + st, ok := status.FromError(err) + if !ok { + return false + } + + // Check for common rate limiting gRPC codes + switch st.Code() { + case codes.ResourceExhausted: + return true + case codes.Unavailable: + // Sometimes rate limits are returned as unavailable + return strings.Contains(st.Message(), "rate") || + strings.Contains(st.Message(), "limit") || + strings.Contains(st.Message(), "throttle") + case codes.Aborted: + // Some services return rate limits as aborted + return strings.Contains(st.Message(), "rate") || + strings.Contains(st.Message(), "limit") + } + + return false +} + +// exponentialBackoffWithJitter calculates delay with exponential backoff and jitter +func exponentialBackoffWithJitter(attempt int) time.Duration { + if attempt <= 0 { + return baseDelay + } + + // Calculate exponential delay: baseDelay * 2^(attempt-1) + delay := float64(baseDelay) * math.Pow(2, float64(attempt-1)) + + // Cap at maxDelay + if delay > float64(maxDelay) { + delay = float64(maxDelay) + } + + // Add jitter: ±10% random variation + jitter := delay * jitterFactor * (2*rand.Float64() - 1) + delay += jitter + + // Ensure minimum delay + if delay < float64(baseDelay) { + delay = float64(baseDelay) + } + + return time.Duration(delay) +} + +// retryWithBackoff executes a function with exponential backoff retry on rate limit errors +func (s *Store) retryWithBackoff(ctx context.Context, operation string, fn func() error) error { + var lastErr error + + for attempt := 0; attempt <= maxRetries; attempt++ { + // Check circuit breaker first + if !s.circuitBreaker.canMakeRequest() { + s.logger.Debug(). + Str("operation", operation). + Msg("Circuit breaker is open, skipping request") + return fmt.Errorf("circuit breaker is open") + } + + err := fn() + if err == nil { + s.circuitBreaker.recordSuccess() + return nil + } + + lastErr = err + + // Only record failures for rate limit errors + if isRateLimitError(err) { + s.circuitBreaker.recordFailure() + } + + // Check if this is a rate limit error + if isRateLimitError(err) { + s.logger.Info(). + Str("operation", operation). + Msg("Rate limit detected, will retry with backoff") + } + + // For all errors (including rate limits), continue with retry logic + if attempt == maxRetries { + s.logger.Warn(). + Str("operation", operation). + Int("attempt", attempt+1). + Err(err). + Msg("Request failed after max attempts") + return err + } + + // Calculate delay and wait + delay := exponentialBackoffWithJitter(attempt) + s.logger.Debug(). + Str("operation", operation). + Int("attempt", attempt+1). + Dur("delay", delay). + Err(err). + Msg("Request failed, retrying with backoff") + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + // Continue to next attempt + } + } + + return lastErr +} + +// circuitBreaker implements a simple circuit breaker pattern +type circuitBreaker struct { + mu sync.RWMutex + failures int + lastFail time.Time + timeout time.Duration +} + +// canMakeRequest checks if requests can be made (circuit breaker is closed) +func (cb *circuitBreaker) canMakeRequest() bool { + cb.mu.RLock() + defer cb.mu.RUnlock() + + // If we've had recent failures, check timeout + if cb.failures > 0 && time.Since(cb.lastFail) < cb.timeout { + return false + } + + return true +} + +// recordFailure records a failure and opens the circuit breaker +func (cb *circuitBreaker) recordFailure() { + cb.mu.Lock() + defer cb.mu.Unlock() + + cb.failures++ + cb.lastFail = time.Now() +} + +// recordSuccess records a success and closes the circuit breaker +func (cb *circuitBreaker) recordSuccess() { + cb.mu.Lock() + defer cb.mu.Unlock() + + cb.failures = 0 // Reset on success +} + type Store struct { *sqlite.Store executionClient executiondata.ExecutionDataAPIClient @@ -54,6 +223,7 @@ type Store struct { chainID flowgo.ChainID forkHeight uint64 logger *zerolog.Logger + circuitBreaker *circuitBreaker // COMPATIBILITY SHIM: Account Key Deduplication Migration // TODO: Remove after Flow release - this shim provides backward compatibility // for pre-migration networks by synthesizing migrated registers from legacy data @@ -111,6 +281,9 @@ func New(provider *sqlite.Store, logger *zerolog.Logger, options ...Option) (*St store := &Store{ Store: provider, logger: logger, + circuitBreaker: &circuitBreaker{ + timeout: circuitTimeout, + }, } for _, opt := range options { @@ -136,7 +309,12 @@ func New(provider *sqlite.Store, logger *zerolog.Logger, options ...Option) (*St store.accessClient = access.NewAccessAPIClient(conn) } - params, err := store.accessClient.GetNetworkParameters(context.Background(), &access.GetNetworkParametersRequest{}) + var params *access.GetNetworkParametersResponse + err := store.retryWithBackoff(context.Background(), "GetNetworkParameters", func() error { + var err error + params, err = store.accessClient.GetNetworkParameters(context.Background(), &access.GetNetworkParametersRequest{}) + return err + }) if err != nil { return nil, fmt.Errorf("could not get network parameters: %w", err) } @@ -182,7 +360,12 @@ func (s *Store) initializeStartBlock(ctx context.Context) error { // use the current latest block from the rpc host if no height was provided if s.forkHeight == 0 { - resp, err := s.accessClient.GetLatestBlockHeader(ctx, &access.GetLatestBlockHeaderRequest{IsSealed: true}) + var resp *access.BlockHeaderResponse + err := s.retryWithBackoff(ctx, "GetLatestBlockHeader", func() error { + var err error + resp, err = s.accessClient.GetLatestBlockHeader(ctx, &access.GetLatestBlockHeaderRequest{IsSealed: true}) + return err + }) if err != nil { return fmt.Errorf("could not get last block height: %w", err) } @@ -216,7 +399,12 @@ func (s *Store) BlockByID(ctx context.Context, blockID flowgo.Identifier) (*flow if err == nil { height = block.Height } else if errors.Is(err, storage.ErrNotFound) { - heightRes, err := s.accessClient.GetBlockHeaderByID(ctx, &access.GetBlockHeaderByIDRequest{Id: blockID[:]}) + var heightRes *access.BlockHeaderResponse + err := s.retryWithBackoff(ctx, "GetBlockHeaderByID", func() error { + var err error + heightRes, err = s.accessClient.GetBlockHeaderByID(ctx, &access.GetBlockHeaderByIDRequest{Id: blockID[:]}) + return err + }) if err != nil { return nil, err } @@ -256,7 +444,12 @@ func (s *Store) BlockByHeight(ctx context.Context, height uint64) (*flowgo.Block return nil, &types.BlockNotFoundByHeightError{Height: height} } - blockRes, err := s.accessClient.GetBlockHeaderByHeight(ctx, &access.GetBlockHeaderByHeightRequest{Height: height}) + var blockRes *access.BlockHeaderResponse + err = s.retryWithBackoff(ctx, "GetBlockHeaderByHeight", func() error { + var err error + blockRes, err = s.accessClient.GetBlockHeaderByHeight(ctx, &access.GetBlockHeaderByHeightRequest{Height: height}) + return err + }) if err != nil { return nil, err } @@ -313,10 +506,16 @@ func (s *Store) LedgerByHeight( } registerID := convert.RegisterIDToMessage(flowgo.RegisterID{Key: id.Key, Owner: id.Owner}) - response, err := s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ - BlockHeight: lookupHeight, - RegisterIds: []*entities.RegisterID{registerID}, + var response *executiondata.GetRegisterValuesResponse + + err = s.retryWithBackoff(ctx, "GetRegisterValues", func() error { + response, err = s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ + BlockHeight: lookupHeight, + RegisterIds: []*entities.RegisterID{registerID}, + }) + return err }) + if err != nil { if status.Code(err) != codes.NotFound { return nil, err @@ -484,10 +683,16 @@ func (s *Store) fetchRemoteRegisters(ctx context.Context, owner string, keys []s registerIDs[i] = convert.RegisterIDToMessage(flowgo.RegisterID{Key: key, Owner: owner}) } - response, err := s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ - BlockHeight: height, - RegisterIds: registerIDs, + var response *executiondata.GetRegisterValuesResponse + err := s.retryWithBackoff(ctx, "GetRegisterValuesBatch", func() error { + var err error + response, err = s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ + BlockHeight: height, + RegisterIds: registerIDs, + }) + return err }) + if err != nil { if status.Code(err) == codes.NotFound { return make(map[string][]byte), nil From 7f7bd634cb5c73b89c94e5c6024cac12d7b550c7 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 00:08:12 -0700 Subject: [PATCH 08/28] format --- server/utils/emulator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/emulator.go b/server/utils/emulator.go index f9103ba7..e560cd23 100644 --- a/server/utils/emulator.go +++ b/server/utils/emulator.go @@ -25,9 +25,9 @@ import ( "strconv" "github.com/gorilla/mux" - flowgo "github.com/onflow/flow-go/model/flow" "github.com/onflow/flow-emulator/adapters" "github.com/onflow/flow-emulator/emulator" + flowgo "github.com/onflow/flow-go/model/flow" ) type BlockResponse struct { From 103df083b9ba3b7fcb51407f0e6a6b4556c78740 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 00:09:26 -0700 Subject: [PATCH 09/28] add license --- server/fork_integration_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/fork_integration_test.go b/server/fork_integration_test.go index 3ff3951e..03ec6c06 100644 --- a/server/fork_integration_test.go +++ b/server/fork_integration_test.go @@ -1,3 +1,21 @@ +/* + * Flow Emulator + * + * Copyright 2019-2024 Dapper Labs, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package server import ( From c9f0678a9d849f03d8bd2c47f302ad805156f78e Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 00:54:15 -0700 Subject: [PATCH 10/28] cleanup --- cmd/emulator/start/start.go | 5 +- server/fork_integration_test.go | 119 +++++++++++--------------------- server/server.go | 2 +- storage/remote/store.go | 4 -- 4 files changed, 43 insertions(+), 87 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index ad034b0a..d41636db 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -162,9 +162,8 @@ func Cmd(config StartConfig) *cobra.Command { // If forking, ignore provided chain-id and detect from remote later in server if conf.ForkURL != "" { - if conf.ForkBlockNumber == 0 { - // default to latest sealed handled in remote store - } + // If ForkBlockNumber is 0, default to latest sealed handled in remote store + _ = conf.ForkBlockNumber } else { // Non-fork mode cannot accept deprecated fork-only flags if conf.StartBlockHeight > 0 || conf.ForkBlockNumber > 0 { diff --git a/server/fork_integration_test.go b/server/fork_integration_test.go index 03ec6c06..6b4a6f7f 100644 --- a/server/fork_integration_test.go +++ b/server/fork_integration_test.go @@ -20,9 +20,9 @@ package server import ( "context" - "strings" "testing" + "github.com/onflow/cadence" "github.com/onflow/flow-emulator/convert" flowsdk "github.com/onflow/flow-go-sdk" flowgo "github.com/onflow/flow-go/model/flow" @@ -41,7 +41,7 @@ func TestForkingAgainstTestnet(t *testing.T) { if err != nil { t.Fatalf("dial remote: %v", err) } - defer conn.Close() + defer func() { _ = conn.Close() }() remote := flowaccess.NewAccessAPIClient(conn) rh, err := remote.GetLatestBlockHeader(context.Background(), &flowaccess.GetLatestBlockHeaderRequest{IsSealed: true}) if err != nil { @@ -177,7 +177,7 @@ func TestForkingAgainstMainnet(t *testing.T) { if err != nil { t.Fatalf("dial remote: %v", err) } - defer conn.Close() + defer func() { _ = conn.Close() }() remote := flowaccess.NewAccessAPIClient(conn) rh, err := remote.GetLatestBlockHeader(context.Background(), &flowaccess.GetLatestBlockHeaderRequest{IsSealed: true}) if err != nil { @@ -211,37 +211,34 @@ func TestForkingAgainstMainnet(t *testing.T) { } // Test account key retrieval for a known mainnet account with multiple keys - // This tests the account key deduplication shim + // This tests the account key deduplication shim by executing a script that accesses + // keys and ensures no errors occur (successful execution proves the shim works) testAccountScript := []byte(` - transaction { - prepare(acct: auth(Storage) &Account) { - // Test getting account keys for a known mainnet account - let account = getAccount(0xe467b9dd11fa00df) - - // Test accessing specific key indices - let key0 = account.keys.get(keyIndex: 0) - if key0 != nil { - log("Key 0 weight: ".concat(key0!.weight.toString())) - if key0!.isRevoked { - log("Key 0 is revoked") - } else { - log("Key 0 is not revoked") - } - } - - let key1 = account.keys.get(keyIndex: 1) - if key1 != nil { - log("Key 1 weight: ".concat(key1!.weight.toString())) - if key1!.isRevoked { - log("Key 1 is revoked") - } else { - log("Key 1 is not revoked") - } - } - - // Test that we can access keys without errors - log("Account key access test completed") + access(all) fun main(): Bool { + // Test getting account keys for a known mainnet account + let account = getAccount(0xe467b9dd11fa00df) + + // Test accessing specific key indices + let key0 = account.keys.get(keyIndex: 0) + if key0 == nil { + return false + } + // Access weight and revoked status to test parsing + // (successful access without errors proves the shim works) + if key0!.weight < 0.0 || key0!.isRevoked == key0!.isRevoked { + // Just access the properties, don't actually test values + } + + let key1 = account.keys.get(keyIndex: 1) + if key1 == nil { + return false + } + // Access weight and revoked status to test parsing + if key1!.weight < 0.0 || key1!.isRevoked == key1!.isRevoked { + // Just access the properties, don't actually test values } + + return true } `) @@ -253,56 +250,20 @@ func TestForkingAgainstMainnet(t *testing.T) { if latest.Height != remoteHeight+1 { t.Fatalf("fork height mismatch: emulator %d not in {remote, remote+1} where remote=%d", latest.Height, remoteHeight) } - sk := srv.Emulator().ServiceKey() - - tx := flowsdk.NewTransaction(). - SetScript(testAccountScript). - SetReferenceBlockID(flowsdk.Identifier(latest.ID())). - SetProposalKey(flowsdk.Address(sk.Address), sk.Index, sk.SequenceNumber). - SetPayer(flowsdk.Address(sk.Address)). - AddAuthorizer(flowsdk.Address(sk.Address)) - - signer, err := sk.Signer() + // Execute the script to test account key retrieval + scriptResult, err := srv.Emulator().ExecuteScript(testAccountScript, nil) if err != nil { - t.Fatalf("signer: %v", err) + t.Fatalf("test script failed: %v", err) } - if err := tx.SignEnvelope(flowsdk.Address(sk.Address), sk.Index, signer); err != nil { - t.Fatalf("sign envelope: %v", err) - } - if err := srv.Emulator().AddTransaction(*convert.SDKTransactionToFlow(*tx)); err != nil { - t.Fatalf("add tx: %v", err) - } - if _, results, err := srv.Emulator().ExecuteAndCommitBlock(); err != nil { - t.Fatalf("execute block: %v", err) - } else { - if len(results) != 1 { - t.Fatalf("expected 1 tx result, got %d", len(results)) - } - r := results[0] - if !r.Succeeded() { - t.Fatalf("tx failed: %v", r.Error) - } - - // Check that we got meaningful logs about the account keys - logs := r.Logs - hasKeyWeight := false - hasCompletion := false - for _, log := range logs { - if strings.Contains(log, "weight:") { - hasKeyWeight = true - } - if strings.Contains(log, "Account key access test completed") { - hasCompletion = true - } - } - if !hasKeyWeight { - t.Fatalf("expected log with key weight, got: %v", logs) - } - if !hasCompletion { - t.Fatalf("expected completion log, got: %v", logs) - } + if !scriptResult.Succeeded() { + t.Fatalf("test script error: %v", scriptResult.Error) + } - t.Logf("Account key test successful. Logs: %v", logs) + // Check that the script returned true (all verifications passed) + if scriptResult.Value != cadence.Bool(true) { + t.Fatalf("test script returned %v, expected true", scriptResult.Value) } + + t.Logf("Account key test successful. Script result: %v", scriptResult.Value) } diff --git a/server/server.go b/server/server.go index 428b67db..7974786b 100644 --- a/server/server.go +++ b/server/server.go @@ -269,7 +269,7 @@ func detectRemoteChainID(url string) (flowgo.ChainID, error) { if err != nil { return "", err } - defer conn.Close() + defer func() { _ = conn.Close() }() client := flowaccess.NewAccessAPIClient(conn) resp, err := client.GetNetworkParameters(context.Background(), &flowaccess.GetNetworkParametersRequest{}) if err != nil { diff --git a/storage/remote/store.go b/storage/remote/store.go index 4f1ada97..4735e52b 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -236,10 +236,6 @@ type Option func(*Store) // Expects raw host:port with no scheme. func WithForkURL(url string) Option { return func(store *Store) { - // enforce raw host:port only - if strings.Contains(url, "://") { - // keep as-is; the New() will error when dialing - } store.host = url } } From d5c5b305672964824877767b5a229cae0260085a Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 01:44:12 -0700 Subject: [PATCH 11/28] rename flags & format --- cmd/emulator/start/start.go | 36 ++++++++++++++++----------------- server/fork_integration_test.go | 8 ++++---- server/server.go | 16 +++++++-------- storage/remote/store.go | 16 +++++++-------- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index d41636db..ced84d9e 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -75,11 +75,11 @@ type Config struct { SqliteURL string `default:"" flag:"sqlite-url" info:"sqlite db URL for persisting sqlite storage backend "` CoverageReportingEnabled bool `default:"false" flag:"coverage-reporting" info:"enable Cadence code coverage reporting"` LegacyContractUpgradeEnabled bool `default:"false" flag:"legacy-upgrade" info:"enable Cadence legacy contract upgrade"` - ForkURL string `default:"" flag:"fork-url" info:"gRPC access node address (host:port) to fork from"` - ForkBlockNumber uint64 `default:"0" flag:"fork-block-number" info:"block number/height to pin fork; defaults to latest sealed"` + ForkHost string `default:"" flag:"fork-host" info:"gRPC access node address (host:port) to fork from"` + ForkHeight uint64 `default:"0" flag:"fork-height" info:"height to pin fork; defaults to latest sealed"` // Deprecated hidden aliases - StartBlockHeight uint64 `default:"0" flag:"start-block-height" info:"(deprecated) use --fork-block-number"` - RPCHost string `default:"" flag:"rpc-host" info:"(deprecated) use --fork-url"` + StartBlockHeight uint64 `default:"0" flag:"start-block-height" info:"(deprecated) use --fork-height"` + RPCHost string `default:"" flag:"rpc-host" info:"(deprecated) use --fork-host"` CheckpointPath string `default:"" flag:"checkpoint-dir" info:"checkpoint directory to load the emulator state from"` StateHash string `default:"" flag:"state-hash" info:"state hash of the checkpoint to load the emulator state from"` ComputationReportingEnabled bool `default:"false" flag:"computation-reporting" info:"enable Cadence computation reporting"` @@ -151,23 +151,23 @@ func Cmd(config StartConfig) *cobra.Command { } // Deprecation shims: map old flags to new and warn - if conf.RPCHost != "" && conf.ForkURL == "" { - logger.Warn().Msg("❗ --rpc-host is deprecated; use --fork-url") - conf.ForkURL = conf.RPCHost + if conf.RPCHost != "" && conf.ForkHost == "" { + logger.Warn().Msg("❗ --rpc-host is deprecated; use --fork-host") + conf.ForkHost = conf.RPCHost } - if conf.StartBlockHeight > 0 && conf.ForkBlockNumber == 0 { - logger.Warn().Msg("❗ --start-block-height is deprecated; use --fork-block-number") - conf.ForkBlockNumber = conf.StartBlockHeight + if conf.StartBlockHeight > 0 && conf.ForkHeight == 0 { + logger.Warn().Msg("❗ --start-block-height is deprecated; use --fork-height") + conf.ForkHeight = conf.StartBlockHeight } // If forking, ignore provided chain-id and detect from remote later in server - if conf.ForkURL != "" { - // If ForkBlockNumber is 0, default to latest sealed handled in remote store - _ = conf.ForkBlockNumber + if conf.ForkHost != "" { + // If ForkHeight is 0, default to latest sealed handled in remote store + _ = conf.ForkHeight } else { // Non-fork mode cannot accept deprecated fork-only flags - if conf.StartBlockHeight > 0 || conf.ForkBlockNumber > 0 { - Exit(1, "❗ --fork-block-number requires --fork-url") + if conf.StartBlockHeight > 0 || conf.ForkHeight > 0 { + Exit(1, "❗ --fork-height requires --fork-host") } } @@ -233,8 +233,8 @@ func Cmd(config StartConfig) *cobra.Command { ContractRemovalEnabled: conf.ContractRemovalEnabled, SqliteURL: conf.SqliteURL, CoverageReportingEnabled: conf.CoverageReportingEnabled, - ForkURL: conf.ForkURL, - ForkBlockNumber: conf.ForkBlockNumber, + ForkHost: conf.ForkHost, + ForkHeight: conf.ForkHeight, CheckpointPath: conf.CheckpointPath, StateHash: conf.StateHash, ComputationReportingEnabled: conf.ComputationReportingEnabled, @@ -261,7 +261,7 @@ func Cmd(config StartConfig) *cobra.Command { _ = cmd.PersistentFlags().MarkHidden("rpc-host") _ = cmd.PersistentFlags().MarkDeprecated("rpc-host", "use --fork-url") _ = cmd.PersistentFlags().MarkHidden("start-block-height") - _ = cmd.PersistentFlags().MarkDeprecated("start-block-height", "use --fork-block-number") + _ = cmd.PersistentFlags().MarkDeprecated("start-block-height", "use --fork-height") return cmd } diff --git a/server/fork_integration_test.go b/server/fork_integration_test.go index 6b4a6f7f..e443e978 100644 --- a/server/fork_integration_test.go +++ b/server/fork_integration_test.go @@ -56,8 +56,8 @@ func TestForkingAgainstTestnet(t *testing.T) { Snapshot: false, SkipTransactionValidation: true, ChainID: flowgo.Testnet, // will be overridden by detectRemoteChainID - ForkURL: "access.testnet.nodes.onflow.org:9000", - ForkBlockNumber: remoteHeight, + ForkHost: "access.testnet.nodes.onflow.org:9000", + ForkHeight: remoteHeight, } srv := NewEmulatorServer(&logger, cfg) @@ -192,8 +192,8 @@ func TestForkingAgainstMainnet(t *testing.T) { Snapshot: false, SkipTransactionValidation: true, ChainID: flowgo.Mainnet, // will be overridden by detectRemoteChainID - ForkURL: "access.mainnet.nodes.onflow.org:9000", - ForkBlockNumber: remoteHeight, + ForkHost: "access.mainnet.nodes.onflow.org:9000", + ForkHeight: remoteHeight, } srv := NewEmulatorServer(&logger, cfg) diff --git a/server/server.go b/server/server.go index 7974786b..c134179d 100644 --- a/server/server.go +++ b/server/server.go @@ -141,10 +141,10 @@ type Config struct { SqliteURL string // CoverageReportingEnabled enables/disables Cadence code coverage reporting. CoverageReportingEnabled bool - // ForkURL is the gRPC access node address to fork from (host:port). - ForkURL string - // ForkBlockNumber is the height at which to start the emulator when forking. - ForkBlockNumber uint64 + // ForkHost is the gRPC access node address to fork from (host:port). + ForkHost string + // ForkHeight is the height at which to start the emulator when forking. + ForkHeight uint64 // CheckpointPath is the path to the checkpoint folder to use when starting the network on top of existing state. // StateHash should be provided as well. CheckpointPath string @@ -416,7 +416,7 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto } } - if conf.ForkURL != "" { + if conf.ForkHost != "" { // TODO: any reason redis shouldn't work? baseProvider, ok := storageProvider.(*sqlite.Store) if !ok { @@ -424,8 +424,8 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto } provider, err := remote.New(baseProvider, logger, - remote.WithForkURL(conf.ForkURL), - remote.WithForkBlockNumber(conf.ForkBlockNumber), + remote.WithForkHost(conf.ForkHost), + remote.WithForkHeight(conf.ForkHeight), ) if err != nil { return nil, err @@ -434,7 +434,7 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto // detect and override chain ID from remote parameters (no dependency on store internals) // TODO: do not mutate conf here; derive chain ID immutably during setup - if parsed, err := detectRemoteChainID(conf.ForkURL); err == nil { + if parsed, err := detectRemoteChainID(conf.ForkHost); err == nil { conf.ChainID = parsed } } diff --git a/storage/remote/store.go b/storage/remote/store.go index 4735e52b..97ebe607 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -232,15 +232,15 @@ type Store struct { type Option func(*Store) -// WithForkURL configures the remote access/observer node gRPC endpoint. +// WithForkHost configures the remote access/observer node gRPC endpoint. // Expects raw host:port with no scheme. -func WithForkURL(url string) Option { +func WithForkHost(host string) Option { return func(store *Store) { - store.host = url + store.host = host } } -// WithRPCHost sets access/observer node host. Deprecated: use WithForkURL. +// WithRPCHost sets access/observer node host. Deprecated: use WithForkHost. func WithRPCHost(host string, chainID flowgo.ChainID) Option { return func(store *Store) { // Keep legacy behavior: set host and (optionally) chainID for validation. @@ -250,15 +250,15 @@ func WithRPCHost(host string, chainID flowgo.ChainID) Option { } // WithStartBlockHeight sets the start height for the store. -// WithForkBlockNumber sets the pinned fork block/height. -func WithForkBlockNumber(height uint64) Option { +// WithForkHeight sets the pinned fork height. +func WithForkHeight(height uint64) Option { return func(store *Store) { store.forkHeight = height } } -// WithStartBlockHeight is deprecated: use WithForkBlockNumber. -func WithStartBlockHeight(height uint64) Option { return WithForkBlockNumber(height) } +// WithStartBlockHeight is deprecated: use WithForkHeight. +func WithStartBlockHeight(height uint64) Option { return WithForkHeight(height) } // WithClient can set an rpc host client // From 2c8b4ca242a5afab17e20d26be79f4dfc1aa78f4 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 15:05:52 -0700 Subject: [PATCH 12/28] Add ConfigureServer hook --- cmd/emulator/start/start.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index ced84d9e..65142374 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -103,6 +103,7 @@ type HttpMiddleware func(http.Handler) http.Handler type StartConfig struct { GetServiceKey serviceKeyFunc RestMiddlewares []HttpMiddleware + ConfigureServer func(server *server.Config) error } func Cmd(config StartConfig) *cobra.Command { @@ -243,6 +244,14 @@ func Cmd(config StartConfig) *cobra.Command { SetupVMBridgeEnabled: conf.SetupVMBridgeEnabled, } + // Allow caller to customize the server configuration before startup + if config.ConfigureServer != nil { + err := config.ConfigureServer(serverConf) + if err != nil { + Exit(1, err.Error()) + } + } + emu := server.NewEmulatorServer(logger, serverConf) if emu != nil { for _, middleware := range config.RestMiddlewares { From 351f218fa1f9dfc4e4ac83c2661d7ca8b11cd3fc Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 15:40:05 -0700 Subject: [PATCH 13/28] fix emu logs --- cmd/emulator/start/start.go | 106 +++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 20 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index 65142374..05348241 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -19,6 +19,7 @@ package start import ( + "context" "encoding/hex" "fmt" "log" @@ -36,6 +37,10 @@ import ( "github.com/rs/zerolog" "github.com/spf13/cobra" + flowaccess "github.com/onflow/flow/protobuf/go/flow/access" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "github.com/onflow/flow-emulator/server" ) @@ -161,6 +166,21 @@ func Cmd(config StartConfig) *cobra.Command { conf.ForkHeight = conf.StartBlockHeight } + // Pre-hook: allow higher-level wrapper to provide defaults before emulator consumes config + if config.ConfigureServer != nil { + preConf := &server.Config{ForkHost: conf.ForkHost, ForkHeight: conf.ForkHeight} + if err := config.ConfigureServer(preConf); err != nil { + Exit(1, err.Error()) + } + // Apply only if not set by flags/env (flags/env take precedence) + if conf.ForkHost == "" && preConf.ForkHost != "" { + conf.ForkHost = preConf.ForkHost + } + if conf.ForkHeight == 0 && preConf.ForkHeight != 0 { + conf.ForkHeight = preConf.ForkHeight + } + } + // If forking, ignore provided chain-id and detect from remote later in server if conf.ForkHost != "" { // If ForkHeight is 0, default to latest sealed handled in remote store @@ -172,23 +192,8 @@ func Cmd(config StartConfig) *cobra.Command { } } - serviceAddress := flowsdk.ServiceAddress(flowsdk.ChainID(flowChainID)) - if conf.SimpleAddresses { - serviceAddress = flowsdk.HexToAddress("0x1") - } - - serviceFields := map[string]any{ - "serviceAddress": serviceAddress.Hex(), - "servicePubKey": hex.EncodeToString(servicePublicKey.Encode()), - "serviceSigAlgo": serviceKeySigAlgo.String(), - "serviceHashAlgo": serviceKeyHashAlgo.String(), - } - - if servicePrivateKey != nil { - serviceFields["servicePrivKey"] = hex.EncodeToString(servicePrivateKey.Encode()) - } - - logger.Info().Fields(serviceFields).Msgf("⚙️ Using service account 0x%s", serviceAddress.Hex()) + // Service account logging is deferred until after server configuration to allow + // higher-level wrappers to customize fork settings via ConfigureServer. minimumStorageReservation := fvm.DefaultMinimumStorageReservation if conf.MinimumAccountBalance != "" { @@ -244,14 +249,46 @@ func Cmd(config StartConfig) *cobra.Command { SetupVMBridgeEnabled: conf.SetupVMBridgeEnabled, } - // Allow caller to customize the server configuration before startup + // Post-hook support remains for advanced scenarios; typically pre-hook above is preferred. if config.ConfigureServer != nil { - err := config.ConfigureServer(serverConf) - if err != nil { + if err := config.ConfigureServer(serverConf); err != nil { Exit(1, err.Error()) } } + // Decide fork mode after possible customization by ConfigureServer + forkMode := serverConf.ForkHost != "" + + // Recompute chain ID and service address accurately for fork mode by querying the node. + serviceAddress := flowsdk.ServiceAddress(flowsdk.ChainID(flowChainID)) + if forkMode { + if parsed, err := detectRemoteChainIDStart(serverConf.ForkHost); err == nil { + // Use remote chain ID semantics, but service account remains local overlay. + serviceAddress = flowsdk.ServiceAddress(flowsdk.ChainID(parsed)) + } + } + if conf.SimpleAddresses { + serviceAddress = flowsdk.HexToAddress("0x1") + } + + serviceFields := map[string]any{ + "serviceAddress": serviceAddress.Hex(), + "servicePubKey": hex.EncodeToString(servicePublicKey.Encode()), + "serviceSigAlgo": serviceKeySigAlgo.String(), + "serviceHashAlgo": serviceKeyHashAlgo.String(), + } + + if servicePrivateKey != nil { + serviceFields["servicePrivKey"] = hex.EncodeToString(servicePrivateKey.Encode()) + } + + if forkMode { + logger.Info().Fields(serviceFields).Msgf("⚙️ Using local overlay service account 0x%s", serviceAddress.Hex()) + logger.Info().Msgf("Using fork host %s", serverConf.ForkHost) + } else { + logger.Info().Fields(serviceFields).Msgf("⚙️ Using service account 0x%s", serviceAddress.Hex()) + } + emu := server.NewEmulatorServer(logger, serverConf) if emu != nil { for _, middleware := range config.RestMiddlewares { @@ -345,6 +382,35 @@ func getSDKChainID(chainID string) (flowgo.ChainID, error) { } } +// parseFlowChainIDStart maps string chain IDs to flowgo.ChainID for start package use +func parseFlowChainIDStart(id string) (flowgo.ChainID, error) { + switch id { + case flowgo.Mainnet.String(): + return flowgo.Mainnet, nil + case flowgo.Testnet.String(): + return flowgo.Testnet, nil + case flowgo.Emulator.String(): + return flowgo.Emulator, nil + default: + return "", fmt.Errorf("unknown chain id: %s", id) + } +} + +// detectRemoteChainIDStart connects to the remote access node and fetches network parameters to obtain the chain ID. +func detectRemoteChainIDStart(url string) (flowgo.ChainID, error) { + conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return "", err + } + defer func() { _ = conn.Close() }() + client := flowaccess.NewAccessAPIClient(conn) + resp, err := client.GetNetworkParameters(context.Background(), &flowaccess.GetNetworkParametersRequest{}) + if err != nil { + return "", err + } + return parseFlowChainIDStart(resp.ChainId) +} + func checkKeyAlgorithms(sigAlgo crypto.SignatureAlgorithm, hashAlgo crypto.HashAlgorithm) { if sigAlgo == crypto.UnknownSignatureAlgorithm { Exit(1, "Must specify service key signature algorithm (e.g. --service-sig-algo=ECDSA_P256)") From 19039a1c0d5f10694f6e65a15f0e441d30acda84 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 16:36:17 -0700 Subject: [PATCH 14/28] update naming --- README.md | 12 ++++++------ cmd/emulator/start/start.go | 2 +- docs/overview.md | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5955b53d..5fab00ad 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ values. | `--host` | `FLOW_HOST` | ` ` | Host to listen on for emulator GRPC/REST/Admin servers (default: All Interfaces) | | `--chain-id` | `FLOW_CHAINID` | `emulator` | Chain to simulate, if 'mainnet' or 'testnet' values are used, you will be able to run transactions against that network and a local fork will be created. Valid values are: 'emulator', 'testnet', 'mainnet' | | `--redis-url` | `FLOW_REDIS_URL` | '' | Redis-server URL for persisting redis storage backend ( `redis://[[username:]password@]host[:port][/database]` ) | -| `--fork-url` | `FLOW_FORKURL` | '' | gRPC access node address (`host:port`) to fork from | -| `--fork-block-number` | `FLOW_FORKBLOCKNUMBER` | `0` | Block number/height to pin the fork (defaults to latest sealed) | +| `--fork-host` | `FLOW_FORK_HOST` | '' | gRPC access node address (`host:port`) to fork from | +| `--fork-height` | `FLOW_FORK_HEIGHT` | `0` | Block height to pin the fork (defaults to latest sealed) | | `--legacy-upgrade` | `FLOW_LEGACYUPGRADE` | `false` | Enable upgrading of legacy contracts | | `--computation-reporting` | `FLOW_COMPUTATIONREPORTING` | `false` | Enable computation reporting for Cadence scripts & transactions | | `--checkpoint-dir` | `FLOW_CHECKPOINTDIR` | '' | Checkpoint directory to load the emulator state from, if starting the emulator from a checkpoint | @@ -155,7 +155,7 @@ Post Data: height={block height} ``` Note: it is only possible to roll back state to a height that was previously executed by the emulator. -To pin the starting block height when using a fork, use the `--fork-block-number` flag. +To pin the starting block height when using a fork, use the `--fork-height` flag. ## Managing emulator state It's possible to manage emulator state by using the admin API. You can at any point @@ -268,14 +268,14 @@ you must specify the network name for the chain ID flag and the RPC host to connect to. ``` -flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 -flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 --fork-block-number 12345 +flow emulator --fork-host access.mainnet.nodes.onflow.org:9000 +flow emulator --fork-host access.mainnet.nodes.onflow.org:9000 --fork-height 12345 ``` Please note, that the actual execution on the real network may differ depending on the exact state when the transaction is executed. -By default, the forked network will start from the latest sealed block when the emulator is started. You can specify a different starting block height by using the `--fork-block-number` flag. +By default, the forked network will start from the latest sealed block when the emulator is started. You can specify a different starting block height by using the `--fork-height` flag. You can also store all of your changes and cached registers to a persistent db by using the `--persist` flag, along with the other SQLite settings. diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index 05348241..555afa55 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -305,7 +305,7 @@ func Cmd(config StartConfig) *cobra.Command { // Hide and deprecate legacy flags while keeping them functional _ = cmd.PersistentFlags().MarkHidden("rpc-host") - _ = cmd.PersistentFlags().MarkDeprecated("rpc-host", "use --fork-url") + _ = cmd.PersistentFlags().MarkDeprecated("rpc-host", "use --fork-host") _ = cmd.PersistentFlags().MarkHidden("start-block-height") _ = cmd.PersistentFlags().MarkDeprecated("start-block-height", "use --fork-height") diff --git a/docs/overview.md b/docs/overview.md index fcc7a61c..b66b091f 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -73,8 +73,8 @@ values. | `--host` | `FLOW_HOST` | ` ` | Host to listen on for emulator GRPC/REST/Admin servers (default: all interfaces) | | `--chain-id` | `FLOW_CHAINID` | `emulator` | Chain to emulate for address generation. Valid values are: 'emulator', 'testnet', 'mainnet' | | `--redis-url` | `FLOW_REDIS_URL` | '' | Redis-server URL for persisting redis storage backend ( `redis://[[username:]password@]host[:port][/database]` ) | -| `--fork-url` | `FLOW_FORKURL` | '' | gRPC access node address (`host:port`) to fork from | -| `--fork-block-number` | `FLOW_FORKBLOCKNUMBER` | `0` | Block number/height to pin the fork (defaults to latest sealed) | +| `--fork-host` | `FLOW_FORK_HOST` | '' | gRPC access node address (`host:port`) to fork from | +| `--fork-height` | `FLOW_FORK_HEIGHT` | `0` | Block height to pin the fork (defaults to latest sealed) | ## Running the emulator with the Flow CLI @@ -149,7 +149,7 @@ Post Data: height={block height} ``` Note: it is only possible to roll back state to a height that was previously executed by the emulator. -To pin the starting block height when using a fork, use the `--fork-block-number` flag. +To pin the starting block height when using a fork, use the `--fork-height` flag. ## Managing emulator state @@ -245,13 +245,13 @@ you must specify the network name for the chain ID flag as well as the RPC host to connect to. ``` -flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 -flow emulator --fork-url access.mainnet.nodes.onflow.org:9000 --fork-block-number 12345 +flow emulator --fork-host access.mainnet.nodes.onflow.org:9000 +flow emulator --fork-host access.mainnet.nodes.onflow.org:9000 --fork-height 12345 ``` Please note, the actual execution on the real network may differ depending on the exact state when the transaction is executed. -By default, the forked network will start from the latest sealed block when the emulator is started. You can specify a different starting block height by using the `--fork-block-number` flag. +By default, the forked network will start from the latest sealed block when the emulator is started. You can specify a different starting block height by using the `--fork-height` flag. You can also store all of your changes and cached registers to a persistent db by using the `--persist` flag, along with the other sqlite settings. From bca6b85fa20f7aa00bc6099b87b49fe9b7491528 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 16:53:34 -0700 Subject: [PATCH 15/28] switch syntax and reduce code --- cmd/emulator/start/start.go | 66 ++++++++----------------------------- server/server.go | 4 +-- 2 files changed, 15 insertions(+), 55 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index 555afa55..32c62b59 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -19,7 +19,6 @@ package start import ( - "context" "encoding/hex" "fmt" "log" @@ -37,10 +36,6 @@ import ( "github.com/rs/zerolog" "github.com/spf13/cobra" - flowaccess "github.com/onflow/flow/protobuf/go/flow/access" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "github.com/onflow/flow-emulator/server" ) @@ -82,15 +77,16 @@ type Config struct { LegacyContractUpgradeEnabled bool `default:"false" flag:"legacy-upgrade" info:"enable Cadence legacy contract upgrade"` ForkHost string `default:"" flag:"fork-host" info:"gRPC access node address (host:port) to fork from"` ForkHeight uint64 `default:"0" flag:"fork-height" info:"height to pin fork; defaults to latest sealed"` + CheckpointPath string `default:"" flag:"checkpoint-dir" info:"checkpoint directory to load the emulator state from"` + StateHash string `default:"" flag:"state-hash" info:"state hash of the checkpoint to load the emulator state from"` + ComputationReportingEnabled bool `default:"false" flag:"computation-reporting" info:"enable Cadence computation reporting"` + ScheduledTransactionsEnabled bool `default:"true" flag:"scheduled-transactions" info:"enable Cadence scheduled transactions"` + SetupEVMEnabled bool `default:"true" flag:"setup-evm" info:"enable EVM setup for the emulator, this will deploy the EVM contracts"` + SetupVMBridgeEnabled bool `default:"true" flag:"setup-vm-bridge" info:"enable VM Bridge setup for the emulator, this will deploy the VM Bridge contracts"` + // Deprecated hidden aliases - StartBlockHeight uint64 `default:"0" flag:"start-block-height" info:"(deprecated) use --fork-height"` - RPCHost string `default:"" flag:"rpc-host" info:"(deprecated) use --fork-host"` - CheckpointPath string `default:"" flag:"checkpoint-dir" info:"checkpoint directory to load the emulator state from"` - StateHash string `default:"" flag:"state-hash" info:"state hash of the checkpoint to load the emulator state from"` - ComputationReportingEnabled bool `default:"false" flag:"computation-reporting" info:"enable Cadence computation reporting"` - ScheduledTransactionsEnabled bool `default:"true" flag:"scheduled-transactions" info:"enable Cadence scheduled transactions"` - SetupEVMEnabled bool `default:"true" flag:"setup-evm" info:"enable EVM setup for the emulator, this will deploy the EVM contracts"` - SetupVMBridgeEnabled bool `default:"true" flag:"setup-vm-bridge" info:"enable VM Bridge setup for the emulator, this will deploy the VM Bridge contracts"` + StartBlockHeight uint64 `default:"0" flag:"start-block-height" info:"(deprecated) use --fork-height"` + RPCHost string `default:"" flag:"rpc-host" info:"(deprecated) use --fork-host"` } const EnvPrefix = "FLOW" @@ -166,30 +162,9 @@ func Cmd(config StartConfig) *cobra.Command { conf.ForkHeight = conf.StartBlockHeight } - // Pre-hook: allow higher-level wrapper to provide defaults before emulator consumes config - if config.ConfigureServer != nil { - preConf := &server.Config{ForkHost: conf.ForkHost, ForkHeight: conf.ForkHeight} - if err := config.ConfigureServer(preConf); err != nil { - Exit(1, err.Error()) - } - // Apply only if not set by flags/env (flags/env take precedence) - if conf.ForkHost == "" && preConf.ForkHost != "" { - conf.ForkHost = preConf.ForkHost - } - if conf.ForkHeight == 0 && preConf.ForkHeight != 0 { - conf.ForkHeight = preConf.ForkHeight - } - } - - // If forking, ignore provided chain-id and detect from remote later in server - if conf.ForkHost != "" { - // If ForkHeight is 0, default to latest sealed handled in remote store - _ = conf.ForkHeight - } else { - // Non-fork mode cannot accept deprecated fork-only flags - if conf.StartBlockHeight > 0 || conf.ForkHeight > 0 { - Exit(1, "❗ --fork-height requires --fork-host") - } + // In non-fork mode, fork-only flags are invalid + if conf.ForkHost == "" && (conf.StartBlockHeight > 0 || conf.ForkHeight > 0) { + Exit(1, "❗ --fork-height requires --fork-host") } // Service account logging is deferred until after server configuration to allow @@ -262,7 +237,7 @@ func Cmd(config StartConfig) *cobra.Command { // Recompute chain ID and service address accurately for fork mode by querying the node. serviceAddress := flowsdk.ServiceAddress(flowsdk.ChainID(flowChainID)) if forkMode { - if parsed, err := detectRemoteChainIDStart(serverConf.ForkHost); err == nil { + if parsed, err := server.DetectRemoteChainID(serverConf.ForkHost); err == nil { // Use remote chain ID semantics, but service account remains local overlay. serviceAddress = flowsdk.ServiceAddress(flowsdk.ChainID(parsed)) } @@ -396,21 +371,6 @@ func parseFlowChainIDStart(id string) (flowgo.ChainID, error) { } } -// detectRemoteChainIDStart connects to the remote access node and fetches network parameters to obtain the chain ID. -func detectRemoteChainIDStart(url string) (flowgo.ChainID, error) { - conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - return "", err - } - defer func() { _ = conn.Close() }() - client := flowaccess.NewAccessAPIClient(conn) - resp, err := client.GetNetworkParameters(context.Background(), &flowaccess.GetNetworkParametersRequest{}) - if err != nil { - return "", err - } - return parseFlowChainIDStart(resp.ChainId) -} - func checkKeyAlgorithms(sigAlgo crypto.SignatureAlgorithm, hashAlgo crypto.HashAlgorithm) { if sigAlgo == crypto.UnknownSignatureAlgorithm { Exit(1, "Must specify service key signature algorithm (e.g. --service-sig-algo=ECDSA_P256)") diff --git a/server/server.go b/server/server.go index c134179d..f65c7be3 100644 --- a/server/server.go +++ b/server/server.go @@ -263,7 +263,7 @@ func parseFlowChainID(id string) (flowgo.ChainID, error) { } // detectRemoteChainID connects to the remote access node and fetches network parameters to obtain the chain ID. -func detectRemoteChainID(url string) (flowgo.ChainID, error) { +func DetectRemoteChainID(url string) (flowgo.ChainID, error) { // Expect raw host:port conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { @@ -434,7 +434,7 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto // detect and override chain ID from remote parameters (no dependency on store internals) // TODO: do not mutate conf here; derive chain ID immutably during setup - if parsed, err := detectRemoteChainID(conf.ForkHost); err == nil { + if parsed, err := DetectRemoteChainID(conf.ForkHost); err == nil { conf.ChainID = parsed } } From 51778f44c5492a6e0453da952e14e00c4af67725 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 17:16:09 -0700 Subject: [PATCH 16/28] cleanup --- cmd/emulator/start/start.go | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index 32c62b59..6b76f0e7 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -104,7 +104,6 @@ type HttpMiddleware func(http.Handler) http.Handler type StartConfig struct { GetServiceKey serviceKeyFunc RestMiddlewares []HttpMiddleware - ConfigureServer func(server *server.Config) error } func Cmd(config StartConfig) *cobra.Command { @@ -224,24 +223,17 @@ func Cmd(config StartConfig) *cobra.Command { SetupVMBridgeEnabled: conf.SetupVMBridgeEnabled, } - // Post-hook support remains for advanced scenarios; typically pre-hook above is preferred. - if config.ConfigureServer != nil { - if err := config.ConfigureServer(serverConf); err != nil { - Exit(1, err.Error()) - } - } - - // Decide fork mode after possible customization by ConfigureServer - forkMode := serverConf.ForkHost != "" - // Recompute chain ID and service address accurately for fork mode by querying the node. - serviceAddress := flowsdk.ServiceAddress(flowsdk.ChainID(flowChainID)) + resolvedChainID := flowChainID + forkMode := serverConf.ForkHost != "" if forkMode { - if parsed, err := server.DetectRemoteChainID(serverConf.ForkHost); err == nil { - // Use remote chain ID semantics, but service account remains local overlay. - serviceAddress = flowsdk.ServiceAddress(flowsdk.ChainID(parsed)) + parsed, err := server.DetectRemoteChainID(serverConf.ForkHost) + if err != nil { + Exit(1, fmt.Sprintf("failed to detect remote chain id from %s: %v", serverConf.ForkHost, err)) } + resolvedChainID = parsed } + serviceAddress := flowsdk.ServiceAddress(flowsdk.ChainID(resolvedChainID)) if conf.SimpleAddresses { serviceAddress = flowsdk.HexToAddress("0x1") } From 85731ef4832da5478c11003be38a7a75c414eaf6 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 15 Oct 2025 17:39:30 -0700 Subject: [PATCH 17/28] tidy logs --- cmd/emulator/start/start.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index 6b76f0e7..90794c3b 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -249,12 +249,7 @@ func Cmd(config StartConfig) *cobra.Command { serviceFields["servicePrivKey"] = hex.EncodeToString(servicePrivateKey.Encode()) } - if forkMode { - logger.Info().Fields(serviceFields).Msgf("⚙️ Using local overlay service account 0x%s", serviceAddress.Hex()) - logger.Info().Msgf("Using fork host %s", serverConf.ForkHost) - } else { - logger.Info().Fields(serviceFields).Msgf("⚙️ Using service account 0x%s", serviceAddress.Hex()) - } + logger.Info().Fields(serviceFields).Msgf("⚙️ Using service account 0x%s", serviceAddress.Hex()) emu := server.NewEmulatorServer(logger, serverConf) if emu != nil { From 80ff998aa92ed27d8c2ff7911133f7645f758a53 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 16 Oct 2025 14:27:37 -0700 Subject: [PATCH 18/28] don't retry not found errors as they are part of normal operation --- storage/remote/store.go | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/storage/remote/store.go b/storage/remote/store.go index 97ebe607..d6f7b4a2 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -134,19 +134,15 @@ func (s *Store) retryWithBackoff(ctx context.Context, operation string, fn func( lastErr = err - // Only record failures for rate limit errors - if isRateLimitError(err) { - s.circuitBreaker.recordFailure() + // Only retry on recognized, transient rate limit errors + if !isRateLimitError(err) { + return err } - // Check if this is a rate limit error - if isRateLimitError(err) { - s.logger.Info(). - Str("operation", operation). - Msg("Rate limit detected, will retry with backoff") - } + // Record circuit breaker failure for rate limit errors + s.circuitBreaker.recordFailure() - // For all errors (including rate limits), continue with retry logic + // Continue with retry logic for rate limits only if attempt == maxRetries { s.logger.Warn(). Str("operation", operation). From 02b68b10399191f396eddbedabe8813284b03539 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 16 Oct 2025 15:02:29 -0700 Subject: [PATCH 19/28] add chainid to logs --- storage/remote/store.go | 1 + 1 file changed, 1 insertion(+) diff --git a/storage/remote/store.go b/storage/remote/store.go index d6f7b4a2..bce99c33 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -367,6 +367,7 @@ func (s *Store) initializeStartBlock(ctx context.Context) error { s.logger.Info(). Uint64("forkHeight", s.forkHeight). Str("host", s.host). + Str("chainId", s.chainID.String()). Msg("Using fork height") // store the initial fork height. any future queries for data on the rpc host will be fixed From 35a02ca43ac37ef50d565b5faa13cf1e744e80f6 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 20 Oct 2025 14:45:20 -0700 Subject: [PATCH 20/28] start: remove unused parseFlowChainIDStart helper --- cmd/emulator/start/start.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index 90794c3b..a99d5152 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -344,20 +344,6 @@ func getSDKChainID(chainID string) (flowgo.ChainID, error) { } } -// parseFlowChainIDStart maps string chain IDs to flowgo.ChainID for start package use -func parseFlowChainIDStart(id string) (flowgo.ChainID, error) { - switch id { - case flowgo.Mainnet.String(): - return flowgo.Mainnet, nil - case flowgo.Testnet.String(): - return flowgo.Testnet, nil - case flowgo.Emulator.String(): - return flowgo.Emulator, nil - default: - return "", fmt.Errorf("unknown chain id: %s", id) - } -} - func checkKeyAlgorithms(sigAlgo crypto.SignatureAlgorithm, hashAlgo crypto.HashAlgorithm) { if sigAlgo == crypto.UnknownSignatureAlgorithm { Exit(1, "Must specify service key signature algorithm (e.g. --service-sig-algo=ECDSA_P256)") From f348913a1440e0ec1888103e8b47eb0e341f7417 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 20 Oct 2025 14:48:18 -0700 Subject: [PATCH 21/28] remove unused fn --- server/server.go | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/server/server.go b/server/server.go index f65c7be3..9260c634 100644 --- a/server/server.go +++ b/server/server.go @@ -249,19 +249,6 @@ func NewEmulatorServer(logger *zerolog.Logger, conf *Config) *EmulatorServer { return server } -func parseFlowChainID(id string) (flowgo.ChainID, error) { - switch id { - case flowgo.Mainnet.String(): - return flowgo.Mainnet, nil - case flowgo.Testnet.String(): - return flowgo.Testnet, nil - case flowgo.Emulator.String(): - return flowgo.Emulator, nil - default: - return "", fmt.Errorf("unknown chain id: %s", id) - } -} - // detectRemoteChainID connects to the remote access node and fetches network parameters to obtain the chain ID. func DetectRemoteChainID(url string) (flowgo.ChainID, error) { // Expect raw host:port @@ -275,7 +262,7 @@ func DetectRemoteChainID(url string) (flowgo.ChainID, error) { if err != nil { return "", err } - return parseFlowChainID(resp.ChainId) + return flowgo.ChainID(resp.ChainId), nil } // Listen starts listening for incoming connections. From da63c1fda5cfa4ba1931143dc1a3be2af947e443 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 20 Oct 2025 15:37:07 -0700 Subject: [PATCH 22/28] immutably set chain ID --- server/server.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/server/server.go b/server/server.go index 9260c634..48d11877 100644 --- a/server/server.go +++ b/server/server.go @@ -175,7 +175,15 @@ func NewEmulatorServer(logger *zerolog.Logger, conf *Config) *EmulatorServer { return nil } - emulatedBlockchain, err := configureBlockchain(logger, conf, store) + // Derive chain ID for the emulator setup. In fork mode, prefer remote chain ID. + resolvedChainID := conf.ChainID + if conf.ForkHost != "" { + if parsed, err := DetectRemoteChainID(conf.ForkHost); err == nil { + resolvedChainID = parsed + } + } + + emulatedBlockchain, err := configureBlockchain(logger, resolvedChainID, conf, store) if err != nil { logger.Err(err).Msg("❗ Failed to configure emulated emulator") return nil @@ -418,12 +426,6 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto return nil, err } storageProvider = provider - - // detect and override chain ID from remote parameters (no dependency on store internals) - // TODO: do not mutate conf here; derive chain ID immutably during setup - if parsed, err := DetectRemoteChainID(conf.ForkHost); err == nil { - conf.ChainID = parsed - } } if conf.Snapshot { @@ -439,7 +441,7 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto return storageProvider, err } -func configureBlockchain(logger *zerolog.Logger, conf *Config, store storage.Store) (*emulator.Blockchain, error) { +func configureBlockchain(logger *zerolog.Logger, chainID flowgo.ChainID, conf *Config, store storage.Store) (*emulator.Blockchain, error) { options := []emulator.Option{ emulator.WithServerLogger(*logger), emulator.WithStore(store), @@ -451,7 +453,7 @@ func configureBlockchain(logger *zerolog.Logger, conf *Config, store storage.Sto emulator.WithMinimumStorageReservation(conf.MinimumStorageReservation), emulator.WithStorageMBPerFLOW(conf.StorageMBPerFLOW), emulator.WithTransactionFeesEnabled(conf.TransactionFeesEnabled), - emulator.WithChainID(conf.ChainID), + emulator.WithChainID(chainID), emulator.WithContractRemovalEnabled(conf.ContractRemovalEnabled), emulator.WithSetupVMBridgeEnabled(conf.SetupVMBridgeEnabled), } From 6e4947f8a43f3f57c6346581ea74dac7cfd4c0ea Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 22 Oct 2025 10:39:55 -0700 Subject: [PATCH 23/28] remove compat shim --- storage/remote/store.go | 424 ---------------------------------------- 1 file changed, 424 deletions(-) diff --git a/storage/remote/store.go b/storage/remote/store.go index bce99c33..a0d45da2 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -20,18 +20,15 @@ package remote import ( "context" - "encoding/hex" "fmt" "math" "math/rand" - "strconv" "strings" "sync" "time" lru "github.com/hashicorp/golang-lru/v2" "github.com/onflow/flow-go/engine/common/rpc/convert" - "github.com/onflow/flow-go/fvm/environment" "github.com/onflow/flow-go/fvm/errors" "github.com/onflow/flow-go/fvm/storage/snapshot" flowgo "github.com/onflow/flow-go/model/flow" @@ -220,10 +217,6 @@ type Store struct { forkHeight uint64 logger *zerolog.Logger circuitBreaker *circuitBreaker - // COMPATIBILITY SHIM: Account Key Deduplication Migration - // TODO: Remove after Flow release - this shim provides backward compatibility - // for pre-migration networks by synthesizing migrated registers from legacy data - applyKeyDeduplication bool } type Option func(*Store) @@ -323,10 +316,6 @@ func New(provider *sqlite.Store, logger *zerolog.Logger, options ...Option) (*St store.chainID = flowgo.ChainID(params.ChainId) } - // COMPATIBILITY SHIM: Enable for all networks - // TODO: Remove after Forte release - provides backward compatibility - store.applyKeyDeduplication = true - if err := store.initializeStartBlock(context.Background()); err != nil { return nil, err } @@ -519,58 +508,6 @@ func (s *Store) LedgerByHeight( value = response.Values[0] } - // COMPATIBILITY SHIM: Account Key Deduplication Migration - // TODO: Remove after Flow release - synthesizes migrated registers from legacy data - if s.applyKeyDeduplication { - normalizedKey := normalizeKey(id.Key) - - // COMPATIBILITY SHIM: Synthesize migrated registers from legacy data - // TODO: Remove after Flow release - these functions provide backward compatibility - if isAPK0Key(normalizedKey) && len(value) == 0 { - // Fallback apk_0 -> public_key_0 - legacy, err := s.fetchRemoteRegister(ctx, id.Owner, "public_key_0", lookupHeight) - if err != nil { - return nil, err - } - if len(legacy) > 0 { - value = legacy - } - } else if isPKBKey(normalizedKey) && len(value) == 0 { - // Synthesize pk_b from individual public_key_* registers - batchIdx, ok := parsePKBBatchIndex(normalizedKey) - if ok { - synthesized, err := s.synthesizeBatchPublicKeys(ctx, id.Owner, batchIdx, lookupHeight) - if err != nil { - return nil, err - } - if len(synthesized) > 0 { - value = synthesized - } - } - } else if isSNKey(normalizedKey) && len(value) == 0 { - // Synthesize sn_ from public_key_ sequence numbers - keyIdx, ok := parseSNKeyIndex(normalizedKey) - if ok { - synthesized, err := s.synthesizeSequenceNumber(ctx, id.Owner, keyIdx, lookupHeight) - if err != nil { - return nil, err - } - if len(synthesized) > 0 { - value = synthesized - } - } - } else if isAccountStatusKey(normalizedKey) { - // Synthesize account status v4 with key metadata from legacy registers - synthesized, err := s.synthesizeAccountStatusV4(ctx, id.Owner, lookupHeight) - if err != nil { - return nil, err - } - if len(synthesized) > 0 { - value = synthesized - } - } - } - // cache the value for future use err = s.DataSetter.SetBytesWithVersion( ctx, @@ -590,364 +527,3 @@ func (s *Store) LedgerByHeight( func (s *Store) Stop() { _ = s.grpcConn.Close() } - -// COMPATIBILITY SHIM: Helper functions for account key deduplication migration -// TODO: Remove after Flow release - these functions provide backward compatibility - -// normalizeKey decodes a hex-prefixed ("#") key into its string form; otherwise returns the key unchanged. -func normalizeKey(key string) string { - if strings.HasPrefix(key, "#") { - hexPart := key[1:] - if b, err := hex.DecodeString(hexPart); err == nil { - return string(b) - } - } - return key -} - -func isAPK0Key(key string) bool { - return key == flowgo.AccountPublicKey0RegisterKey -} - -func isPKBKey(key string) bool { - return strings.HasPrefix(key, flowgo.BatchPublicKeyRegisterKeyPrefix) -} - -func isSNKey(key string) bool { - return strings.HasPrefix(key, flowgo.SequenceNumberRegisterKeyPrefix) -} - -func isAccountStatusKey(key string) bool { - return key == flowgo.AccountStatusKey -} - -func parsePKBBatchIndex(key string) (uint32, bool) { - if !isPKBKey(key) { - return 0, false - } - suffix := strings.TrimPrefix(key, flowgo.BatchPublicKeyRegisterKeyPrefix) - n, err := strconv.ParseUint(suffix, 10, 32) - if err != nil { - return 0, false - } - return uint32(n), true -} - -func parseSNKeyIndex(key string) (uint32, bool) { - if !isSNKey(key) { - return 0, false - } - suffix := strings.TrimPrefix(key, flowgo.SequenceNumberRegisterKeyPrefix) - n, err := strconv.ParseUint(suffix, 10, 32) - if err != nil { - return 0, false - } - return uint32(n), true -} - -// fetchRemoteRegister fetches a single register value from the remote at a fixed height. -func (s *Store) fetchRemoteRegister(ctx context.Context, owner string, key string, height uint64) ([]byte, error) { - registerID := convert.RegisterIDToMessage(flowgo.RegisterID{Key: key, Owner: owner}) - response, err := s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ - BlockHeight: height, - RegisterIds: []*entities.RegisterID{registerID}, - }) - if err != nil { - if status.Code(err) == codes.NotFound { - return nil, nil - } - return nil, err - } - if response != nil && len(response.Values) > 0 { - return response.Values[0], nil - } - return nil, nil -} - -// COMPATIBILITY SHIM: Batch register fetching to reduce RPC calls -// TODO: Remove after Flow release - batches multiple register lookups into single RPC call -func (s *Store) fetchRemoteRegisters(ctx context.Context, owner string, keys []string, height uint64) (map[string][]byte, error) { - if len(keys) == 0 { - return make(map[string][]byte), nil - } - - registerIDs := make([]*entities.RegisterID, len(keys)) - for i, key := range keys { - registerIDs[i] = convert.RegisterIDToMessage(flowgo.RegisterID{Key: key, Owner: owner}) - } - - var response *executiondata.GetRegisterValuesResponse - err := s.retryWithBackoff(ctx, "GetRegisterValuesBatch", func() error { - var err error - response, err = s.executionClient.GetRegisterValues(ctx, &executiondata.GetRegisterValuesRequest{ - BlockHeight: height, - RegisterIds: registerIDs, - }) - return err - }) - - if err != nil { - if status.Code(err) == codes.NotFound { - return make(map[string][]byte), nil - } - return nil, err - } - - result := make(map[string][]byte) - if response != nil && len(response.Values) > 0 { - for i, key := range keys { - if i < len(response.Values) && len(response.Values[i]) > 0 { - result[key] = response.Values[i] - } - } - } - return result, nil -} - -// COMPATIBILITY SHIM: Batch public key synthesis -// TODO: Remove after Flow release - builds batch public key payloads from individual public_key_* registers -func (s *Store) synthesizeBatchPublicKeys(ctx context.Context, owner string, batchIndex uint32, height uint64) ([]byte, error) { - // Load account status to get key count - statusBytes, err := s.fetchRemoteRegister(ctx, owner, flowgo.AccountStatusKey, height) - if err != nil { - return nil, err - } - if len(statusBytes) == 0 { - return nil, nil - } - status, err := environment.AccountStatusFromBytes(statusBytes) - if err != nil { - return nil, fmt.Errorf("could not parse account status: %w", err) - } - count := status.AccountPublicKeyCount() - if count == 0 { - return nil, nil - } - - const max = environment.MaxPublicKeyCountInBatch - start := batchIndex * max - // storedKeyIndex range is [start, min(start+max-1, count-1)] - if start >= count { - return nil, nil - } - end := start + max - 1 - if end > count-1 { - end = count - 1 - } - - batch := make([]byte, 0, 1+(end-start+1)*8) // rough capacity - - // Batch 0 reserves index 0 as nil placeholder to align indices - if batchIndex == 0 { - batch = append(batch, 0x00) - } - - // Batch fetch all legacy public key registers for this batch to reduce RPC calls - legacyKeys := make([]string, 0, end-start+1) - for i := start; i <= end; i++ { - if i == 0 { - continue // skip key 0 - } - legacyKeys = append(legacyKeys, fmt.Sprintf("public_key_%d", i)) - } - - legacyValues, err := s.fetchRemoteRegisters(ctx, owner, legacyKeys, height) - if err != nil { - return nil, err - } - - for i := start; i <= end; i++ { - if i == 0 { - // stored key 0 is apk_0 and not included in batch payload (nil placeholder already added) - continue - } - legacyKey := fmt.Sprintf("public_key_%d", i) - legacyVal := legacyValues[legacyKey] - - if len(legacyVal) == 0 { - // keep index alignment with zero-length entry - batch = append(batch, 0x00) - continue - } - - // Decode legacy account public key to extract public material - decoded, err := flowgo.DecodeAccountPublicKey(legacyVal, uint32(i)) - if err != nil { - // cannot decode -> keep alignment with zero-length entry - batch = append(batch, 0x00) - continue - } - stored := flowgo.StoredPublicKey{ - PublicKey: decoded.PublicKey, - SignAlgo: decoded.SignAlgo, - HashAlgo: decoded.HashAlgo, - } - enc, err := flowgo.EncodeStoredPublicKey(stored) - if err != nil { - batch = append(batch, 0x00) - continue - } - if len(enc) > 255 { - // out of spec for batch encoding; skip with placeholder - batch = append(batch, 0x00) - continue - } - batch = append(batch, byte(len(enc))) - batch = append(batch, enc...) - } - - return batch, nil -} - -// COMPATIBILITY SHIM: Sequence number synthesis -// TODO: Remove after Flow release - builds sequence number registers from legacy public_key_* registers -func (s *Store) synthesizeSequenceNumber(ctx context.Context, owner string, keyIndex uint32, height uint64) ([]byte, error) { - if keyIndex == 0 { - // key 0 sequence number lives in apk_0 - return nil, nil - } - legacyKey := fmt.Sprintf("public_key_%d", keyIndex) - legacyVal, err := s.fetchRemoteRegister(ctx, owner, legacyKey, height) - if err != nil { - return nil, err - } - if len(legacyVal) == 0 { - return nil, nil - } - decoded, err := flowgo.DecodeAccountPublicKey(legacyVal, keyIndex) - if err != nil { - return nil, nil - } - if decoded.SeqNumber == 0 { - return nil, nil - } - enc, err := flowgo.EncodeSequenceNumber(decoded.SeqNumber) - if err != nil { - return nil, nil - } - return enc, nil -} - -// COMPATIBILITY SHIM: Account status v4 synthesis -// TODO: Remove after Flow release - synthesizes v4 account status with key metadata from legacy registers -func (s *Store) synthesizeAccountStatusV4(ctx context.Context, owner string, height uint64) ([]byte, error) { - // Load existing account status (v3) - statusBytes, err := s.fetchRemoteRegister(ctx, owner, flowgo.AccountStatusKey, height) - if err != nil { - return nil, err - } - if len(statusBytes) == 0 { - return nil, nil - } - - status, err := environment.AccountStatusFromBytes(statusBytes) - if err != nil { - return nil, fmt.Errorf("could not parse account status: %w", err) - } - - count := status.AccountPublicKeyCount() - if count <= 1 { - // No key metadata needed for accounts with 0-1 keys - return statusBytes, nil - } - - // Build key metadata from legacy registers - keyMetadata, err := s.buildKeyMetadataFromLegacy(ctx, owner, count, height) - if err != nil { - return nil, fmt.Errorf("could not build key metadata: %w", err) - } - - // Create new status with key metadata by parsing the original bytes and appending metadata - // The AccountStatus struct has unexported fields, so we work with the byte representation - originalBytes := status.ToBytes() - - // Set account status v4 flag (0x40 = accountStatusV4WithNoDeduplicationFlag) - if len(originalBytes) > 0 { - originalBytes[0] = 0x40 - } - - // Append key metadata to the original account status bytes - newBytes := make([]byte, len(originalBytes)+len(keyMetadata)) - copy(newBytes, originalBytes) - copy(newBytes[len(originalBytes):], keyMetadata) - - return newBytes, nil -} - -// COMPATIBILITY SHIM: Key metadata construction -// TODO: Remove after Flow release - builds key metadata from legacy public_key_* registers -func (s *Store) buildKeyMetadataFromLegacy(ctx context.Context, owner string, count uint32, height uint64) ([]byte, error) { - // For pre-migration networks, we build minimal key metadata: - // - Weight and revoked status for keys 1..count-1 (RLE encoded) - // - No deduplication mappings (storedKeyIndex == keyIndex) - // - No digests (not needed for basic functionality) - - if count <= 1 { - return nil, nil - } - - // Batch fetch all legacy public key registers to reduce RPC calls - legacyKeys := make([]string, count-1) - for i := uint32(1); i < count; i++ { - legacyKeys[i-1] = fmt.Sprintf("public_key_%d", i) - } - - legacyValues, err := s.fetchRemoteRegisters(ctx, owner, legacyKeys, height) - if err != nil { - return nil, err - } - - // Build weight and revoked status for keys 1..count-1 - var weightAndRevoked []byte - - for i := uint32(1); i < count; i++ { - legacyKey := fmt.Sprintf("public_key_%d", i) - legacyVal := legacyValues[legacyKey] - - if len(legacyVal) == 0 { - // Default values for missing keys - weightAndRevoked = append(weightAndRevoked, 0, 0) // weight=0, revoked=false - continue - } - - decoded, err := flowgo.DecodeAccountPublicKey(legacyVal, i) - if err != nil { - // Default values for unparseable keys - weightAndRevoked = append(weightAndRevoked, 0, 0) - continue - } - - // Encode weight and revoked status (RLE format) - weight := uint16(decoded.Weight) - if weight > 1000 { - weight = 1000 // clamp to max - } - - revokedFlag := uint16(0) - if decoded.Revoked { - revokedFlag = 0x8000 // high bit set - } - - weightAndRevoked = append(weightAndRevoked, byte(weight>>8), byte(weight&0xFF)) - weightAndRevoked = append(weightAndRevoked, byte(revokedFlag>>8), byte(revokedFlag&0xFF)) - } - - // Build minimal key metadata: - // - Length-prefixed weight and revoked status - // - Start index for digests (0, no digests) - // - Length-prefixed empty digests - - metadata := make([]byte, 0, 4+len(weightAndRevoked)+4+4) - - // Length-prefixed weight and revoked status - metadata = append(metadata, byte(len(weightAndRevoked)>>24), byte(len(weightAndRevoked)>>16), byte(len(weightAndRevoked)>>8), byte(len(weightAndRevoked))) - metadata = append(metadata, weightAndRevoked...) - - // Start index for digests (0) - metadata = append(metadata, 0, 0, 0, 0) - - // Length-prefixed empty digests - metadata = append(metadata, 0, 0, 0, 0) - - return metadata, nil -} From 61980535c076e0ca84e24404ae9acfa0bf1cce99 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 22 Oct 2025 11:03:30 -0700 Subject: [PATCH 24/28] disable mainnet fork test until forte release --- server/fork_integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/fork_integration_test.go b/server/fork_integration_test.go index e443e978..10747bdf 100644 --- a/server/fork_integration_test.go +++ b/server/fork_integration_test.go @@ -170,6 +170,7 @@ func TestForkingAgainstTestnet(t *testing.T) { // TestForkingAgainstMainnet exercises the forking path with mainnet and tests the account key deduplication shim func TestForkingAgainstMainnet(t *testing.T) { + t.Skip("remove after Forte release") logger := zerolog.Nop() // Get remote latest sealed height to pin fork From ded5af3f40d4eff6a8468aec7c8b380110582d39 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 22 Oct 2025 11:13:16 -0700 Subject: [PATCH 25/28] cleanup retries --- server/fork_integration_test.go | 2 +- storage/remote/store.go | 60 +++++++++++++++------------------ 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/server/fork_integration_test.go b/server/fork_integration_test.go index 10747bdf..b9ae18f8 100644 --- a/server/fork_integration_test.go +++ b/server/fork_integration_test.go @@ -168,7 +168,7 @@ func TestForkingAgainstTestnet(t *testing.T) { } } -// TestForkingAgainstMainnet exercises the forking path with mainnet and tests the account key deduplication shim +// TestForkingAgainstMainnet exercises the forking path with mainnet func TestForkingAgainstMainnet(t *testing.T) { t.Skip("remove after Forte release") logger := zerolog.Nop() diff --git a/storage/remote/store.go b/storage/remote/store.go index a0d45da2..de10b325 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -23,7 +23,6 @@ import ( "fmt" "math" "math/rand" - "strings" "sync" "time" @@ -48,11 +47,12 @@ import ( // Retry and circuit breaker configuration const ( - maxRetries = 5 - baseDelay = 100 * time.Millisecond - maxDelay = 30 * time.Second - jitterFactor = 0.1 - circuitTimeout = 30 * time.Second // Circuit breaker timeout + maxRetries = 5 + baseDelay = 100 * time.Millisecond + maxDelay = 30 * time.Second + jitterFactor = 0.1 + circuitTimeout = 30 * time.Second // Circuit breaker timeout + circuitFailureThreshold = 3 // Number of consecutive failures before opening circuit ) // isRateLimitError checks if the error is a rate limiting error @@ -66,22 +66,8 @@ func isRateLimitError(err error) bool { return false } - // Check for common rate limiting gRPC codes - switch st.Code() { - case codes.ResourceExhausted: - return true - case codes.Unavailable: - // Sometimes rate limits are returned as unavailable - return strings.Contains(st.Message(), "rate") || - strings.Contains(st.Message(), "limit") || - strings.Contains(st.Message(), "throttle") - case codes.Aborted: - // Some services return rate limits as aborted - return strings.Contains(st.Message(), "rate") || - strings.Contains(st.Message(), "limit") - } - - return false + // ResourceExhausted is the standard gRPC code for rate limiting + return st.Code() == codes.ResourceExhausted } // exponentialBackoffWithJitter calculates delay with exponential backoff and jitter @@ -115,12 +101,19 @@ func (s *Store) retryWithBackoff(ctx context.Context, operation string, fn func( var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { - // Check circuit breaker first - if !s.circuitBreaker.canMakeRequest() { + // Wait if circuit breaker is open (defer request instead of failing) + if waitTime := s.circuitBreaker.getWaitTime(); waitTime > 0 { s.logger.Debug(). Str("operation", operation). - Msg("Circuit breaker is open, skipping request") - return fmt.Errorf("circuit breaker is open") + Dur("wait", waitTime). + Msg("Circuit breaker open, deferring request") + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(waitTime): + // Continue after wait + } } err := fn() @@ -177,17 +170,20 @@ type circuitBreaker struct { timeout time.Duration } -// canMakeRequest checks if requests can be made (circuit breaker is closed) -func (cb *circuitBreaker) canMakeRequest() bool { +// getWaitTime returns how long to wait before making a request (0 if circuit is closed) +func (cb *circuitBreaker) getWaitTime() time.Duration { cb.mu.RLock() defer cb.mu.RUnlock() - // If we've had recent failures, check timeout - if cb.failures > 0 && time.Since(cb.lastFail) < cb.timeout { - return false + // Only apply backpressure if we've exceeded the failure threshold + if cb.failures >= circuitFailureThreshold { + elapsed := time.Since(cb.lastFail) + if elapsed < cb.timeout { + return cb.timeout - elapsed + } } - return true + return 0 } // recordFailure records a failure and opens the circuit breaker From 6ac3b1ac81a6b3be94e32f42e10725717e32621d Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 22 Oct 2025 11:25:23 -0700 Subject: [PATCH 26/28] simplify retries --- storage/remote/store.go | 94 +++++++++-------------------------------- 1 file changed, 19 insertions(+), 75 deletions(-) diff --git a/storage/remote/store.go b/storage/remote/store.go index de10b325..3c1aa50a 100644 --- a/storage/remote/store.go +++ b/storage/remote/store.go @@ -23,7 +23,6 @@ import ( "fmt" "math" "math/rand" - "sync" "time" lru "github.com/hashicorp/golang-lru/v2" @@ -45,14 +44,13 @@ import ( "github.com/onflow/flow-emulator/types" ) -// Retry and circuit breaker configuration +// Retry and concurrency configuration const ( - maxRetries = 5 - baseDelay = 100 * time.Millisecond - maxDelay = 30 * time.Second - jitterFactor = 0.1 - circuitTimeout = 30 * time.Second // Circuit breaker timeout - circuitFailureThreshold = 3 // Number of consecutive failures before opening circuit + maxRetries = 5 + baseDelay = 100 * time.Millisecond + maxDelay = 30 * time.Second + jitterFactor = 0.1 + maxConcurrentRequests = 10 // Maximum concurrent requests to remote node ) // isRateLimitError checks if the error is a rate limiting error @@ -98,27 +96,19 @@ func exponentialBackoffWithJitter(attempt int) time.Duration { // retryWithBackoff executes a function with exponential backoff retry on rate limit errors func (s *Store) retryWithBackoff(ctx context.Context, operation string, fn func() error) error { + // Acquire semaphore to limit concurrent requests + select { + case s.concurrencySem <- struct{}{}: + defer func() { <-s.concurrencySem }() + case <-ctx.Done(): + return ctx.Err() + } + var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { - // Wait if circuit breaker is open (defer request instead of failing) - if waitTime := s.circuitBreaker.getWaitTime(); waitTime > 0 { - s.logger.Debug(). - Str("operation", operation). - Dur("wait", waitTime). - Msg("Circuit breaker open, deferring request") - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(waitTime): - // Continue after wait - } - } - err := fn() if err == nil { - s.circuitBreaker.recordSuccess() return nil } @@ -129,9 +119,6 @@ func (s *Store) retryWithBackoff(ctx context.Context, operation string, fn func( return err } - // Record circuit breaker failure for rate limit errors - s.circuitBreaker.recordFailure() - // Continue with retry logic for rate limits only if attempt == maxRetries { s.logger.Warn(). @@ -149,7 +136,7 @@ func (s *Store) retryWithBackoff(ctx context.Context, operation string, fn func( Int("attempt", attempt+1). Dur("delay", delay). Err(err). - Msg("Request failed, retrying with backoff") + Msg("Rate limited, retrying with backoff") select { case <-ctx.Done(): @@ -162,47 +149,6 @@ func (s *Store) retryWithBackoff(ctx context.Context, operation string, fn func( return lastErr } -// circuitBreaker implements a simple circuit breaker pattern -type circuitBreaker struct { - mu sync.RWMutex - failures int - lastFail time.Time - timeout time.Duration -} - -// getWaitTime returns how long to wait before making a request (0 if circuit is closed) -func (cb *circuitBreaker) getWaitTime() time.Duration { - cb.mu.RLock() - defer cb.mu.RUnlock() - - // Only apply backpressure if we've exceeded the failure threshold - if cb.failures >= circuitFailureThreshold { - elapsed := time.Since(cb.lastFail) - if elapsed < cb.timeout { - return cb.timeout - elapsed - } - } - - return 0 -} - -// recordFailure records a failure and opens the circuit breaker -func (cb *circuitBreaker) recordFailure() { - cb.mu.Lock() - defer cb.mu.Unlock() - - cb.failures++ - cb.lastFail = time.Now() -} - -// recordSuccess records a success and closes the circuit breaker -func (cb *circuitBreaker) recordSuccess() { - cb.mu.Lock() - defer cb.mu.Unlock() - - cb.failures = 0 // Reset on success -} - type Store struct { *sqlite.Store executionClient executiondata.ExecutionDataAPIClient @@ -212,7 +158,7 @@ type Store struct { chainID flowgo.ChainID forkHeight uint64 logger *zerolog.Logger - circuitBreaker *circuitBreaker + concurrencySem chan struct{} // Semaphore to limit concurrent requests } type Option func(*Store) @@ -260,11 +206,9 @@ func WithClient( func New(provider *sqlite.Store, logger *zerolog.Logger, options ...Option) (*Store, error) { store := &Store{ - Store: provider, - logger: logger, - circuitBreaker: &circuitBreaker{ - timeout: circuitTimeout, - }, + Store: provider, + logger: logger, + concurrencySem: make(chan struct{}, maxConcurrentRequests), } for _, opt := range options { From c662102def9bb85c2b8c574ea0a44938b4c05728 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 22 Oct 2025 11:34:35 -0700 Subject: [PATCH 27/28] address feedback --- cmd/emulator/start/start.go | 24 +++++++++++++----------- server/server.go | 11 +++++++---- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/cmd/emulator/start/start.go b/cmd/emulator/start/start.go index a99d5152..31b83348 100644 --- a/cmd/emulator/start/start.go +++ b/cmd/emulator/start/start.go @@ -179,6 +179,18 @@ func Cmd(config StartConfig) *cobra.Command { storageMBPerFLOW = parseCadenceUFix64(conf.StorageMBPerFLOW, "storage-per-flow") } + // Recompute chain ID and service address accurately for fork mode by querying the node. + forkHost := conf.ForkHost + resolvedChainID := flowChainID + forkMode := forkHost != "" + if forkMode { + parsed, err := server.DetectRemoteChainID(forkHost) + if err != nil { + Exit(1, fmt.Sprintf("failed to detect remote chain id from %s: %v", forkHost, err)) + } + resolvedChainID = parsed + } + serverConf := &server.Config{ GRPCPort: conf.Port, GRPCDebug: conf.GRPCDebug, @@ -208,7 +220,7 @@ func Cmd(config StartConfig) *cobra.Command { SkipTransactionValidation: conf.SkipTxValidation, SimpleAddressesEnabled: conf.SimpleAddresses, Host: conf.Host, - ChainID: flowChainID, + ChainID: resolvedChainID, RedisURL: conf.RedisURL, ContractRemovalEnabled: conf.ContractRemovalEnabled, SqliteURL: conf.SqliteURL, @@ -223,16 +235,6 @@ func Cmd(config StartConfig) *cobra.Command { SetupVMBridgeEnabled: conf.SetupVMBridgeEnabled, } - // Recompute chain ID and service address accurately for fork mode by querying the node. - resolvedChainID := flowChainID - forkMode := serverConf.ForkHost != "" - if forkMode { - parsed, err := server.DetectRemoteChainID(serverConf.ForkHost) - if err != nil { - Exit(1, fmt.Sprintf("failed to detect remote chain id from %s: %v", serverConf.ForkHost, err)) - } - resolvedChainID = parsed - } serviceAddress := flowsdk.ServiceAddress(flowsdk.ChainID(resolvedChainID)) if conf.SimpleAddresses { serviceAddress = flowsdk.HexToAddress("0x1") diff --git a/server/server.go b/server/server.go index 48d11877..aaf254ed 100644 --- a/server/server.go +++ b/server/server.go @@ -175,12 +175,15 @@ func NewEmulatorServer(logger *zerolog.Logger, conf *Config) *EmulatorServer { return nil } - // Derive chain ID for the emulator setup. In fork mode, prefer remote chain ID. + // Derive chain ID for the emulator setup. resolvedChainID := conf.ChainID - if conf.ForkHost != "" { - if parsed, err := DetectRemoteChainID(conf.ForkHost); err == nil { - resolvedChainID = parsed + if conf.ForkHost != "" && conf.ChainID == "" { + parsed, err := DetectRemoteChainID(conf.ForkHost) + if err != nil { + logger.Error().Err(err).Msg("❗ Failed to detect remote chain ID") + return nil } + resolvedChainID = parsed } emulatedBlockchain, err := configureBlockchain(logger, resolvedChainID, conf, store) From 228ccb566c0f3569236afc3befabc38a5cf601cb Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 22 Oct 2025 11:38:49 -0700 Subject: [PATCH 28/28] add port validation --- server/server.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/server.go b/server/server.go index aaf254ed..9c17725d 100644 --- a/server/server.go +++ b/server/server.go @@ -25,6 +25,7 @@ import ( "net/http" "os" "sort" + "strings" "time" "github.com/onflow/cadence" @@ -415,6 +416,11 @@ func configureStorage(logger *zerolog.Logger, conf *Config) (storageProvider sto } if conf.ForkHost != "" { + // Validate fork host has port + if !strings.Contains(conf.ForkHost, ":") { + return nil, fmt.Errorf("fork-host must include port (e.g., access.mainnet.nodes.onflow.org:9000)") + } + // TODO: any reason redis shouldn't work? baseProvider, ok := storageProvider.(*sqlite.Store) if !ok {