diff --git a/config/contract.go b/config/contract.go index bcfa883d..d04fd3ce 100644 --- a/config/contract.go +++ b/config/contract.go @@ -35,6 +35,7 @@ type Contract struct { Location string Aliases Aliases IsDependency bool + Canonical string // Reference to canonical contract name if this is an alias } // Alias defines an existing pre-deployed contract address for specific network. @@ -74,6 +75,19 @@ func (c *Contract) IsAliased() bool { return len(c.Aliases) > 0 } +// IsAlias checks if this contract is an alias to another contract. +func (c *Contract) IsAlias() bool { + return c.Canonical != "" +} + +// CanonicalName returns the canonical contract name if this is an alias, otherwise returns the contract's own name. +func (c *Contract) CanonicalName() string { + if c.Canonical != "" { + return c.Canonical + } + return c.Name +} + // ByName get contract by name or return an error if it doesn't exist. func (c *Contracts) ByName(name string) (*Contract, error) { for i, contract := range *c { @@ -112,6 +126,30 @@ func (c *Contracts) Remove(name string) error { return nil } +// ValidateCanonical validates that all canonical references are valid. +func (c *Contracts) ValidateCanonical() error { + for _, contract := range *c { + if contract.Canonical != "" { + // Check self-reference + if contract.Canonical == contract.Name { + return fmt.Errorf("contract %s cannot have itself as canonical", contract.Name) + } + } + } + return nil +} + +// GetAliases returns all contracts that have the given contract as their canonical. +func (c *Contracts) GetAliases(canonicalName string) []*Contract { + var aliases []*Contract + for i, contract := range *c { + if contract.Canonical == canonicalName { + aliases = append(aliases, &(*c)[i]) + } + } + return aliases +} + const dependencyManagerDirectory = "imports" const ( diff --git a/config/contract_test.go b/config/contract_test.go index 2b9ab21f..74ceedfa 100644 --- a/config/contract_test.go +++ b/config/contract_test.go @@ -109,3 +109,122 @@ func TestContracts_AddDependencyAsContract(t *testing.T) { assert.Equal(t, "imports/0000000000abcdef/TestContract.cdc", contract.Location) assert.Len(t, contract.Aliases, 1) } + +func TestContract_IsAlias(t *testing.T) { + tests := []struct { + name string + contract Contract + expected bool + }{ + { + name: "contract with canonical is an alias", + contract: Contract{Name: "FUSD1", Canonical: "FUSD"}, + expected: true, + }, + { + name: "contract without canonical is not an alias", + contract: Contract{Name: "FUSD"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.contract.IsAlias()) + }) + } +} + +func TestContract_CanonicalName(t *testing.T) { + tests := []struct { + name string + contract Contract + expected string + }{ + { + name: "alias returns canonical name", + contract: Contract{Name: "FUSD1", Canonical: "FUSD"}, + expected: "FUSD", + }, + { + name: "non-alias returns its own name", + contract: Contract{Name: "FUSD"}, + expected: "FUSD", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.contract.CanonicalName()) + }) + } +} + +func TestContracts_ValidateCanonical(t *testing.T) { + tests := []struct { + name string + contracts Contracts + wantErr bool + errMsg string + }{ + { + name: "valid canonical reference", + contracts: Contracts{ + {Name: "FUSD", Location: "FUSD.cdc"}, + {Name: "FUSD1", Location: "FUSD.cdc", Canonical: "FUSD"}, + }, + wantErr: false, + }, + { + name: "self-referential canonical", + contracts: Contracts{ + {Name: "FUSD", Location: "FUSD.cdc", Canonical: "FUSD"}, + }, + wantErr: true, + errMsg: "contract FUSD cannot have itself as canonical", + }, + { + name: "multiple aliases to same canonical", + contracts: Contracts{ + {Name: "FUSD", Location: "FUSD.cdc"}, + {Name: "FUSD1", Location: "FUSD.cdc", Canonical: "FUSD"}, + {Name: "FUSD2", Location: "FUSD.cdc", Canonical: "FUSD"}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.contracts.ValidateCanonical() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestContracts_GetAliases(t *testing.T) { + contracts := Contracts{ + {Name: "FUSD", Location: "FUSD.cdc"}, + {Name: "FUSD1", Location: "FUSD.cdc", Canonical: "FUSD"}, + {Name: "FUSD2", Location: "FUSD.cdc", Canonical: "FUSD"}, + {Name: "FT", Location: "FT.cdc"}, + {Name: "FT1", Location: "FT.cdc", Canonical: "FT"}, + } + + fusdAliases := contracts.GetAliases("FUSD") + assert.Len(t, fusdAliases, 2) + assert.Equal(t, "FUSD1", fusdAliases[0].Name) + assert.Equal(t, "FUSD2", fusdAliases[1].Name) + + ftAliases := contracts.GetAliases("FT") + assert.Len(t, ftAliases, 1) + assert.Equal(t, "FT1", ftAliases[0].Name) + + noAliases := contracts.GetAliases("NonExistent") + assert.Len(t, noAliases, 0) +} diff --git a/config/json/contract.go b/config/json/contract.go index 804f43cb..5f20837e 100644 --- a/config/json/contract.go +++ b/config/json/contract.go @@ -45,8 +45,9 @@ func (j jsonContracts) transformToConfig() (config.Contracts, error) { contracts = append(contracts, contract) } else { contract := config.Contract{ - Name: contractName, - Location: c.Advanced.Source, + Name: contractName, + Location: c.Advanced.Source, + Canonical: c.Advanced.Canonical, } for network, alias := range c.Advanced.Aliases { address := flow.HexToAddress(alias) @@ -73,8 +74,8 @@ func transformContractsToJSON(contracts config.Contracts) jsonContracts { continue } - // if simple case - if !c.IsAliased() { + // if simple case (no aliases and no canonical) + if !c.IsAliased() && c.Canonical == "" { jsonContracts[c.Name] = jsonContract{ Simple: filepath.ToSlash(c.Location), } @@ -87,8 +88,9 @@ func transformContractsToJSON(contracts config.Contracts) jsonContracts { jsonContracts[c.Name] = jsonContract{ Advanced: jsonContractAdvanced{ - Source: filepath.ToSlash(c.Location), - Aliases: aliases, + Source: filepath.ToSlash(c.Location), + Aliases: aliases, + Canonical: c.Canonical, }, } } @@ -99,8 +101,9 @@ func transformContractsToJSON(contracts config.Contracts) jsonContracts { // jsonContractAdvanced for json parsing advanced config. type jsonContractAdvanced struct { - Source string `json:"source"` - Aliases map[string]string `json:"aliases"` + Source string `json:"source"` + Aliases map[string]string `json:"aliases"` + Canonical string `json:"canonical,omitempty"` } // jsonContract structure for json parsing. diff --git a/flowkit.go b/flowkit.go index 3d1f5f03..1f480814 100644 --- a/flowkit.go +++ b/flowkit.go @@ -289,6 +289,7 @@ func (f *Flowkit) AddContract( importReplacer := project.NewImportReplacer( contracts, state.AliasesForNetwork(f.network), + state.CanonicalContractMapping(), ) program, err = importReplacer.Replace(program) @@ -833,6 +834,7 @@ func (f *Flowkit) ExecuteScript(ctx context.Context, script Script, query Script importReplacer := project.NewImportReplacer( contracts, state.AliasesForNetwork(f.network), + state.CanonicalContractMapping(), ) if state == nil { @@ -990,6 +992,7 @@ func (f *Flowkit) BuildTransaction( importReplacer := project.NewImportReplacer( contracts, state.AliasesForNetwork(f.network), + state.CanonicalContractMapping(), ) program, err = importReplacer.Replace(program) @@ -1122,6 +1125,7 @@ func (f *Flowkit) ReplaceImportsInScript( importReplacer := project.NewImportReplacer( contracts, state.AliasesForNetwork(f.network), + state.CanonicalContractMapping(), ) program, err := project.NewProgram(script.Code, script.Args, script.Location) diff --git a/project/imports.go b/project/imports.go index 268b0e86..6935b3b8 100644 --- a/project/imports.go +++ b/project/imports.go @@ -32,14 +32,22 @@ type Account interface { // ImportReplacer implements file import replacements functionality for the project contracts with optionally included aliases. type ImportReplacer struct { - contracts []*Contract - aliases LocationAliases + contracts []*Contract + aliases LocationAliases + canonicalMapping map[string]string // maps alias names to their canonical contract names } -func NewImportReplacer(contracts []*Contract, aliases LocationAliases) *ImportReplacer { +func NewImportReplacer(contracts []*Contract, aliases LocationAliases, canonicalMapping ...map[string]string) *ImportReplacer { + canonical := make(map[string]string) + // If canonical mapping is provided, use it + if len(canonicalMapping) > 0 && canonicalMapping[0] != nil { + canonical = canonicalMapping[0] + } + return &ImportReplacer{ - contracts: contracts, - aliases: aliases, + contracts: contracts, + aliases: aliases, + canonicalMapping: canonical, } } @@ -52,13 +60,17 @@ func (i *ImportReplacer) Replace(program *Program) (*Program, error) { importLocation := filepath.Clean(absolutePath(program.Location(), imp)) address, isPath := contractsLocations[importLocation] if isPath { - program.replaceImport(imp, address) + // Check if this import is an alias + canonicalName := i.getCanonicalNameForImport(imp, address) + program.replaceImport(imp, address, canonicalName) continue } // check if import by identifier exists (e.g. import ["X"]) address, isIdentifier := contractsLocations[imp] if isIdentifier { - program.replaceImport(imp, address) + // Check if this import is an alias + canonicalName := i.getCanonicalNameForImport(imp, address) + program.replaceImport(imp, address, canonicalName) continue } @@ -84,6 +96,25 @@ func (i *ImportReplacer) getContractsLocations() map[string]string { return locationAddress } +// getCanonicalNameForImport determines the canonical contract name for an import. +// Returns the canonical name if the import is an alias, otherwise returns the import name. +func (i *ImportReplacer) getCanonicalNameForImport(importName string, address string) string { + // Extract just the contract name from the import path if it's a path + contractName := importName + if filepath.Ext(importName) == ".cdc" { + contractName = filepath.Base(importName) + contractName = contractName[:len(contractName)-4] // Remove .cdc extension + } + + // Check if this is an alias by looking up in canonical mapping + if canonicalName, isAlias := i.canonicalMapping[contractName]; isAlias { + return canonicalName + } + + // Not an alias, return the original contract name + return contractName +} + func absolutePath(basePath, relativePath string) string { return filepath.Join(filepath.Dir(basePath), relativePath) } diff --git a/project/imports_test.go b/project/imports_test.go index 546c2cf1..eb621e54 100644 --- a/project/imports_test.go +++ b/project/imports_test.go @@ -135,4 +135,112 @@ func TestResolver(t *testing.T) { assert.Equal(t, cleanCode(expected), cleanCode(replaced.Code())) }) + t.Run("Resolve imports with canonical aliases", func(t *testing.T) { + // Create contracts - FUSD1 and FUSD2 are alias deployments of FUSD + // In practice, only the canonical contract (FUSD) would be deployed, and + // FUSD1/FUSD2 would be aliases pointing to different addresses where FUSD is deployed + contracts := []*Contract{ + NewContract("FUSD", "./contracts/FUSD.cdc", nil, flow.HexToAddress("0x1"), "", nil), + NewContract("FUSD1", "", nil, flow.HexToAddress("0x2"), "", nil), // Alias deployment + NewContract("FUSD2", "", nil, flow.HexToAddress("0x3"), "", nil), // Another alias deployment + NewContract("FT", "./contracts/FT.cdc", nil, flow.HexToAddress("0x4"), "", nil), // Regular contract + } + + // Canonical mapping to simulate FUSD1 and FUSD2 having FUSD as canonical + canonicalMapping := map[string]string{ + "FUSD1": "FUSD", + "FUSD2": "FUSD", + } + + replacer := &ImportReplacer{ + contracts: contracts, + aliases: nil, + canonicalMapping: canonicalMapping, + } + + t.Run("basic alias replacement", func(t *testing.T) { + code := []byte(` + import "FUSD" + import "FUSD1" + import "FUSD2" + import "FT" + + access(all) contract Test {} + `) + + program, err := NewProgram(code, nil, "./Test.cdc") + require.NoError(t, err) + + replaced, err := replacer.Replace(program) + require.NoError(t, err) + + expected := []byte(` + import FUSD from 0x0000000000000001 + import FUSD as FUSD1 from 0x0000000000000002 + import FUSD as FUSD2 from 0x0000000000000003 + import FT from 0x0000000000000004 + + access(all) contract Test {} + `) + + assert.Equal(t, cleanCode(expected), cleanCode(replaced.Code())) + }) + }) + + t.Run("ConvertAddressImports with aliases", func(t *testing.T) { + code := []byte(` + import FUSD from 0x0000000000000001 + import FUSD as FUSD1 from 0x0000000000000002 + import FUSD as FUSD2 from 0x0000000000000003 + import FT from 0x0000000000000004 + + access(all) contract Test {} + `) + + program, err := NewProgram(code, nil, "./Test.cdc") + require.NoError(t, err) + + // ConvertAddressImports should convert both regular and alias imports back to identifier imports + // For alias imports (import X as Y from 0x...), it should use the alias name (Y) + expected := []byte(` + import "FUSD" + import "FUSD1" + import "FUSD2" + import "FT" + + access(all) contract Test {} + `) + + assert.Equal(t, cleanCode(expected), cleanCode(program.CodeWithUnprocessedImports())) + }) + + t.Run("Import replacer with no canonical mapping", func(t *testing.T) { + // Test that contracts work normally without canonical mapping + contracts := []*Contract{ + NewContract("Token", "./contracts/Token.cdc", nil, flow.HexToAddress("0x1"), "", nil), + } + + replacer := NewImportReplacer(contracts, nil) + + code := []byte(` + import "Token" + + access(all) contract Test {} + `) + + program, err := NewProgram(code, nil, "./Test.cdc") + require.NoError(t, err) + + replaced, err := replacer.Replace(program) + require.NoError(t, err) + + expected := []byte(` + import Token from 0x0000000000000001 + + access(all) contract Test {} + `) + + assert.Equal(t, cleanCode(expected), cleanCode(replaced.Code())) + }) + } diff --git a/project/program.go b/project/program.go index f0fa0a79..658aa519 100644 --- a/project/program.go +++ b/project/program.go @@ -42,13 +42,18 @@ func NewProgram(code []byte, args []cadence.Value, location string) (*Program, e return nil, err } - return &Program{ + p := &Program{ code: code, args: args, location: location, astProgram: astProgram, codeWithUnprocessedImports: code, // has converted import syntax e.g. 'import "Foo"' - }, nil + } + + // Convert address imports to identifier imports for codeWithUnprocessedImports + p.ConvertAddressImports() + + return p, nil } func (p *Program) AddressImportDeclarations() []*ast.ImportDeclaration { @@ -88,15 +93,45 @@ func (p *Program) HasImports() bool { return len(p.imports()) > 0 } -func (p *Program) replaceImport(from string, to string) *Program { +func (p *Program) replaceImport(from string, to string, canonicalName ...string) *Program { code := string(p.Code()) - pathRegex := regexp.MustCompile(fmt.Sprintf(`import\s+(\w+)\s+from\s+"%s"`, from)) - identifierRegex := regexp.MustCompile(fmt.Sprintf(`import\s+"(%s)"`, from)) + // Extract the import name from the 'from' parameter + importName := from + if regexp.MustCompile(`\.cdc$`).MatchString(from) { + // If it's a path, extract the contract name + matches := regexp.MustCompile(`([^/]+)\.cdc$`).FindStringSubmatch(from) + if len(matches) > 1 { + importName = matches[1] + } + } - replacement := fmt.Sprintf(`import $1 from 0x%s`, to) - code = pathRegex.ReplaceAllString(code, replacement) - code = identifierRegex.ReplaceAllString(code, replacement) + // Handle path imports (e.g., import X from "./X.cdc") + pathRegex := regexp.MustCompile(fmt.Sprintf(`import\s+(\w+)\s+from\s+"%s"`, regexp.QuoteMeta(from))) + // Handle identifier imports (e.g., import "X") + identifierRegex := regexp.MustCompile(fmt.Sprintf(`import\s+"%s"`, regexp.QuoteMeta(from))) + + // Determine if we need alias syntax + canonical := "" + if len(canonicalName) > 0 { + canonical = canonicalName[0] + } + + if canonical != "" && canonical != importName { + // Use alias syntax: import CanonicalName as AliasName from 0xAddress + pathReplacement := fmt.Sprintf(`import %s as $1 from 0x%s`, canonical, to) + identifierReplacement := fmt.Sprintf(`import %s as %s from 0x%s`, canonical, importName, to) + + code = pathRegex.ReplaceAllString(code, pathReplacement) + code = identifierRegex.ReplaceAllString(code, identifierReplacement) + } else { + // Use regular syntax: import Name from 0xAddress + replacement := fmt.Sprintf(`import $1 from 0x%s`, to) + code = pathRegex.ReplaceAllString(code, replacement) + + identifierReplacement := fmt.Sprintf(`import %s from 0x%s`, importName, to) + code = identifierRegex.ReplaceAllString(code, identifierReplacement) + } p.code = []byte(code) p.reload() @@ -138,8 +173,13 @@ func (p *Program) Name() (string, error) { func (p *Program) ConvertAddressImports() { code := string(p.code) + // Handle regular imports: import X from 0xAddress addressImportRegex := regexp.MustCompile(`import\s+(\w+)\s+from\s+0x[0-9a-fA-F]+`) modifiedCode := addressImportRegex.ReplaceAllString(code, `import "$1"`) + + // Handle alias imports: import X as Y from 0xAddress -> import "Y" + aliasImportRegex := regexp.MustCompile(`import\s+\w+\s+as\s+(\w+)\s+from\s+0x[0-9a-fA-F]+`) + modifiedCode = aliasImportRegex.ReplaceAllString(modifiedCode, `import "$1"`) p.codeWithUnprocessedImports = []byte(modifiedCode) } diff --git a/schema.json b/schema.json index 48d412c1..4f9b2453 100644 --- a/schema.json +++ b/schema.json @@ -195,6 +195,9 @@ } }, "type": "object" + }, + "canonical": { + "type": "string" } }, "additionalProperties": false, diff --git a/state.go b/state.go index 9360fb5f..ee95b103 100644 --- a/state.go +++ b/state.go @@ -288,6 +288,36 @@ func (p *State) AliasesForNetwork(network config.Network) project.LocationAliase return aliases } +// CanonicalContractMapping returns a mapping of alias contract names to their canonical contract names. +// +// Example usage with import aliases: +// +// // Given flow.json with: +// // "FUSD": { "source": "./FUSD.cdc", "aliases": {...} } +// // "FUSD_v2": { "source": "./FUSD.cdc", "canonical": "FUSD", "aliases": {...} } +// +// state, _ := flowkit.Load([]string{"flow.json"}, flowkit.NewReaderWriter()) +// +// // Get canonical mappings +// mapping := state.CanonicalContractMapping() +// // Returns: {"FUSD_v2": "FUSD"} +// +// // Use with import replacer +// contracts, _ := state.DeploymentContractsByNetwork(network) +// aliases := state.AliasesForNetwork(network) +// replacer := project.NewImportReplacer(contracts, aliases, mapping) +// +// // Process imports: "FUSD_v2" → "import FUSD as FUSD_v2 from 0x..." +func (p *State) CanonicalContractMapping() map[string]string { + canonicalMapping := make(map[string]string) + for _, contract := range p.conf.Contracts { + if contract.IsAlias() { + canonicalMapping[contract.Name] = contract.Canonical + } + } + return canonicalMapping +} + // Load loads a project configuration and returns the resulting project. func Load(configFilePaths []string, readerWriter ReaderWriter) (*State, error) { confLoader := config.NewLoader(readerWriter)