diff --git a/chains/solana/deployment/utils/build.go b/chains/solana/deployment/utils/build.go new file mode 100644 index 0000000000..3bad2523d2 --- /dev/null +++ b/chains/solana/deployment/utils/build.go @@ -0,0 +1,367 @@ +package utils + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +const ( + repoURL = "https://github.com/smartcontractkit/chainlink-ccip.git" + anchorDir = "chains/solana/contracts" + deployDir = "chains/solana/contracts/target/deploy" +) + +// ProgramToRustFile maps contract types to their Rust source paths (relative to the anchor project root) +// used for key replacement during upgrade builds. +var ProgramToRustFile = map[cldf.ContractType]string{ + "Router": "programs/ccip-router/src/lib.rs", + "CCIPCommon": "programs/ccip-common/src/lib.rs", + "FeeQuoter": "programs/fee-quoter/src/lib.rs", + "OffRamp": "programs/ccip-offramp/src/lib.rs", + "BurnMintTokenPool": "programs/burnmint-token-pool/src/lib.rs", + "LockReleaseTokenPool": "programs/lockrelease-token-pool/src/lib.rs", + "RMNRemote": "programs/rmn-remote/src/lib.rs", + "AccessControllerProgram": "programs/access-controller/src/lib.rs", + "ManyChainMultiSigProgram": "programs/mcm/src/lib.rs", + "RBACTimelockProgram": "programs/timelock/src/lib.rs", + "CCTPTokenPool": "programs/cctp-token-pool/src/lib.rs", +} + +// ProgramToVanityPrefix maps contract types to the vanity key prefix used with `solana-keygen grind`. +var ProgramToVanityPrefix = map[cldf.ContractType]string{ + "Router": "Ccip", + "FeeQuoter": "FeeQ", + "OffRamp": "off", + "RMNRemote": "Rmn", +} + +// SolanaBuildConfig configures how Solana program artifacts are prepared. +type SolanaBuildConfig struct { + // ContractVersion is the version tag (e.g. "solana-v0.1.2") that maps to a commit via VersionToFullCommitSHA. + ContractVersion string + // DestinationDir is where built/downloaded .so files and keypairs are placed. + // Typically chain.ProgramsPath. + DestinationDir string + // WorkDir is the temporary working directory for git clone and build operations. + // Each build must use a unique directory to avoid interference when running in + // parallel. If empty, a new temp directory is created automatically. + WorkDir string + // LocalBuild controls the local build pipeline. If nil or BuildLocally is false, + // artifacts are downloaded from the GitHub release instead. + LocalBuild *LocalBuildConfig +} + +type LocalBuildConfig struct { + BuildLocally bool + CleanDestinationDir bool + CreateDestinationDir bool + // CleanGitDir forces re-clone of the git directory. Useful for forcing key regeneration. + CleanGitDir bool + // GenerateVanityKeys generates vanity keypairs for programs that have configured prefixes. + GenerateVanityKeys bool + // UpgradeKeys maps contract types to their existing on-chain program addresses. + // Used during upgrade builds to replace declare_id!() in Rust source so the + // rebuilt binary's embedded key matches the deployed program. + UpgradeKeys map[cldf.ContractType]string +} + +// BuildSolana either downloads pre-built artifacts or builds them locally. +func BuildSolana(ctx context.Context, lggr logger.Logger, config SolanaBuildConfig) error { + if config.LocalBuild == nil || !config.LocalBuild.BuildLocally { + lggr.Info("Downloading Solana CCIP program artifacts...") + sha, ok := VersionToShortCommitSHA[config.ContractVersion] + if !ok { + return fmt.Errorf("solana contract version not found: %s", config.ContractVersion) + } + return DownloadSolanaCCIPProgramArtifacts(ctx, config.DestinationDir, sha) + } + + if config.WorkDir == "" { + dir, err := os.MkdirTemp("", "solana-build-*") + if err != nil { + return fmt.Errorf("failed to create temp work directory: %w", err) + } + config.WorkDir = dir + defer os.RemoveAll(dir) + } + + lggr.Infow("Building Solana CCIP program artifacts locally", "workDir", config.WorkDir) + return buildLocally(lggr, config) +} + +func buildLocally(lggr logger.Logger, config SolanaBuildConfig) error { + commitSHA, ok := VersionToFullCommitSHA[config.ContractVersion] + if !ok { + return fmt.Errorf("solana contract version not found: %s", config.ContractVersion) + } + + repoDir := filepath.Join(config.WorkDir, "repo") + + if err := cloneRepo(lggr, repoDir, commitSHA, config.LocalBuild.CleanGitDir); err != nil { + return fmt.Errorf("error cloning repo: %w", err) + } + + if err := replaceKeys(lggr, repoDir); err != nil { + return fmt.Errorf("error replacing keys: %w", err) + } + + if config.LocalBuild.GenerateVanityKeys { + if config.LocalBuild.UpgradeKeys == nil { + config.LocalBuild.UpgradeKeys = make(map[cldf.ContractType]string) + } + if err := generateVanityKeys(lggr, config.WorkDir, repoDir, config.LocalBuild.UpgradeKeys); err != nil { + return fmt.Errorf("error generating vanity keys: %w", err) + } + } + + if err := replaceKeysForUpgrade(lggr, repoDir, config.LocalBuild.UpgradeKeys); err != nil { + return fmt.Errorf("error replacing keys for upgrade: %w", err) + } + + if err := syncRouterAndCommon(repoDir); err != nil { + return fmt.Errorf("error syncing router and common program files: %w", err) + } + + if err := buildProject(lggr, repoDir); err != nil { + return fmt.Errorf("error building project: %w", err) + } + + if config.LocalBuild.CleanDestinationDir { + lggr.Infow("Cleaning destination dir", "dir", config.DestinationDir) + if err := os.RemoveAll(config.DestinationDir); err != nil { + return fmt.Errorf("error cleaning build folder: %w", err) + } + if err := os.MkdirAll(config.DestinationDir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create build directory: %w", err) + } + } else if config.LocalBuild.CreateDestinationDir { + if err := os.MkdirAll(config.DestinationDir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create build directory: %w", err) + } + } + + deployFilePath := filepath.Join(repoDir, deployDir) + lggr.Infow("Reading deploy directory", "path", deployFilePath) + files, err := os.ReadDir(deployFilePath) + if err != nil { + return fmt.Errorf("failed to read deploy directory: %w", err) + } + + for _, file := range files { + src := filepath.Join(deployFilePath, file.Name()) + dst := filepath.Join(config.DestinationDir, file.Name()) + lggr.Infow("Copying artifact", "src", src, "dst", dst) + if err := copyFile(src, dst); err != nil { + return fmt.Errorf("failed to copy file %s: %w", file.Name(), err) + } + } + return nil +} + +func cloneRepo(lggr logger.Logger, repoDir string, revision string, forceClean bool) error { + if forceClean { + lggr.Infow("Cleaning repository", "dir", repoDir) + if err := os.RemoveAll(repoDir); err != nil { + return fmt.Errorf("failed to clean repository: %w", err) + } + } + if _, err := os.Stat(filepath.Join(repoDir, ".git")); err == nil { + lggr.Infow("Repository already exists, resetting and fetching", "dir", repoDir) + if _, err := RunCommand("git", []string{"reset", "--hard"}, repoDir); err != nil { + return fmt.Errorf("failed to discard local changes: %w", err) + } + if _, err := RunCommand("git", []string{"fetch", "origin"}, repoDir); err != nil { + return fmt.Errorf("failed to fetch origin: %w", err) + } + } else { + lggr.Infow("Cloning repository", "url", repoURL, "revision", revision) + if _, err := RunCommand("git", []string{"clone", repoURL, repoDir}, "."); err != nil { + return fmt.Errorf("failed to clone repository: %w", err) + } + } + + lggr.Infow("Checking out revision", "revision", revision) + if _, err := RunCommand("git", []string{"checkout", revision}, repoDir); err != nil { + return fmt.Errorf("failed to checkout revision %s: %w", revision, err) + } + return nil +} + +// replaceKeys runs `make docker-update-contracts` which calls `anchor keys sync` +// to update the declare_id!() in source files to match the generated keypairs. +func replaceKeys(lggr logger.Logger, repoDir string) error { + solanaDir := filepath.Join(repoDir, anchorDir, "..") + lggr.Infow("Replacing keys via anchor keys sync", "dir", solanaDir) + output, err := RunCommand("make", []string{"docker-update-contracts"}, solanaDir) + if err != nil { + return fmt.Errorf("anchor key replacement failed: %s %w", output, err) + } + return nil +} + +// replaceKeysForUpgrade explicitly replaces declare_id!() macros in Rust source files +// with the keys of already-deployed programs. This ensures the rebuilt binary matches +// the on-chain program address for in-place upgrades. +func replaceKeysForUpgrade(lggr logger.Logger, repoDir string, keys map[cldf.ContractType]string) error { + if len(keys) == 0 { + return nil + } + lggr.Info("Replacing keys in Rust files for upgrade...") + declareIDRegex := regexp.MustCompile(`declare_id!\(".*?"\);`) + for program, key := range keys { + filePath, exists := ProgramToRustFile[program] + if !exists { + return fmt.Errorf("no file path found for program %s", program) + } + + fullPath := filepath.Join(repoDir, anchorDir, filePath) + content, err := os.ReadFile(fullPath) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", fullPath, err) + } + + updatedContent := declareIDRegex.ReplaceAllString(string(content), fmt.Sprintf(`declare_id!("%s");`, key)) + if err := os.WriteFile(fullPath, []byte(updatedContent), 0644); err != nil { + return fmt.Errorf("failed to write updated keys to file %s: %w", fullPath, err) + } + lggr.Infow("Updated declare_id for upgrade", "program", program, "file", filePath) + } + return nil +} + +// syncRouterAndCommon ensures the ccip-common lib.rs declare_id matches the router's, +// since ccip-common is a shared crate that must carry the router's program ID. +func syncRouterAndCommon(repoDir string) error { + routerFile := filepath.Join(repoDir, anchorDir, ProgramToRustFile["Router"]) + commonFile := filepath.Join(repoDir, anchorDir, ProgramToRustFile["CCIPCommon"]) + + file, err := os.Open(routerFile) + if err != nil { + return fmt.Errorf("error opening router file: %w", err) + } + defer file.Close() + + declareRegex := regexp.MustCompile(`declare_id!\("(.+?)"\);`) + scanner := bufio.NewScanner(file) + var declareID string + for scanner.Scan() { + if match := declareRegex.FindStringSubmatch(scanner.Text()); match != nil { + declareID = match[0] + break + } + } + if declareID == "" { + return fmt.Errorf("declare_id not found in router file") + } + + commonContent, err := os.ReadFile(commonFile) + if err != nil { + return fmt.Errorf("error reading common file: %w", err) + } + updatedContent := declareRegex.ReplaceAllString(string(commonContent), declareID) + return os.WriteFile(commonFile, []byte(updatedContent), 0644) +} + +func generateVanityKeys(lggr logger.Logger, workDir string, repoDir string, keys map[cldf.ContractType]string) error { + lggr.Info("Generating vanity keys...") + jsonFilePattern := regexp.MustCompile(`Wrote keypair to (.*\.json)`) + for program, prefix := range ProgramToVanityPrefix { + if _, exists := keys[program]; exists { + lggr.Infow("Vanity key already exists, skipping", "program", program) + continue + } + + output, err := RunCommand("solana-keygen", []string{"grind", "--starts-with", prefix + ":1"}, workDir) + if err != nil { + return fmt.Errorf("failed to generate vanity key for %s: %w", program, err) + } + + scanner := bufio.NewScanner(strings.NewReader(output)) + var jsonFilePath string + for scanner.Scan() { + if matches := jsonFilePattern.FindStringSubmatch(scanner.Text()); len(matches) > 1 { + jsonFilePath = matches[1] + break + } + } + if jsonFilePath == "" { + return fmt.Errorf("failed to parse vanity key output for %s", program) + } + + // keygen writes relative to workDir; resolve from there + if !filepath.IsAbs(jsonFilePath) { + jsonFilePath = filepath.Join(workDir, jsonFilePath) + } + + fileName := filepath.Base(jsonFilePath) + keys[program] = strings.TrimSuffix(fileName, ".json") + + destination := filepath.Join(repoDir, deployDir, programTypeToDeployName(program)+"-keypair.json") + if err := os.Rename(jsonFilePath, destination); err != nil { + return fmt.Errorf("failed to move vanity key from %s to %s: %w", jsonFilePath, destination, err) + } + lggr.Infow("Generated vanity key", "program", program, "key", keys[program]) + } + return nil +} + +func buildProject(lggr logger.Logger, repoDir string) error { + solanaDir := filepath.Join(repoDir, anchorDir, "..") + lggr.Infow("Building project", "dir", solanaDir) + output, err := RunCommand("make", []string{"docker-build-contracts"}, solanaDir) + if err != nil { + return fmt.Errorf("anchor build failed: %s %w", output, err) + } + return nil +} + +func copyFile(src, dst string) (retErr error) { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer func() { + if cerr := out.Close(); retErr == nil { + retErr = cerr + } + }() + + _, err = io.Copy(out, in) + return err +} + +// programTypeToDeployName maps a contract type to its compiled program base name. +var programDeployNames = map[cldf.ContractType]string{ + "Router": "ccip_router", + "FeeQuoter": "fee_quoter", + "OffRamp": "ccip_offramp", + "BurnMintTokenPool": "burnmint_token_pool", + "LockReleaseTokenPool": "lockrelease_token_pool", + "RMNRemote": "rmn_remote", + "AccessControllerProgram": "access_controller", + "ManyChainMultiSigProgram": "mcm", + "RBACTimelockProgram": "timelock", + "CCTPTokenPool": "cctp_token_pool", +} + +func programTypeToDeployName(ct cldf.ContractType) string { + if name, ok := programDeployNames[ct]; ok { + return name + } + return string(ct) +} diff --git a/chains/solana/deployment/utils/upgrade.go b/chains/solana/deployment/utils/upgrade.go new file mode 100644 index 0000000000..b5e90193fb --- /dev/null +++ b/chains/solana/deployment/utils/upgrade.go @@ -0,0 +1,153 @@ +package utils + +import ( + "context" + "encoding/binary" + "fmt" + + "github.com/gagliardetto/solana-go" + solrpc "github.com/gagliardetto/solana-go/rpc" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + cldf_solana "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" +) + +// SetUpgradeAuthority creates a BPF Loader Upgradeable SetAuthority instruction (ID=4). +// isBuffer indicates whether the target is a buffer account (true) or a deployed program (false). +func SetUpgradeAuthority( + programID solana.PublicKey, + currentAuthority solana.PublicKey, + newAuthority solana.PublicKey, + isBuffer bool, +) solana.Instruction { + var target solana.PublicKey + if isBuffer { + target = programID + } else { + target, _, _ = solana.FindProgramAddress([][]byte{programID.Bytes()}, solana.BPFLoaderUpgradeableProgramID) + } + + keys := solana.AccountMetaSlice{ + solana.NewAccountMeta(target, true, false), + solana.NewAccountMeta(currentAuthority, false, true), + solana.NewAccountMeta(newAuthority, false, false), + } + + return solana.NewInstruction( + solana.BPFLoaderUpgradeableProgramID, + keys, + []byte{4, 0, 0, 0}, + ) +} + +// GenerateUpgradeInstruction creates a BPF Loader Upgradeable Upgrade instruction (ID=3). +// This replaces the deployed program's binary with the contents of the buffer. +func GenerateUpgradeInstruction( + programID solana.PublicKey, + bufferAddress solana.PublicKey, + spillAddress solana.PublicKey, + upgradeAuthority solana.PublicKey, +) solana.Instruction { + programDataAccount, _, _ := solana.FindProgramAddress([][]byte{programID.Bytes()}, solana.BPFLoaderUpgradeableProgramID) + + keys := solana.AccountMetaSlice{ + solana.NewAccountMeta(programDataAccount, true, false), + solana.NewAccountMeta(programID, true, false), + solana.NewAccountMeta(bufferAddress, true, false), + solana.NewAccountMeta(spillAddress, true, false), + solana.NewAccountMeta(solana.SysVarRentPubkey, false, false), + solana.NewAccountMeta(solana.SysVarClockPubkey, false, false), + solana.NewAccountMeta(upgradeAuthority, false, true), + } + + return solana.NewInstruction( + solana.BPFLoaderUpgradeableProgramID, + keys, + []byte{3, 0, 0, 0}, + ) +} + +// GenerateCloseBufferInstruction creates a BPF Loader Upgradeable Close instruction (ID=5) +// for closing a buffer account and reclaiming its rent. +func GenerateCloseBufferInstruction( + bufferAddress solana.PublicKey, + recipient solana.PublicKey, + authority solana.PublicKey, +) solana.Instruction { + keys := solana.AccountMetaSlice{ + solana.Meta(bufferAddress).WRITE(), + solana.Meta(recipient).WRITE(), + solana.Meta(authority).SIGNER(), + } + + return solana.NewInstruction( + solana.BPFLoaderUpgradeableProgramID, + keys, + []byte{5, 0, 0, 0}, + ) +} + +// GenerateExtendInstruction creates a BPF Loader Upgradeable ExtendProgram instruction (ID=6). +// This extends the program data account to accommodate a larger binary. This is permissionless; +// any payer can extend any program's buffer. +// Returns nil if the program already has enough space. +func GenerateExtendInstruction( + chain cldf_solana.Chain, + programID solana.PublicKey, + payer solana.PublicKey, + newBinarySize int, +) (*solana.GenericInstruction, error) { + programDataAccount, _, _ := solana.FindProgramAddress([][]byte{programID.Bytes()}, solana.BPFLoaderUpgradeableProgramID) + + currentSize, err := GetSolProgramSize(chain, programDataAccount) + if err != nil { + return nil, fmt.Errorf("failed to get current program size: %w", err) + } + + if newBinarySize <= currentSize { + return nil, nil + } + + extraBytes := newBinarySize - currentSize + data := binary.LittleEndian.AppendUint32([]byte{}, 6) // ExtendProgram instruction identifier + //nolint:gosec // G115 - checked above + data = binary.LittleEndian.AppendUint32(data, uint32(extraBytes+1024)) // padding for future growth + + keys := solana.AccountMetaSlice{ + solana.NewAccountMeta(programDataAccount, true, false), + solana.NewAccountMeta(programID, true, false), + solana.NewAccountMeta(solana.SystemProgramID, false, false), + solana.NewAccountMeta(payer, true, true), + } + + return solana.NewInstruction( + solana.BPFLoaderUpgradeableProgramID, + keys, + data, + ), nil +} + +// DeployToBuffer deploys a program binary to a buffer account using `chain.DeployProgram` +// with isUpgrade=true. Returns the buffer's public key. +func DeployToBuffer(chain cldf_solana.Chain, lggr logger.Logger, programName string) (solana.PublicKey, error) { + bufferID, err := chain.DeployProgram(lggr, cldf_solana.ProgramInfo{ + Name: programName, + }, true, false) + if err != nil { + return solana.PublicKey{}, fmt.Errorf("failed to deploy program to buffer: %w", err) + } + return solana.MustPublicKeyFromBase58(bufferID), nil +} + +// GetBufferSize returns the size of a buffer or program data account. +func GetBufferSize(chain cldf_solana.Chain, account solana.PublicKey) (int, error) { + accountInfo, err := chain.Client.GetAccountInfoWithOpts(context.Background(), account, &solrpc.GetAccountInfoOpts{ + Commitment: cldf_solana.SolDefaultCommitment, + }) + if err != nil { + return 0, fmt.Errorf("failed to get account info: %w", err) + } + if accountInfo == nil || accountInfo.Value == nil { + return 0, fmt.Errorf("account not found: %s", account.String()) + } + return len(accountInfo.Value.Data.GetBinary()), nil +} diff --git a/chains/solana/deployment/v1_6_0/sequences/adapter.go b/chains/solana/deployment/v1_6_0/sequences/adapter.go index 4fd483fd98..96e1020107 100644 --- a/chains/solana/deployment/v1_6_0/sequences/adapter.go +++ b/chains/solana/deployment/v1_6_0/sequences/adapter.go @@ -2,13 +2,17 @@ package sequences import ( "encoding/binary" + "fmt" "math/big" + "os" + "path/filepath" "github.com/Masterminds/semver/v3" "github.com/gagliardetto/solana-go" chain_selectors "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/utils" "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/fee_quoter" @@ -27,11 +31,13 @@ func init() { if err != nil { panic(err) } - laneapi.GetLaneAdapterRegistry().RegisterLaneAdapter(chain_selectors.FamilySolana, v, &SolanaAdapter{}) - deployapi.GetRegistry().RegisterDeployer(chain_selectors.FamilySolana, v, &SolanaAdapter{}) - deployapi.GetTransferOwnershipRegistry().RegisterAdapter(chain_selectors.FamilySolana, v, &SolanaAdapter{}) - mcmsreaderapi.GetRegistry().RegisterMCMSReader(chain_selectors.FamilySolana, &SolanaAdapter{}) - tokensapi.GetTokenAdapterRegistry().RegisterTokenAdapter(chain_selectors.FamilySolana, v, &SolanaAdapter{}) + adapter := &SolanaAdapter{} + laneapi.GetLaneAdapterRegistry().RegisterLaneAdapter(chain_selectors.FamilySolana, v, adapter) + deployapi.GetRegistry().RegisterDeployer(chain_selectors.FamilySolana, v, adapter) + deployapi.GetUpgraderRegistry().RegisterUpgrader(chain_selectors.FamilySolana, v, adapter) + deployapi.GetTransferOwnershipRegistry().RegisterAdapter(chain_selectors.FamilySolana, v, adapter) + mcmsreaderapi.GetRegistry().RegisterMCMSReader(chain_selectors.FamilySolana, adapter) + tokensapi.GetTokenAdapterRegistry().RegisterTokenAdapter(chain_selectors.FamilySolana, v, adapter) } type SolanaAdapter struct { @@ -78,6 +84,45 @@ func (a *SolanaAdapter) GetRouterAddress(ds datastore.DataStore, chainSelector u return addr, nil } +// PrepareArtifacts implements deploy.ArtifactPreparer. +// It ensures Solana program .so files are available in ProgramsPath before deployment. +// If ChainSpecific contains a *utils.SolanaBuildConfig, it uses that to drive the +// build/download. Otherwise, it checks that artifacts already exist (the in-memory test path). +func (a *SolanaAdapter) PrepareArtifacts(e cldf.Environment, chainSelector uint64, cfg deployapi.ContractDeploymentConfigPerChain) error { + chain, ok := e.BlockChains.SolanaChains()[chainSelector] + if !ok { + return fmt.Errorf("solana chain not found for selector %d", chainSelector) + } + + buildCfg, _ := cfg.ChainSpecific.(*utils.SolanaBuildConfig) + if buildCfg == nil { + // No explicit build config — check if artifacts already exist on disk. + // In-memory tests preload programs before genesis, so this is a no-op. + if _, err := os.Stat(chain.ProgramsPath); err != nil { + return fmt.Errorf("no build config provided and programs path does not exist: %s", chain.ProgramsPath) + } + // Verify at least one .so file exists + entries, err := os.ReadDir(chain.ProgramsPath) + if err != nil { + return fmt.Errorf("failed to read programs path: %w", err) + } + for _, entry := range entries { + if filepath.Ext(entry.Name()) == ".so" { + e.Logger.Infow("Artifacts already present, skipping build", "path", chain.ProgramsPath) + return nil + } + } + return fmt.Errorf("no .so artifacts found in %s and no build config provided", chain.ProgramsPath) + } + + // Override destination to the chain's programs path if not explicitly set + if buildCfg.DestinationDir == "" { + buildCfg.DestinationDir = chain.ProgramsPath + } + + return utils.BuildSolana(e.GetContext(), e.Logger, *buildCfg) +} + func (a *SolanaAdapter) GetRMNRemoteAddress(ds datastore.DataStore, chainSelector uint64) ([]byte, error) { addr, err := datastore_utils.FindAndFormatRef(ds, datastore.AddressRef{ ChainSelector: chainSelector, diff --git a/chains/solana/deployment/v1_6_0/sequences/upgrade_chain_contracts.go b/chains/solana/deployment/v1_6_0/sequences/upgrade_chain_contracts.go new file mode 100644 index 0000000000..45657dfab4 --- /dev/null +++ b/chains/solana/deployment/v1_6_0/sequences/upgrade_chain_contracts.go @@ -0,0 +1,186 @@ +package sequences + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/gagliardetto/solana-go" + mcmsTypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/utils" + fqops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/fee_quoter" + mcmsops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/mcms" + offrampops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/offramp" + rmnremoteops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/rmn_remote" + routerops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/router" + tokenpoolops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/token_pools" + deployapi "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" + common_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_solana "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// contractTypeToProgram maps contract types to their compiled program binary names. +// NOTE: The legacy system had a separate "redeploy" path for the offramp (fresh deploy + +// re-wire fee quoter). In practice, the offramp is always upgraded in place like other +// programs, so the redeploy path was intentionally not carried forward. +var contractTypeToProgram = map[cldf.ContractType]string{ + routerops.ContractType: routerops.ProgramName, + fqops.ContractType: fqops.ProgramName, + offrampops.ContractType: offrampops.ProgramName, + rmnremoteops.ContractType: rmnremoteops.ProgramName, + common_utils.BurnMintTokenPool: tokenpoolops.BurnMintProgramName, + common_utils.LockReleaseTokenPool: tokenpoolops.LockReleaseProgramName, + utils.McmProgramType: mcmsops.McmProgramName, + utils.TimelockProgramType: mcmsops.TimelockProgramName, + utils.AccessControllerProgramType: mcmsops.AccessControllerProgramName, +} + +func (a *SolanaAdapter) UpgradeChainContracts() *cldf_ops.Sequence[deployapi.ContractUpgradeConfigWithAddress, sequences.OnChainOutput, cldf_chain.BlockChains] { + return UpgradeChainContracts +} + +var UpgradeChainContracts = cldf_ops.NewSequence( + "upgrade-chain-contracts", + semver.MustParse("1.6.0"), + "Upgrades deployed Solana CCIP programs in place via BPF Loader Upgradeable", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input deployapi.ContractUpgradeConfigWithAddress) (sequences.OnChainOutput, error) { + chain, ok := chains.SolanaChains()[input.ChainSelector] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("solana chain selector %d not found in environment", input.ChainSelector) + } + + addresses := make([]datastore.AddressRef, 0) + allBatchOps := make([]mcmsTypes.BatchOperation, 0) + + timelockSignerPDA := utils.GetTimelockSignerPDA( + input.ExistingAddresses, + input.ChainSelector, + common_utils.CLLQualifier, + ) + + for _, contractType := range input.Contracts { + programName, ok := contractTypeToProgram[contractType] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("no program name mapping for contract type %s", contractType) + } + + existingAddr := findExistingAddress(input.ExistingAddresses, contractType) + if existingAddr == "" { + return sequences.OnChainOutput{}, fmt.Errorf("no existing address found for %s to upgrade", contractType) + } + programID := solana.MustPublicKeyFromBase58(existingAddr) + + upgradeAuthority, err := utils.GetUpgradeAuthority(chain.Client, programID) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to read upgrade authority for %s (%s): %w", contractType, programID.String(), err) + } + + batchOps, err := upgradeProgram( + b, + chain, + programID, + programName, + contractType, + upgradeAuthority, + timelockSignerPDA, + ) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to upgrade %s: %w", contractType, err) + } + allBatchOps = append(allBatchOps, batchOps...) + + addresses = append(addresses, datastore.AddressRef{ + Address: programID.String(), + ChainSelector: chain.Selector, + Type: datastore.ContractType(contractType), + Version: input.Version, + }) + } + + return sequences.OnChainOutput{ + Addresses: addresses, + BatchOps: allBatchOps, + }, nil + }, +) + +func upgradeProgram( + b cldf_ops.Bundle, + chain cldf_solana.Chain, + programID solana.PublicKey, + programName string, + contractType cldf.ContractType, + upgradeAuthority solana.PublicKey, + timelockSignerPDA solana.PublicKey, +) ([]mcmsTypes.BatchOperation, error) { + b.Logger.Infow("Deploying upgrade buffer", "program", contractType, "name", programName) + bufferAddress, err := utils.DeployToBuffer(chain, b.Logger, programName) + if err != nil { + return nil, fmt.Errorf("failed to deploy buffer: %w", err) + } + b.Logger.Infow("Buffer deployed", "program", contractType, "buffer", bufferAddress.String()) + + // Transfer buffer authority to the on-chain upgrade authority + setAuthIxn := utils.SetUpgradeAuthority(bufferAddress, chain.DeployerKey.PublicKey(), upgradeAuthority, true) + if err := chain.Confirm([]solana.Instruction{setAuthIxn}); err != nil { + return nil, fmt.Errorf("failed to set buffer authority: %w", err) + } + + // Extend program if the new binary is larger (permissionless — any payer) + bufferSize, err := utils.GetBufferSize(chain, bufferAddress) + if err != nil { + return nil, fmt.Errorf("failed to get buffer size: %w", err) + } + extendIxn, err := utils.GenerateExtendInstruction(chain, programID, chain.DeployerKey.PublicKey(), bufferSize) + if err != nil { + return nil, fmt.Errorf("failed to generate extend instruction: %w", err) + } + if extendIxn != nil { + if err := chain.Confirm([]solana.Instruction{extendIxn}); err != nil { + return nil, fmt.Errorf("failed to extend program: %w", err) + } + } + + upgradeIxn := utils.GenerateUpgradeInstruction(programID, bufferAddress, chain.DeployerKey.PublicKey(), upgradeAuthority) + closeIxn := utils.GenerateCloseBufferInstruction(bufferAddress, chain.DeployerKey.PublicKey(), upgradeAuthority) + + if upgradeAuthority == chain.DeployerKey.PublicKey() { + if err := chain.Confirm([]solana.Instruction{upgradeIxn, closeIxn}); err != nil { + return nil, fmt.Errorf("failed to confirm upgrade: %w", err) + } + return nil, nil + } + if upgradeAuthority != timelockSignerPDA { + return nil, fmt.Errorf( + "unsupported upgrade authority %s: expected deployer %s for direct execution or timelock signer PDA %s for MCMS", + upgradeAuthority.String(), + chain.DeployerKey.PublicKey().String(), + timelockSignerPDA.String(), + ) + } + + batchOp, err := utils.BuildMCMSBatchOperation( + chain.Selector, + []solana.Instruction{upgradeIxn, closeIxn}, + solana.BPFLoaderUpgradeableProgramID.String(), + string(contractType), + ) + if err != nil { + return nil, fmt.Errorf("failed to build MCMS batch: %w", err) + } + return []mcmsTypes.BatchOperation{batchOp}, nil +} + +func findExistingAddress(refs []datastore.AddressRef, contractType cldf.ContractType) string { + for _, ref := range refs { + if ref.Type == datastore.ContractType(contractType) { + return ref.Address + } + } + return "" +} diff --git a/deployment/deploy/contracts.go b/deployment/deploy/contracts.go index 20d11ba235..e3fc8e37be 100644 --- a/deployment/deploy/contracts.go +++ b/deployment/deploy/contracts.go @@ -49,6 +49,11 @@ type ContractDeploymentConfigPerChain struct { // PING PONG DEMO CONFIG // DeployPingPongDapp enables deployment of the PingPongDemo contract (default: false) DeployPingPongDapp bool + // ChainSpecific holds chain-family-specific configuration that the adapter + // is responsible for type-asserting. Nil for chains with no extra config. + // For example, Solana adapters expect *SolanaBuildConfig here to control + // artifact downloading, local builds, and key replacement. + ChainSpecific any } type ContractDeploymentConfigPerChainWithAddress struct { @@ -86,12 +91,17 @@ func deployContractsApply(d *DeployerRegistry) func(cldf.Environment, ContractDe if err != nil { return cldf.ChangesetOutput{}, err } - deployer, exists := d.GetDeployer(family, contractCfg.Version) - if !exists { - return cldf.ChangesetOutput{}, fmt.Errorf("no deployer registered for chain family %s and version %s", family, contractCfg.Version.String()) + deployer, exists := d.GetDeployer(family, contractCfg.Version) + if !exists { + return cldf.ChangesetOutput{}, fmt.Errorf("no deployer registered for chain family %s and version %s", family, contractCfg.Version.String()) + } + if preparer, ok := deployer.(ArtifactPreparer); ok { + if err := preparer.PrepareArtifacts(e, selector, contractCfg); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to prepare artifacts for chain %d: %w", selector, err) } - // find existing addresses for this chain from the env - existingAddrs := d.ExistingAddressesForChain(e, selector) + } + // find existing addresses for this chain from the env + existingAddrs := d.ExistingAddressesForChain(e, selector) // create the sequence input seqCfg := ContractDeploymentConfigPerChainWithAddress{ ContractDeploymentConfigPerChain: contractCfg, diff --git a/deployment/deploy/product.go b/deployment/deploy/product.go index 64ae147281..13213783d5 100644 --- a/deployment/deploy/product.go +++ b/deployment/deploy/product.go @@ -36,6 +36,14 @@ type Deployer interface { UpdateMCMSConfig() *cldf_ops.Sequence[UpdateMCMSConfigInputPerChainWithSelector, sequences.OnChainOutput, cldf_chain.BlockChains] } +// ArtifactPreparer is an optional interface that Deployer adapters may implement +// to prepare chain-specific artifacts (e.g. program binaries) before deployment. +// This is checked via type assertion in the deploy path — chains that don't need +// artifact preparation (e.g. EVM) simply don't implement it. +type ArtifactPreparer interface { + PrepareArtifacts(e cldf.Environment, chainSelector uint64, cfg ContractDeploymentConfigPerChain) error +} + type DeployerRegistry struct { mu sync.Mutex deployers map[string]Deployer diff --git a/deployment/deploy/upgrade.go b/deployment/deploy/upgrade.go new file mode 100644 index 0000000000..abbc4c4b4c --- /dev/null +++ b/deployment/deploy/upgrade.go @@ -0,0 +1,154 @@ +package deploy + +import ( + "fmt" + "sync" + + "github.com/Masterminds/semver/v3" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" +) + +// Upgrader is a separate interface from Deployer for chains that support +// in-place program/contract upgrades. Not all chain families support this +// (e.g. EVM uses proxy patterns instead), so this is kept separate to avoid +// forcing all Deployer implementations to stub upgrade methods. +type Upgrader interface { + UpgradeChainContracts() *cldf_ops.Sequence[ContractUpgradeConfigWithAddress, sequences.OnChainOutput, cldf_chain.BlockChains] +} + +// ContractUpgradeConfig targets a single chain. Upgrades are chain-specific +// operations — unlike deploys, they won't be batched across chains. +type ContractUpgradeConfig struct { + ChainSelector uint64 + // Version selects which upgrader adapter to use from the registry. + Version *semver.Version + // Contracts lists the contract types to upgrade. The upgrade authority + // and existing addresses are read from on-chain state by the adapter. + Contracts []cldf.ContractType + // ChainSpecific holds chain-family-specific upgrade configuration. + // Solana adapters expect *SolanaBuildConfig here for artifact preparation. + ChainSpecific any + MCMS mcms.Input +} + +// ContractUpgradeConfigWithAddress is the input passed to the upgrade sequence, +// enriched with existing on-chain addresses by the changeset. +type ContractUpgradeConfigWithAddress struct { + ContractUpgradeConfig + ExistingAddresses []datastore.AddressRef +} + +// UpgraderRegistry is a registry for chain-family-specific upgrade adapters. +type UpgraderRegistry struct { + mu sync.Mutex + upgraders map[string]Upgrader +} + +var ( + singletonUpgraderRegistry *UpgraderRegistry + upgraderRegistryOnce sync.Once +) + +func newUpgraderRegistry() *UpgraderRegistry { + return &UpgraderRegistry{ + upgraders: make(map[string]Upgrader), + } +} + +func GetUpgraderRegistry() *UpgraderRegistry { + upgraderRegistryOnce.Do(func() { + singletonUpgraderRegistry = newUpgraderRegistry() + }) + return singletonUpgraderRegistry +} + +func (r *UpgraderRegistry) RegisterUpgrader(chainFamily string, version *semver.Version, upgrader Upgrader) { + r.mu.Lock() + defer r.mu.Unlock() + id := utils.NewRegistererID(chainFamily, version) + if _, exists := r.upgraders[id]; !exists { + r.upgraders[id] = upgrader + } +} + +func (r *UpgraderRegistry) GetUpgrader(chainFamily string, version *semver.Version) (Upgrader, bool) { + r.mu.Lock() + defer r.mu.Unlock() + id := utils.NewRegistererID(chainFamily, version) + upgrader, ok := r.upgraders[id] + return upgrader, ok +} + +func UpgradeContracts(upgraderReg *UpgraderRegistry, deployerReg *DeployerRegistry) cldf.ChangeSetV2[ContractUpgradeConfig] { + return cldf.CreateChangeSet(upgradeContractsApply(upgraderReg, deployerReg), upgradeContractsVerify) +} + +func upgradeContractsVerify(_ cldf.Environment, cfg ContractUpgradeConfig) error { + _, err := chain_selectors.GetSelectorFamily(cfg.ChainSelector) + if err != nil { + return fmt.Errorf("no selector %d found: %w", cfg.ChainSelector, err) + } + if cfg.Version == nil { + return fmt.Errorf("no version specified for chain with selector %d", cfg.ChainSelector) + } + if len(cfg.Contracts) == 0 { + return fmt.Errorf("no contracts specified for upgrade on chain with selector %d", cfg.ChainSelector) + } + return nil +} + +func upgradeContractsApply(u *UpgraderRegistry, d *DeployerRegistry) func(cldf.Environment, ContractUpgradeConfig) (cldf.ChangesetOutput, error) { + return func(e cldf.Environment, cfg ContractUpgradeConfig) (cldf.ChangesetOutput, error) { + family, err := chain_selectors.GetSelectorFamily(cfg.ChainSelector) + if err != nil { + return cldf.ChangesetOutput{}, err + } + upgrader, exists := u.GetUpgrader(family, cfg.Version) + if !exists { + return cldf.ChangesetOutput{}, fmt.Errorf("no upgrader registered for chain family %s and version %s", family, cfg.Version.String()) + } + // If the deployer also implements ArtifactPreparer, run it for upgrades too. + // Upgrade builds typically need key replacement to match existing program IDs. + deployer, deployerExists := d.GetDeployer(family, cfg.Version) + if deployerExists { + if preparer, ok := deployer.(ArtifactPreparer); ok { + deployCfg := ContractDeploymentConfigPerChain{ + Version: cfg.Version, + ChainSpecific: cfg.ChainSpecific, + } + if err := preparer.PrepareArtifacts(e, cfg.ChainSelector, deployCfg); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to prepare artifacts for upgrade on chain %d: %w", cfg.ChainSelector, err) + } + } + } + existingAddrs := d.ExistingAddressesForChain(e, cfg.ChainSelector) + seqCfg := ContractUpgradeConfigWithAddress{ + ContractUpgradeConfig: cfg, + ExistingAddresses: existingAddrs, + } + upgradeReport, err := cldf_ops.ExecuteSequence(e.OperationsBundle, upgrader.UpgradeChainContracts(), e.BlockChains, seqCfg) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to upgrade contracts on chain with selector %d: %w", cfg.ChainSelector, err) + } + ds := datastore.NewMemoryDataStore() + for _, r := range upgradeReport.Output.Addresses { + if err := ds.Addresses().Add(r); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to add %s %s with address %s on chain %d to datastore: %w", r.Type, r.Version, r.Address, r.ChainSelector, err) + } + } + + return changesets.NewOutputBuilder(e, nil). + WithReports(upgradeReport.ExecutionReports). + WithDataStore(ds). + Build(cfg.MCMS) + } +} diff --git a/integration-tests/deployment/upgrade_chain_contracts_test.go b/integration-tests/deployment/upgrade_chain_contracts_test.go new file mode 100644 index 0000000000..ab4613ddb0 --- /dev/null +++ b/integration-tests/deployment/upgrade_chain_contracts_test.go @@ -0,0 +1,256 @@ +package deployment + +import ( + "math/big" + "os" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/gagliardetto/solana-go" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/stretchr/testify/require" + + solutils "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/utils" + fqops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/fee_quoter" + offrampops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/offramp" + routerops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/router" + _ "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/sequences" + deployapi "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" + common_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" +) + +func skipInCI(t *testing.T) { + t.Helper() + if os.Getenv("CI") == "true" { + t.Skip("Skipping in CI — local build is expensive") + } +} + +// TestDeployWithLocalBuild tests the full artifact build flow by building +// Solana programs locally (clone, key replacement, anchor build) and deploying +// them through the unified DeployContracts changeset. +// This is skipped in CI because the local docker build is expensive. +func TestDeployWithLocalBuild(t *testing.T) { + t.Parallel() + skipInCI(t) + + solSelector := chain_selectors.SOLANA_MAINNET.Selector + programsPath := t.TempDir() + + // We don't preload programs — the ArtifactPreparer will build them + e, err := environment.New(t.Context(), + environment.WithSolanaContainer(t, []uint64{solSelector}, programsPath, solanaProgramIDs), + ) + require.NoError(t, err) + + version := semver.MustParse("1.6.0") + mint, _ := solana.NewRandomPrivateKey() + + dReg := deployapi.GetRegistry() + _, err = deployapi.DeployContracts(dReg).Apply(*e, deployapi.ContractDeploymentConfig{ + MCMS: mcms.Input{}, + Chains: map[uint64]deployapi.ContractDeploymentConfigPerChain{ + solSelector: { + Version: version, + TokenPrivKey: mint.String(), + TokenDecimals: 9, + MaxFeeJuelsPerMsg: big.NewInt(0).Mul(big.NewInt(200), big.NewInt(1e18)), + PermissionLessExecutionThresholdSeconds: uint32((20 * time.Minute).Seconds()), + ChainSpecific: &solutils.SolanaBuildConfig{ + ContractVersion: solutils.VersionSolanaV1_6_0, + DestinationDir: programsPath, + LocalBuild: &solutils.LocalBuildConfig{ + BuildLocally: true, + CleanDestinationDir: true, + GenerateVanityKeys: true, + }, + }, + }, + }, + }) + require.NoError(t, err) + + // Verify deployed contracts exist in datastore + addresses := e.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(solSelector)) + require.NotEmpty(t, addresses) + + foundRouter := false + foundFeeQuoter := false + foundOffRamp := false + for _, addr := range addresses { + switch cldf.ContractType(addr.Type) { + case routerops.ContractType: + foundRouter = true + case fqops.ContractType: + foundFeeQuoter = true + case offrampops.ContractType: + foundOffRamp = true + } + } + require.True(t, foundRouter, "Router should be deployed") + require.True(t, foundFeeQuoter, "FeeQuoter should be deployed") + require.True(t, foundOffRamp, "OffRamp should be deployed") +} + +// TestUpgradeChainContracts tests the full upgrade flow: +// 1. Build and deploy original contracts with vanity keys +// 2. Deploy MCMS and transfer ownership +// 3. Build upgraded contracts with matching keys (declare_id replacement) +// 4. Upgrade programs in place via the UpgradeContracts changeset +// 5. Verify programs were upgraded in place (same addresses) +// +// Skipped in CI because the two local docker builds are expensive. +func TestUpgradeChainContracts(t *testing.T) { + t.Parallel() + skipInCI(t) + + solSelector := chain_selectors.SOLANA_MAINNET.Selector + programsPath := t.TempDir() + + e, err := environment.New(t.Context(), + environment.WithSolanaContainer(t, []uint64{solSelector}, programsPath, solanaProgramIDs), + ) + require.NoError(t, err) + + version := semver.MustParse("1.6.0") + mint, _ := solana.NewRandomPrivateKey() + + // Step 1: Initial deploy with local build + vanity keys + dReg := deployapi.GetRegistry() + _, err = deployapi.DeployContracts(dReg).Apply(*e, deployapi.ContractDeploymentConfig{ + MCMS: mcms.Input{}, + Chains: map[uint64]deployapi.ContractDeploymentConfigPerChain{ + solSelector: { + Version: version, + TokenPrivKey: mint.String(), + TokenDecimals: 9, + MaxFeeJuelsPerMsg: big.NewInt(0).Mul(big.NewInt(200), big.NewInt(1e18)), + PermissionLessExecutionThresholdSeconds: uint32((20 * time.Minute).Seconds()), + ChainSpecific: &solutils.SolanaBuildConfig{ + ContractVersion: solutils.VersionSolanaV1_6_0, + DestinationDir: programsPath, + LocalBuild: &solutils.LocalBuildConfig{ + BuildLocally: true, + CleanDestinationDir: true, + GenerateVanityKeys: true, + }, + }, + }, + }, + }) + require.NoError(t, err) + + // Step 2: Deploy MCMS and transfer ownership + DeployMCMS(t, e, solSelector, []string{common_utils.CLLQualifier}) + SolanaTransferMCMSContracts(t, e, solSelector, common_utils.CLLQualifier, true) + SolanaTransferOwnership(t, e, solSelector) + + // Capture deployed program addresses before upgrade + allAddresses := e.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(solSelector)) + preUpgradeAddresses := make(map[cldf.ContractType]string) + for _, addr := range allAddresses { + preUpgradeAddresses[cldf.ContractType(addr.Type)] = addr.Address + } + + routerAddr := preUpgradeAddresses[routerops.ContractType] + fqAddr := preUpgradeAddresses[fqops.ContractType] + offRampAddr := preUpgradeAddresses[offrampops.ContractType] + require.NotEmpty(t, routerAddr, "Router address should exist") + require.NotEmpty(t, fqAddr, "FeeQuoter address should exist") + require.NotEmpty(t, offRampAddr, "OffRamp address should exist") + + // Step 3: Build upgrade artifacts with keys matching deployed programs + uReg := deployapi.GetUpgraderRegistry() + _, err = deployapi.UpgradeContracts(uReg, dReg).Apply(*e, deployapi.ContractUpgradeConfig{ + ChainSelector: solSelector, + Version: version, + Contracts: []cldf.ContractType{ + routerops.ContractType, + fqops.ContractType, + offrampops.ContractType, + }, + ChainSpecific: &solutils.SolanaBuildConfig{ + ContractVersion: solutils.VersionSolanaV1_6_1, + DestinationDir: programsPath, + LocalBuild: &solutils.LocalBuildConfig{ + BuildLocally: true, + CleanDestinationDir: true, + CleanGitDir: true, + UpgradeKeys: map[cldf.ContractType]string{ + routerops.ContractType: routerAddr, + fqops.ContractType: fqAddr, + offrampops.ContractType: offRampAddr, + }, + }, + }, + MCMS: mcms.Input{}, + }) + require.NoError(t, err) + + // Step 4: Verify programs were upgraded in place (same addresses) + postUpgradeAddresses := e.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(solSelector)) + + routerCount := 0 + fqCount := 0 + offRampCount := 0 + for _, addr := range postUpgradeAddresses { + switch cldf.ContractType(addr.Type) { + case routerops.ContractType: + routerCount++ + require.Equal(t, routerAddr, addr.Address, "Router should be upgraded in place") + case fqops.ContractType: + fqCount++ + require.Equal(t, fqAddr, addr.Address, "FeeQuoter should be upgraded in place") + case offrampops.ContractType: + offRampCount++ + require.Equal(t, offRampAddr, addr.Address, "OffRamp should be upgraded in place") + } + } + require.Equal(t, 1, routerCount, "Should have exactly one Router") + require.Equal(t, 1, fqCount, "Should have exactly one FeeQuoter") + require.Equal(t, 1, offRampCount, "Should have exactly one OffRamp") +} + +// TestDeployWithDownloadedArtifacts tests the preloaded artifact path +// (the default in-memory test flow) through the unified API. +// This does NOT use skipInCI — it works with preloaded .so files. +func TestDeployWithDownloadedArtifacts(t *testing.T) { + t.Parallel() + + solSelector := chain_selectors.SOLANA_MAINNET.Selector + programsPath, ds, err := PreloadSolanaEnvironment(t, solSelector) + require.NoError(t, err) + + e, err := environment.New(t.Context(), + environment.WithSolanaContainer(t, []uint64{solSelector}, programsPath, solanaProgramIDs), + ) + require.NoError(t, err) + e.DataStore = ds.Seal() + + version := semver.MustParse("1.6.0") + mint, _ := solana.NewRandomPrivateKey() + + dReg := deployapi.GetRegistry() + _, err = deployapi.DeployContracts(dReg).Apply(*e, deployapi.ContractDeploymentConfig{ + MCMS: mcms.Input{}, + Chains: map[uint64]deployapi.ContractDeploymentConfigPerChain{ + solSelector: { + Version: version, + TokenPrivKey: mint.String(), + TokenDecimals: 9, + MaxFeeJuelsPerMsg: big.NewInt(0).Mul(big.NewInt(200), big.NewInt(1e18)), + PermissionLessExecutionThresholdSeconds: uint32((20 * time.Minute).Seconds()), + // No ChainSpecific — artifacts are already preloaded + }, + }, + }) + require.NoError(t, err) + + addresses := e.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(solSelector)) + require.NotEmpty(t, addresses) +}