diff --git a/go.mod b/go.mod index e3846de..076ad19 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,17 @@ go 1.25.7 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/aptos-labs/aptos-go-sdk v1.12.0 + github.com/ethereum/go-ethereum v1.17.1 github.com/gagliardetto/solana-go v1.13.0 + github.com/smartcontractkit/ccip-owner-contracts v0.1.0 github.com/smartcontractkit/chain-selectors v1.0.97 - github.com/smartcontractkit/chainlink-deployments-framework v0.97.0 + github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7 + github.com/smartcontractkit/chainlink-deployments-framework v0.98.0 + github.com/smartcontractkit/chainlink-evm v0.3.3 + github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828 github.com/smartcontractkit/mcms v0.40.1 github.com/stretchr/testify v1.11.1 + golang.org/x/mod v0.33.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -76,7 +82,6 @@ require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect - github.com/ethereum/go-ethereum v1.17.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fbsobreira/gotron-sdk v0.0.0-20250403083053-2943ce8c759b // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -205,11 +210,9 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/smartcontractkit/ccip-owner-contracts v0.1.0 // indirect github.com/smartcontractkit/chainlink-aptos v0.0.0-20260306142855-8d629e752265 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260121163256-85accaf3d28d // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20250912190424-fd2e35d7deb5 // indirect - github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 // indirect github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396 // indirect github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 // indirect diff --git a/go.sum b/go.sum index 65357c0..4cc5e17 100644 --- a/go.sum +++ b/go.sum @@ -633,8 +633,8 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= -github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -744,8 +744,12 @@ github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356c github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7/go.mod h1:HXgSKzmZ/bhSx8nHU7hHW6dR+BHSXkdcpFv2T8qJcS8= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY= -github.com/smartcontractkit/chainlink-deployments-framework v0.97.0 h1:LC8SJ4WW3FBHTDhAtpEOqQnZk+s3Qm8+pnGEI3dnkvw= -github.com/smartcontractkit/chainlink-deployments-framework v0.97.0/go.mod h1:24dwRW1PYolrlxSth///ddG3auGqR+50xaJiXfUHhkg= +github.com/smartcontractkit/chainlink-deployments-framework v0.98.0 h1:Ov/KOEtubOHXX8oa9UtARhHmkQNCOIjWNt+Zi0AuzHM= +github.com/smartcontractkit/chainlink-deployments-framework v0.98.0/go.mod h1:24dwRW1PYolrlxSth///ddG3auGqR+50xaJiXfUHhkg= +github.com/smartcontractkit/chainlink-evm v0.3.3 h1:JqwyJEtnNEUaoQQPoOBTT4sn2lpdIZHtf0Hr0M60YDw= +github.com/smartcontractkit/chainlink-evm v0.3.3/go.mod h1:q0ZBvaoisNaqC8NcMYWNPTjee88nQktDEeJMQHq3hVI= +github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828 h1:BmsFk/TSHL6dPPR86GTqgSrUXLSINNFC6cfpFRrQX+4= +github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828/go.mod h1:a260YnLyWq2NHLUN5cSVyMGk9nhO6RguCaTI2rsVqyA= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396 h1:03tbcwjyIEjvHba1IWOj1sfThwebm2XNzyFHSuZtlWc= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= diff --git a/link/view/link_token.go b/link/view/link_token.go new file mode 100644 index 0000000..ea2f62c --- /dev/null +++ b/link/view/link_token.go @@ -0,0 +1,60 @@ +package view + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + cldchangesetscommon "github.com/smartcontractkit/cld-changesets/pkg/common" +) + +type LinkTokenView struct { + cldchangesetscommon.ContractMetaData + Decimals uint8 `json:"decimals"` + Supply *big.Int `json:"supply"` + Minters []common.Address `json:"minters"` + Burners []common.Address `json:"burners"` +} + +func GenerateLinkTokenView(lt *link_token.LinkToken) (LinkTokenView, error) { + owner, err := lt.Owner(nil) + if err != nil { + owner = common.Address{} + } + decimals, err := lt.Decimals(nil) + if err != nil { + return LinkTokenView{}, fmt.Errorf("failed to get decimals %s: %w", lt.Address(), err) + } + totalSupply, err := lt.TotalSupply(nil) + if err != nil { + return LinkTokenView{}, fmt.Errorf("failed to get total supply %s: %w", lt.Address(), err) + } + minters, err := lt.GetMinters(nil) + if err != nil { + minters = []common.Address{} + } + burners, err := lt.GetBurners(nil) + if err != nil { + burners = []common.Address{} + } + return LinkTokenView{ + ContractMetaData: cldchangesetscommon.ContractMetaData{ + TypeAndVersion: cldf.TypeAndVersion{ + Type: linkcontracts.LinkToken, + Version: cldchangesetscommon.Version1_0_0, + }.String(), + Address: lt.Address(), + Owner: owner, + }, + Decimals: decimals, + Supply: totalSupply, + Minters: minters, + Burners: burners, + }, nil +} diff --git a/link/view/link_token_test.go b/link/view/link_token_test.go new file mode 100644 index 0000000..0623889 --- /dev/null +++ b/link/view/link_token_test.go @@ -0,0 +1,82 @@ +package v1_0 + +import ( + "math/big" + "testing" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" +) + +func TestLinkTokenView(t *testing.T) { + selector := chainselectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[selector] + _, tx, lt, err := link_token.DeployLinkToken(chain.DeployerKey, chain.Client) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + testLinkTokenViewWithChain(t, chain, lt) +} + +func TestLinkTokenViewZk(t *testing.T) { + // Timeouts in CI + tests.SkipFlakey(t, "https://smartcontract-it.atlassian.net/browse/CCIP-6427") + + selector := chainselectors.TEST_90000050.Selector + env, err := environment.New(t.Context(), + environment.WithZKSyncContainer(t, []uint64{selector}), + ) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[selector] + _, _, lt, err := link_token.DeployLinkTokenZk(nil, chain.ClientZkSyncVM, chain.DeployerKeyZkSyncVM, chain.Client) + require.NoError(t, err) + + testLinkTokenViewWithChain(t, chain, lt) +} + +func testLinkTokenViewWithChain(t *testing.T, chain cldf_evm.Chain, lt *link_token.LinkToken) { + v, err := GenerateLinkTokenView(lt) + require.NoError(t, err) + + assert.Equal(t, v.Owner, chain.DeployerKey.From) + assert.Equal(t, "LinkToken 1.0.0", v.TypeAndVersion) + assert.Equal(t, uint8(18), v.Decimals) + // Initially nothing minted and no minters/burners. + assert.Equal(t, "0", v.Supply.String()) + require.Empty(t, v.Minters) + require.Empty(t, v.Burners) + + // Add some minters + tx, err := lt.GrantMintAndBurnRoles(chain.DeployerKey, chain.DeployerKey.From) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + tx, err = lt.Mint(chain.DeployerKey, chain.DeployerKey.From, big.NewInt(100)) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + v, err = GenerateLinkTokenView(lt) + require.NoError(t, err) + + assert.Equal(t, "100", v.Supply.String()) + require.Len(t, v.Minters, 1) + require.Equal(t, v.Minters[0].String(), chain.DeployerKey.From.String()) + require.Len(t, v.Burners, 1) + require.Equal(t, v.Burners[0].String(), chain.DeployerKey.From.String()) +} diff --git a/link/view/static_link_token.go b/link/view/static_link_token.go new file mode 100644 index 0000000..ce1bef1 --- /dev/null +++ b/link/view/static_link_token.go @@ -0,0 +1,42 @@ +package v1_0 + +import ( + "fmt" + "math/big" + + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/link_token_interface" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/cld-changesets/pkg/common" +) + +type StaticLinkTokenView struct { + common.ContractMetaData + Decimals uint8 `json:"decimals"` + Supply *big.Int `json:"supply"` +} + +func GenerateStaticLinkTokenView(lt *link_token_interface.LinkToken) (StaticLinkTokenView, error) { + decimals, err := lt.Decimals(nil) + if err != nil { + return StaticLinkTokenView{}, fmt.Errorf("failed to get decimals %s: %w", lt.Address(), err) + } + totalSupply, err := lt.TotalSupply(nil) + if err != nil { + return StaticLinkTokenView{}, fmt.Errorf("failed to get total supply %s: %w", lt.Address(), err) + } + return StaticLinkTokenView{ + ContractMetaData: common.ContractMetaData{ + TypeAndVersion: cldf.TypeAndVersion{ + Type: linkcontracts.StaticLinkToken, + Version: common.Version1_0_0, + }.String(), + Address: lt.Address(), + // No owner. + }, + Decimals: decimals, + Supply: totalSupply, + }, nil +} diff --git a/link/view/static_link_token_test.go b/link/view/static_link_token_test.go new file mode 100644 index 0000000..350698d --- /dev/null +++ b/link/view/static_link_token_test.go @@ -0,0 +1,37 @@ +package v1_0 + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/link_token_interface" +) + +func TestStaticLinkTokenView(t *testing.T) { + selector := chain_selectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[selector] + _, tx, lt, err := link_token_interface.DeployLinkToken(chain.DeployerKey, chain.Client) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + v, err := GenerateStaticLinkTokenView(lt) + require.NoError(t, err) + + assert.Equal(t, v.Owner, common.HexToAddress("0x0")) // Ownerless + assert.Equal(t, "StaticLinkToken 1.0.0", v.TypeAndVersion) + assert.Equal(t, uint8(18), v.Decimals) + assert.Equal(t, "1000000000000000000000000000", v.Supply.String()) +} diff --git a/mcms/common/view/v1_0/mcms.go b/mcms/common/view/v1_0/mcms.go new file mode 100644 index 0000000..fdbc7a2 --- /dev/null +++ b/mcms/common/view/v1_0/mcms.go @@ -0,0 +1,300 @@ +package v1_0 + +import ( + "context" + "fmt" + "math/big" + "slices" + + "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + owner_helpers "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + mcmsevmsdk "github.com/smartcontractkit/mcms/sdk/evm" + "github.com/smartcontractkit/mcms/sdk/evm/bindings" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-evm/pkg/utils" + + cldcommon "github.com/smartcontractkit/cld-changesets/pkg/common" +) + +type Role struct { + ID common.Hash + Name string +} + +const ( + EXECUTOR_ROLE_STR = "EXECUTOR_ROLE" + BYPASSER_ROLE_STR = "BYPASSER_ROLE" + CANCELLER_ROLE_STR = "CANCELLER_ROLE" + PROPOSER_ROLE_STR = "PROPOSER_ROLE" + ADMIN_ROLE_STR = "ADMIN_ROLE" +) + +// https://github.com/smartcontractkit/ccip-owner-contracts/blob/9d81692b324ce7ea2ef8a75e683889edbc7e2dd0/src/RBACTimelock.sol#L71 +// Just to avoid invoking the Go binding to get these. +var ( + ADMIN_ROLE = Role{ + ID: utils.MustHash(ADMIN_ROLE_STR), + Name: ADMIN_ROLE_STR, + } + PROPOSER_ROLE = Role{ + ID: utils.MustHash(PROPOSER_ROLE_STR), + Name: PROPOSER_ROLE_STR, + } + BYPASSER_ROLE = Role{ + ID: utils.MustHash(BYPASSER_ROLE_STR), + Name: BYPASSER_ROLE_STR, + } + CANCELLER_ROLE = Role{ + ID: utils.MustHash(CANCELLER_ROLE_STR), + Name: CANCELLER_ROLE_STR, + } + EXECUTOR_ROLE = Role{ + ID: utils.MustHash(EXECUTOR_ROLE_STR), + Name: EXECUTOR_ROLE_STR, + } +) + +// --- evm --- + +type MCMSView struct { + cldcommon.ContractMetaData + // Note config is json marshallable. + Config mcmstypes.Config `json:"config"` +} + +func GenerateMCMSView(mcms owner_helpers.ManyChainMultiSig) (MCMSView, error) { + owner, err := mcms.Owner(nil) + if err != nil { + return MCMSView{}, err + } + mcmsConfig, err := mcms.GetConfig(nil) + if err != nil { + return MCMSView{}, err + } + + mapSigners := func(in []owner_helpers.ManyChainMultiSigSigner) []bindings.ManyChainMultiSigSigner { + out := make([]bindings.ManyChainMultiSigSigner, len(in)) + for i, s := range in { + out[i] = bindings.ManyChainMultiSigSigner{Addr: s.Addr, Index: s.Index, Group: s.Group} + } + return out + } + + parsedConfig, err := mcmsevmsdk.NewConfigTransformer().ToConfig(bindings.ManyChainMultiSigConfig{ + Signers: mapSigners(mcmsConfig.Signers), + GroupQuorums: mcmsConfig.GroupQuorums, + GroupParents: mcmsConfig.GroupParents, + }) + if err != nil { + return MCMSView{}, err + } + return MCMSView{ + // Has no type and version on the contract + ContractMetaData: cldcommon.ContractMetaData{ + Owner: owner, + Address: mcms.Address(), + }, + Config: *parsedConfig, + }, nil +} + +type TimelockView struct { + cldcommon.ContractMetaData + MembersByRole map[string][]common.Address `json:"membersByRole"` +} + +func GenerateTimelockView(tl owner_helpers.RBACTimelock) (TimelockView, error) { + membersByRole := make(map[string][]common.Address) + for _, role := range []Role{ADMIN_ROLE, PROPOSER_ROLE, BYPASSER_ROLE, CANCELLER_ROLE, EXECUTOR_ROLE} { + numMembers, err := tl.GetRoleMemberCount(nil, role.ID) + if err != nil { + return TimelockView{}, fmt.Errorf("get role member count for role %s (%s): %w", role.Name, role.ID.Hex(), err) + } + for i := int64(0); i < numMembers.Int64(); i++ { + member, err2 := tl.GetRoleMember(nil, role.ID, big.NewInt(i)) + if err2 != nil { + return TimelockView{}, fmt.Errorf("get role member for role %s (%s) at index %d: %w", role.Name, role.ID.Hex(), i, err2) + } + membersByRole[role.Name] = append(membersByRole[role.Name], member) + } + } + return TimelockView{ + // Has no type and version or owner. + ContractMetaData: cldcommon.ContractMetaData{ + Address: tl.Address(), + }, + MembersByRole: membersByRole, + }, nil +} + +type CallProxyView struct { + cldcommon.ContractMetaData +} + +func GenerateCallProxyView(cp owner_helpers.CallProxy) (CallProxyView, error) { + return CallProxyView{ + ContractMetaData: cldcommon.ContractMetaData{ + Address: cp.Address(), + }, + }, nil +} + +type MCMSWithTimelockView struct { + Bypasser MCMSView `json:"bypasser"` + Canceller MCMSView `json:"canceller"` + Proposer MCMSView `json:"proposer"` + Timelock TimelockView `json:"timelock"` + CallProxy CallProxyView `json:"callProxy"` +} + +func GenerateMCMSWithTimelockView( + bypasser owner_helpers.ManyChainMultiSig, + canceller owner_helpers.ManyChainMultiSig, + proposer owner_helpers.ManyChainMultiSig, + timelock owner_helpers.RBACTimelock, + callProxy owner_helpers.CallProxy, +) (MCMSWithTimelockView, error) { + timelockView, err := GenerateTimelockView(timelock) + if err != nil { + return MCMSWithTimelockView{}, err + } + callProxyView, err := GenerateCallProxyView(callProxy) + if err != nil { + return MCMSWithTimelockView{}, err + } + bypasserView, err := GenerateMCMSView(bypasser) + if err != nil { + return MCMSWithTimelockView{}, err + } + proposerView, err := GenerateMCMSView(proposer) + if err != nil { + return MCMSWithTimelockView{}, err + } + cancellerView, err := GenerateMCMSView(canceller) + if err != nil { + return MCMSWithTimelockView{}, err + } + + return MCMSWithTimelockView{ + Timelock: timelockView, + Bypasser: bypasserView, + Proposer: proposerView, + Canceller: cancellerView, + CallProxy: callProxyView, + }, nil +} + +// --- solana --- + +type MCMSWithTimelockViewSolana struct { + Bypasser MCMViewSolana `json:"bypasser"` + Canceller MCMViewSolana `json:"canceller"` + Proposer MCMViewSolana `json:"proposer"` + Timelock TimelockViewSolana `json:"timelock"` +} + +func GenerateMCMSWithTimelockViewSolana( + ctx context.Context, + inspector *mcmssolanasdk.Inspector, + timelockInspector *mcmssolanasdk.TimelockInspector, + mcmProgram solana.PublicKey, + proposerMcmSeed [32]byte, + cancellerMcmSeed [32]byte, + bypasserMcmSeed [32]byte, + timelockProgram solana.PublicKey, + timelockSeed [32]byte, +) (MCMSWithTimelockViewSolana, error) { + timelockView, err := GenerateTimelockViewSolana(ctx, timelockInspector, timelockProgram, timelockSeed) + if err != nil { + return MCMSWithTimelockViewSolana{}, fmt.Errorf("unable to generate timelock view: %w", err) + } + bypasserView, err := GenerateMCMViewSolana(ctx, inspector, mcmProgram, bypasserMcmSeed) + if err != nil { + return MCMSWithTimelockViewSolana{}, fmt.Errorf("unable to generate bypasser mcm view: %w", err) + } + proposerView, err := GenerateMCMViewSolana(ctx, inspector, mcmProgram, proposerMcmSeed) + if err != nil { + return MCMSWithTimelockViewSolana{}, fmt.Errorf("unable to generate proposer mcm view: %w", err) + } + cancellerView, err := GenerateMCMViewSolana(ctx, inspector, mcmProgram, cancellerMcmSeed) + if err != nil { + return MCMSWithTimelockViewSolana{}, fmt.Errorf("unable to generate canceller mcm view: %w", err) + } + + return MCMSWithTimelockViewSolana{ + Timelock: timelockView, + Bypasser: bypasserView, + Proposer: proposerView, + Canceller: cancellerView, + }, nil +} + +type MCMViewSolana struct { + ProgramID solana.PublicKey `json:"programID"` + Seed string `json:"seed"` + Owner solana.PublicKey `json:"owner"` + Config mcmstypes.Config `json:"config"` +} + +func GenerateMCMViewSolana( + ctx context.Context, inspector *mcmssolanasdk.Inspector, programID solana.PublicKey, seed [32]byte, +) (MCMViewSolana, error) { + address := mcmssolanasdk.ContractAddress(programID, mcmssolanasdk.PDASeed(seed)) + config, err := inspector.GetConfig(ctx, address) + if err != nil { + return MCMViewSolana{}, fmt.Errorf("unable to get config from mcm (%v): %w", address, err) + } + + return MCMViewSolana{ + ProgramID: programID, + Seed: string(seed[:]), + Owner: solana.PublicKey{}, // FIXME: needs inspector.GetOwner() in mcms solana sdk + Config: *config, + }, nil +} + +type TimelockViewSolana struct { + ProgramID solana.PublicKey `json:"programID"` + Seed string `json:"seed"` + Owner solana.PublicKey `json:"owner"` + Proposers []string `json:"proposers"` + Executors []string `json:"executors"` + Cancellers []string `json:"cancellers"` + Bypassers []string `json:"bypassers"` +} + +func GenerateTimelockViewSolana( + ctx context.Context, inspector *mcmssolanasdk.TimelockInspector, programID solana.PublicKey, seed [32]byte, +) (TimelockViewSolana, error) { + address := mcmssolanasdk.ContractAddress(programID, mcmssolanasdk.PDASeed(seed)) + + proposers, err := inspector.GetProposers(ctx, address) + if err != nil { + return TimelockViewSolana{}, fmt.Errorf("unable to get proposers from timelock (%v): %w", address, err) + } + executors, err := inspector.GetExecutors(ctx, address) + if err != nil { + return TimelockViewSolana{}, fmt.Errorf("unable to get executors from timelock (%v): %w", address, err) + } + cancellers, err := inspector.GetCancellers(ctx, address) + if err != nil { + return TimelockViewSolana{}, fmt.Errorf("unable to get cancellers from timelock (%v): %w", address, err) + } + bypassers, err := inspector.GetBypassers(ctx, address) + if err != nil { + return TimelockViewSolana{}, fmt.Errorf("unable to get bypassers from timelock (%v): %w", address, err) + } + + return TimelockViewSolana{ + ProgramID: programID, + Seed: string(seed[:]), + Owner: solana.PublicKey{}, // FIXME: needs inspector.GetOwner() in mcms solana sdk + Proposers: slices.Sorted(slices.Values(proposers)), + Executors: slices.Sorted(slices.Values(executors)), + Cancellers: slices.Sorted(slices.Values(cancellers)), + Bypassers: slices.Sorted(slices.Values(bypassers)), + }, nil +} diff --git a/pkg/common/types.go b/pkg/common/types.go new file mode 100644 index 0000000..bff4222 --- /dev/null +++ b/pkg/common/types.go @@ -0,0 +1,35 @@ +package common + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +type ContractMetaData struct { + TypeAndVersion string `json:"typeAndVersion,omitempty"` + Address common.Address `json:"address,omitempty"` + Owner common.Address `json:"owner,omitempty"` +} + +func NewContractMetaData(tv Meta, addr common.Address) (ContractMetaData, error) { + tvStr, err := tv.TypeAndVersion(nil) + if err != nil { + return ContractMetaData{}, fmt.Errorf("failed to get type and version addr %s: %w", addr.String(), err) + } + owner, err := tv.Owner(nil) + if err != nil { + return ContractMetaData{}, fmt.Errorf("failed to get owner addr %s: %w", addr.String(), err) + } + return ContractMetaData{ + TypeAndVersion: tvStr, + Address: addr, + Owner: owner, + }, nil +} + +type Meta interface { + TypeAndVersion(opts *bind.CallOpts) (string, error) + Owner(opts *bind.CallOpts) (common.Address, error) +} diff --git a/pkg/common/version.go b/pkg/common/version.go new file mode 100644 index 0000000..b53c72c --- /dev/null +++ b/pkg/common/version.go @@ -0,0 +1,21 @@ +package common + +import ( + "github.com/Masterminds/semver/v3" +) + +var ( + Version0_5_0 = *semver.MustParse("0.5.0") + Version1_0_0 = *semver.MustParse("1.0.0") + Version1_1_0 = *semver.MustParse("1.1.0") + Version1_2_0 = *semver.MustParse("1.2.0") + Version1_5_0 = *semver.MustParse("1.5.0") + Version1_5_1 = *semver.MustParse("1.5.1") + Version1_6_0 = *semver.MustParse("1.6.0") + Version1_6_1 = *semver.MustParse("1.6.1") + Version1_6_1Dev = *semver.MustParse("1.6.1-dev") + Version1_6_2 = *semver.MustParse("1.6.2") + Version1_6_3Dev = *semver.MustParse("1.6.3-dev") + Version1_6_3 = *semver.MustParse("1.6.3") + Version1_7_0 = *semver.MustParse("1.7.0") +) diff --git a/pkg/family/evm/state.go b/pkg/family/evm/state.go new file mode 100644 index 0000000..d4f4217 --- /dev/null +++ b/pkg/family/evm/state.go @@ -0,0 +1,378 @@ +package state + +import ( + "errors" + "fmt" + "maps" + + "github.com/ethereum/go-ethereum/common" + bindings "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/link_token_interface" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + linkview "github.com/smartcontractkit/cld-changesets/link/view" + + "github.com/smartcontractkit/cld-changesets/mcms/common/view/v1_0" + common2 "github.com/smartcontractkit/cld-changesets/pkg/common" +) + +// MCMSWithTimelockState holds the Go bindings +// for a MCMSWithTimelock contract mcms. +// It is public for use in product specific packages. +// Either all fields are nil or all fields are non-nil. +type MCMSWithTimelockState struct { + CancellerMcm *bindings.ManyChainMultiSig + BypasserMcm *bindings.ManyChainMultiSig + ProposerMcm *bindings.ManyChainMultiSig + Timelock *bindings.RBACTimelock + CallProxy *bindings.CallProxy +} + +// Validate checks that all fields are non-nil, ensuring it's ready +// for use generating views or interactions. +func (state MCMSWithTimelockState) Validate() error { + if state.Timelock == nil { + return errors.New("timelock not found") + } + if state.CancellerMcm == nil { + return errors.New("canceller not found") + } + if state.ProposerMcm == nil { + return errors.New("proposer not found") + } + if state.BypasserMcm == nil { + return errors.New("bypasser not found") + } + if state.CallProxy == nil { + return errors.New("call proxy not found") + } + return nil +} + +func (state MCMSWithTimelockState) GenerateMCMSWithTimelockView() (v1_0.MCMSWithTimelockView, error) { + if err := state.Validate(); err != nil { + return v1_0.MCMSWithTimelockView{}, fmt.Errorf("unable to validate McmsWithTimelock state: %w", err) + } + + return v1_0.GenerateMCMSWithTimelockView(*state.BypasserMcm, *state.CancellerMcm, *state.ProposerMcm, + *state.Timelock, *state.CallProxy) +} + +// AddressesForChain combines addresses from both DataStore and AddressBook making it backward compatible. +// This version supports qualifiers for filtering DataStore addresses. +// When a qualifier is specified, only DataStore addresses with that qualifier are returned (no AddressBook merge) +// to ensure isolation between different deployments. +func AddressesForChain(env cldf.Environment, chainSelector uint64, qualifier string) (map[string]cldf.TypeAndVersion, error) { + // If a qualifier is specified, only use DataStore to ensure isolation between deployments + if qualifier != "" { + if env.DataStore != nil { + return LoadAddressesFromDataStore(env.DataStore, chainSelector, qualifier) + } + return nil, fmt.Errorf("DataStore not available but qualifier %s specified", qualifier) + } + + // For backward compatibility without qualifier, merge both sources + // Start with addresses from AddressBook + addressBookAddresses := make(map[string]cldf.TypeAndVersion) + if addresses, err := env.ExistingAddresses.AddressesForChain(chainSelector); err == nil { + addressBookAddresses = addresses + } else if !errors.Is(err, cldf.ErrChainNotFound) { + return nil, fmt.Errorf("failed to load addresses from AddressBook: %w", err) + } + + // If no DataStore, just return AddressBook addresses + if env.DataStore == nil { + return addressBookAddresses, nil + } + + // Try to load addresses from DataStore (without qualifier for general case) + dataStoreAddresses, err := LoadAddressesFromDataStore(env.DataStore, chainSelector, "") + if err != nil { + // If DataStore has no addresses or returns an error, fall back to AddressBook addresses only + return addressBookAddresses, nil + } + + // Merge the two maps - DataStore addresses take precedence + mergedAddresses := make(map[string]cldf.TypeAndVersion) + + // First add all AddressBook addresses + maps.Copy(mergedAddresses, addressBookAddresses) + + // Then add DataStore addresses (overwriting any conflicts) + maps.Copy(mergedAddresses, dataStoreAddresses) + + return mergedAddresses, nil +} + +// MaybeLoadMCMSWithTimelockStateDataStore loads the MCMSWithTimelockState state for each chain in the given environment from the DataStore. +func MaybeLoadMCMSWithTimelockStateDataStore(env cldf.Environment, chainSelectors []uint64) (map[uint64]*MCMSWithTimelockState, error) { + return MaybeLoadMCMSWithTimelockStateDataStoreWithQualifier(env, chainSelectors, "") +} + +func MaybeLoadMCMSWithTimelockStateDataStoreWithQualifier(env cldf.Environment, chainSelectors []uint64, qualifier string) (map[uint64]*MCMSWithTimelockState, error) { + result := map[uint64]*MCMSWithTimelockState{} + ds := env.DataStore + if ds == nil { + return nil, fmt.Errorf("datastore not available") + } + for _, chainSelector := range chainSelectors { + chain, ok := env.BlockChains.EVMChains()[chainSelector] + if !ok { + return nil, fmt.Errorf("chain %d not found", chainSelector) + } + state, err := GetMCMSWithTimelockState(ds.Addresses(), chain, qualifier) + if err != nil { + return nil, fmt.Errorf("failed to get MCMSWithTimelock state for chain %d, qualifier %s: %w", chainSelector, qualifier, err) + } + result[chainSelector] = state + } + return result, nil +} + +// GetMCMSWithTimelockState loads the MCMSWithTimelockState for a specific chain and qualifier from the DataStore. +// It filters AddressRefs to avoid key collisions that occur when multiple contract types share the same address (e.g. bypasser and canceller). +func GetMCMSWithTimelockState(store datastore.AddressRefStore, chain cldf_evm.Chain, qualifier string) (*MCMSWithTimelockState, error) { + filters := []datastore.FilterFunc[datastore.AddressRefKey, datastore.AddressRef]{datastore.AddressRefByChainSelector(chain.Selector)} + if qualifier != "" { + filters = append(filters, datastore.AddressRefByQualifier(qualifier)) + } + + refs := store.Filter(filters...) + if len(refs) == 0 { + return nil, fmt.Errorf("no addresses found for chain %d with qualifier %q", chain.Selector, qualifier) + } + + return MaybeLoadMCMSWithTimelockChainStateFromRefs(chain, refs) +} + +// LoadAddressesFromDataStore loads addresses from DataStore with optional qualifier. +// This is a public utility function that can be used by other packages to avoid duplication. +// +// Deprecated: Use GetAddressTypeVersionByQualifier instead. +func LoadAddressesFromDataStore(ds datastore.DataStore, chainSelector uint64, qualifier string) (map[string]cldf.TypeAndVersion, error) { + addressesChain, err := GetAddressTypeVersionByQualifier(ds.Addresses(), chainSelector, qualifier) + if err != nil { + return nil, err + } + return addressesChain, nil +} + +// MaybeLoadMCMSWithTimelockChainStateFromRefs is the DataStore-native equivalent of MaybeLoadMCMSWithTimelockChainState. +// It accepts []datastore.AddressRef directly, to preserve entries when multiple contract types share the same address (e.g. bypasser and canceller). +func MaybeLoadMCMSWithTimelockChainStateFromRefs(chain cldf_evm.Chain, refs []datastore.AddressRef) (*MCMSWithTimelockState, error) { + state := MCMSWithTimelockState{} + var ( + // We expect one of each contract on the chain. + timelock = cldf.NewTypeAndVersion(mcmscontracts.RBACTimelock, common2.Version1_0_0) + callProxy = cldf.NewTypeAndVersion(mcmscontracts.CallProxy, common2.Version1_0_0) + proposer = cldf.NewTypeAndVersion(mcmscontracts.ProposerManyChainMultisig, common2.Version1_0_0) + canceller = cldf.NewTypeAndVersion(mcmscontracts.CancellerManyChainMultisig, common2.Version1_0_0) + bypasser = cldf.NewTypeAndVersion(mcmscontracts.BypasserManyChainMultisig, common2.Version1_0_0) + ) + + wantTypes := []cldf.TypeAndVersion{timelock, proposer, canceller, bypasser, callProxy} + + dedupMap := make(map[string]cldf.TypeAndVersion, len(refs)) + for _, ref := range refs { + if ref.Version == nil { + return nil, fmt.Errorf("invalid MCMS ref on chain %s: nil version for address %s type %s", chain.Name(), ref.Address, ref.Type) + } + tv := cldf.TypeAndVersion{ + Type: cldf.ContractType(ref.Type), + Version: *ref.Version, + } + if !ref.Labels.IsEmpty() { + tv.Labels = cldf.NewLabelSet(ref.Labels.List()...) + } + dedupMap[ref.Key().String()] = tv + } + + // Ensure we either have the bundle or not. + _, err := cldf.EnsureDeduped(dedupMap, wantTypes) + if err != nil { + return nil, fmt.Errorf("unable to check MCMS contracts on chain %s error: %w", chain.Name(), err) + } + + for _, ref := range refs { + if ref.Version == nil { + return nil, fmt.Errorf("invalid MCMS ref on chain %s: nil version for address %s type %s", chain.Name(), ref.Address, ref.Type) + } + addr := common.HexToAddress(ref.Address) + tv := cldf.TypeAndVersion{ + Type: cldf.ContractType(ref.Type), + Version: *ref.Version, + } + + switch { + case tv.Type == timelock.Type && tv.Version.String() == timelock.Version.String(): + tl, err := bindings.NewRBACTimelock(addr, chain.Client) + if err != nil { + return nil, err + } + state.Timelock = tl + case tv.Type == callProxy.Type && tv.Version.String() == callProxy.Version.String(): + cp, err := bindings.NewCallProxy(addr, chain.Client) + if err != nil { + return nil, err + } + state.CallProxy = cp + case tv.Type == proposer.Type && tv.Version.String() == proposer.Version.String(): + mcms, err := bindings.NewManyChainMultiSig(addr, chain.Client) + if err != nil { + return nil, err + } + state.ProposerMcm = mcms + case tv.Type == bypasser.Type && tv.Version.String() == bypasser.Version.String(): + mcms, err := bindings.NewManyChainMultiSig(addr, chain.Client) + if err != nil { + return nil, err + } + state.BypasserMcm = mcms + case tv.Type == canceller.Type && tv.Version.String() == canceller.Version.String(): + mcms, err := bindings.NewManyChainMultiSig(addr, chain.Client) + if err != nil { + return nil, err + } + state.CancellerMcm = mcms + } + } + return &state, nil +} + +type LinkTokenState struct { + LinkToken *link_token.LinkToken +} + +func (s LinkTokenState) GenerateLinkView() (linkview.LinkTokenView, error) { + if s.LinkToken == nil { + return linkview.LinkTokenView{}, errors.New("link token not found") + } + return linkview.GenerateLinkTokenView(s.LinkToken) +} + +func MaybeLoadLinkTokenChainState(chain cldf_evm.Chain, addresses map[string]cldf.TypeAndVersion) (*LinkTokenState, error) { + state := LinkTokenState{} + linkToken := cldf.NewTypeAndVersion(linkcontracts.LinkToken, common2.Version1_0_0) + + // Convert map keys to a slice + wantTypes := []cldf.TypeAndVersion{linkToken} + + // Ensure we either have the bundle or not. + _, err := cldf.EnsureDeduped(addresses, wantTypes) + if err != nil { + return nil, fmt.Errorf("unable to check link token on chain %s error: %w", chain.Name(), err) + } + + for address, tvStr := range addresses { + if tvStr.Type == linkToken.Type && tvStr.Version.String() == linkToken.Version.String() { + lt, err := link_token.NewLinkToken(common.HexToAddress(address), chain.Client) + if err != nil { + return nil, err + } + state.LinkToken = lt + } + } + return &state, nil +} + +type StaticLinkTokenState struct { + StaticLinkToken *link_token_interface.LinkToken +} + +func (s StaticLinkTokenState) GenerateStaticLinkView() (linkview.StaticLinkTokenView, error) { + if s.StaticLinkToken == nil { + return linkview.StaticLinkTokenView{}, errors.New("static link token not found") + } + return linkview.GenerateStaticLinkTokenView(s.StaticLinkToken) +} + +func MaybeLoadStaticLinkTokenState(chain cldf_evm.Chain, addresses map[string]cldf.TypeAndVersion) (*StaticLinkTokenState, error) { + state := StaticLinkTokenState{} + staticLinkToken := cldf.NewTypeAndVersion(linkcontracts.StaticLinkToken, common2.Version1_0_0) + + // Convert map keys to a slice + wantTypes := []cldf.TypeAndVersion{staticLinkToken} + + // Ensure we either have the bundle or not. + _, err := cldf.EnsureDeduped(addresses, wantTypes) + if err != nil { + return nil, fmt.Errorf("unable to check static link token on chain %s error: %w", chain.Name(), err) + } + + for address, tvStr := range addresses { + if tvStr.Type == staticLinkToken.Type && tvStr.Version.String() == staticLinkToken.Version.String() { + lt, err := link_token_interface.NewLinkToken(common.HexToAddress(address), chain.Client) + if err != nil { + return nil, err + } + state.StaticLinkToken = lt + } + } + return &state, nil +} + +// GetAddressTypeVersionByQualifier loads addresses from DataStore for a specific chain and qualifier. +// It returns a map of address to TypeAndVersion. Refs with a nil Version are skipped; if none remain, +// it returns an error. Each address must be a non-zero hex-encoded EVM address (see common.IsHexAddress). +func GetAddressTypeVersionByQualifier(store datastore.AddressRefStore, chainSelector uint64, qualifier string) (map[string]cldf.TypeAndVersion, error) { + addressesChain := make(map[string]cldf.TypeAndVersion) + + // Build filter list starting with chain selector + filters := []datastore.FilterFunc[datastore.AddressRefKey, datastore.AddressRef]{datastore.AddressRefByChainSelector(chainSelector)} + + // Add qualifier filter if provided + if qualifier != "" { + filters = append(filters, datastore.AddressRefByQualifier(qualifier)) + } + + addresses := store.Filter(filters...) + if len(addresses) == 0 { + if qualifier != "" { + return nil, fmt.Errorf("no addresses found for chain %d with qualifier %q", chainSelector, qualifier) + } + + return nil, fmt.Errorf("no addresses found for chain %d", chainSelector) + } + + for _, addressRef := range addresses { + if addressRef.Version == nil { + continue + } + if _, err := parseValidatedEVMAddress(addressRef.Address); err != nil { + return nil, fmt.Errorf("datastore address ref for chain %d type=%s version=%s: %w", + chainSelector, addressRef.Type, addressRef.Version.String(), err) + } + tv := cldf.TypeAndVersion{ + Type: cldf.ContractType(addressRef.Type), + Version: *addressRef.Version, + } + // Preserve labels from DataStore + if !addressRef.Labels.IsEmpty() { + tv.Labels = cldf.NewLabelSet(addressRef.Labels.List()...) + } + addressesChain[addressRef.Address] = tv + } + + if len(addressesChain) == 0 { + return nil, fmt.Errorf("no address refs with a non-nil contract version for chain %d", chainSelector) + } + + return addressesChain, nil +} +func parseValidatedEVMAddress(raw string) (common.Address, error) { + if !common.IsHexAddress(raw) { + return common.Address{}, fmt.Errorf("not a valid hex-encoded EVM address: %q", raw) + } + addr := common.HexToAddress(raw) + if addr == (common.Address{}) { + return common.Address{}, fmt.Errorf("EVM address must not be the zero address: %q", raw) + } + + return addr, nil +} diff --git a/pkg/family/evm/state_test.go b/pkg/family/evm/state_test.go new file mode 100644 index 0000000..3943040 --- /dev/null +++ b/pkg/family/evm/state_test.go @@ -0,0 +1,548 @@ +package state + +import ( + "encoding/json" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + bindings "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/mcms/sdk" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + cldcommon "github.com/smartcontractkit/cld-changesets/pkg/common" +) + +func TestMCMSWithTimelockState_GenerateMCMSWithTimelockViewV2(t *testing.T) { + selector := chain_selectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[selector] + + proposerMcm := deployMCMEvm(t, chain, &mcmstypes.Config{Quorum: 1, Signers: []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000001"), + }}) + cancellerMcm := deployMCMEvm(t, chain, &mcmstypes.Config{Quorum: 1, Signers: []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000002"), + }}) + bypasserMcm := deployMCMEvm(t, chain, &mcmstypes.Config{Quorum: 1, Signers: []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000003"), + }}) + timelock := deployTimelockEvm(t, chain, big.NewInt(1), + common.HexToAddress("0x0000000000000000000000000000000000000004"), + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000005")}, + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000006")}, + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000007")}, + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000008")}, + ) + callProxy := deployCallProxyEvm(t, chain, + common.HexToAddress("0x0000000000000000000000000000000000000009")) + + tests := []struct { + name string + contracts *MCMSWithTimelockState + want string + wantErr string + }{ + { + name: "success", + contracts: &MCMSWithTimelockState{ + ProposerMcm: proposerMcm, + CancellerMcm: cancellerMcm, + BypasserMcm: bypasserMcm, + Timelock: timelock, + CallProxy: callProxy, + }, + want: fmt.Sprintf(`{ + "proposer": { + "address": "%s", + "owner": "%s", + "config": { + "quorum": 1, + "signers": ["0x0000000000000000000000000000000000000001"], + "groupSigners": [] + } + }, + "canceller": { + "address": "%s", + "owner": "%s", + "config": { + "quorum": 1, + "signers": ["0x0000000000000000000000000000000000000002"], + "groupSigners": [] + } + }, + "bypasser": { + "address": "%s", + "owner": "%s", + "config": { + "quorum": 1, + "signers": ["0x0000000000000000000000000000000000000003"], + "groupSigners": [] + } + }, + "timelock": { + "address": "%s", + "owner": "0x0000000000000000000000000000000000000000", + "membersByRole": { + "ADMIN_ROLE": [ "0x0000000000000000000000000000000000000004" ], + "PROPOSER_ROLE": [ "0x0000000000000000000000000000000000000005" ], + "EXECUTOR_ROLE": [ "0x0000000000000000000000000000000000000006" ], + "CANCELLER_ROLE": [ "0x0000000000000000000000000000000000000007" ], + "BYPASSER_ROLE": [ "0x0000000000000000000000000000000000000008" ] + } + }, + "callProxy": { + "address": "%s", + "owner": "0x0000000000000000000000000000000000000000" + } + }`, evmAddr(proposerMcm.Address()), evmAddr(chain.DeployerKey.From), + evmAddr(cancellerMcm.Address()), evmAddr(chain.DeployerKey.From), + evmAddr(bypasserMcm.Address()), evmAddr(chain.DeployerKey.From), + evmAddr(timelock.Address()), evmAddr(callProxy.Address())), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := tt.contracts + + got, err := state.GenerateMCMSWithTimelockView() + + if tt.wantErr == "" { + require.NoError(t, err) + require.JSONEq(t, tt.want, toJSON(t, &got)) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestAddressesForChain(t *testing.T) { + chainSelector := chain_selectors.ETHEREUM_MAINNET.Selector + + t.Run("environment with AddressBook only", func(t *testing.T) { + // Create environment with only AddressBook + addressBook := cldf.NewMemoryAddressBook() + err := addressBook.Save(chainSelector, "0x1234567890123456789012345678901234567890", + cldf.NewTypeAndVersion(linkcontracts.LinkToken, cldcommon.Version1_0_0)) + require.NoError(t, err) + + env := cldf.Environment{ + ExistingAddresses: addressBook, + DataStore: nil, // No DataStore + } + + // Test the merge function + mergedAddresses, err := AddressesForChain(env, chainSelector, "") + require.NoError(t, err) + + // Should have address from AddressBook only + require.Len(t, mergedAddresses, 1) + require.Contains(t, mergedAddresses, "0x1234567890123456789012345678901234567890") + }) + + t.Run("environment with DataStore only", func(t *testing.T) { + // Create environment with only DataStore + dataStore := datastore.NewMemoryDataStore() + err := dataStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Address: "0xABCDEF1234567890123456789012345678901234", + Type: datastore.ContractType(mcmscontracts.RBACTimelock), + Version: &cldcommon.Version1_0_0, + }) + require.NoError(t, err) + + addressBook := cldf.NewMemoryAddressBook() + + env := cldf.Environment{ + ExistingAddresses: addressBook, + DataStore: dataStore.Seal(), + } + + // Test the merge function + mergedAddresses, err := AddressesForChain(env, chainSelector, "") + require.NoError(t, err) + + // Should have address from DataStore only + require.Len(t, mergedAddresses, 1) + require.Contains(t, mergedAddresses, "0xABCDEF1234567890123456789012345678901234") + }) + t.Run("environment with AddressBook and DataStore without qualifier", func(t *testing.T) { + // Create a mock environment with both AddressBook and DataStore + addressBook := cldf.NewMemoryAddressBook() + err := addressBook.Save(chainSelector, "0x1234567890123456789012345678901234567890", + cldf.NewTypeAndVersion(linkcontracts.LinkToken, cldcommon.Version1_0_0)) + require.NoError(t, err) + + dataStore := datastore.NewMemoryDataStore() + err = dataStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Address: "0xABCDEF1234567890123456789012345678901234", + Type: datastore.ContractType(mcmscontracts.RBACTimelock), + Version: &cldcommon.Version1_0_0, + Labels: datastore.NewLabelSet( + "team:core", + "environment:production", + "role:timelock", + ), + }) + require.NoError(t, err) + + env := cldf.Environment{ + ExistingAddresses: addressBook, + DataStore: dataStore.Seal(), + } + + // Test the merge function + mergedAddresses, err := AddressesForChain(env, chainSelector, "") + require.NoError(t, err) + + // Should have addresses from both sources + require.Len(t, mergedAddresses, 2) + require.Contains(t, mergedAddresses, "0x1234567890123456789012345678901234567890") + require.Contains(t, mergedAddresses, "0xABCDEF1234567890123456789012345678901234") + + // Verify that types are correctly preserved + linkTokenTV := mergedAddresses["0x1234567890123456789012345678901234567890"] + require.Equal(t, linkcontracts.LinkToken, linkTokenTV.Type) + require.Equal(t, cldcommon.Version1_0_0, linkTokenTV.Version) + + timelockTV := mergedAddresses["0xABCDEF1234567890123456789012345678901234"] + require.Equal(t, mcmscontracts.RBACTimelock, timelockTV.Type) + require.Equal(t, cldcommon.Version1_0_0, timelockTV.Version) + + // Verify labels are preserved in DataStore + refs := env.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(chainSelector)) + require.Len(t, refs, 1) + + timelockRef := refs[0] + require.Equal(t, "0xABCDEF1234567890123456789012345678901234", timelockRef.Address) + require.True(t, timelockRef.Labels.Contains("team:core")) + require.True(t, timelockRef.Labels.Contains("environment:production")) + require.True(t, timelockRef.Labels.Contains("role:timelock")) + }) + + t.Run("environment with AddressBook and DataStore with qualifier", func(t *testing.T) { + dataStore := datastore.NewMemoryDataStore() + + // Add contracts with different qualifiers + err := dataStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Address: "0x1111111111111111111111111111111111111111", + Type: datastore.ContractType(mcmscontracts.RBACTimelock), + Version: &cldcommon.Version1_0_0, + Qualifier: "team-a", + Labels: datastore.NewLabelSet( + "team:team-a", + "role:timelock", + ), + }) + require.NoError(t, err) + + err = dataStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Address: "0x2222222222222222222222222222222222222222", + Type: datastore.ContractType(mcmscontracts.RBACTimelock), + Version: &cldcommon.Version1_0_0, + Qualifier: "team-b", + Labels: datastore.NewLabelSet( + "team:team-b", + "role:timelock", + ), + }) + require.NoError(t, err) + + env := cldf.Environment{ + ExistingAddresses: cldf.NewMemoryAddressBook(), + DataStore: dataStore.Seal(), + } + + // Test filtering by qualifier + mergedAddresses, err := AddressesForChain(env, chainSelector, "team-a") + require.NoError(t, err) + + // Should only have team-a contract + require.Len(t, mergedAddresses, 1) + require.Contains(t, mergedAddresses, "0x1111111111111111111111111111111111111111") + require.NotContains(t, mergedAddresses, "0x2222222222222222222222222222222222222222") + + // Verify the correct contract type + timelockTV := mergedAddresses["0x1111111111111111111111111111111111111111"] + require.Equal(t, mcmscontracts.RBACTimelock, timelockTV.Type) + + // Verify labels are preserved for the filtered contract + refs := env.DataStore.Addresses().Filter( + datastore.AddressRefByChainSelector(chainSelector), + datastore.AddressRefByQualifier("team-a"), + ) + require.Len(t, refs, 1) + + teamARef := refs[0] + require.Equal(t, "0x1111111111111111111111111111111111111111", teamARef.Address) + require.Equal(t, "team-a", teamARef.Qualifier) + require.True(t, teamARef.Labels.Contains("team:team-a")) + require.True(t, teamARef.Labels.Contains("role:timelock")) + }) + + t.Run("environment with duplicated addresses in AddressBook and DataStore", func(t *testing.T) { + const ( + duplicateAddress = "0x1234567890123456789012345678901234567890" + uniqueAddress = "0xABCDEF1234567890123456789012345678901234" + ) + + // Create environment with same address in both AddressBook and DataStore + addressBook := cldf.NewMemoryAddressBook() + // Add LinkToken to AddressBook + err := addressBook.Save(chainSelector, duplicateAddress, + cldf.NewTypeAndVersion(linkcontracts.LinkToken, cldcommon.Version1_0_0)) + require.NoError(t, err) + + dataStore := datastore.NewMemoryDataStore() + + // Add the SAME address to DataStore but with different type/version and labels + err = dataStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Address: duplicateAddress, // Same address as AddressBook + Type: datastore.ContractType(mcmscontracts.RBACTimelock), // Different type from AddressBook LinkToken + Version: &cldcommon.Version1_6_0, // Different version + Labels: datastore.NewLabelSet( + "team:datastore-team", + "environment:staging", + "override:true", + ), + }) + require.NoError(t, err) + + // Also add a unique DataStore address + err = dataStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Address: uniqueAddress, + Type: datastore.ContractType(mcmscontracts.RBACTimelock), + Version: &cldcommon.Version1_0_0, + Labels: datastore.NewLabelSet( + "team:unique-entry", + "role:timelock", + ), + }) + require.NoError(t, err) + + env := cldf.Environment{ + ExistingAddresses: addressBook, + DataStore: dataStore.Seal(), + } + + // Test the merge function + mergedAddresses, err := AddressesForChain(env, chainSelector, "") + require.NoError(t, err) + + // Should have 2 addresses total (duplicate should be merged, unique should be included) + require.Len(t, mergedAddresses, 2) + require.Contains(t, mergedAddresses, duplicateAddress) + require.Contains(t, mergedAddresses, uniqueAddress) + + // The duplicate address should use DataStore values (DataStore takes precedence) + duplicateTV := mergedAddresses[duplicateAddress] + require.Equal(t, mcmscontracts.RBACTimelock, duplicateTV.Type, "DataStore type should override AddressBook type") + require.Equal(t, cldcommon.Version1_6_0, duplicateTV.Version, "DataStore version should override AddressBook version") + + // The unique address should have correct type + uniqueTV := mergedAddresses[uniqueAddress] + require.Equal(t, mcmscontracts.RBACTimelock, uniqueTV.Type) + require.Equal(t, cldcommon.Version1_0_0, uniqueTV.Version) + + // Verify that DataStore labels are preserved for both addresses + refs := env.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(chainSelector)) + require.Len(t, refs, 2) + + // Find the refs by address + var duplicateRef, uniqueRef *datastore.AddressRef + for i := range refs { + switch refs[i].Address { + case duplicateAddress: + duplicateRef = &refs[i] + case uniqueAddress: + uniqueRef = &refs[i] + } + } + + require.NotNil(t, duplicateRef, "Should find duplicate address in DataStore") + require.NotNil(t, uniqueRef, "Should find unique address in DataStore") + + // Verify labels are preserved for the duplicate address (which should come from DataStore) + require.True(t, duplicateRef.Labels.Contains("team:datastore-team")) + require.True(t, duplicateRef.Labels.Contains("environment:staging")) + require.True(t, duplicateRef.Labels.Contains("override:true")) + + // Verify labels for the unique address + require.True(t, uniqueRef.Labels.Contains("team:unique-entry")) + require.True(t, uniqueRef.Labels.Contains("role:timelock")) + }) +} + +func TestGetMCMSWithTimelockState(t *testing.T) { + selector := chain_selectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[selector] + + sharedMcm := deployMCMEvm(t, chain, &mcmstypes.Config{Quorum: 1, Signers: []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000001"), + }}) + sharedAddress := strings.ToLower(sharedMcm.Address().Hex()) + + timelock := deployTimelockEvm(t, chain, big.NewInt(1), + common.HexToAddress("0x0000000000000000000000000000000000000004"), + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000005")}, + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000006")}, + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000007")}, + []common.Address{common.HexToAddress("0x0000000000000000000000000000000000000008")}, + ) + callProxy := deployCallProxyEvm(t, chain, + common.HexToAddress("0x0000000000000000000000000000000000000009")) + proposerMcm := deployMCMEvm(t, chain, &mcmstypes.Config{Quorum: 1, Signers: []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000002"), + }}) + + // timelock, callProxy, proposer shared by both stores + commonRefs := []datastore.AddressRef{ + {ChainSelector: selector, Address: strings.ToLower(timelock.Address().Hex()), Type: datastore.ContractType(mcmscontracts.RBACTimelock), Version: &cldcommon.Version1_0_0}, + {ChainSelector: selector, Address: strings.ToLower(callProxy.Address().Hex()), Type: datastore.ContractType(mcmscontracts.CallProxy), Version: &cldcommon.Version1_0_0}, + {ChainSelector: selector, Address: strings.ToLower(proposerMcm.Address().Hex()), Type: datastore.ContractType(mcmscontracts.ProposerManyChainMultisig), Version: &cldcommon.Version1_0_0}, + } + + t.Run("shared address for bypasser and canceller", func(t *testing.T) { + // Store DS with bypasser/canceller sharing the same address + store := datastore.NewMemoryDataStore() + for _, ref := range commonRefs { + require.NoError(t, store.Addresses().Add(ref)) + } + require.NoError(t, store.Addresses().Add(datastore.AddressRef{ + ChainSelector: selector, Address: sharedAddress, + Type: datastore.ContractType(mcmscontracts.BypasserManyChainMultisig), Version: &cldcommon.Version1_0_0, Qualifier: "bypasser", + })) + require.NoError(t, store.Addresses().Add(datastore.AddressRef{ + ChainSelector: selector, Address: sharedAddress, + Type: datastore.ContractType(mcmscontracts.CancellerManyChainMultisig), Version: &cldcommon.Version1_0_0, Qualifier: "canceller", + })) + + state, err := GetMCMSWithTimelockState(store.Seal().Addresses(), chain, "") + require.NoError(t, err) + + require.NotNil(t, state.Timelock, "timelock should be loaded") + require.NotNil(t, state.CallProxy, "call proxy should be loaded") + require.NotNil(t, state.ProposerMcm, "proposer should be loaded") + require.NotNil(t, state.BypasserMcm, "bypasser should be loaded despite shared address") + require.NotNil(t, state.CancellerMcm, "canceller should be loaded despite shared address") + + require.Equal(t, sharedMcm.Address(), state.BypasserMcm.Address()) + require.Equal(t, sharedMcm.Address(), state.CancellerMcm.Address()) + }) + + t.Run("legacy ManyChainMultisig type is ignored", func(t *testing.T) { + // Store with legacy ManyChainMultisig typed bypasser/canceller + legacyStore := datastore.NewMemoryDataStore() + for _, ref := range commonRefs { + require.NoError(t, legacyStore.Addresses().Add(ref)) + } + require.NoError(t, legacyStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: selector, Address: sharedAddress, + Type: datastore.ContractType(mcmscontracts.ManyChainMultisig), Version: &cldcommon.Version1_0_0, Qualifier: "bypasser", + Labels: datastore.NewLabelSet(mcmscontracts.BypasserRole.String()), + })) + require.NoError(t, legacyStore.Addresses().Add(datastore.AddressRef{ + ChainSelector: selector, Address: sharedAddress, + Type: datastore.ContractType(mcmscontracts.ManyChainMultisig), Version: &cldcommon.Version1_0_0, Qualifier: "canceller", + Labels: datastore.NewLabelSet(mcmscontracts.CancellerRole.String()), + })) + + state, err := GetMCMSWithTimelockState(legacyStore.Seal().Addresses(), chain, "") + require.NoError(t, err) + + require.NotNil(t, state.Timelock, "timelock should still load") + require.NotNil(t, state.CallProxy, "callProxy should still load") + require.NotNil(t, state.ProposerMcm, "proposer should still load") + require.Nil(t, state.BypasserMcm, "legacy ManyChainMultisig bypasser should not load") + require.Nil(t, state.CancellerMcm, "legacy ManyChainMultisig canceller should not load") + }) +} + +// ----- helpers ----- + +func toJSON[T any](t *testing.T, value T) string { + t.Helper() + + bytes, err := json.Marshal(value) + require.NoError(t, err) + + return string(bytes) +} + +func deployMCMEvm( + t *testing.T, chain cldf_evm.Chain, config *mcmstypes.Config, +) *bindings.ManyChainMultiSig { + t.Helper() + + _, tx, contract, err := bindings.DeployManyChainMultiSig(chain.DeployerKey, chain.Client) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + groupQuorums, groupParents, signerAddresses, signerGroups, err := sdk.ExtractSetConfigInputs(config) + require.NoError(t, err) + tx, err = contract.SetConfig(chain.DeployerKey, signerAddresses, signerGroups, groupQuorums, groupParents, false) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + return contract +} + +func deployTimelockEvm( + t *testing.T, chain cldf_evm.Chain, minDelay *big.Int, admin common.Address, + proposers, executors, cancellers, bypassers []common.Address, +) *bindings.RBACTimelock { + t.Helper() + _, tx, contract, err := bindings.DeployRBACTimelock( + chain.DeployerKey, chain.Client, minDelay, admin, proposers, executors, cancellers, bypassers) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + return contract +} + +func deployCallProxyEvm( + t *testing.T, chain cldf_evm.Chain, target common.Address, +) *bindings.CallProxy { + t.Helper() + _, tx, contract, err := bindings.DeployCallProxy(chain.DeployerKey, chain.Client, target) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + return contract +} + +func evmAddr(addr common.Address) string { + return strings.ToLower(addr.Hex()) +} diff --git a/pkg/family/solana/testutils/artifacts.go b/pkg/family/solana/testutils/artifacts.go new file mode 100644 index 0000000..fb6bdc2 --- /dev/null +++ b/pkg/family/solana/testutils/artifacts.go @@ -0,0 +1,53 @@ +package soltestutils + +import ( + "path/filepath" + "runtime" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + solutils "github.com/smartcontractkit/cld-changesets/pkg/family/solana/utils" +) + +var ( + // onceCCIP is used to ensure that the program artifacts from the chainlink-ccip repository are only downloaded once. + onceCCIP = &sync.Once{} +) + +// downloadFunc is a function type for downloading program artifacts +type downloadFunc func(t *testing.T) string + +// downloadChainlinkCCIPProgramArtifacts downloads the Chainlink CCIP program artifacts for the +// test environment. +// +// The artifacts that are downloaded contain both the CCIP and MCMS program artifacts (even though +// this is called "CCIP" program artifacts). +func downloadChainlinkCCIPProgramArtifacts(t *testing.T) string { + t.Helper() + + cachePath := programsCachePath() + + onceCCIP.Do(func() { + err := solutils.DownloadChainlinkCCIPProgramArtifacts(t.Context(), cachePath, "", nil) + require.NoError(t, err) + }) + + return cachePath +} + +// programsCachePath returns the path to the cache directory for the program artifacts. +// +// This is used to cache the program artifacts so that they do not need to be downloaded every time +// the tests are run. +// +// The cache directory is located in the same directory as the current file. +func programsCachePath() string { + // Get the directory of the current file + _, currentFile, _, _ := runtime.Caller(0) + + dir := filepath.Dir(currentFile) + + return filepath.Join(dir, "programs_cache") +} diff --git a/pkg/family/solana/testutils/datastore.go b/pkg/family/solana/testutils/datastore.go new file mode 100644 index 0000000..7a6c0e9 --- /dev/null +++ b/pkg/family/solana/testutils/datastore.go @@ -0,0 +1,35 @@ +package soltestutils + +import ( + "testing" + + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/cld-changesets/pkg/common" + solutils "github.com/smartcontractkit/cld-changesets/pkg/family/solana/utils" +) + +// PreloadAddressBookWithMCMSPrograms creates and returns an address book containing preloaded MCMS +// Solana program addresses for the specified selector. +func PreloadAddressBookWithMCMSPrograms(t *testing.T, selector uint64) *cldf.AddressBookMap { + t.Helper() + + ab := cldf.NewMemoryAddressBook() + + tv := cldf.NewTypeAndVersion(mcmscontracts.ManyChainMultisigProgram, common.Version1_0_0) + err := ab.Save(selector, solutils.GetProgramID(solutils.ProgMCM), tv) + require.NoError(t, err) + + tv = cldf.NewTypeAndVersion(mcmscontracts.AccessControllerProgram, common.Version1_0_0) + err = ab.Save(selector, solutils.GetProgramID(solutils.ProgAccessController), tv) + require.NoError(t, err) + + tv = cldf.NewTypeAndVersion(mcmscontracts.RBACTimelockProgram, common.Version1_0_0) + err = ab.Save(selector, solutils.GetProgramID(solutils.ProgTimelock), tv) + require.NoError(t, err) + + return ab +} diff --git a/pkg/family/solana/testutils/preload.go b/pkg/family/solana/testutils/preload.go new file mode 100644 index 0000000..84aa3a3 --- /dev/null +++ b/pkg/family/solana/testutils/preload.go @@ -0,0 +1,83 @@ +package soltestutils + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + solutils "github.com/smartcontractkit/cld-changesets/pkg/family/solana/utils" +) + +// LoadMCMSPrograms loads the MCMS program artifacts into the given directory. +// +// Returns the path to the temporary test directory and a map of program names to IDs. +func LoadMCMSPrograms(t *testing.T, dir string) (string, map[string]string) { + t.Helper() + + progIDs := loadProgramArtifacts(t, + solutils.MCMSProgramNames, downloadChainlinkCCIPProgramArtifacts, dir, + ) + + return dir, progIDs +} + +// PreloadMCMS provides a convenience function to preload the MCMS program artifacts and address +// book for a given selector. +func PreloadMCMS(t *testing.T, selector uint64) (string, map[string]string, *cldf.AddressBookMap) { + t.Helper() + + dir := t.TempDir() + + _, programIDs := LoadMCMSPrograms(t, dir) + + ab := PreloadAddressBookWithMCMSPrograms(t, selector) + + return dir, programIDs, ab +} + +// loadProgramArtifacts is a helper function that loads program artifacts into a temporary test directory. +// It downloads artifacts using the provided download function and copies the specified programs. +// +// Returns the map of program names to IDs. +func loadProgramArtifacts(t *testing.T, programNames []string, downloadFn downloadFunc, targetDir string) map[string]string { + t.Helper() + + // Download the program artifacts using the provided download function + cachePath := downloadFn(t) + + progIDs := make(map[string]string, len(programNames)) + + // Copy the specific artifacts to the target directory and add the program ID to the map + for _, name := range programNames { + id := solutils.GetProgramID(name) + require.NotEmpty(t, id, "program id not found for program name: %s", name) + + src := filepath.Join(cachePath, name+".so") + dst := filepath.Join(targetDir, name+".so") + + // Copy the cached artifacts to the target directory + srcFile, err := os.Open(src) + require.NoError(t, err) + + dstFile, err := os.Create(dst) + require.NoError(t, err) + + _, err = io.Copy(dstFile, srcFile) + require.NoError(t, err) + + srcFile.Close() + dstFile.Close() + + // Add the program ID to the map + progIDs[name] = id + t.Logf("copied solana program %s to %s", name, dst) + } + + // Return the path to the cached artifacts and the map of program IDs + return progIDs +} diff --git a/pkg/family/solana/utils/artifacts.go b/pkg/family/solana/utils/artifacts.go new file mode 100644 index 0000000..23d989a --- /dev/null +++ b/pkg/family/solana/utils/artifacts.go @@ -0,0 +1,295 @@ +package solutils + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/mod/modfile" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +// DownloadChainlinkCCIPProgramArtifacts downloads CCIP program artifacts from the +// smartcontractkit/chainlink-ccip GitHub repository. +// +// The function downloads a tar.gz archive containing Solana program binaries and extracts +// them to the specified target directory. If sha is empty, it automatically resolves +// the version by parsing the "github.com/smartcontractkit/chainlink-ccip/chains/solana" +// dependency from the nearest go.mod file. +// +// Parameters: +// - ctx: Context for cancellation and timeout control +// - targetDir: Directory where extracted artifacts will be stored +// - sha: Git commit SHA or version identifier. If empty, auto-resolved from go.mod +// - lggr: Logger for progress and debug information. Can be nil to disable logging +// +// Returns an error if the download fails, extraction fails, or SHA resolution fails. +func DownloadChainlinkCCIPProgramArtifacts(ctx context.Context, targetDir string, sha string, lggr logger.Logger) error { + const ( + owner = "smartcontractkit" + repo = "chainlink-ccip" + name = "artifacts.tar.gz" + ) + + if sha == "" { + version, err := getDependencySHA("github.com/smartcontractkit/chainlink-ccip/chains/solana") + if err != nil { + return err + } + sha = version + } + tag := "solana-artifacts-localtest-" + sha + + if lggr != nil { + lggr.Infof("Downloading chainlink-ccip program artifacts (tag = %s)", tag) + } + + return downloadProgramArtifacts(ctx, githubReleaseURL(owner, repo, tag, name), targetDir, lggr) +} + +// downloadProgramArtifacts downloads and extracts program artifacts from a GitHub release URL. +// +// This internal function handles the HTTP download of a tar.gz archive and extracts all +// regular files to the target directory. It creates parent directories as needed and +// logs each extracted file if a logger is provided. +// +// The function performs the following steps: +// 1. Downloads the tar.gz archive from the provided URL +// 2. Decompresses the gzip stream +// 3. Extracts each regular file from the tar archive +// 4. Creates necessary parent directories +// 5. Writes files to the target directory using only the base filename +// +// Parameters: +// - ctx: Context for cancellation and timeout control +// - url: Full URL to the tar.gz release asset +// - targetDir: Directory where extracted files will be stored +// - lggr: Logger for progress information. Can be nil to disable logging +// +// Returns an error if the download fails, decompression fails, or file extraction fails. +func downloadProgramArtifacts(ctx context.Context, url string, targetDir string, lggr logger.Logger) error { + // Download the artifact + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + + res, err := (&http.Client{}).Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d - could not download tar.gz release artifact (url = '%s')", res.StatusCode, url) + } + + // Extract the artifact to the target directory + gzipReader, err := gzip.NewReader(res.Body) + if err != nil { + return err + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + + // Protection against decompression bombs + const ( + maxFiles = 1000 // Maximum number of files to extract + maxTotalSize = 500 * 1024 * 1024 // Maximum total extraction size (500MB) + ) + var ( + fileCount int + totalSize int64 + ) + + cleanTargetDir := filepath.Clean(targetDir) + for { + header, err := tarReader.Next() + // End of tar archive + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return err + } + + // Skip non-regular files + if header.Typeflag != tar.TypeReg { + continue + } + + // Validate archive entry path to prevent path traversal. + cleanName := filepath.Clean(strings.ReplaceAll(header.Name, "\\", "/")) + if cleanName == "." || cleanName == "" || filepath.IsAbs(cleanName) || + cleanName == ".." || strings.HasPrefix(cleanName, "../") { + return fmt.Errorf("invalid archive entry path: %q", header.Name) + } + + // Check limits to prevent decompression bombs + fileCount++ + if fileCount > maxFiles { + return fmt.Errorf("archive contains too many files (limit: %d)", maxFiles) + } + + if totalSize+header.Size > maxTotalSize { + return fmt.Errorf("archive total size exceeds limit (limit: %d bytes)", maxTotalSize) + } + + // Copy the file to the target directory + outPath := filepath.Join(cleanTargetDir, filepath.Base(cleanName)) + relPath, err := filepath.Rel(cleanTargetDir, outPath) + if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) { + return fmt.Errorf("archive entry resolves outside target directory: %q", header.Name) + } + if err := os.MkdirAll(filepath.Dir(outPath), os.ModePerm); err != nil { + return err + } + + outFile, err := os.Create(outPath) + if err != nil { + return err + } + + // Limit individual file size to 100MB to prevent decompression bombs + const maxFileSize = 100 * 1024 * 1024 // 100MB + limitedReader := io.LimitReader(tarReader, maxFileSize) + bytesWritten, err := io.Copy(outFile, limitedReader) + if err != nil { + outFile.Close() + return err + } + + // Update total size counter + totalSize += bytesWritten + + if lggr != nil { + lggr.Infof("Extracted Solana chainlink-solana artifact: %s", outPath) + } + + outFile.Close() + } + + return nil +} + +// githubReleaseURL constructs a GitHub release asset download URL. +// +// Builds a URL in the format: https://github.com/{owner}/{repo}/releases/download/{tag}/{name} +// +// Parameters: +// - owner: GitHub repository owner (e.g., "smartcontractkit") +// - repo: Repository name (e.g., "chainlink-ccip") +// - tag: Release tag or version (e.g., "solana-artifacts-localtest-abc123") +// - name: Asset filename (e.g., "artifacts.tar.gz") +// +// Returns the complete download URL for the GitHub release asset. +func githubReleaseURL(owner string, repo string, tag string, name string) string { + return fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", owner, repo, tag, name) +} + +// getModFilePath locates the nearest go.mod file by traversing up the directory tree. +// +// Starting from the current source file's directory, this function walks up the +// filesystem hierarchy until it finds a go.mod file. This is useful for locating +// the project root and parsing dependency information. +// +// The search stops when either: +// - A go.mod file is found (returns the full path) +// - The filesystem root is reached (returns an error) +// +// Returns the absolute path to the go.mod file, or an error if none is found. +func getModFilePath() (string, error) { + _, currentFile, _, _ := runtime.Caller(0) + + rootDir := filepath.Dir(currentFile) + for { + modPath := filepath.Join(rootDir, "go.mod") + if _, err := os.Stat(modPath); err == nil { + return modPath, nil + } + + // Move up one directory + parent := filepath.Dir(rootDir) + + // If we've reached the filesystem root, stop + if parent == rootDir { + return "", errors.New("go.mod file not found in any parent directory") + } + + rootDir = parent + } +} + +// getDependencyVersion extracts the version of a specific dependency from a go.mod file. +// +// This function parses the go.mod file at the given path and searches for the specified +// dependency in the require section. It uses the golang.org/x/mod/modfile package for +// robust parsing that handles various go.mod formats. +// +// Parameters: +// - modFilePath: Absolute path to the go.mod file to parse +// - depPath: Full module path of the dependency (e.g., "github.com/user/repo") +// +// Returns the version string as specified in the go.mod file (e.g., "v1.2.3" or +// "v0.0.0-20230101000000-abc123def456"), or an error if the dependency is not found +// or the file cannot be parsed. +func getDependencyVersion(modFilePath, depPath string) (string, error) { + gomod, err := os.ReadFile(modFilePath) + if err != nil { + return "", err + } + + modFile, err := modfile.ParseLax("go.mod", gomod, nil) + if err != nil { + return "", err + } + + for _, dep := range modFile.Require { + if dep.Mod.Path == depPath { + return dep.Mod.Version, nil + } + } + + return "", fmt.Errorf("dependency %s not found", depPath) +} + +// getDependencySHA extracts the commit SHA from a dependency version in go.mod. +// +// This function combines go.mod file discovery and dependency version parsing to extract +// the commit SHA from pseudo-versions. It expects dependency versions in the format +// "v0.0.0-YYYYMMDDHHMMSS-{12-char-sha}" and returns the SHA portion. +// +// Parameters: +// - depPath: Full module path of the dependency to look up +// +// Returns the 12-character commit SHA, or an error if the go.mod file cannot be found, +// the dependency is not present, or the version format is invalid. +func getDependencySHA(depPath string) (version string, err error) { + modFilePath, err := getModFilePath() + if err != nil { + return "", err + } + + ver, err := getDependencyVersion(modFilePath, depPath) + if err != nil { + return "", err + } + tokens := strings.Split(ver, "-") + if len(tokens) == 3 { + version := tokens[len(tokens)-1] + return version, nil + } + + return "", fmt.Errorf("invalid go.mod version: %s", ver) +} diff --git a/pkg/family/solana/utils/artifacts_test.go b/pkg/family/solana/utils/artifacts_test.go new file mode 100644 index 0000000..ba1f43b --- /dev/null +++ b/pkg/family/solana/utils/artifacts_test.go @@ -0,0 +1,233 @@ +package solutils + +import ( + "archive/tar" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +func TestDownloadProgramArtifacts(t *testing.T) { + tests := []struct { + name string + setupServer func() *httptest.Server + wantFiles []string + wantErr string + }{ + { + name: "successful download and extraction", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Create a test tar.gz with some files + w.Header().Set("Content-Type", "application/gzip") + + gzWriter := gzip.NewWriter(w) + defer gzWriter.Close() + + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + + // Add test files to the tar + testFiles := map[string]string{ + "program1.so": "fake program 1 content", + "program2.so": "fake program 2 content", + "config.json": `{"test": "config"}`, + } + + for filename, content := range testFiles { + header := &tar.Header{ + Name: filename, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + + err := tarWriter.WriteHeader(header) + if err != nil { + t.Errorf("Failed to write tar header: %v", err) + return + } + + _, err = tarWriter.Write([]byte(content)) + if err != nil { + t.Errorf("Failed to write tar content: %v", err) + return + } + } + })) + }, + wantFiles: []string{"program1.so", "program2.so", "config.json"}, + }, + { + name: "server returns 404", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + }, + wantErr: "download failed with status 404", + }, + { + name: "server returns 500", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + }, + wantErr: "download failed with status 500", + }, + { + name: "invalid gzip content", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + _, err := w.Write([]byte("invalid gzip content")) + if err != nil { + t.Errorf("Failed to write gzip content: %v", err) + return + } + })) + }, + wantErr: "gzip", + }, + { + name: "empty tar archive", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + + gzWriter := gzip.NewWriter(w) + defer gzWriter.Close() + + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + // Don't add any files - empty archive + })) + }, + wantFiles: []string{}, // No files expected + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + server := tt.setupServer() + defer server.Close() + + // Create temporary directory for extraction + tempDir := t.TempDir() + + // Execute + err := downloadProgramArtifacts( + t.Context(), server.URL, tempDir, logger.Test(t), + ) + + // Assert + if tt.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + + // Check that expected files were created + for _, expectedFile := range tt.wantFiles { + filePath := filepath.Join(tempDir, expectedFile) + assert.FileExists(t, filePath, "Expected file %s to exist", expectedFile) + + // Verify file is not empty (except for empty archive test) + if len(tt.wantFiles) > 0 { + info, err := os.Stat(filePath) + require.NoError(t, err) + assert.Positive(t, info.Size(), "File %s should not be empty", expectedFile) + } + } + + // Check that no unexpected files were created + entries, err := os.ReadDir(tempDir) + require.NoError(t, err) + assert.Len(t, entries, len(tt.wantFiles), "Unexpected number of files extracted") + } + }) + } +} + +func TestDownloadProgramArtifacts_ContextCancellation(t *testing.T) { + // Create a server that delays response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate a slow server + select { + case <-r.Context().Done(): + return + case <-make(chan struct{}): + // This will never be reached due to context cancellation + } + })) + defer server.Close() + + // Create a context that gets cancelled immediately + ctx, cancel := context.WithCancel(t.Context()) + cancel() // Cancel immediately + + err := downloadProgramArtifacts(ctx, server.URL, t.TempDir(), logger.Test(t)) + require.Error(t, err) + require.ErrorContains(t, err, "context canceled") +} + +func TestDownloadProgramArtifacts_InvalidURL(t *testing.T) { + tempDir := t.TempDir() + + err := downloadProgramArtifacts(t.Context(), "http://invalid-url", tempDir, logger.Test(t)) + require.ErrorContains(t, err, "dial tcp: lookup invalid-url: no such host") +} + +func TestDownloadProgramArtifacts_NonExistentTargetDir(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + + gzWriter := gzip.NewWriter(w) + defer gzWriter.Close() + + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + + // Add a test file + content := "test content" + header := &tar.Header{ + Name: "test.so", + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + + err := tarWriter.WriteHeader(header) + if err != nil { + t.Errorf("Failed to write tar header: %v", err) + return + } + _, err = tarWriter.Write([]byte(content)) + if err != nil { + t.Errorf("Failed to write tar content: %v", err) + return + } + })) + defer server.Close() + + // Use a non-existent directory path + nonExistentDir := "/tmp/non_existent_parent_dir_12345/target" + + err := downloadProgramArtifacts(t.Context(), server.URL, nonExistentDir, logger.Test(t)) + require.NoError(t, err) // Should succeed because MkdirAll creates parent directories + + // Verify the file was created + assert.FileExists(t, filepath.Join(nonExistentDir, "test.so")) + + // Cleanup + os.RemoveAll("/tmp/non_existent_parent_dir_12345") +} diff --git a/pkg/family/solana/utils/directory.go b/pkg/family/solana/utils/directory.go new file mode 100644 index 0000000..231a17e --- /dev/null +++ b/pkg/family/solana/utils/directory.go @@ -0,0 +1,71 @@ +package solutils + +// Program names +const ( + ProgMCM = "mcm" + ProgTimelock = "timelock" + ProgAccessController = "access_controller" +) + +// MCMSProgramNames names grouped by their usage. +var ( + MCMSProgramNames = []string{ProgMCM, ProgTimelock, ProgAccessController} +) + +// Repositories that contain the program artifacts. +const ( + repoCCIP = "chainlink-ccip" +) + +// Directory maps program names to their corresponding program information, including program ID, repository, and buffer size for upgrades. +var Directory = directory{ + + // MCMS Programs + ProgMCM: {ID: "5vNJx78mz7KVMjhuipyr9jKBKcMrKYGdjGkgE4LUmjKk", Repo: repoCCIP, ProgramBufferBytes: 1 * 1024 * 1024}, + ProgTimelock: {ID: "DoajfR5tK24xVw51fWcawUZWhAXD8yrBJVacc13neVQA", Repo: repoCCIP, ProgramBufferBytes: 1 * 1024 * 1024}, + ProgAccessController: {ID: "6KsN58MTnRQ8FfPaXHiFPPFGDRioikj9CdPvPxZJdCjb", Repo: repoCCIP, ProgramBufferBytes: 1 * 1024 * 1024}, +} + +// GetProgramID returns the program ID for a given program name. +// +// Returns the program ID for the given program name or an empty string if the program is not +// found. +func GetProgramID(name string) string { + info, ok := Directory[name] + if !ok { + return "" + } + + return info.ID +} + +// GetProgramBufferBytes returns the size of the program buffer in bytes for the given program name. +// +// Returns 0 if the program is not found or if the program is not upgradable. +func GetProgramBufferBytes(name string) int { + info, ok := Directory[name] + if !ok { + return 0 + } + + return info.ProgramBufferBytes +} + +// programInfo contains the information about a program. +type programInfo struct { + // ID is the program ID of the program. + ID string + + // Repo is the repository name of where the program is located. + Repo string + + // ProgramBufferBytes is the size of the program buffer in bytes. Used for upgrades. + // Can be left blank if the program is not upgradable. + // + // https://docs.google.com/document/d/1Fk76lOeyS2z2X6MokaNX_QTMFAn5wvSZvNXJluuNV1E/edit?tab=t.0#heading=h.uij286zaarkz + // https://docs.google.com/document/d/1nCNuam0ljOHiOW0DUeiZf4ntHf_1Bw94Zi7ThPGoKR4/edit?tab=t.0#heading=h.hju45z55bnqd + ProgramBufferBytes int +} + +// directory maps the program name to the program information. +type directory map[string]programInfo