diff --git a/cmd/contract/README.md b/cmd/contract/README.md new file mode 100644 index 00000000..2a5a6c7b --- /dev/null +++ b/cmd/contract/README.md @@ -0,0 +1,221 @@ +# Contract Deployment + +The `cre contract deploy` command compiles and deploys smart contracts to the blockchain. + +## Prerequisites + +### Install Foundry + +Foundry is required to compile Solidity contracts. Install it by running: + +```bash +curl -L https://foundry.paradigm.xyz | bash +foundryup +``` + +## Quick Start + +### Step 1: Initialize a New Project + +```bash +cre init +``` + +When prompted, select: +- **Language**: `Golang` +- **Template**: `Custom data feed: Updating on-chain data periodically using offchain API data` +- **RPC URL**: Enter your Sepolia RPC URL (e.g., `https://ethereum-sepolia-rpc.publicnode.com`) + +Example: +``` +Project name? [my-project]: my-project +✔ Golang +✔ Custom data feed: Updating on-chain data periodically using offchain API data +Sepolia RPC URL? [https://ethereum-sepolia-rpc.publicnode.com]: https://ethereum-sepolia-rpc.publicnode.com +Workflow name? [my-workflow]: my-workflow +``` + +### Step 2: Navigate to Your Project + +```bash +cd my-project +``` + +### Step 3: Configure Your Private Key + +Edit the `.env` file and add your private key: + +```bash +CRE_ETH_PRIVATE_KEY=0x...your_private_key... +``` + +> ⚠️ **Important**: Never commit your private key to version control. The `.env` file should be in `.gitignore`. + +### Step 4: Deploy Contracts + +```bash +cre contract deploy +``` + +The command will: +1. Compile all Solidity contracts using Foundry +2. Display the contracts to be deployed +3. Ask for confirmation +4. Deploy each contract and display the address + +Example output: +``` +Compiling contracts with Foundry... +Compiler run successful! + +Contract Deployment +=================== +Project Root: /path/to/my-project +Target Chain: ethereum-testnet-sepolia +Config File: /path/to/my-project/contracts/contracts.yaml + +Contracts: + - BalanceReader (balance_reader): deploy + - MessageEmitter (message_emitter): deploy + - ReserveManager (reserve_manager): deploy + - IERC20 (ierc20): skip + +Deploying BalanceReader... + Address: 0xf95DF418d791e8da0D12C6E88Bc4443a056A9E22 + Tx Hash: 0x62815513c355be832cab16bb9840d1c26de0e074efe65441591a09b28c7e68a2 + +Deploying MessageEmitter... + Address: 0x63Cb753C77908cbD2Cc9A4B37B0D6DC7F5fF00a1 + Tx Hash: 0x741bb347f74aaa85ae9c97bcb670de4cf2b6f638a0a14a837ff0508e6f3c0c94 + +Deploying ReserveManager... + Address: 0x8cFc0495AaAAF2fa6BC39eaaA5952d5027e79C88 + Tx Hash: 0xb47ffb1f8d80e17852644cd0ea38d4dfed8a287a81753c6fcf13ff7867f75683 + +[OK] Contracts deployed successfully +Deployed addresses saved to: /path/to/my-project/contracts/deployed_contracts.yaml +``` + +## Command Options + +```bash +cre contract deploy [flags] +``` + +| Flag | Description | +|------|-------------| +| `--chain` | Override the target chain from contracts.yaml | +| `--dry-run` | Validate configuration without deploying | +| `--yes` | Skip confirmation prompt | +| `-v, --verbose` | Show detailed logs | + +### Examples + +```bash +# Deploy with confirmation prompt +cre contract deploy + +# Deploy without confirmation +cre contract deploy --yes + +# Validate without deploying +cre contract deploy --dry-run + +# Deploy to a different chain +cre contract deploy --chain ethereum-testnet-sepolia +``` + +## Configuration + +### contracts.yaml + +Located at `contracts/contracts.yaml`, this file defines which contracts to deploy: + +```yaml +chain: ethereum-testnet-sepolia +contracts: + - name: BalanceReader + package: balance_reader + deploy: true + constructor: [] + + - name: MessageEmitter + package: message_emitter + deploy: true + constructor: [] + + - name: ReserveManager + package: reserve_manager + deploy: true + constructor: [] + + - name: IERC20 + package: ierc20 + deploy: false # Skip deployment (interface only) +``` + +### deployed_contracts.yaml + +After deployment, contract addresses are saved to `contracts/deployed_contracts.yaml`: + +```yaml +chain_id: 16015286601757825753 +chain_name: ethereum-testnet-sepolia +timestamp: "2026-01-06T22:00:37Z" +contracts: + BalanceReader: + address: 0xf95DF418d791e8da0D12C6E88Bc4443a056A9E22 + tx_hash: 0x62815513c355be832cab16bb9840d1c26de0e074efe65441591a09b28c7e68a2 + MessageEmitter: + address: 0x63Cb753C77908cbD2Cc9A4B37B0D6DC7F5fF00a1 + tx_hash: 0x741bb347f74aaa85ae9c97bcb670de4cf2b6f638a0a14a837ff0508e6f3c0c94 + ReserveManager: + address: 0x8cFc0495AaAAF2fa6BC39eaaA5952d5027e79C88 + tx_hash: 0xb47ffb1f8d80e17852644cd0ea38d4dfed8a287a81753c6fcf13ff7867f75683 +``` + +## Using Deployed Addresses in Workflows + +After deployment, you can reference contract addresses in your workflow configuration using placeholders: + +```json +{ + "contract_address": "{{contracts.MessageEmitter.address}}" +} +``` + +These placeholders are automatically replaced with actual addresses when you run `cre workflow deploy`. + +## Troubleshooting + +### "forge is required but not installed" + +Install Foundry: +```bash +curl -L https://foundry.paradigm.xyz | bash +foundryup +``` + +### "failed to parse private key" + +Ensure your `.env` file contains a valid private key: +```bash +CRE_ETH_PRIVATE_KEY=0x... # Must be a valid hex string +``` + +### "no RPC URL configured for chain" + +Check that your `project.yaml` has the correct RPC configuration: +```yaml +rpcs: + - chain_name: ethereum-testnet-sepolia + url: https://ethereum-sepolia-rpc.publicnode.com +``` + +### Compilation errors + +If contracts fail to compile, check: +1. Solidity version compatibility in your `.sol` files +2. All imported files exist in `contracts/evm/src/` +3. Import paths are correct (use relative paths like `./keystone/IReceiver.sol`) + diff --git a/cmd/contract/contract.go b/cmd/contract/contract.go new file mode 100644 index 00000000..6a06bdac --- /dev/null +++ b/cmd/contract/contract.go @@ -0,0 +1,20 @@ +package contract + +import ( + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/cmd/contract/deploy" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func New(runtimeContext *runtime.Context) *cobra.Command { + contractCmd := &cobra.Command{ + Use: "contract", + Short: "Manages smart contracts", + Long: `The contract command allows you to deploy and manage smart contracts at the project level.`, + } + + contractCmd.AddCommand(deploy.New(runtimeContext)) + + return contractCmd +} diff --git a/cmd/contract/deploy/config.go b/cmd/contract/deploy/config.go new file mode 100644 index 00000000..143ebf2d --- /dev/null +++ b/cmd/contract/deploy/config.go @@ -0,0 +1,156 @@ +package deploy + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// ContractsConfig represents the structure of contracts.yaml +type ContractsConfig struct { + Chain string `yaml:"chain"` + Contracts []ContractConfig `yaml:"contracts"` +} + +// ContractConfig represents a single contract configuration +type ContractConfig struct { + Name string `yaml:"name"` + Package string `yaml:"package"` + Deploy bool `yaml:"deploy"` + Constructor []ConstructorArg `yaml:"constructor"` +} + +// ConstructorArg represents a constructor argument +type ConstructorArg struct { + Type string `yaml:"type"` + Value string `yaml:"value"` +} + +// ParseContractsConfig reads and parses the contracts.yaml file +func ParseContractsConfig(path string) (*ContractsConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var config ContractsConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + return &config, nil +} + +// Validate validates the contracts configuration +func (c *ContractsConfig) Validate() error { + if strings.TrimSpace(c.Chain) == "" { + return fmt.Errorf("chain is required") + } + + // Validate chain name is valid + if err := settings.IsValidChainName(c.Chain); err != nil { + return fmt.Errorf("invalid chain name: %w", err) + } + + if len(c.Contracts) == 0 { + return fmt.Errorf("at least one contract must be defined") + } + + seenNames := make(map[string]bool) + for i, contract := range c.Contracts { + if strings.TrimSpace(contract.Name) == "" { + return fmt.Errorf("contract[%d]: name is required", i) + } + + if seenNames[contract.Name] { + return fmt.Errorf("duplicate contract name: %s", contract.Name) + } + seenNames[contract.Name] = true + + if strings.TrimSpace(contract.Package) == "" { + return fmt.Errorf("contract[%d] (%s): package is required", i, contract.Name) + } + + // Validate constructor arguments + for j, arg := range contract.Constructor { + if strings.TrimSpace(arg.Type) == "" { + return fmt.Errorf("contract[%d] (%s): constructor[%d]: type is required", i, contract.Name, j) + } + + if !isValidSolidityType(arg.Type) { + return fmt.Errorf("contract[%d] (%s): constructor[%d]: invalid type %q", i, contract.Name, j, arg.Type) + } + } + } + + return nil +} + +// GetContractsToDeploy returns contracts that have deploy: true +func (c *ContractsConfig) GetContractsToDeploy() []ContractConfig { + var contracts []ContractConfig + for _, contract := range c.Contracts { + if contract.Deploy { + contracts = append(contracts, contract) + } + } + return contracts +} + +// GetContractByName returns a contract by name +func (c *ContractsConfig) GetContractByName(name string) *ContractConfig { + for _, contract := range c.Contracts { + if contract.Name == name { + return &contract + } + } + return nil +} + +// isValidSolidityType checks if the type is a valid Solidity type +func isValidSolidityType(t string) bool { + validTypes := map[string]bool{ + // Basic types + "address": true, + "bool": true, + "string": true, + "bytes": true, + + // Integers + "int": true, + "int8": true, + "int16": true, + "int32": true, + "int64": true, + "int128": true, + "int256": true, + + "uint": true, + "uint8": true, + "uint16": true, + "uint32": true, + "uint64": true, + "uint128": true, + "uint256": true, + + // Fixed-size bytes + "bytes1": true, + "bytes2": true, + "bytes4": true, + "bytes8": true, + "bytes16": true, + "bytes32": true, + } + + // Check for array types (e.g., "address[]", "uint256[]") + if strings.HasSuffix(t, "[]") { + baseType := strings.TrimSuffix(t, "[]") + return validTypes[baseType] + } + + return validTypes[t] +} diff --git a/cmd/contract/deploy/deploy.go b/cmd/contract/deploy/deploy.go new file mode 100644 index 00000000..39b6de9d --- /dev/null +++ b/cmd/contract/deploy/deploy.go @@ -0,0 +1,460 @@ +package deploy + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/smartcontractkit/chainlink-testing-framework/seth" + + "github.com/smartcontractkit/cre-cli/cmd/client" + "github.com/smartcontractkit/cre-cli/internal/prompt" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/validation" +) + +const ( + contractsFolder = "contracts" + contractsConfigFile = "contracts.yaml" + deployedContractsFile = "deployed_contracts.yaml" +) + +type Inputs struct { + ProjectRoot string `validate:"required,dir"` + ContractsPath string `validate:"required,dir"` + ConfigPath string `validate:"required,file"` + OutputPath string `validate:"required"` + ChainOverride string `validate:"omitempty"` + DryRun bool + SkipConfirmation bool +} + +type handler struct { + log *zerolog.Logger + v *viper.Viper + settings *settings.Settings + inputs Inputs + stdin io.Reader + runtimeContext *runtime.Context + validated bool + config *ContractsConfig +} + +func New(runtimeContext *runtime.Context) *cobra.Command { + var deployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploys smart contracts to the blockchain", + Long: `Deploys smart contracts defined in contracts/contracts.yaml to the target blockchain. +The deployed contract addresses are stored in contracts/deployed_contracts.yaml +and can be referenced in workflow configurations using placeholders.`, + Example: ` cre contract deploy + cre contract deploy --dry-run + cre contract deploy --chain ethereum-testnet-sepolia`, + RunE: func(cmd *cobra.Command, args []string) error { + h := newHandler(runtimeContext, cmd.InOrStdin()) + + inputs, err := h.ResolveInputs(runtimeContext.Viper) + if err != nil { + return err + } + h.inputs = inputs + + if err := h.ValidateInputs(); err != nil { + return err + } + return h.Execute() + }, + } + + deployCmd.Flags().Bool("dry-run", false, "Validate configuration without deploying contracts") + deployCmd.Flags().String("chain", "", "Override the target chain from contracts.yaml") + settings.AddSkipConfirmation(deployCmd) + + return deployCmd +} + +func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { + return &handler{ + log: ctx.Logger, + v: ctx.Viper, + settings: ctx.Settings, + stdin: stdin, + runtimeContext: ctx, + validated: false, + } +} + +func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { + projectRoot := h.settings.ProjectRoot + + contractsPath := filepath.Join(projectRoot, contractsFolder) + configPath := filepath.Join(contractsPath, contractsConfigFile) + outputPath := filepath.Join(contractsPath, deployedContractsFile) + + return Inputs{ + ProjectRoot: projectRoot, + ContractsPath: contractsPath, + ConfigPath: configPath, + OutputPath: outputPath, + ChainOverride: v.GetString("chain"), + DryRun: v.GetBool("dry-run"), + SkipConfirmation: v.GetBool(settings.Flags.SkipConfirmation.Name), + }, nil +} + +func (h *handler) ValidateInputs() error { + validate, err := validation.NewValidator() + if err != nil { + return fmt.Errorf("failed to initialize validator: %w", err) + } + + // Check contracts folder exists + if _, err := os.Stat(h.inputs.ContractsPath); os.IsNotExist(err) { + return fmt.Errorf("contracts folder not found at %s. Create a contracts/ folder in your project root", h.inputs.ContractsPath) + } + + // Check contracts.yaml exists + if _, err := os.Stat(h.inputs.ConfigPath); os.IsNotExist(err) { + return fmt.Errorf("contracts.yaml not found at %s. Create a contracts.yaml file in your contracts/ folder", h.inputs.ConfigPath) + } + + if err := validate.Struct(h.inputs); err != nil { + return validate.ParseValidationErrors(err) + } + + // Parse and validate the contracts config + config, err := ParseContractsConfig(h.inputs.ConfigPath) + if err != nil { + return fmt.Errorf("failed to parse contracts.yaml: %w", err) + } + + // Apply chain override if specified + if h.inputs.ChainOverride != "" { + config.Chain = h.inputs.ChainOverride + } + + if err := config.Validate(); err != nil { + return fmt.Errorf("invalid contracts.yaml: %w", err) + } + + h.config = config + h.validated = true + return nil +} + +func (h *handler) Execute() error { + if !h.validated { + return fmt.Errorf("inputs not validated") + } + + // Check if forge is installed (required for compiling contracts) + forgePath, err := h.checkForgeInstalled() + if err != nil { + return h.promptForgeInstall() + } + h.log.Debug().Str("forge", forgePath).Msg("Found forge installation") + + // Compile contracts using forge + if err := h.compileContracts(); err != nil { + return fmt.Errorf("failed to compile contracts: %w", err) + } + + h.displayDeploymentDetails() + + if h.inputs.DryRun { + fmt.Println("\n[DRY RUN] Configuration validated successfully. No contracts were deployed.") + return nil + } + + // Ask for confirmation before deploying + if !h.inputs.SkipConfirmation { + contractsToDeploy := h.config.GetContractsToDeploy() + if len(contractsToDeploy) == 0 { + fmt.Println("\nNo contracts marked for deployment.") + return nil + } + + confirm, err := prompt.YesNoPrompt(os.Stdin, fmt.Sprintf("Deploy %d contract(s) to %s?", len(contractsToDeploy), h.config.Chain)) + if err != nil { + return err + } + if !confirm { + return fmt.Errorf("deployment cancelled by user") + } + } + + // Deploy contracts + results, err := h.deployContracts() + if err != nil { + return fmt.Errorf("failed to deploy contracts: %w", err) + } + + // Write deployed contracts file + if err := WriteDeployedContracts(h.inputs.OutputPath, h.config.Chain, results); err != nil { + return fmt.Errorf("failed to write deployed contracts file: %w", err) + } + + fmt.Printf("\n[OK] Contracts deployed successfully\n") + fmt.Printf("Deployed addresses saved to: %s\n", h.inputs.OutputPath) + + return nil +} + +func (h *handler) displayDeploymentDetails() { + fmt.Printf("\nContract Deployment\n") + fmt.Printf("===================\n") + fmt.Printf("Project Root: %s\n", h.inputs.ProjectRoot) + fmt.Printf("Target Chain: %s\n", h.config.Chain) + fmt.Printf("Config File: %s\n", h.inputs.ConfigPath) + fmt.Printf("\nContracts:\n") + + for _, contract := range h.config.Contracts { + status := "skip" + if contract.Deploy { + status = "deploy" + } + fmt.Printf(" - %s (%s): %s\n", contract.Name, contract.Package, status) + } +} + +func (h *handler) deployContracts() ([]DeploymentResult, error) { + contractsToDeploy := h.config.GetContractsToDeploy() + if len(contractsToDeploy) == 0 { + return nil, nil + } + + // Get RPC URL for the target chain + rpcURL, err := h.getRPCForChain(h.config.Chain) + if err != nil { + return nil, fmt.Errorf("failed to get RPC URL for chain %s: %w", h.config.Chain, err) + } + + // Create eth client using the existing NewEthClientFromEnv function + ethClient, err := client.NewEthClientFromEnv(h.v, h.log, rpcURL) + if err != nil { + return nil, fmt.Errorf("failed to create eth client: %w", err) + } + + // Load contract bindings into the client's ContractStore (from chainlink-evm) + if err := client.LoadContracts(h.log, ethClient); err != nil { + return nil, fmt.Errorf("failed to load contract bindings: %w", err) + } + + // Load local contracts from project's ABI/BIN files + abiDir := filepath.Join(h.inputs.ContractsPath, "evm", "src", "abi") + if err := h.loadLocalContracts(ethClient, abiDir, contractsToDeploy); err != nil { + h.log.Warn().Err(err).Msg("Failed to load some local contracts") + } + + deployer := NewContractDeployer(h.log, ethClient, h.inputs.ContractsPath) + + var results []DeploymentResult + for _, contract := range contractsToDeploy { + fmt.Printf("\nDeploying %s...\n", contract.Name) + + result, err := deployer.Deploy(contract) + if err != nil { + return nil, fmt.Errorf("failed to deploy %s: %w", contract.Name, err) + } + + results = append(results, *result) + fmt.Printf(" Address: %s\n", result.Address) + fmt.Printf(" Tx Hash: %s\n", result.TxHash) + } + + return results, nil +} + +// loadLocalContracts loads contracts from local ABI and BIN files into the ContractStore +func (h *handler) loadLocalContracts(ethClient *seth.Client, abiDir string, contracts []ContractConfig) error { + for _, contract := range contracts { + // Skip if already loaded (from chainlink-evm) + if _, ok := ethClient.ContractStore.GetABI(contract.Name); ok { + h.log.Debug().Str("contract", contract.Name).Msg("Contract already loaded from chainlink-evm") + continue + } + + // Try to load from local ABI and BIN files + abiPath := filepath.Join(abiDir, contract.Name+".abi") + binPath := filepath.Join(abiDir, contract.Name+".bin") + + // Read ABI file + abiData, err := os.ReadFile(abiPath) + if err != nil { + h.log.Debug().Str("contract", contract.Name).Err(err).Msg("No local ABI file found") + continue + } + + // Parse ABI + contractABI, err := abi.JSON(strings.NewReader(string(abiData))) + if err != nil { + h.log.Warn().Str("contract", contract.Name).Err(err).Msg("Failed to parse ABI") + continue + } + + // Read BIN file (bytecode) + binData, err := os.ReadFile(binPath) + if err != nil { + h.log.Warn().Str("contract", contract.Name).Err(err).Msg("No BIN file found - contract cannot be deployed. Compile your Solidity contracts to generate .bin files") + continue + } + + // Add to ContractStore + ethClient.ContractStore.AddABI(contract.Name, contractABI) + ethClient.ContractStore.AddBIN(contract.Name, common.FromHex(string(binData))) + h.log.Debug().Str("contract", contract.Name).Msg("Loaded contract from local ABI/BIN files") + } + + return nil +} + +func (h *handler) getRPCForChain(chainName string) (string, error) { + if h.settings == nil || len(h.settings.Workflow.RPCs) == 0 { + return "", fmt.Errorf("no RPC endpoints configured. Add RPCs to your project.yaml") + } + + for _, rpc := range h.settings.Workflow.RPCs { + if rpc.ChainName == chainName { + return rpc.Url, nil + } + } + + return "", fmt.Errorf("no RPC URL configured for chain %s", chainName) +} + +// checkForgeInstalled checks if Foundry's forge is installed and available in PATH +func (h *handler) checkForgeInstalled() (string, error) { + forgePath, err := exec.LookPath("forge") + if err != nil { + return "", fmt.Errorf("forge not found in PATH") + } + return forgePath, nil +} + +// promptForgeInstall displays installation instructions for Foundry +func (h *handler) promptForgeInstall() error { + fmt.Println("\n⚠️ Foundry (forge) is required to compile smart contracts") + fmt.Println("\nFoundry is a blazing fast toolkit for Ethereum development.") + fmt.Println("\nTo install Foundry, run:") + fmt.Println("\n curl -L https://foundry.paradigm.xyz | bash") + fmt.Println(" foundryup") + fmt.Println("\nFor more information, visit: https://book.getfoundry.sh/getting-started/installation") + fmt.Println("\nAfter installation, run 'cre contract deploy' again.") + return fmt.Errorf("forge is required but not installed") +} + +// compileContracts runs forge build to compile Solidity contracts +func (h *handler) compileContracts() error { + evmDir := filepath.Join(h.inputs.ContractsPath, "evm") + + // Check if evm directory exists (indicates Solidity contracts are present) + if _, err := os.Stat(evmDir); os.IsNotExist(err) { + h.log.Debug().Msg("No evm directory found, skipping compilation") + return nil + } + + // Check if there are any .sol files + solFiles, err := filepath.Glob(filepath.Join(evmDir, "src", "*.sol")) + if err != nil || len(solFiles) == 0 { + h.log.Debug().Msg("No Solidity files found, skipping compilation") + return nil + } + + fmt.Println("\nCompiling contracts with Foundry...") + + // Run forge build + cmd := exec.Command("forge", "build") + cmd.Dir = evmDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("forge build failed: %w", err) + } + + fmt.Println("Compilation successful!") + + // Extract bytecode from compiled artifacts + if err := h.extractBytecode(evmDir); err != nil { + return fmt.Errorf("failed to extract bytecode: %w", err) + } + + return nil +} + +// forgeArtifact represents the structure of a Forge compilation artifact +type forgeArtifact struct { + Bytecode struct { + Object string `json:"object"` + } `json:"bytecode"` +} + +// extractBytecode extracts bytecode from Forge compilation output to .bin files +func (h *handler) extractBytecode(evmDir string) error { + outDir := filepath.Join(evmDir, "out") + abiDir := filepath.Join(evmDir, "src", "abi") + + // Check if out directory exists + if _, err := os.Stat(outDir); os.IsNotExist(err) { + return fmt.Errorf("forge output directory not found at %s", outDir) + } + + // Ensure abi directory exists + if err := os.MkdirAll(abiDir, 0750); err != nil { + return fmt.Errorf("failed to create abi directory: %w", err) + } + + // Get contracts to deploy + contractsToDeploy := h.config.GetContractsToDeploy() + + for _, contract := range contractsToDeploy { + // Look for the compiled artifact + artifactPath := filepath.Join(outDir, contract.Name+".sol", contract.Name+".json") + if _, err := os.Stat(artifactPath); os.IsNotExist(err) { + h.log.Debug().Str("contract", contract.Name).Msg("No compiled artifact found, skipping bytecode extraction") + continue + } + + // Read the artifact + artifactData, err := os.ReadFile(artifactPath) + if err != nil { + h.log.Warn().Str("contract", contract.Name).Err(err).Msg("Failed to read artifact") + continue + } + + // Parse the artifact + var artifact forgeArtifact + if err := json.Unmarshal(artifactData, &artifact); err != nil { + h.log.Warn().Str("contract", contract.Name).Err(err).Msg("Failed to parse artifact") + continue + } + + // Extract bytecode (remove trailing newline if present) + bytecode := strings.TrimSpace(artifact.Bytecode.Object) + if bytecode == "" || bytecode == "0x" { + h.log.Debug().Str("contract", contract.Name).Msg("No bytecode in artifact (might be an interface)") + continue + } + + // Write bytecode to .bin file + binPath := filepath.Join(abiDir, contract.Name+".bin") + if err := os.WriteFile(binPath, []byte(bytecode), 0600); err != nil { + return fmt.Errorf("failed to write bytecode for %s: %w", contract.Name, err) + } + + h.log.Debug().Str("contract", contract.Name).Str("path", binPath).Msg("Extracted bytecode") + } + + return nil +} diff --git a/cmd/contract/deploy/deployer.go b/cmd/contract/deploy/deployer.go new file mode 100644 index 00000000..3d0d9b17 --- /dev/null +++ b/cmd/contract/deploy/deployer.go @@ -0,0 +1,270 @@ +package deploy + +import ( + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/rs/zerolog" + + "github.com/smartcontractkit/chainlink-testing-framework/seth" +) + +// DeploymentResult represents the result of deploying a single contract +type DeploymentResult struct { + Name string `yaml:"name"` + Address string `yaml:"address"` + TxHash string `yaml:"tx_hash"` +} + +// ContractDeployer handles the deployment of contracts +type ContractDeployer struct { + log *zerolog.Logger + client *seth.Client + contractsPath string +} + +// NewContractDeployer creates a new contract deployer +func NewContractDeployer(log *zerolog.Logger, client *seth.Client, contractsPath string) *ContractDeployer { + return &ContractDeployer{ + log: log, + client: client, + contractsPath: contractsPath, + } +} + +// Deploy deploys a single contract using its Go bindings from the ContractStore +func (d *ContractDeployer) Deploy(config ContractConfig) (*DeploymentResult, error) { + d.log.Debug(). + Str("contract", config.Name). + Str("package", config.Package). + Int("constructor_args", len(config.Constructor)). + Msg("Starting contract deployment") + + // Check if contract is in the ContractStore + contractABI, ok := d.client.ContractStore.GetABI(config.Name) + if !ok { + return nil, fmt.Errorf("contract %s not found in ContractStore. Make sure to load the contract bindings first", config.Name) + } + + bytecode, hasBytecode := d.client.ContractStore.GetBIN(config.Name) + if !hasBytecode || len(bytecode) == 0 { + return nil, fmt.Errorf("contract %s has no bytecode in ContractStore", config.Name) + } + + // Parse constructor arguments + args, err := d.parseConstructorArgs(config, contractABI) + if err != nil { + return nil, fmt.Errorf("failed to parse constructor arguments: %w", err) + } + + // Deploy the contract + txOpts := d.client.NewTXOpts() + + var deployedContract seth.DeploymentData + if len(args) == 0 { + // No constructor arguments + deployedContract, err = d.client.DeployContractFromContractStore(txOpts, config.Name) + } else { + // With constructor arguments + deployedContract, err = d.client.DeployContractFromContractStore(txOpts, config.Name, args...) + } + + if err != nil { + return nil, fmt.Errorf("deployment failed: %w", err) + } + + // Get the transaction hash from the deployment + txHash := "" + if deployedContract.Transaction != nil { + txHash = deployedContract.Transaction.Hash().Hex() + } + + return &DeploymentResult{ + Name: config.Name, + Address: deployedContract.Address.Hex(), + TxHash: txHash, + }, nil +} + +// parseConstructorArgs converts constructor arguments from config to Go types +func (d *ContractDeployer) parseConstructorArgs(config ContractConfig, contractABI *abi.ABI) ([]interface{}, error) { + if len(contractABI.Constructor.Inputs) == 0 { + if len(config.Constructor) > 0 { + return nil, fmt.Errorf("contract has no constructor arguments but %d were provided", len(config.Constructor)) + } + return nil, nil + } + + if len(config.Constructor) != len(contractABI.Constructor.Inputs) { + return nil, fmt.Errorf("expected %d constructor arguments, got %d", + len(contractABI.Constructor.Inputs), len(config.Constructor)) + } + + args := make([]interface{}, len(config.Constructor)) + for i, arg := range config.Constructor { + abiArg := contractABI.Constructor.Inputs[i] + + // Validate type matches (loosely) + if !typesMatch(arg.Type, abiArg.Type.String()) { + d.log.Warn(). + Str("config_type", arg.Type). + Str("abi_type", abiArg.Type.String()). + Int("arg_index", i). + Msg("Type mismatch warning - proceeding with ABI type") + } + + parsedArg, err := parseArgValue(arg.Value, abiArg.Type) + if err != nil { + return nil, fmt.Errorf("failed to parse argument %d (%s): %w", i, abiArg.Name, err) + } + args[i] = parsedArg + } + + return args, nil +} + +// typesMatch checks if the config type matches the ABI type +func typesMatch(configType, abiType string) bool { + // Normalize types for comparison + configType = strings.ToLower(strings.TrimSpace(configType)) + abiType = strings.ToLower(strings.TrimSpace(abiType)) + + // Handle common aliases + if configType == "uint" { + configType = "uint256" + } + if configType == "int" { + configType = "int256" + } + + return configType == abiType +} + +// parseArgValue parses a string value into the appropriate Go type based on ABI type +func parseArgValue(value string, abiType abi.Type) (interface{}, error) { + switch abiType.T { + case abi.AddressTy: + if !common.IsHexAddress(value) { + return nil, fmt.Errorf("invalid address: %s", value) + } + return common.HexToAddress(value), nil + + case abi.UintTy, abi.IntTy: + n := new(big.Int) + n, ok := n.SetString(value, 0) + if !ok { + return nil, fmt.Errorf("invalid integer: %s", value) + } + + // Convert to appropriate size based on ABI type + switch abiType.Size { + case 8: + if abiType.T == abi.UintTy { + if !n.IsUint64() || n.Uint64() > 255 { + return nil, fmt.Errorf("value %s overflows uint8", value) + } + return uint8(n.Uint64()), nil //nolint:gosec // bounds checked above + } + if !n.IsInt64() || n.Int64() < -128 || n.Int64() > 127 { + return nil, fmt.Errorf("value %s overflows int8", value) + } + return int8(n.Int64()), nil //nolint:gosec // bounds checked above + case 16: + if abiType.T == abi.UintTy { + if !n.IsUint64() || n.Uint64() > 65535 { + return nil, fmt.Errorf("value %s overflows uint16", value) + } + return uint16(n.Uint64()), nil //nolint:gosec // bounds checked above + } + if !n.IsInt64() || n.Int64() < -32768 || n.Int64() > 32767 { + return nil, fmt.Errorf("value %s overflows int16", value) + } + return int16(n.Int64()), nil //nolint:gosec // bounds checked above + case 32: + if abiType.T == abi.UintTy { + if !n.IsUint64() || n.Uint64() > 4294967295 { + return nil, fmt.Errorf("value %s overflows uint32", value) + } + return uint32(n.Uint64()), nil //nolint:gosec // bounds checked above + } + if !n.IsInt64() || n.Int64() < -2147483648 || n.Int64() > 2147483647 { + return nil, fmt.Errorf("value %s overflows int32", value) + } + return int32(n.Int64()), nil //nolint:gosec // bounds checked above + case 64: + if abiType.T == abi.UintTy { + return n.Uint64(), nil + } + return n.Int64(), nil + default: + // For larger integers, return *big.Int + return n, nil + } + + case abi.BoolTy: + switch strings.ToLower(value) { + case "true", "1": + return true, nil + case "false", "0": + return false, nil + default: + return nil, fmt.Errorf("invalid boolean: %s", value) + } + + case abi.StringTy: + return value, nil + + case abi.BytesTy: + return common.FromHex(value), nil + + case abi.FixedBytesTy: + bytes := common.FromHex(value) + if len(bytes) != abiType.Size { + return nil, fmt.Errorf("bytes%d requires exactly %d bytes, got %d", abiType.Size, abiType.Size, len(bytes)) + } + // Convert to fixed-size array + return convertToFixedBytes(bytes, abiType.Size) + + case abi.SliceTy: + // For array types, we'd need to parse JSON arrays + return nil, fmt.Errorf("array types not yet supported in constructor arguments") + + default: + return nil, fmt.Errorf("unsupported type: %s", abiType.String()) + } +} + +// convertToFixedBytes converts a byte slice to a fixed-size byte array +func convertToFixedBytes(bytes []byte, size int) (interface{}, error) { + switch size { + case 1: + var arr [1]byte + copy(arr[:], bytes) + return arr, nil + case 2: + var arr [2]byte + copy(arr[:], bytes) + return arr, nil + case 4: + var arr [4]byte + copy(arr[:], bytes) + return arr, nil + case 8: + var arr [8]byte + copy(arr[:], bytes) + return arr, nil + case 16: + var arr [16]byte + copy(arr[:], bytes) + return arr, nil + case 32: + var arr [32]byte + copy(arr[:], bytes) + return arr, nil + default: + return nil, fmt.Errorf("unsupported fixed bytes size: %d", size) + } +} diff --git a/cmd/contract/deploy/output.go b/cmd/contract/deploy/output.go new file mode 100644 index 00000000..52584181 --- /dev/null +++ b/cmd/contract/deploy/output.go @@ -0,0 +1,83 @@ +package deploy + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// DeployedContracts represents the structure of deployed_contracts.yaml +type DeployedContracts struct { + ChainID uint64 `yaml:"chain_id"` + ChainName string `yaml:"chain_name"` + Timestamp string `yaml:"timestamp"` + Contracts map[string]DeployedContract `yaml:"contracts"` +} + +// DeployedContract represents a deployed contract entry +type DeployedContract struct { + Address string `yaml:"address"` + TxHash string `yaml:"tx_hash"` +} + +// WriteDeployedContracts writes the deployment results to deployed_contracts.yaml +func WriteDeployedContracts(path string, chainName string, results []DeploymentResult) error { + // Get chain ID from chain name + chainID, err := settings.GetChainSelectorByChainName(chainName) + if err != nil { + return fmt.Errorf("failed to get chain ID for %s: %w", chainName, err) + } + + deployed := DeployedContracts{ + ChainID: chainID, + ChainName: chainName, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Contracts: make(map[string]DeployedContract), + } + + for _, result := range results { + deployed.Contracts[result.Name] = DeployedContract{ + Address: result.Address, + TxHash: result.TxHash, + } + } + + data, err := yaml.Marshal(deployed) + if err != nil { + return fmt.Errorf("failed to marshal deployed contracts: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write deployed contracts file: %w", err) + } + + return nil +} + +// ReadDeployedContracts reads the deployed_contracts.yaml file +func ReadDeployedContracts(path string) (*DeployedContracts, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read deployed contracts file: %w", err) + } + + var deployed DeployedContracts + if err := yaml.Unmarshal(data, &deployed); err != nil { + return nil, fmt.Errorf("failed to parse deployed contracts: %w", err) + } + + return &deployed, nil +} + +// GetContractAddress returns the address of a deployed contract by name +func (d *DeployedContracts) GetContractAddress(name string) (string, error) { + contract, ok := d.Contracts[name] + if !ok { + return "", fmt.Errorf("contract %s not found in deployed contracts", name) + } + return contract.Address, nil +} diff --git a/cmd/creinit/template/workflow/blankTemplate/contracts/contracts.yaml.tpl b/cmd/creinit/template/workflow/blankTemplate/contracts/contracts.yaml.tpl new file mode 100644 index 00000000..4108a906 --- /dev/null +++ b/cmd/creinit/template/workflow/blankTemplate/contracts/contracts.yaml.tpl @@ -0,0 +1,21 @@ +# Contract deployment configuration +# Run 'cre contract deploy' to deploy contracts listed here +# Deployed addresses will be saved to deployed_contracts.yaml + +# Target chain for deployment (must match a chain in project.yaml rpcs) +chain: ethereum-testnet-sepolia + +# List of contracts to deploy +# Each contract must have Go bindings in contracts/evm/src/generated/{package}/ +# Generate bindings with: cre generate-bindings evm +contracts: [] + # Example contract configuration: + # - name: MyContract # Contract name (must match binding name) + # package: my_contract # Go package name from generated bindings + # deploy: true # Set to false to skip deployment + # constructor: # Constructor arguments (if any) + # - type: address + # value: "0x..." + # - type: uint256 + # value: "1000000" + diff --git a/cmd/creinit/template/workflow/porExampleDev/contracts/contracts.yaml.tpl b/cmd/creinit/template/workflow/porExampleDev/contracts/contracts.yaml.tpl new file mode 100644 index 00000000..b5ba4beb --- /dev/null +++ b/cmd/creinit/template/workflow/porExampleDev/contracts/contracts.yaml.tpl @@ -0,0 +1,31 @@ +# Contract deployment configuration +# Run 'cre contract deploy' to deploy contracts listed here +# Deployed addresses will be saved to deployed_contracts.yaml + +# Target chain for deployment (must match a chain in project.yaml rpcs) +chain: ethereum-testnet-sepolia + +# List of contracts to deploy +# Each contract must have Go bindings in contracts/evm/src/generated/{package}/ +# Generate bindings with: cre generate-bindings evm +contracts: + - name: BalanceReader + package: balance_reader + deploy: true + constructor: [] + + - name: MessageEmitter + package: message_emitter + deploy: true + constructor: [] + + - name: ReserveManager + package: reserve_manager + deploy: true + constructor: [] + + # IERC20 is typically an existing token contract, not deployed + - name: IERC20 + package: ierc20 + deploy: false + diff --git a/cmd/creinit/template/workflow/porExampleDev/contracts/evm/src/ITypeAndVersion.sol.tpl b/cmd/creinit/template/workflow/porExampleDev/contracts/evm/src/ITypeAndVersion.sol.tpl new file mode 100644 index 00000000..e700df10 --- /dev/null +++ b/cmd/creinit/template/workflow/porExampleDev/contracts/evm/src/ITypeAndVersion.sol.tpl @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface ITypeAndVersion { + function typeAndVersion() external view returns (string memory); +} + diff --git a/cmd/creinit/template/workflow/porExampleDev/contracts/evm/src/ReserveManager.sol.tpl b/cmd/creinit/template/workflow/porExampleDev/contracts/evm/src/ReserveManager.sol.tpl index 6eeffc54..2735c88b 100644 --- a/cmd/creinit/template/workflow/porExampleDev/contracts/evm/src/ReserveManager.sol.tpl +++ b/cmd/creinit/template/workflow/porExampleDev/contracts/evm/src/ReserveManager.sol.tpl @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; -import {IReceiver} from "../../keystone/interfaces/IReceiver.sol"; -import {IERC165} from "@openzeppelin/contracts@5.0.2/interfaces/IERC165.sol"; +import {IReceiver} from "./keystone/IReceiver.sol"; +import {IERC165} from "./keystone/IERC165.sol"; contract ReserveManager is IReceiver { uint256 public lastTotalMinted; diff --git a/cmd/creinit/template/workflow/typescriptPorExampleDev/contracts/contracts.yaml.tpl b/cmd/creinit/template/workflow/typescriptPorExampleDev/contracts/contracts.yaml.tpl new file mode 100644 index 00000000..01307de6 --- /dev/null +++ b/cmd/creinit/template/workflow/typescriptPorExampleDev/contracts/contracts.yaml.tpl @@ -0,0 +1,19 @@ +# Contract deployment configuration +# Run 'cre contract deploy' to deploy contracts listed here +# Deployed addresses will be saved to deployed_contracts.yaml + +# Note: Contract deployment currently requires Go bindings. +# For TypeScript workflows, you may need to deploy contracts separately +# or use Go bindings generated with 'cre generate-bindings evm'. + +# Target chain for deployment (must match a chain in project.yaml rpcs) +chain: ethereum-testnet-sepolia + +# List of contracts to deploy +contracts: [] + # Example: If you generate Go bindings for your contracts, add them here: + # - name: ReserveManager + # package: reserve_manager + # deploy: true + # constructor: [] + diff --git a/cmd/root.go b/cmd/root.go index 51af5fb8..4290694f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/account" "github.com/smartcontractkit/cre-cli/cmd/client" + "github.com/smartcontractkit/cre-cli/cmd/contract" "github.com/smartcontractkit/cre-cli/cmd/creinit" generatebindings "github.com/smartcontractkit/cre-cli/cmd/generate-bindings" "github.com/smartcontractkit/cre-cli/cmd/login" @@ -209,6 +210,7 @@ func newRootCommand() *cobra.Command { secretsCmd := secrets.New(runtimeContext) workflowCmd := workflow.New(runtimeContext) + contractCmd := contract.New(runtimeContext) versionCmd := version.New(runtimeContext) loginCmd := login.New(runtimeContext) logoutCmd := logout.New(runtimeContext) @@ -220,11 +222,13 @@ func newRootCommand() *cobra.Command { secretsCmd.RunE = helpRunE workflowCmd.RunE = helpRunE + contractCmd.RunE = helpRunE accountCmd.RunE = helpRunE // Define groups (order controls display order) rootCmd.AddGroup(&cobra.Group{ID: "getting-started", Title: "Getting Started"}) rootCmd.AddGroup(&cobra.Group{ID: "account", Title: "Account"}) + rootCmd.AddGroup(&cobra.Group{ID: "contract", Title: "Contract"}) rootCmd.AddGroup(&cobra.Group{ID: "workflow", Title: "Workflow"}) rootCmd.AddGroup(&cobra.Group{ID: "secret", Title: "Secret"}) @@ -235,6 +239,7 @@ func newRootCommand() *cobra.Command { accountCmd.GroupID = "account" whoamiCmd.GroupID = "account" + contractCmd.GroupID = "contract" secretsCmd.GroupID = "secret" workflowCmd.GroupID = "workflow" @@ -245,6 +250,7 @@ func newRootCommand() *cobra.Command { logoutCmd, accountCmd, whoamiCmd, + contractCmd, secretsCmd, workflowCmd, genBindingsCmd, @@ -271,6 +277,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre help": {}, "cre update": {}, "cre workflow": {}, + "cre contract": {}, "cre account": {}, "cre secrets": {}, "cre": {}, @@ -293,6 +300,8 @@ func isLoadCredentials(cmd *cobra.Command) bool { "cre generate-bindings": {}, "cre update": {}, "cre workflow": {}, + "cre contract": {}, + "cre contract deploy": {}, "cre account": {}, "cre secrets": {}, "cre": {}, @@ -308,6 +317,7 @@ func isLoadDeploymentRPC(cmd *cobra.Command) bool { "cre workflow pause": {}, "cre workflow activate": {}, "cre workflow delete": {}, + "cre contract deploy": {}, "cre account link-key": {}, "cre account unlink-key": {}, "cre secrets create": {}, diff --git a/cmd/workflow/deploy/prepare.go b/cmd/workflow/deploy/prepare.go index dc51522e..8923745c 100644 --- a/cmd/workflow/deploy/prepare.go +++ b/cmd/workflow/deploy/prepare.go @@ -4,8 +4,11 @@ import ( "encoding/base64" "fmt" "os" + "path/filepath" workflowUtils "github.com/smartcontractkit/chainlink-common/pkg/workflows" + + "github.com/smartcontractkit/cre-cli/internal/placeholder" ) type workflowArtifact struct { @@ -35,11 +38,77 @@ func (h *handler) prepareWorkflowConfig() ([]byte, error) { h.log.Error().Err(err).Str("path", h.inputs.ConfigPath).Msg("Failed to read config file") return nil, err } + + // Apply placeholder substitution for deployed contract addresses + configData, err = h.substituteContractPlaceholders(configData) + if err != nil { + return nil, fmt.Errorf("failed to substitute contract placeholders: %w", err) + } } h.log.Debug().Msg("Workflow config is ready") return configData, nil } +// substituteContractPlaceholders replaces {{contracts.Name.address}} placeholders with deployed addresses +func (h *handler) substituteContractPlaceholders(configData []byte) ([]byte, error) { + // Find project root by looking for parent directory containing project.yaml + workflowDir := filepath.Dir(h.inputs.WorkflowPath) + projectRoot := findProjectRoot(workflowDir) + if projectRoot == "" { + h.log.Debug().Msg("Project root not found, skipping placeholder substitution") + return configData, nil + } + + substitutor, err := placeholder.NewSubstitutor(projectRoot) + if err != nil { + return nil, fmt.Errorf("failed to create placeholder substitutor: %w", err) + } + + if !substitutor.HasDeployedContracts() { + // Check if there are placeholders that need substitution + placeholders := placeholder.FindPlaceholders(string(configData)) + if len(placeholders) > 0 { + return nil, fmt.Errorf("found %d contract placeholder(s) in config but no deployed_contracts.yaml found. Run 'cre contract deploy' first", len(placeholders)) + } + h.log.Debug().Msg("No deployed_contracts.yaml found, skipping placeholder substitution") + return configData, nil + } + + substituted, err := substitutor.SubstituteString(string(configData)) + if err != nil { + return nil, err + } + + // Log substitutions that were made + contracts := substitutor.GetAllDeployedContracts() + if len(contracts) > 0 { + h.log.Debug().Int("contracts", len(contracts)).Msg("Substituted contract address placeholders") + for name, addr := range contracts { + h.log.Debug().Str("contract", name).Str("address", addr).Msg("Placeholder substitution") + } + } + + return []byte(substituted), nil +} + +// findProjectRoot searches upward from the given directory for a project.yaml file +func findProjectRoot(startDir string) string { + dir := startDir + for { + projectFile := filepath.Join(dir, "project.yaml") + if _, err := os.Stat(projectFile); err == nil { + return dir + } + + parentDir := filepath.Dir(dir) + if parentDir == dir { + // Reached filesystem root + return "" + } + dir = parentDir + } +} + func (h *handler) PrepareWorkflowArtifact() error { var err error binaryData, err := h.prepareWorkflowBinary() diff --git a/docs/cre.md b/docs/cre.md index b6278310..ee492e23 100644 --- a/docs/cre.md +++ b/docs/cre.md @@ -23,6 +23,7 @@ cre [optional flags] ### SEE ALSO * [cre account](cre_account.md) - Manages account +* [cre contract](cre_contract.md) - Manages smart contracts * [cre generate-bindings](cre_generate-bindings.md) - Generate bindings from contract ABI * [cre init](cre_init.md) - Initialize a new cre project (recommended starting point) * [cre login](cre_login.md) - Start authentication flow diff --git a/docs/cre_contract.md b/docs/cre_contract.md new file mode 100644 index 00000000..5112474e --- /dev/null +++ b/docs/cre_contract.md @@ -0,0 +1,32 @@ +## cre contract + +Manages smart contracts + +### Synopsis + +The contract command allows you to deploy and manage smart contracts at the project level. + +``` +cre contract [optional flags] +``` + +### Options + +``` + -h, --help help for contract +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info (default ".env") + -R, --project-root string Path to the project root + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre](cre.md) - CRE CLI tool +* [cre contract deploy](cre_contract_deploy.md) - Deploys smart contracts to the blockchain + diff --git a/docs/cre_contract_deploy.md b/docs/cre_contract_deploy.md new file mode 100644 index 00000000..122068e3 --- /dev/null +++ b/docs/cre_contract_deploy.md @@ -0,0 +1,44 @@ +## cre contract deploy + +Deploys smart contracts to the blockchain + +### Synopsis + +Deploys smart contracts defined in contracts/contracts.yaml to the target blockchain. +The deployed contract addresses are stored in contracts/deployed_contracts.yaml +and can be referenced in workflow configurations using placeholders. + +``` +cre contract deploy [optional flags] +``` + +### Examples + +``` + cre contract deploy + cre contract deploy --dry-run + cre contract deploy --chain ethereum-testnet-sepolia +``` + +### Options + +``` + --chain string Override the target chain from contracts.yaml + --dry-run Validate configuration without deploying contracts + -h, --help help for deploy + --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info (default ".env") + -R, --project-root string Path to the project root + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre contract](cre_contract.md) - Manages smart contracts + diff --git a/internal/placeholder/placeholder.go b/internal/placeholder/placeholder.go new file mode 100644 index 00000000..17e44a01 --- /dev/null +++ b/internal/placeholder/placeholder.go @@ -0,0 +1,189 @@ +package placeholder + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + contractsFolder = "contracts" + deployedContractsFile = "deployed_contracts.yaml" +) + +// DeployedContracts represents the structure of deployed_contracts.yaml +type DeployedContracts struct { + ChainID uint64 `yaml:"chain_id"` + ChainName string `yaml:"chain_name"` + Timestamp string `yaml:"timestamp"` + Contracts map[string]DeployedContract `yaml:"contracts"` +} + +// DeployedContract represents a deployed contract entry +type DeployedContract struct { + Address string `yaml:"address"` + TxHash string `yaml:"tx_hash"` +} + +// placeholderPattern matches {{contracts.ContractName.address}} or {{contracts.ContractName.tx_hash}} +var placeholderPattern = regexp.MustCompile(`\{\{contracts\.([a-zA-Z0-9_]+)\.(address|tx_hash)\}\}`) + +// Substitutor handles placeholder substitution in configuration files +type Substitutor struct { + projectRoot string + deployed *DeployedContracts +} + +// NewSubstitutor creates a new placeholder substitutor +func NewSubstitutor(projectRoot string) (*Substitutor, error) { + deployedPath := filepath.Join(projectRoot, contractsFolder, deployedContractsFile) + + // Check if deployed_contracts.yaml exists + if _, err := os.Stat(deployedPath); os.IsNotExist(err) { + // No deployed contracts file - return substitutor that does nothing + return &Substitutor{ + projectRoot: projectRoot, + deployed: nil, + }, nil + } + + // Read deployed contracts + data, err := os.ReadFile(deployedPath) + if err != nil { + return nil, fmt.Errorf("failed to read deployed contracts: %w", err) + } + + var deployed DeployedContracts + if err := yaml.Unmarshal(data, &deployed); err != nil { + return nil, fmt.Errorf("failed to parse deployed contracts: %w", err) + } + + return &Substitutor{ + projectRoot: projectRoot, + deployed: &deployed, + }, nil +} + +// HasDeployedContracts returns true if deployed_contracts.yaml was found +func (s *Substitutor) HasDeployedContracts() bool { + return s.deployed != nil +} + +// SubstituteString replaces placeholders in a string with deployed contract values +func (s *Substitutor) SubstituteString(content string) (string, error) { + if s.deployed == nil { + return content, nil + } + + var substitutionErrors []string + + result := placeholderPattern.ReplaceAllStringFunc(content, func(match string) string { + // Extract contract name and field from the match + submatches := placeholderPattern.FindStringSubmatch(match) + if len(submatches) != 3 { + substitutionErrors = append(substitutionErrors, fmt.Sprintf("invalid placeholder format: %s", match)) + return match + } + + contractName := submatches[1] + field := submatches[2] + + contract, ok := s.deployed.Contracts[contractName] + if !ok { + substitutionErrors = append(substitutionErrors, fmt.Sprintf("contract %q not found in deployed_contracts.yaml", contractName)) + return match + } + + switch field { + case "address": + return contract.Address + case "tx_hash": + return contract.TxHash + default: + substitutionErrors = append(substitutionErrors, fmt.Sprintf("unknown field %q for contract %q", field, contractName)) + return match + } + }) + + if len(substitutionErrors) > 0 { + return "", fmt.Errorf("placeholder substitution errors: %s", strings.Join(substitutionErrors, "; ")) + } + + return result, nil +} + +// SubstituteFile reads a file, substitutes placeholders, and returns the modified content +func (s *Substitutor) SubstituteFile(filePath string) ([]byte, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + substituted, err := s.SubstituteString(string(content)) + if err != nil { + return nil, err + } + + return []byte(substituted), nil +} + +// SubstituteFileInPlace reads a file, substitutes placeholders, and writes back +func (s *Substitutor) SubstituteFileInPlace(filePath string) error { + content, err := s.SubstituteFile(filePath) + if err != nil { + return err + } + + return os.WriteFile(filePath, content, 0600) +} + +// FindPlaceholders returns all placeholders found in the content +func FindPlaceholders(content string) []string { + matches := placeholderPattern.FindAllString(content, -1) + return matches +} + +// ValidatePlaceholders checks if all placeholders in the content can be resolved +func (s *Substitutor) ValidatePlaceholders(content string) error { + if s.deployed == nil { + placeholders := FindPlaceholders(content) + if len(placeholders) > 0 { + return fmt.Errorf("found %d placeholder(s) but no deployed_contracts.yaml exists. Run 'cre contract deploy' first", len(placeholders)) + } + return nil + } + + _, err := s.SubstituteString(content) + return err +} + +// GetDeployedContractAddress returns the address of a deployed contract +func (s *Substitutor) GetDeployedContractAddress(contractName string) (string, error) { + if s.deployed == nil { + return "", fmt.Errorf("no deployed contracts available") + } + + contract, ok := s.deployed.Contracts[contractName] + if !ok { + return "", fmt.Errorf("contract %q not found in deployed contracts", contractName) + } + + return contract.Address, nil +} + +// GetAllDeployedContracts returns all deployed contract names and addresses +func (s *Substitutor) GetAllDeployedContracts() map[string]string { + if s.deployed == nil { + return nil + } + + result := make(map[string]string) + for name, contract := range s.deployed.Contracts { + result[name] = contract.Address + } + return result +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 32454f49..2136bd0c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -33,6 +33,7 @@ const bindEnvErrorMessage = "Not able to bind environment variables that represe // Settings holds user, project, and workflow configurations. type Settings struct { + ProjectRoot string Workflow WorkflowSettings User UserSettings StorageSettings WorkflowStorageSettings @@ -84,7 +85,14 @@ func New(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Command, registryCha rawPrivKey := v.GetString(EthPrivateKeyEnvVar) normPrivKey := NormalizeHexKey(rawPrivKey) + // Get project root from CWD (already set by SetProjectContext in root.go) + projectRoot, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get project root: %w", err) + } + return &Settings{ + ProjectRoot: projectRoot, User: UserSettings{ EthPrivateKey: normPrivKey, TargetName: target,