diff --git a/flowkit/schema.json b/flowkit/schema.json index 0c60ac139..16d8ab7e4 100644 --- a/flowkit/schema.json +++ b/flowkit/schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/onflow/flow-cli/flowkit/config/json/json-config", + "$id": "https://github.com/onflow/flowkit/v2/config/json/json-config", "$ref": "#/$defs/jsonConfig", "$defs": { "account": { @@ -111,10 +111,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "host" - ] + "type": "object" }, "contractDeployment": { "properties": { @@ -122,9 +119,7 @@ "type": "string" }, "args": { - "items": { - "type": "object" - }, + "items": true, "type": "array" } }, @@ -199,6 +194,9 @@ } }, "type": "object" + }, + "canonical": { + "type": "string" } }, "additionalProperties": false, @@ -242,6 +240,9 @@ "hash": { "type": "string" }, + "block_height": { + "type": "integer" + }, "aliases": { "patternProperties": { ".*": { @@ -249,6 +250,9 @@ } }, "type": "object" + }, + "canonical": { + "type": "string" } }, "additionalProperties": false, diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 0d44b9a07..ac560a5c6 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -23,8 +23,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "os" "path/filepath" + "strings" "github.com/psiemens/sconfig" @@ -147,6 +147,7 @@ type DependencyInstaller struct { installCount int // Track number of dependencies installed pendingPrompts []pendingPrompt // Dependencies that need prompts after tree display prompter Prompter // Optional: for testing. If nil, uses real prompts + blockHeightCache map[string]uint64 // Cache of latest block heights per network for consistent pinning } type Prompter interface { @@ -204,6 +205,7 @@ func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveStat accountAliases: make(map[string]map[string]flowsdk.Address), pendingPrompts: make([]pendingPrompt, 0), prompter: prompter{}, + blockHeightCache: make(map[string]uint64), }, nil } @@ -459,16 +461,60 @@ func (di *DependencyInstaller) processDependency(dependency config.Dependency) e return di.processDependencies(dependency) } +// getLatestBlockHeight returns the current block height for a given network. +// Results are cached per network to ensure all dependencies in a single install +// operation get pinned to the same block height for consistency. +func (di *DependencyInstaller) getLatestBlockHeight(network string) (uint64, error) { + // Check cache first + if height, ok := di.blockHeightCache[network]; ok { + return height, nil + } + + gw, ok := di.Gateways[network] + if !ok { + return 0, fmt.Errorf("gateway for network %s not found", network) + } + + ctx := context.Background() + latestBlock, err := gw.GetLatestBlock(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get latest block: %w", err) + } + + // Cache the result + di.blockHeightCache[network] = latestBlock.Height + return latestBlock.Height, nil +} + func (di *DependencyInstaller) getContracts(network string, address flowsdk.Address) (map[string][]byte, error) { + return di.getContractsAtBlockHeight(network, address, 0) +} + +// getContractsAtBlockHeight retrieves contracts at a specific block height. +// If blockHeight is 0, it fetches the latest version. +// Uses GetAccountAtBlockHeight from flowkit Gateway interface for historical queries. +func (di *DependencyInstaller) getContractsAtBlockHeight(network string, address flowsdk.Address, blockHeight uint64) (map[string][]byte, error) { gw, ok := di.Gateways[network] if !ok { return nil, fmt.Errorf("gateway for network %s not found", network) } ctx := context.Background() - acct, err := gw.GetAccount(ctx, address) - if err != nil { - return nil, fmt.Errorf("failed to get account at %s on %s: %w", address, network, err) + var acct *flowsdk.Account + var err error + + if blockHeight > 0 { + // Query at specific block height (historical) + acct, err = gw.GetAccountAtBlockHeight(ctx, address, blockHeight) + if err != nil { + return nil, fmt.Errorf("failed to get account at block height %d on %s: %w", blockHeight, network, err) + } + } else { + // Query latest version + acct, err = gw.GetAccount(ctx, address) + if err != nil { + return nil, fmt.Errorf("failed to get account at %s on %s: %w", address, network, err) + } } if acct == nil { @@ -533,9 +579,61 @@ func (di *DependencyInstaller) fetchDependenciesWithDepth(dependency config.Depe return fmt.Errorf("error adding dependency: %w", err) } - accountContracts, err := di.getContracts(networkName, address) + // Prevent duplicate dependencies: check if this contract is already managed via a different network + // Example: Foo stored as mainnet://0xabc, but discovered transitively as testnet://0xdef (aliased) + shouldContinue, err := di.checkForCrossNetworkDuplicate(dependency.Name, networkName, address.String()) + if err != nil { + return err + } + if !shouldContinue { + return nil // Already managed via different network, skip + } + + // Determine which block height to use for querying + // If --update flag is set, always use latest (even for pinned dependencies) + // Otherwise, use existing block height for frozen dependencies + existingDependency := di.State.Dependencies().ByName(dependency.Name) + var blockHeight uint64 + hadSporkRecovery := false // Track if we had to do spork recovery + + if di.Update || existingDependency == nil || existingDependency.BlockHeight == 0 { + // Use latest block height for: + // 1. --update flag (force update to latest) + // 2. New dependencies + // 3. Dependencies without pinned block height + latestHeight, err := di.getLatestBlockHeight(networkName) + if err != nil { + return fmt.Errorf("failed to get latest block height: %w", err) + } + blockHeight = latestHeight + } else { + // Use pinned block height for frozen dependencies + blockHeight = existingDependency.BlockHeight + } + + accountContracts, err := di.getContractsAtBlockHeight(networkName, address, blockHeight) if err != nil { - return fmt.Errorf("error fetching contracts: %w", err) + // If we get a spork-related error (block height too old), fall back to latest + // This happens when flow.json has old block heights from before the current spork + // We'll check the hash later - if it matches, we just update metadata; if not, normal update flow applies + if strings.Contains(err.Error(), "spork root block height") || strings.Contains(err.Error(), "key not found") { + di.Logger.Info(fmt.Sprintf(" %s Block height %d is from before current spork, fetching latest version", util.PrintEmoji("⚠️"), blockHeight)) + hadSporkRecovery = true + // Get the current block height (will be cached from above for new deps) + latestHeight, err := di.getLatestBlockHeight(networkName) + if err != nil { + return fmt.Errorf("failed to get latest block height: %w", err) + } + // Fetch at that specific block height + accountContracts, err = di.getContractsAtBlockHeight(networkName, address, latestHeight) + if err != nil { + return fmt.Errorf("error fetching contracts: %w", err) + } + // Update blockHeight so it's used consistently for this dependency + blockHeight = latestHeight + } else { + return fmt.Errorf("error fetching contracts: %w", err) + } } contract, ok := accountContracts[contractName] @@ -548,7 +646,7 @@ func (di *DependencyInstaller) fetchDependenciesWithDepth(dependency config.Depe return fmt.Errorf("failed to parse program: %w", err) } - if err := di.handleFoundContract(dependency, program); err != nil { + if err := di.handleFoundContract(dependency, program, blockHeight, hadSporkRecovery); err != nil { return fmt.Errorf("failed to handle found contract: %w", err) } @@ -645,7 +743,7 @@ func (di *DependencyInstaller) verifyLocalFileIntegrity(contractAddr, contractNa return nil } -func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, program *project.Program) error { +func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, program *project.Program, fetchedBlockHeight uint64, hadSporkRecovery bool) error { networkName := dependency.Source.NetworkName contractAddr := dependency.Source.Address.String() contractName := dependency.Source.ContractName @@ -662,18 +760,6 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, existingDependency := di.State.Dependencies().ByName(dependency.Name) - // If a dependency by this name already exists and its remote source network or address does not match, - // allow it only if an existing alias matches the incoming network+address; otherwise terminate. - if existingDependency != nil && (existingDependency.Source.NetworkName != networkName || existingDependency.Source.Address.String() != contractAddr) { - if !di.existingAliasMatches(dependency.Name, networkName, contractAddr) { - di.Logger.Info(fmt.Sprintf("%s A dependency named %s already exists with a different remote source. Please fix the conflict and retry.", util.PrintEmoji("🚫"), dependency.Name)) - os.Exit(0) - return nil - } - // Alias matched - contract already stored, encountered via different network. Skip state update. - return nil - } - // Check if remote source version is different from local version // Decide what to do: defer prompt, skip (frozen), or auto-update hashMismatch := existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != contractDataHash @@ -687,7 +773,27 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, return fmt.Errorf("cannot install with --skip-update-prompts flag when local files have been modified. %w", err) } - // File exists and matches stored hash - keep using it (frozen at old version) + // File exists and matches stored hash - but network version changed + // Check if we fetched at a different block height (e.g., pre-spork recovery) + // Only error if the dependency was actually pinned (BlockHeight > 0) + if existingDependency.BlockHeight > 0 && fetchedBlockHeight != existingDependency.BlockHeight { + // Pre-spork recovery scenario: we couldn't fetch the old block, had to use latest + // But the hash on the network differs from what we have frozen + // This means we can't truly keep it frozen - ERROR OUT + return fmt.Errorf( + "dependency %s: cannot keep frozen with --skip-update-prompts. "+ + "The stored block height (%d) is no longer accessible (likely pre-spork), "+ + "and the contract on-chain at the current block height (%d) has a different hash. "+ + "Run 'flow dependencies install --update' to fetch the latest version, "+ + "or remove --skip-update-prompts to be prompted for updates", + dependency.Name, + existingDependency.BlockHeight, + fetchedBlockHeight, + ) + } + + // File exists, matches stored hash, and we fetched at the stored block height + // This is truly frozen - keep it as is return nil } @@ -718,12 +824,14 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, } return nil } + // Fall through: --update or --skip-update-prompts without file → install from network } - // Check if file exists and needs repair (out of sync with flow.json) + // Check if file exists and needs repair (out of sync with current network version) fileExists := di.contractFileExists(contractAddr, contractName) fileModified := false if fileExists { + // Check if the file matches what we just fetched from the network if err := di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash); err != nil { fileModified = true } @@ -732,10 +840,14 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, // Install or update: new deps, out-of-sync files, or network updates with --update/--skip-update-prompts isNewDep := di.State.Dependencies().ByName(dependency.Name) == nil - err := di.updateDependencyState(dependency, contractDataHash) - if err != nil { - di.Logger.Error(fmt.Sprintf("Error updating state: %v", err)) - return err + // Determine final block height and save + blockHeight := di.shouldUpdateBlockHeight(dependency.Name, fetchedBlockHeight, hashMismatch, hadSporkRecovery) + dep := dependency + dep.Hash = contractDataHash + dep.BlockHeight = blockHeight + + if err := di.saveDependencyState(dep); err != nil { + return fmt.Errorf("error updating state: %w", err) } // Log if this was an auto-update (with --update flag) or file repair @@ -915,33 +1027,83 @@ func (di *DependencyInstaller) updateDependencyAlias(contractName, aliasNetwork return nil } -func (di *DependencyInstaller) updateDependencyState(originalDependency config.Dependency, contractHash string) error { - // Create the dependency to save, preserving aliases and canonical from the original - dep := config.Dependency{ - Name: originalDependency.Name, - Source: originalDependency.Source, - Hash: contractHash, - Aliases: originalDependency.Aliases, - Canonical: originalDependency.Canonical, +// checkForCrossNetworkDuplicate prevents adding duplicate dependencies when the same contract +// is managed on one network but discovered transitively via another network (aliased). +// +// Example scenario: +// - Foo is stored as mainnet://0x0a in flow.json (source network) +// - Foo has an alias: testnet = 0x0b (same contract, different address) +// - User installs Bar from testnet +// - Bar imports "Foo from 0x0b" (transitive) +// - We discover testnet://0x0b.Foo during traversal +// - This check detects: "Foo already managed via mainnet, this is just an alias, skip" +// +// Returns (shouldContinue, error): +// - true = new dependency or same source, continue processing +// - false = duplicate (via alias) or conflict detected, skip/stop +func (di *DependencyInstaller) checkForCrossNetworkDuplicate(depName string, incomingNetwork string, incomingAddress string) (bool, error) { + existing := di.State.Dependencies().ByName(depName) + if existing == nil { + return true, nil // New dependency, continue + } + + isSameSource := existing.Source.NetworkName == incomingNetwork && existing.Source.Address.String() == incomingAddress + if isSameSource { + return true, nil // Same source, continue + } + + // Different source - check if it's a valid cross-network alias or a naming conflict + if !di.existingAliasMatches(depName, incomingNetwork, incomingAddress) { + return false, fmt.Errorf( + "dependency '%s' already exists with a different source (%s://%s) but no alias mapping exists for %s://%s. "+ + "This is a naming conflict. Please rename one of the contracts or add an alias mapping", + depName, + existing.Source.NetworkName, existing.Source.Address.String(), + incomingNetwork, incomingAddress, + ) } - isNewDep := di.State.Dependencies().ByName(dep.Name) == nil + // Valid alias - already managed via source network, skip this duplicate discovery + return false, nil +} + +// shouldUpdateBlockHeight determines if we should update to a new block height or keep the existing one +func (di *DependencyInstaller) shouldUpdateBlockHeight(depName string, newHeight uint64, hashChanged bool, hadSporkRecovery bool) uint64 { + existing := di.State.Dependencies().ByName(depName) + + // Always use new height if: new dep, hash changed, spork recovery, old format, or --update flag + if existing == nil || hashChanged || hadSporkRecovery || existing.BlockHeight == 0 || di.Update { + return newHeight + } + // Otherwise keep existing (frozen dependency) + return existing.BlockHeight +} + +// saveDependencyState saves the dependency to state and logs changes +func (di *DependencyInstaller) saveDependencyState(dep config.Dependency) error { + existing := di.State.Dependencies().ByName(dep.Name) + isNew := existing == nil + + // Save to state di.State.Dependencies().AddOrUpdate(dep) - di.State.Contracts().AddDependencyAsContract(dep, originalDependency.Source.NetworkName) + di.State.Contracts().AddDependencyAsContract(dep, dep.Source.NetworkName) - // If this is an aliased import (Name differs from ContractName), set the Canonical field on the contract - // This enables flowkit to generate the correct "import X as Y from address" syntax + // Handle aliased imports (enables "import X as Y from address" syntax) if dep.Name != dep.Source.ContractName { - contract, err := di.State.Contracts().ByName(dep.Name) - if err == nil && contract != nil { + if contract, err := di.State.Contracts().ByName(dep.Name); err == nil && contract != nil { contract.Canonical = dep.Source.ContractName } } - if isNewDep { + // Log changes + if isNew { msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s added to flow.json", dep.Name)) di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + } else if existing.BlockHeight > 0 && existing.BlockHeight != dep.BlockHeight { + msg := util.MessageWithEmojiPrefix("🔄", fmt.Sprintf("%s block height updated (%d → %d)", + dep.Name, existing.BlockHeight, dep.BlockHeight)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) } return nil @@ -1030,15 +1192,24 @@ func (di *DependencyInstaller) processPendingPrompts() error { if shouldUpdate { dependency := di.State.Dependencies().ByName(pending.contractName) if dependency != nil { - err := di.updateDependencyState(*dependency, pending.updateHash) + // Get latest block height for the update + latestHeight, err := di.getLatestBlockHeight(dependency.Source.NetworkName) if err != nil { - di.Logger.Error(fmt.Sprintf("Error updating dependency: %v", err)) - return err + return fmt.Errorf("failed to get latest block height: %w", err) + } + + // User accepted update - hash changed, so use new block height + blockHeight := di.shouldUpdateBlockHeight(dependency.Name, latestHeight, true, false) + dep := *dependency + dep.Hash = pending.updateHash + dep.BlockHeight = blockHeight + + if err := di.saveDependencyState(dep); err != nil { + return fmt.Errorf("error updating dependency: %w", err) } // Write the updated contract file (force overwrite) if err := di.createContractFile(pending.contractAddr, pending.contractName, pending.contractData); err != nil { - di.Logger.Error(fmt.Sprintf("Error updating contract file: %v", err)) return fmt.Errorf("failed to update contract file: %w", err) } diff --git a/internal/dependencymanager/dependencyinstaller_test.go b/internal/dependencymanager/dependencyinstaller_test.go index 5ef81a7fb..f849887b0 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -43,7 +43,7 @@ import ( // mockPrompter for testing type mockPrompter struct { responses []bool // Queue of responses to return - index int + index int // Tracks number of prompts shown (and position in responses) } func (m *mockPrompter) GenericBoolPrompt(msg string) (bool, error) { @@ -76,8 +76,9 @@ func TestDependencyInstallerInstall(t *testing.T) { t.Run("Success", func(t *testing.T) { gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAcc.Address.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -85,7 +86,7 @@ func TestDependencyInstallerInstall(t *testing.T) { tests.ContractHelloString.Name: tests.ContractHelloString.Source, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -94,13 +95,14 @@ func TestDependencyInstallerInstall(t *testing.T) { config.TestnetNetwork.Name: gw.Mock, config.MainnetNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } err := di.Install() @@ -166,8 +168,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { state.Dependencies().AddOrUpdate(dep) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAcc.Address.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -175,7 +178,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": contractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -194,6 +197,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: &mockPrompter{responses: []bool{}}, } @@ -238,8 +242,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { state.Dependencies().AddOrUpdate(dep) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -247,7 +252,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // Mock prompter that returns true (user says "yes") @@ -269,6 +274,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: mockPrompter, } @@ -313,8 +319,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { state.Dependencies().AddOrUpdate(dep) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -322,7 +329,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // Mock prompter that returns false (user says "no") @@ -344,6 +351,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: mockPrompter, } @@ -382,8 +390,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { state.Dependencies().AddOrUpdate(dep) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -391,7 +400,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, // Network has new version } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -411,6 +420,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), } err := di.Install() @@ -462,8 +472,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -471,7 +482,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, // Network has new version } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -491,6 +502,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), } err = di.Install() @@ -543,8 +555,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -552,7 +565,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, // Network has new version } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -572,6 +585,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), } err = di.Install() @@ -608,8 +622,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { state.Dependencies().AddOrUpdate(dep) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -617,7 +632,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // No prompter needed - --update auto-accepts @@ -639,6 +654,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), } err := di.Install() @@ -694,8 +710,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -703,7 +720,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": contractCode, // Same version on network } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // Mock prompter - should NOT be called @@ -725,6 +742,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: mockPrompter, } @@ -784,8 +802,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -793,7 +812,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": contractCode, // Network has the correct version } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // No prompter needed - auto-repairs when network agrees with flow.json @@ -815,6 +834,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: mockPrompter, } @@ -866,8 +886,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -875,7 +896,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": contractCode, // Network has the correct version } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // No prompter needed - auto-repairs regardless of flags @@ -898,6 +919,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), prompter: mockPrompter, + blockHeightCache: make(map[string]uint64), } err = di.Install() @@ -952,8 +974,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -961,7 +984,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // Mock prompter that returns true (user says "yes") @@ -983,6 +1006,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: mockPrompter, } @@ -1038,8 +1062,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -1047,7 +1072,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // Mock prompter that returns false (user says "no") @@ -1069,6 +1094,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: mockPrompter, } @@ -1125,8 +1151,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -1134,7 +1161,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // Mock prompter that returns false (user says "no") @@ -1156,6 +1183,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), + blockHeightCache: make(map[string]uint64), prompter: mockPrompter, } @@ -1203,8 +1231,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.NoError(t, err) gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -1212,7 +1241,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { "Hello": newContractCode, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) // No prompter needed - --update auto-accepts @@ -1234,6 +1263,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), } err = di.Install() @@ -1267,8 +1297,9 @@ func TestDependencyInstallerAdd(t *testing.T) { t.Run("Success", func(t *testing.T) { gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAcc.Address.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -1276,7 +1307,7 @@ func TestDependencyInstallerAdd(t *testing.T) { tests.ContractHelloString.Name: tests.ContractHelloString.Source, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -1285,13 +1316,14 @@ func TestDependencyInstallerAdd(t *testing.T) { config.TestnetNetwork.Name: gw.Mock, config.MainnetNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } sourceStr := fmt.Sprintf("emulator://%s.%s", serviceAddress.String(), tests.ContractHelloString.Name) @@ -1307,16 +1339,18 @@ func TestDependencyInstallerAdd(t *testing.T) { t.Run("Success", func(t *testing.T) { gw := mocks.DefaultMockGateway() - gw.GetAccount.Run(func(args mock.Arguments) { + setupAccountMocks := func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAcc.Address.String()) acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ tests.ContractHelloString.Name: tests.ContractHelloString.Source, } + gw.GetAccountAtBlockHeight.Return(acc, nil) + } - gw.GetAccount.Return(acc, nil) - }) + gw.GetAccount.Run(setupAccountMocks) + gw.GetAccountAtBlockHeight.Run(setupAccountMocks) di := &DependencyInstaller{ Gateways: map[string]gateway.Gateway{ @@ -1324,13 +1358,14 @@ func TestDependencyInstallerAdd(t *testing.T) { config.TestnetNetwork.Name: gw.Mock, config.MainnetNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } dep := config.Dependency{ @@ -1352,8 +1387,9 @@ func TestDependencyInstallerAdd(t *testing.T) { t.Run("Add by core contract name", func(t *testing.T) { gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), "1654653399040a61") acc := tests.NewAccountWithAddress(addr.String()) @@ -1361,7 +1397,7 @@ func TestDependencyInstallerAdd(t *testing.T) { "FlowToken": []byte("access(all) contract FlowToken {}"), } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -1370,13 +1406,14 @@ func TestDependencyInstallerAdd(t *testing.T) { config.TestnetNetwork.Name: gw.Mock, config.MainnetNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } err := di.AddByCoreContractName("FlowToken") @@ -1418,7 +1455,7 @@ func TestDependencyInstallerAddMany(t *testing.T) { t.Run("AddMultipleDependencies", func(t *testing.T) { gw := mocks.DefaultMockGateway() - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress) acc := tests.NewAccountWithAddress(addr.String()) @@ -1426,7 +1463,7 @@ func TestDependencyInstallerAddMany(t *testing.T) { "ContractOne": []byte("access(all) contract ContractOne {}"), "ContractTwo": []byte("access(all) contract ContractTwo {}"), } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -1435,13 +1472,14 @@ func TestDependencyInstallerAddMany(t *testing.T) { config.TestnetNetwork.Name: gw.Mock, config.MainnetNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } err := di.AddMany(dependencies) @@ -1482,15 +1520,18 @@ func TestTransitiveConflictAllowedWithMatchingAlias(t *testing.T) { // Gateways per network gwTestnet := mocks.DefaultMockGateway() + gwTestnet.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) gwMainnet := mocks.DefaultMockGateway() + gwMainnet.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) gwEmulator := mocks.DefaultMockGateway() + gwEmulator.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) // Addresses barAddr := flow.HexToAddress("0x0c") // testnet address hosting Bar fooTestAddr := flow.HexToAddress("0x0b") // testnet Foo address (transitive) - // Testnet GetAccount returns Bar at barAddr and Foo at fooTestAddr - gwTestnet.GetAccount.Run(func(args mock.Arguments) { + // Testnet GetAccountAtBlockHeight returns Bar at barAddr and Foo at fooTestAddr + gwTestnet.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) switch addr.String() { case barAddr.String(): @@ -1498,24 +1539,24 @@ func TestTransitiveConflictAllowedWithMatchingAlias(t *testing.T) { acc.Contracts = map[string][]byte{ "Bar": []byte("import Foo from 0x0b\naccess(all) contract Bar {}"), } - gwTestnet.GetAccount.Return(acc, nil) + gwTestnet.GetAccountAtBlockHeight.Return(acc, nil) case fooTestAddr.String(): acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ "Foo": []byte("access(all) contract Foo {}"), } - gwTestnet.GetAccount.Return(acc, nil) + gwTestnet.GetAccountAtBlockHeight.Return(acc, nil) default: - gwTestnet.GetAccount.Return(nil, fmt.Errorf("not found")) + gwTestnet.GetAccountAtBlockHeight.Return(nil, fmt.Errorf("not found")) } }) // Mainnet/emulator not used for these addresses - gwMainnet.GetAccount.Run(func(args mock.Arguments) { - gwMainnet.GetAccount.Return(nil, fmt.Errorf("not found")) + gwMainnet.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + gwMainnet.GetAccountAtBlockHeight.Return(nil, fmt.Errorf("not found")) }) - gwEmulator.GetAccount.Run(func(args mock.Arguments) { - gwEmulator.GetAccount.Return(nil, fmt.Errorf("not found")) + gwEmulator.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + gwEmulator.GetAccountAtBlockHeight.Return(nil, fmt.Errorf("not found")) }) di := &DependencyInstaller{ @@ -1524,14 +1565,15 @@ func TestTransitiveConflictAllowedWithMatchingAlias(t *testing.T) { config.TestnetNetwork.Name: gwTestnet.Mock, config.MainnetNetwork.Name: gwMainnet.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - dependencies: make(map[string]config.Dependency), - prompter: &mockPrompter{responses: []bool{}}, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), } // Attempt to install Bar from testnet, which imports Foo from testnet transitively @@ -1540,6 +1582,78 @@ func TestTransitiveConflictAllowedWithMatchingAlias(t *testing.T) { assert.NoError(t, err) } +func TestTransitiveConflictErrorsWithoutAlias(t *testing.T) { + logger := output.NewStdoutLogger(output.NoneLog) + _, state, _ := util.TestMocks(t) + + // Pre-install Foo as a mainnet dependency WITHOUT an alias for testnet + state.Dependencies().AddOrUpdate(config.Dependency{ + Name: "Foo", + Source: config.Source{ + NetworkName: config.MainnetNetwork.Name, + Address: flow.HexToAddress("0x0a"), + ContractName: "Foo", + }, + }) + state.Contracts().AddDependencyAsContract(config.Dependency{ + Name: "Foo", + Source: config.Source{ + NetworkName: config.MainnetNetwork.Name, + Address: flow.HexToAddress("0x0a"), + ContractName: "Foo", + }, + }, config.MainnetNetwork.Name) + // NOTE: No alias added - this will cause a conflict + + // Gateways + gwTestnet := mocks.DefaultMockGateway() + gwTestnet.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) + + // Addresses + barAddr := flow.HexToAddress("0x0c") // testnet address hosting Bar + fooTestAddr := flow.HexToAddress("0x0b") // testnet Foo address (different from mainnet 0x0a) + + gwTestnet.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + switch addr.String() { + case barAddr.String(): + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Bar": []byte("import Foo from 0x0b\naccess(all) contract Bar {}"), + } + gwTestnet.GetAccountAtBlockHeight.Return(acc, nil) + case fooTestAddr.String(): + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Foo": []byte("access(all) contract Foo {}"), + } + gwTestnet.GetAccountAtBlockHeight.Return(acc, nil) + default: + gwTestnet.GetAccountAtBlockHeight.Return(nil, fmt.Errorf("not found")) + } + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.TestnetNetwork.Name: gwTestnet.Mock, + }, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + // Attempt to install Bar from testnet, which imports Foo from testnet transitively + // Without a matching alias, this should ERROR (naming conflict) + err := di.AddBySourceString(fmt.Sprintf("%s://%s.%s", config.TestnetNetwork.Name, barAddr.String(), "Bar")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists with a different source") + assert.Contains(t, err.Error(), "naming conflict") +} + func TestDependencyInstallerAliasTracking(t *testing.T) { logger := output.NewStdoutLogger(output.NoneLog) _, state, _ := util.TestMocks(t) @@ -1551,7 +1665,7 @@ func TestDependencyInstallerAliasTracking(t *testing.T) { gw := mocks.DefaultMockGateway() // Mock the same account for both contracts - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAcc.Address.String()) acc := tests.NewAccountWithAddress(addr.String()) @@ -1560,7 +1674,7 @@ func TestDependencyInstallerAliasTracking(t *testing.T) { "ContractTwo": []byte("access(all) contract ContractTwo {}"), } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -1624,14 +1738,15 @@ func TestDependencyFlagsDeploymentAccount(t *testing.T) { t.Run("Valid deployment account - skips prompt", func(t *testing.T) { gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ tests.ContractHelloString.Name: tests.ContractHelloString.Source, } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ @@ -1851,7 +1966,7 @@ func TestAliasedImportHandling(t *testing.T) { t.Run("AliasedImportCreatesCanonicalMapping", func(t *testing.T) { // Testnet GetAccount returns Bar at barAddr and Foo at fooTestAddr - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) switch addr.String() { case barAddr.String(): @@ -1860,13 +1975,13 @@ func TestAliasedImportHandling(t *testing.T) { acc.Contracts = map[string][]byte{ "Bar": []byte("import Foo as FooAlias from 0x0b\naccess(all) contract Bar {}"), } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) case fooTestAddr.String(): acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ "Foo": []byte("access(all) contract Foo {}"), } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) default: gw.GetAccount.Return(nil, fmt.Errorf("not found")) } @@ -1878,13 +1993,14 @@ func TestAliasedImportHandling(t *testing.T) { config.TestnetNetwork.Name: gw.Mock, config.MainnetNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } err := di.AddBySourceString(fmt.Sprintf("%s://%s.%s", config.TestnetNetwork.Name, barAddr.String(), "Bar")) @@ -1917,29 +2033,31 @@ func TestDependencyInstallerWithAlias(t *testing.T) { t.Run("AddBySourceStringWithName", func(t *testing.T) { gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 100}}, nil) - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ "NumberFormatter": []byte("access(all) contract NumberFormatter {}"), } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ Gateways: map[string]gateway.Gateway{ config.EmulatorNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - Name: "NumberFormatterCustom", - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + Name: "NumberFormatterCustom", + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } err := di.AddBySourceString(fmt.Sprintf("%s://%s.%s", config.EmulatorNetwork.Name, serviceAddress.String(), "NumberFormatter")) @@ -1966,27 +2084,28 @@ func TestDependencyInstallerWithAlias(t *testing.T) { t.Run("AddByCoreContractNameWithName", func(t *testing.T) { // Mock the gateway to return FlowToken contract gw := mocks.DefaultMockGateway() - gw.GetAccount.Run(func(args mock.Arguments) { + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { addr := args.Get(1).(flow.Address) acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ "FlowToken": []byte("access(all) contract FlowToken {}"), } - gw.GetAccount.Return(acc, nil) + gw.GetAccountAtBlockHeight.Return(acc, nil) }) di := &DependencyInstaller{ Gateways: map[string]gateway.Gateway{ config.MainnetNetwork.Name: gw.Mock, }, - Logger: logger, - State: state, - SaveState: true, - TargetDir: "", - SkipDeployments: true, - SkipAlias: true, - Name: "FlowTokenCustom", - dependencies: make(map[string]config.Dependency), + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + Name: "FlowTokenCustom", + dependencies: make(map[string]config.Dependency), + blockHeightCache: make(map[string]uint64), } err := di.AddByCoreContractName("FlowToken") @@ -2014,3 +2133,754 @@ func TestDependencyInstallerWithAlias(t *testing.T) { assert.Contains(t, err.Error(), "--name flag is not supported when installing all contracts", "Error message should mention name flag limitation") }) } + +func TestBlockHeightPinning(t *testing.T) { + logger := output.NewStdoutLogger(output.NoneLog) + serviceAddress := flow.HexToAddress("f8d6e0586b0a20c7") + + t.Run("NewDependencyGetsPinnedToCurrentBlockHeight", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{ + BlockHeader: flow.BlockHeader{Height: 12345}, + }, nil) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + acc := tests.NewAccountWithAddress(args.Get(1).(flow.Address).String()) + acc.Contracts = map[string][]byte{ + "MyContract": []byte("access(all) contract MyContract {}"), + } + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + dep := config.Dependency{ + Name: "MyContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "MyContract", + }, + } + + err := di.Add(dep) + assert.NoError(t, err) + + savedDep := state.Dependencies().ByName("MyContract") + assert.Equal(t, uint64(12345), savedDep.BlockHeight) + }) + + t.Run("OldFormatDependencyAutoMigrates", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + contractCode := []byte("access(all) contract LegacyContract {}") + oldDep := config.Dependency{ + Name: "LegacyContract", + BlockHeight: 0, + Hash: computeHash(contractCode), + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "LegacyContract", + }, + } + state.Dependencies().AddOrUpdate(oldDep) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{ + BlockHeader: flow.BlockHeader{Height: 55555}, + }, nil) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{ + "LegacyContract": contractCode, + } + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + err := di.Add(oldDep) + assert.NoError(t, err) + + migratedDep := state.Dependencies().ByName("LegacyContract") + assert.NotNil(t, migratedDep) + assert.Equal(t, uint64(55555), migratedDep.BlockHeight) + }) + + t.Run("FrozenDependencyUsesHistoricalBlockHeight", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + contractCode := []byte("access(all) contract OldVersion {}") + frozenDep := config.Dependency{ + Name: "FrozenContract", + BlockHeight: 10000, + Hash: computeHash(contractCode), + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "FrozenContract", + }, + } + state.Dependencies().AddOrUpdate(frozenDep) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{ + BlockHeader: flow.BlockHeader{Height: 99999}, + }, nil) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + assert.Equal(t, uint64(10000), args.Get(2).(uint64)) + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{"FrozenContract": contractCode} + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + err := di.Add(frozenDep) + assert.NoError(t, err) + + gw.Mock.AssertCalled(t, "GetAccountAtBlockHeight", mock.Anything, serviceAddress, uint64(10000)) + }) + + t.Run("ChangedContractPromptsUpdate", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + oldCode := []byte("access(all) contract OldVersion {}") + newCode := []byte("access(all) contract NewVersion {}") + + existingDep := config.Dependency{ + Name: "UpdatableContract", + BlockHeight: 0, + Hash: computeHash(oldCode), + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "UpdatableContract", + }, + } + state.Dependencies().AddOrUpdate(existingDep) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{ + BlockHeader: flow.BlockHeader{Height: 50000}, + }, nil) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{"UpdatableContract": newCode} + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + prompter := &mockPrompter{responses: []bool{false}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: prompter, + blockHeightCache: make(map[string]uint64), + } + + err := di.Install() + if err != nil { + assert.Contains(t, err.Error(), "file does not exist") + } + + assert.Equal(t, 1, prompter.index) + }) + + t.Run("PinnedDependencyWithUpdateFlagAutoUpdates", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + oldCode := []byte("access(all) contract OldVersion {}") + newCode := []byte("access(all) contract NewVersion {}") + + pinnedDep := config.Dependency{ + Name: "AutoUpdateContract", + BlockHeight: 10000, + Hash: computeHash(oldCode), + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "AutoUpdateContract", + }, + } + state.Dependencies().AddOrUpdate(pinnedDep) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{ + BlockHeader: flow.BlockHeader{Height: 99999}, + }, nil) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{"AutoUpdateContract": newCode} + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + Update: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + err := di.Add(pinnedDep) + assert.NoError(t, err) + + updatedDep := state.Dependencies().ByName("AutoUpdateContract") + assert.Equal(t, uint64(99999), updatedDep.BlockHeight) + assert.Equal(t, computeHash(newCode), updatedDep.Hash) + }) + + t.Run("OutdatedPinWithoutLocalFilePromptsAndUpdatesBlockHeight", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + oldCode := []byte("access(all) contract OldVersion {}") + newCode := []byte("access(all) contract NewVersion {}") + + // flow.json has outdated pin (no local file yet, e.g., after git clone) + outdatedDep := config.Dependency{ + Name: "OutdatedContract", + BlockHeight: 10000, + Hash: computeHash(oldCode), + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "OutdatedContract", + }, + } + state.Dependencies().AddOrUpdate(outdatedDep) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{ + BlockHeader: flow.BlockHeader{Height: 99999}, + }, nil) + + // At pinned block 10000, contract has changed + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{"OutdatedContract": newCode} + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + // User accepts update + prompter := &mockPrompter{responses: []bool{true}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: prompter, + blockHeightCache: make(map[string]uint64), + } + + err := di.Install() + assert.NoError(t, err) + + // Should have prompted + assert.Equal(t, 1, prompter.index) + + // Should update to latest block height and new hash + updatedDep := state.Dependencies().ByName("OutdatedContract") + assert.Equal(t, uint64(99999), updatedDep.BlockHeight) + assert.Equal(t, computeHash(newCode), updatedDep.Hash) + }) + + t.Run("SkipUpdatePromptsWithoutFileInstallsOnChainVersion", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + onChainCode := []byte("access(all) contract ChangedContract {}") + pinnedDep := config.Dependency{ + Name: "ChangedContract", + BlockHeight: 10000, + Hash: "old_hash_different", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "ChangedContract", + }, + } + state.Dependencies().AddOrUpdate(pinnedDep) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{ + BlockHeader: flow.BlockHeader{Height: 50000}, + }, nil) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{"ChangedContract": onChainCode} + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + err := di.Add(pinnedDep) + assert.NoError(t, err) + + savedDep := state.Dependencies().ByName("ChangedContract") + assert.Equal(t, computeHash(onChainCode), savedDep.Hash) + }) + + t.Run("BlockHeightFetchFailureReturnsError", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(nil, fmt.Errorf("network error")) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{ + "TestContract": []byte("access(all) contract TestContract {}"), + } + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + dep := config.Dependency{ + Name: "TestContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "TestContract", + }, + } + + err := di.Add(dep) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get latest block height") + }) + + t.Run("BlockHeightCachedAcrossMultipleDependencies", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + callCount := 0 + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Run(func(args mock.Arguments) { + callCount++ + // Simulate blockchain progressing: each call returns a higher block + block := &flow.Block{BlockHeader: flow.BlockHeader{Height: uint64(10000 + callCount*10)}} + gw.GetLatestBlock.Return(block, nil) + }) + + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "ContractA": []byte("access(all) contract ContractA {}"), + "ContractB": []byte("access(all) contract ContractB {}"), + } + gw.GetAccountAtBlockHeight.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + depA := config.Dependency{ + Name: "ContractA", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "ContractA", + }, + } + + depB := config.Dependency{ + Name: "ContractB", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "ContractB", + }, + } + + err := di.Add(depA) + assert.NoError(t, err) + + err = di.Add(depB) + assert.NoError(t, err) + + // Verify GetLatestBlock was called only ONCE (cached for second dependency) + assert.Equal(t, 1, callCount, "GetLatestBlock should be called only once per network") + + savedDepA := state.Dependencies().ByName("ContractA") + savedDepB := state.Dependencies().ByName("ContractB") + + assert.NotNil(t, savedDepA) + assert.NotNil(t, savedDepB) + + // Both deps should have THE SAME block height (10010 from first call) + assert.Equal(t, uint64(10010), savedDepA.BlockHeight, "ContractA should be pinned to first fetch") + assert.Equal(t, uint64(10010), savedDepB.BlockHeight, "ContractB should reuse cached block height") + assert.Equal(t, savedDepA.BlockHeight, savedDepB.BlockHeight, "All deps in same install should have same block height") + }) + + t.Run("PreSporkBlockHeightWithMatchingHashUpdatesMetadata", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + contractCode := []byte("access(all) contract TestContract { access(all) let name: String; init() { self.name = \"Test\" } }") + + // Add an existing dependency with a pre-spork block height but correct hash + existingDep := config.Dependency{ + Name: "TestContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "TestContract", + }, + BlockHeight: 138158854, // Pre-spork block height + Hash: computeHash(contractCode), // Hash matches current on-chain code + } + state.Dependencies().AddOrUpdate(existingDep) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 280224020}}, nil) + + // Simulate spork error for old block height, success for current block height + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + requestedHeight := args.Get(2).(uint64) // arg 0 = ctx, arg 1 = address, arg 2 = blockHeight + if requestedHeight == 138158854 { + // Old pre-spork block → error + gw.GetAccountAtBlockHeight.Return(nil, fmt.Errorf("not found: block height 138158854 is less than the spork root block height 280224020")) + } else if requestedHeight == 280224020 { + // Current block → success + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{ + "TestContract": contractCode, + } + gw.GetAccountAtBlockHeight.Return(acc, nil) + } + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + Update: false, // NO update flag - but should succeed because hash matches + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + dep := config.Dependency{ + Name: "TestContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "TestContract", + }, + } + + err := di.Add(dep) + assert.NoError(t, err) + + // Verify the block height was updated (metadata fix) + savedDep := state.Dependencies().ByName("TestContract") + assert.NotNil(t, savedDep) + assert.Equal(t, uint64(280224020), savedDep.BlockHeight, "Block height should be updated") + assert.Equal(t, computeHash(contractCode), savedDep.Hash, "Hash should remain the same") + }) + + t.Run("PreSporkBlockHeightWithMismatchedHashAndSkipUpdatePromptsErrors", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + oldCode := []byte("access(all) contract TestContract { access(all) let name: String; init() { self.name = \"OldVersion\" } }") + newCode := []byte("access(all) contract TestContract { access(all) let name: String; init() { self.name = \"NewVersion\" } }") + + // Add an existing dependency with a pre-spork block height and old hash + existingDep := config.Dependency{ + Name: "TestContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "TestContract", + }, + BlockHeight: 138158854, // Pre-spork block height + Hash: computeHash(oldCode), + } + state.Dependencies().AddOrUpdate(existingDep) + + // Create the old file matching the stored hash + filePath := fmt.Sprintf("imports/%s/TestContract.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, oldCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 280224020}}, nil) + + // Simulate pre-spork error then success at current block with NEW hash + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + requestedHeight := args.Get(2).(uint64) + if requestedHeight == 138158854 { + // Old pre-spork block → error + gw.GetAccountAtBlockHeight.Return(nil, fmt.Errorf("not found: block height 138158854 is less than the spork root block height 280224020")) + } else if requestedHeight == 280224020 { + // Current block → success with NEW code + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{ + "TestContract": newCode, + } + gw.GetAccountAtBlockHeight.Return(acc, nil) + } + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: true, // Want to keep frozen, but can't! + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + dep := config.Dependency{ + Name: "TestContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "TestContract", + }, + } + + err = di.Add(dep) + // Should ERROR: pre-spork block not accessible, network has different hash, can't keep frozen + assert.Error(t, err, "Should error when trying to keep frozen with pre-spork block and hash mismatch") + assert.Contains(t, err.Error(), "cannot keep frozen", "Error should mention inability to freeze") + assert.Contains(t, err.Error(), "138158854", "Error should mention the old block height") + assert.Contains(t, err.Error(), "280224020", "Error should mention the new block height") + assert.Contains(t, err.Error(), "no longer accessible", "Error should explain block is not accessible") + }) + + t.Run("PreSporkBlockHeightWithMismatchedHashRequiresUpdateFlag", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + // Add an existing dependency with a pre-spork block height + existingDep := config.Dependency{ + Name: "TestContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "TestContract", + }, + BlockHeight: 138158854, // Pre-spork block height + Hash: "oldhash", + } + state.Dependencies().AddOrUpdate(existingDep) + + contractCode := []byte("access(all) contract TestContract { access(all) let name: String; init() { self.name = \"Test\" } }") + + gw := mocks.DefaultMockGateway() + gw.GetLatestBlock.Return(&flow.Block{BlockHeader: flow.BlockHeader{Height: 280224020}}, nil) + + // Track calls to GetAccountAtBlockHeight + callCount := 0 + gw.GetAccountAtBlockHeight.Run(func(args mock.Arguments) { + callCount++ + requestedHeight := args.Get(2).(uint64) // arg 0 = ctx, arg 1 = address, arg 2 = blockHeight + if requestedHeight == 138158854 { + // Old pre-spork block → error + gw.GetAccountAtBlockHeight.Return(nil, fmt.Errorf("not found: block height 138158854 is less than the spork root block height 280224020")) + } else if requestedHeight == 280224020 { + // Current block → success + acc := tests.NewAccountWithAddress(serviceAddress.String()) + acc.Contracts = map[string][]byte{ + "TestContract": contractCode, + } + gw.GetAccountAtBlockHeight.Return(acc, nil) + } + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{config.EmulatorNetwork.Name: gw.Mock}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + Update: true, // WITH update flag - should succeed + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + dep := config.Dependency{ + Name: "TestContract", + Source: config.Source{ + NetworkName: config.EmulatorNetwork.Name, + Address: serviceAddress, + ContractName: "TestContract", + }, + } + + err := di.Add(dep) + assert.NoError(t, err) + + // Verify that GetAccountAtBlockHeight was called only once + // With --update flag, we skip trying the old block and go straight to latest + assert.Equal(t, 1, callCount, "GetAccountAtBlockHeight should be called once (--update skips old block, goes directly to latest)") + + // Verify the dependency was updated with latest version + savedDep := state.Dependencies().ByName("TestContract") + assert.NotNil(t, savedDep) + assert.Equal(t, uint64(280224020), savedDep.BlockHeight, "Should be updated to current block height") + assert.NotEqual(t, "oldhash", savedDep.Hash, "Hash should be updated") + assert.Equal(t, computeHash(contractCode), savedDep.Hash, "Hash should match the new contract code") + }) + + t.Run("AliasedContractSkipsRediscovery", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + mainnetAddr := flow.HexToAddress("0xf233dcee88fe0abe") + testnetAddr := flow.HexToAddress("0x9a0766d93b6608b7") + + // Add Burner as mainnet dependency + existingBurner := config.Dependency{ + Name: "Burner", + Source: config.Source{ + NetworkName: config.MainnetNetwork.Name, + Address: mainnetAddr, + ContractName: "Burner", + }, + BlockHeight: 95000000, + Hash: "existinghash", + } + state.Dependencies().AddOrUpdate(existingBurner) + + // Add the contract entry with aliases + state.Contracts().AddDependencyAsContract(existingBurner, config.MainnetNetwork.Name) + c, _ := state.Contracts().ByName("Burner") + c.Aliases.Add(config.TestnetNetwork.Name, testnetAddr) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{}, + Logger: logger, + State: state, + SkipDeployments: true, + SkipAlias: true, + dependencies: make(map[string]config.Dependency), + logs: categorizedLogs{}, + prompter: &mockPrompter{responses: []bool{}}, + blockHeightCache: make(map[string]uint64), + } + + // Discover Burner via testnet alias (transitive import scenario) + depViaTestnet := config.Dependency{ + Name: "Burner", + Source: config.Source{ + NetworkName: config.TestnetNetwork.Name, + Address: testnetAddr, + ContractName: "Burner", + }, + } + + err := di.Add(depViaTestnet) + assert.NoError(t, err) + + // Verify: Burner should remain unchanged (alias rediscovery just skips) + savedDep := state.Dependencies().ByName("Burner") + assert.NotNil(t, savedDep) + assert.Equal(t, config.MainnetNetwork.Name, savedDep.Source.NetworkName, "Source should remain mainnet") + assert.Equal(t, mainnetAddr, savedDep.Source.Address, "Address should remain mainnet") + assert.Equal(t, uint64(95000000), savedDep.BlockHeight, "Block height should remain unchanged") + assert.Equal(t, "existinghash", savedDep.Hash, "Hash should remain unchanged") + }) +} + +func computeHash(code []byte) string { + h := sha256.Sum256(code) + return hex.EncodeToString(h[:]) +}