diff --git a/datastore/refkey/refkey.go b/datastore/refkey/refkey.go new file mode 100644 index 0000000..c87e2ce --- /dev/null +++ b/datastore/refkey/refkey.go @@ -0,0 +1,66 @@ +package refkey + +import ( + "errors" + "fmt" + + "github.com/Masterminds/semver/v3" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +// RefKey identifies a contract in the environment datastore without carrying its address. +type RefKey struct { + ChainSelector uint64 `json:"chainSelector"` + Type cldfdatastore.ContractType `json:"type"` + Version *semver.Version `json:"version"` + Qualifier string `json:"qualifier,omitempty"` +} + +// New constructs a serializable ref key. +func New(chainSelector uint64, contractType cldfdatastore.ContractType, version *semver.Version, qualifier string) RefKey { + return RefKey{ + ChainSelector: chainSelector, + Type: contractType, + Version: version, + Qualifier: qualifier, + } +} + +// FromAddressRef derives a ref key from a full AddressRef. +func FromAddressRef(ref cldfdatastore.AddressRef) RefKey { + return RefKey{ + ChainSelector: ref.ChainSelector, + Type: ref.Type, + Version: ref.Version, + Qualifier: ref.Qualifier, + } +} + +// Key converts the serializable key into a datastore AddressRefKey. +func (k RefKey) Key() (cldfdatastore.AddressRefKey, error) { + if k.Version == nil { + return nil, cldfdatastore.ErrAddressRefVersionRequired + } + + return cldfdatastore.NewAddressRefKey(k.ChainSelector, k.Type, k.Version, k.Qualifier), nil +} + +// Resolve loads the matching AddressRef from the environment datastore. +func (k RefKey) Resolve(e cldf.Environment) (cldfdatastore.AddressRef, error) { + if e.DataStore == nil { + return cldfdatastore.AddressRef{}, errors.New("missing datastore in environment") + } + + key, err := k.Key() + if err != nil { + return cldfdatastore.AddressRef{}, err + } + + ref, err := e.DataStore.Addresses().Get(key) + if err != nil { + return cldfdatastore.AddressRef{}, fmt.Errorf("address ref %v: %w", key, err) + } + + return ref, nil +} diff --git a/go.mod b/go.mod index 4ff5574..6723b22 100644 --- a/go.mod +++ b/go.mod @@ -14,14 +14,15 @@ require ( github.com/google/go-cmp v0.7.0 github.com/mr-tron/base58 v1.2.0 github.com/samber/lo v1.53.0 + github.com/segmentio/ksuid v1.0.4 github.com/smartcontractkit/ccip-owner-contracts v0.1.0 github.com/smartcontractkit/chain-selectors v1.0.102 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc - github.com/smartcontractkit/chainlink-deployments-framework v0.109.0 + github.com/smartcontractkit/chainlink-deployments-framework v0.110.0 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828 github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 - github.com/smartcontractkit/mcms v0.45.0 + github.com/smartcontractkit/mcms v0.47.1-0.20260609163952-0b2bf692ba6a github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9 github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 github.com/spf13/cast v1.10.0 @@ -34,6 +35,7 @@ require ( ) require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -47,14 +49,14 @@ require ( github.com/avast/retry-go/v4 v4.7.0 // indirect github.com/awalterschulze/gographviz v2.0.3+incompatible // indirect github.com/aws/aws-sdk-go v1.55.8 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.11 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.59.2 // indirect + github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.61.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.50.1 // indirect @@ -62,7 +64,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect - github.com/aws/smithy-go v1.26.0 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -129,7 +131,7 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -229,13 +231,13 @@ require ( github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/scylladb/go-reflectx v1.0.1 // indirect - github.com/segmentio/ksuid v1.0.4 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect 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/chainlink-aptos v0.0.0-20260430175646-295a7f9a1500 // indirect + github.com/smartcontractkit/chainlink-canton v0.0.0-20260609155219-dcbe77d4a320 // indirect github.com/smartcontractkit/chainlink-common v0.11.2-0.20260506120607-7f10be016c89 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 // indirect github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 // indirect @@ -243,11 +245,12 @@ require ( github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/chainlink-protos/op-catalog v0.1.0 // indirect github.com/smartcontractkit/chainlink-sui v0.0.0-20260527160341-aa3adc0abf67 // indirect - github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.4 // indirect + github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.5 // indirect github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5 // indirect github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260514223130-48bc90aca745 // indirect github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20260408092456-3c6369888d4a // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad // indirect + github.com/smartcontractkit/go-daml v0.0.0-20260604143752-c6f6567940ba // indirect github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect diff --git a/go.sum b/go.sum index 2505a1b..4667414 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= @@ -82,22 +84,22 @@ github.com/aws/aws-sdk-go v1.22.1/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw= +github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.59.2 h1:I1oExVl2b6nJGv//TcU78k9Covm/htQ5gwPIcDlM2PI= -github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.59.2/go.mod h1:sxvHFUS0fM9Y3BpmDvwrO9fnQC0CrFSG8KD9THjv6k4= +github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.61.2 h1:Y72mUMDDsuH4oPVmO0OH798GLF3UaSY86x/KyV3pGWI= +github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.61.2/go.mod h1:L9TyMmB+T+9gsQ0LBcXujgHLZUngEPDTUm2ABkACdIU= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= @@ -112,8 +114,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= -github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= -github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= @@ -368,8 +370,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -847,6 +849,8 @@ github.com/smartcontractkit/chain-selectors v1.0.102 h1:qYP4+72HfvogCHR5ymwRFee3 github.com/smartcontractkit/chain-selectors v1.0.102/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260430175646-295a7f9a1500 h1:045jrHCLI+MpeAyByJkyHbEjq0+aTPt04C7+sbsNNtw= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260430175646-295a7f9a1500/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig= +github.com/smartcontractkit/chainlink-canton v0.0.0-20260609155219-dcbe77d4a320 h1:ix4tCtSTB7S2XGll+uqnhrqAQ+2iW/Zk/vnPjBMYRB0= +github.com/smartcontractkit/chainlink-canton v0.0.0-20260609155219-dcbe77d4a320/go.mod h1:WKmNUX4oy8IvB66ukudrE99uaXjlZ7WghCDwHOTyB1c= github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc h1:mvobZx5JV5PhG/9IXPReV+8mAGnupl0HIWQZ43zxzd4= github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc/go.mod h1:gzCVLUlNov/zFXSC7G6zcGkZU1IfNOHaakbAPDe5Woc= github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc h1:War93neyFmv7pzuElZeZC3qc/OfGtLvEXvqL3qeBfM0= @@ -855,8 +859,8 @@ github.com/smartcontractkit/chainlink-common v0.11.2-0.20260506120607-7f10be016c github.com/smartcontractkit/chainlink-common v0.11.2-0.20260506120607-7f10be016c89/go.mod h1:G2AII0QmWzXx8Ag9IKnGN3h/gwwNnhHUOCviJievdvo= 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.109.0 h1:sURmdL2OnO55SETWIFzIEqQH7RCiHJyW7on8HvfnLY8= -github.com/smartcontractkit/chainlink-deployments-framework v0.109.0/go.mod h1:ubpvoLoRdru8IQHw3TFr7KthbjYpAwmiRmvvNCf2daA= +github.com/smartcontractkit/chainlink-deployments-framework v0.110.0 h1:FkrP1bqV7+6aBuU7fMghz73AZ0SKh2LuQomsiUVNRsU= +github.com/smartcontractkit/chainlink-deployments-framework v0.110.0/go.mod h1:+NGoMnU8UBGc9e+QLFXQ3HdLxLoZ9HDjoSBq0WeZyJE= 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-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg= @@ -871,8 +875,8 @@ github.com/smartcontractkit/chainlink-protos/op-catalog v0.1.0 h1:hGEJFD2X3oNIPX github.com/smartcontractkit/chainlink-protos/op-catalog v0.1.0/go.mod h1:PjZD54vr6rIKEKQj6HNA4hllvYI/QpT+Zefj3tqkFAs= github.com/smartcontractkit/chainlink-sui v0.0.0-20260527160341-aa3adc0abf67 h1:NNvPOgvf5vbOYVLxLST+5E88iPOAnpmzZGPihEx8DFc= github.com/smartcontractkit/chainlink-sui v0.0.0-20260527160341-aa3adc0abf67/go.mod h1:k1HSbHyPaQWPOj6lXDIAe04EuwbC5ge1nK+cpG2E8hE= -github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.4 h1:8M+2pA0qx9rXaxmpKouUHj983vQCGzztHkG0XjE5Eew= -github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.4/go.mod h1:nyOjn4ADJGqRMe3+4ZXSV+J/7nWb1H2Vx8Qk57eLRYA= +github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.5 h1:EiQx0LCPzxlfO9piSPeMCVSZAnp/BxAsPIGh/jBal18= +github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.5/go.mod h1:nyOjn4ADJGqRMe3+4ZXSV+J/7nWb1H2Vx8Qk57eLRYA= github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5 h1:RwZXxdIAOyjp6cwc9Quxgr38k8r7ACz+Lxh9o/A6oH0= github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5/go.mod h1:kHYJnZUqiPF7/xN5273prV+srrLJkS77GbBXHLKQpx0= github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260514223130-48bc90aca745 h1:eieKLvYuzwBPh/FdbUS1gnIanI86zgWby1L10o90g4o= @@ -883,12 +887,14 @@ github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.202510141 github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014120029-d73d15cc23f7/go.mod h1:ea1LESxlSSOgc2zZBqf1RTkXTMthHaspdqUHd7W4lF0= github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad h1:lgHxTHuzJIF3Vj6LSMOnjhqKgRqYW+0MV2SExtCYL1Q= github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad/go.mod h1:T4zH9R8R8lVWKfU7tUvYz2o2jMv1OpGCdpY2j2QZXzU= +github.com/smartcontractkit/go-daml v0.0.0-20260604143752-c6f6567940ba h1:peYJwUWOv54aigdk1VFzkmXdZmZK4xixfxv0Af1l6/I= +github.com/smartcontractkit/go-daml v0.0.0-20260604143752-c6f6567940ba/go.mod h1:SqWfl3Bp9NleC9jhzFUaOGzOZeKfldpY4QOW6A6NSNM= github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12ijqMM9tvYVEm+nR826WsrNi6zCKpwBhuApq127wHs= github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d h1:PvXor5Fjer7FIONSqYXbpd1LkA14hWrlAyxXzOrC9t8= github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d/go.mod h1:PLdNK6GlqfxIWXzziPkU7dCAVlVFeYkyyW7AQY0R+4Q= -github.com/smartcontractkit/mcms v0.45.0 h1:6Zx80KKLQOPXLhvrRkJKClANnBJmPa/r69CV5UUq/0I= -github.com/smartcontractkit/mcms v0.45.0/go.mod h1:/uOE69QmF7opKlaBNzp8djypmBoYSW0mk4V2iKWP418= +github.com/smartcontractkit/mcms v0.47.1-0.20260609163952-0b2bf692ba6a h1:Md5YPRT2oIgSD6U/gvy3BkB3hWXufu+eWIA+0ZFkngc= +github.com/smartcontractkit/mcms v0.47.1-0.20260609163952-0b2bf692ba6a/go.mod h1:7ZPOnN9CeoiiZkGQSPsyk23X+XFEtxQYpIx7kKy2ZP0= github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9 h1:MOEuXYogv+RStASb8dWsyescu/xkigSi/Sv45NEjV7A= github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9/go.mod h1:iwy4yWFuK+1JeoIRTaSOA9pl+8Kf//26zezxEXrAQEQ= github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 h1:zxcODLrFytOKmAd8ty8S/XK6WcIEJEgRBaL7sY/7l4Y= diff --git a/mcms/changesets/set-config/changeset.go b/mcms/changesets/set-config/changeset.go new file mode 100644 index 0000000..4c3d743 --- /dev/null +++ b/mcms/changesets/set-config/changeset.go @@ -0,0 +1,155 @@ +package set_config + +import ( + "errors" + "fmt" + "slices" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +var _ cldf.ChangeSetV2[Input] = Changeset{} + +// Changeset sets MCMS config on contracts identified by datastore refs. +type Changeset struct{} + +func (Changeset) VerifyPreconditions(env cldf.Environment, input Input) error { + if input.MCMS != nil { + if err := input.MCMS.Validate(); err != nil { + return fmt.Errorf("invalid MCMS timelock proposal input: %w", err) + } + } + + return validateConfig(env, input.Cfg, input.MCMS) +} + +func (Changeset) Apply(e cldf.Environment, input Input) (cldf.ChangesetOutput, error) { + byChain, err := groupTargetsByChain(input.Cfg.Targets) + if err != nil { + return cldf.ChangesetOutput{}, err + } + + var ( + reports []operations.Report[any, any] + batchOps []mcmstypes.BatchOperation + ) + + for _, chainSelector := range sortedChainSelectors(byChain) { + targets := byChain[chainSelector] + seq, seqErr := sequenceByChainSelector(chainSelector) + if seqErr != nil { + return cldf.ChangesetOutput{}, seqErr + } + + chainOut, runErr := seq.Run(e, ChainInput{ + ChainSelector: chainSelector, + Targets: targets, + MCMS: input.MCMS, + }) + reports = append(reports, chainOut.Reports...) + if runErr != nil { + return cldf.ChangesetOutput{Reports: reports}, runErr + } + + batchOps = append(batchOps, chainOut.Output.BatchOps...) + } + + ds := datastore.NewMemoryDataStore() + builder := cldf.NewOutputBuilder(e, ds).WithOperationsReports(reports) + if input.MCMS != nil && hasNonEmptyBatchOps(batchOps) { + builder = builder.WithTimelockProposal(*input.MCMS, batchOps) + } + + out, err := builder.Build() + if err != nil { + return out, fmt.Errorf("build changeset output: %w", err) + } + + if input.MCMS != nil && len(out.MCMSTimelockProposals) > 0 { + e.Logger.Infow("SetConfigMCMS proposal created", "proposalCount", len(out.MCMSTimelockProposals)) + } + + return out, nil +} + +func validateConfig(e cldf.Environment, cfg Config, mcms *cldf.MCMSTimelockProposalInput) error { + if len(cfg.Targets) == 0 { + return errors.New("no set-config targets provided") + } + + byChain, err := groupTargetsByChain(cfg.Targets) + if err != nil { + return err + } + + for _, chainSelector := range sortedChainSelectors(byChain) { + targets := byChain[chainSelector] + if !e.BlockChains.Exists(chainSelector) { + return fmt.Errorf("chain %d not found in environment", chainSelector) + } + + seq, err := sequenceByChainSelector(chainSelector) + if err != nil { + return err + } + if err := seq.Verify(e, ChainInput{ + ChainSelector: chainSelector, + Targets: targets, + MCMS: mcms, + }); err != nil { + return err + } + } + + for i, target := range cfg.Targets { + if target.Ref.ChainSelector == 0 { + return fmt.Errorf("targets[%d]: ref chain selector is required", i) + } + if _, err := target.Ref.Key(); err != nil { + return fmt.Errorf("targets[%d]: %w", i, err) + } + if _, err := target.Ref.Resolve(e); err != nil { + return fmt.Errorf("targets[%d]: %w", i, err) + } + if err := target.Config.Validate(); err != nil { + return fmt.Errorf("targets[%d]: invalid config: %w", i, err) + } + } + + return nil +} + +func sortedChainSelectors(byChain map[uint64][]ContractSetConfig) []uint64 { + selectors := make([]uint64, 0, len(byChain)) + for chainSelector := range byChain { + selectors = append(selectors, chainSelector) + } + slices.Sort(selectors) + + return selectors +} + +func groupTargetsByChain(targets []ContractSetConfig) (map[uint64][]ContractSetConfig, error) { + byChain := make(map[uint64][]ContractSetConfig) + for i, target := range targets { + if target.Ref.ChainSelector == 0 { + return nil, fmt.Errorf("targets[%d]: ref chain selector is required", i) + } + byChain[target.Ref.ChainSelector] = append(byChain[target.Ref.ChainSelector], target) + } + + return byChain, nil +} + +func hasNonEmptyBatchOps(ops []mcmstypes.BatchOperation) bool { + for _, op := range ops { + if len(op.Transactions) > 0 { + return true + } + } + + return false +} diff --git a/mcms/changesets/set-config/changeset_test.go b/mcms/changesets/set-config/changeset_test.go new file mode 100644 index 0000000..1e161c4 --- /dev/null +++ b/mcms/changesets/set-config/changeset_test.go @@ -0,0 +1,567 @@ +package set_config_test + +import ( + "crypto/ecdsa" + "fmt" + "strconv" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + solanago "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + "github.com/smartcontractkit/mcms/sdk/evm" + "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/cld-changesets/datastore/refkey" + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacymcms "github.com/smartcontractkit/cld-changesets/legacy/mcms/changesets" + evmstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/evm" + solstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + solchangesets "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/changesets" + soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils" + _ "github.com/smartcontractkit/cld-changesets/mcms" // wires built-in set-config families and readers + setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" +) + +func TestChangeset_VerifyPreconditions(t *testing.T) { + t.Parallel() + + evmSelector := chain_selectors.TEST_90000001.Selector + solSelector := chain_selectors.TEST_22222222222222222222222222222222222222222222.Selector + + programsPath, programIDs, ab := soltestutils.PreloadMCMS(t, solSelector) + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{evmSelector}), + environment.WithSolanaContainer(t, []uint64{solSelector}, programsPath, programIDs), + environment.WithAddressBook(ab), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + config := cldftesthelpers.SingleGroupTimelockConfig(t) + err = rt.Exec( + // TODO: replace with new deploy changeset when available + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + evmSelector: config, + solSelector: config, + }), + ) + require.NoError(t, err) + + env := rt.Environment() + validCfg := cldftesthelpers.SingleGroupMCMS(t) + invalidCfg := cldftesthelpers.SingleGroupMCMS(t) + invalidCfg.Quorum = 0 + + validTargets := append( + evmMCMSTargets(evmSelector, validCfg, validCfg, validCfg), + solanaMCMSTargets(solSelector, validCfg, validCfg, validCfg)..., + ) + validMCMS := newMCMSInput("valid proposal", "") + + evmSelectorStr := strconv.FormatUint(evmSelector, 10) + + tests := []struct { + name string + input setconfig.Input + wantErr string + }{ + { + name: "valid MCMS input", + input: setConfigInput( + validTargets, + validMCMS, + ), + }, + { + name: "valid direct-send input", + input: setConfigInput( + validTargets, + nil, + ), + }, + { + name: "no targets", + input: setConfigInput(nil, nil), + wantErr: "no set-config targets provided", + }, + { + name: "chain not in environment", + input: setConfigInput( + evmMCMSTargets(123, validCfg, validCfg, validCfg), + nil, + ), + wantErr: "chain 123 not found in environment", + }, + { + name: "invalid proposer config", + input: setConfigInput( + append( + evmMCMSTargets(evmSelector, invalidCfg, validCfg, validCfg), + solanaMCMSTargets(solSelector, validCfg, validCfg, validCfg)..., + ), + validMCMS, + ), + wantErr: "targets[0]: invalid config: invalid MCMS config: Quorum must be greater than 0", + }, + { + name: "invalid canceller config", + input: setConfigInput( + append( + evmMCMSTargets(evmSelector, validCfg, invalidCfg, validCfg), + solanaMCMSTargets(solSelector, validCfg, validCfg, validCfg)..., + ), + validMCMS, + ), + wantErr: "targets[1]: invalid config: invalid MCMS config: Quorum must be greater than 0", + }, + { + name: "invalid bypasser config", + input: setConfigInput( + append( + evmMCMSTargets(evmSelector, validCfg, validCfg, invalidCfg), + solanaMCMSTargets(solSelector, validCfg, validCfg, validCfg)..., + ), + validMCMS, + ), + wantErr: "targets[2]: invalid config: invalid MCMS config: Quorum must be greater than 0", + }, + { + name: "partial targets only proposer", + input: setConfigInput( + []setconfig.ContractSetConfig{ + { + Ref: contractRef(evmSelector, mcmscontracts.ProposerManyChainMultisig, ""), + Config: validCfg, + }, + }, + nil, + ), + }, + { + name: "MCMS input missing valid until", + input: setConfigInput( + validTargets, + &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + TimelockDelay: mcmstypes.NewDuration(time.Second), + }, + ), + wantErr: "invalid MCMS timelock proposal input: invalid MCMS timelock proposal input: valid until must be set", + }, + { + name: "MCMS schedule action requires positive delay", + input: setConfigInput( + validTargets, + &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec + TimelockDelay: mcmstypes.NewDuration(0), + }, + ), + wantErr: "invalid MCMS timelock proposal input: invalid MCMS timelock proposal input: timelock delay must be positive for schedule action", + }, + { + name: "ref missing from datastore", + input: setConfigInput( + []setconfig.ContractSetConfig{ + { + Ref: contractRef(evmSelector, mcmscontracts.ProposerManyChainMultisig, "does-not-exist"), + Config: validCfg, + }, + }, + nil, + ), + wantErr: fmt.Sprintf( + "targets[0]: address ref %s_ProposerManyChainMultiSig_1.0.0_does-not-exist: no such address ref can be found for the provided key", + evmSelectorStr, + ), + }, + } + + cs := setconfig.Changeset{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := cs.VerifyPreconditions(env, tt.input) + if tt.wantErr != "" { + require.Error(t, err) + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestChangeset_EVM(t *testing.T) { + t.Parallel() + + selector1 := chain_selectors.TEST_90000001.Selector + selector2 := chain_selectors.TEST_90000002.Selector + + rt := newEVMRuntimeWithDeploy(t, selector1, selector2) + chain1 := rt.Environment().BlockChains.EVMChains()[selector1] + chain2 := rt.Environment().BlockChains.EVMChains()[selector2] + transferEVMMCMSToTimelock(t, rt, selector2) + + for _, tt := range []struct { + name string + chain cldf_evm.Chain + selector uint64 + useMCMS bool + extraTasks []runtime.Executable + }{ + { + name: "direct send", + chain: chain1, + selector: selector1, + useMCMS: false, + }, + { + name: "MCMS proposal", + chain: chain2, + selector: selector2, + useMCMS: true, + extraTasks: []runtime.Executable{ + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mcmsState, _ := evmMCMSChainState(t, rt, tt.selector) + timelockAddress := mcmsState.Timelock.Address() + + cfgProposer := cldftesthelpers.SingleGroupMCMS(t) + cfgProposer.Signers = append(cfgProposer.Signers, timelockAddress) + cfgProposer.Quorum = 2 + cfgCanceller := cldftesthelpers.SingleGroupMCMS(t) + cfgBypasser := cldftesthelpers.SingleGroupMCMS(t) + cfgBypasser.Signers = append(cfgBypasser.Signers, timelockAddress) + cfgBypasser.Signers = append(cfgBypasser.Signers, mcmsState.ProposerMcm.Address()) + cfgBypasser.Quorum = 3 + + var mcmsInput *cldf.MCMSTimelockProposalInput + if tt.useMCMS { + mcmsInput = newMCMSInput("Set config proposal", "") + } + + tasks := make([]runtime.Executable, 0, 1+len(tt.extraTasks)) + tasks = append(tasks, runtime.ChangesetTask(setconfig.Changeset{}, setConfigInput( + evmMCMSTargets(tt.selector, cfgProposer, cfgCanceller, cfgBypasser), + mcmsInput, + ))) + tasks = append(tasks, tt.extraTasks...) + + err := rt.Exec(tasks...) + require.NoError(t, err) + + inspector := evm.NewInspector(tt.chain.Client) + + newConf, err := inspector.GetConfig(t.Context(), mcmsState.ProposerMcm.Address().Hex()) + require.NoError(t, err) + require.ElementsMatch(t, cfgProposer.Signers, newConf.Signers) + require.Equal(t, cfgProposer.Quorum, newConf.Quorum) + + newConf, err = inspector.GetConfig(t.Context(), mcmsState.BypasserMcm.Address().Hex()) + require.NoError(t, err) + require.ElementsMatch(t, cfgBypasser.Signers, newConf.Signers) + require.Equal(t, cfgBypasser.Quorum, newConf.Quorum) + + newConf, err = inspector.GetConfig(t.Context(), mcmsState.CancellerMcm.Address().Hex()) + require.NoError(t, err) + require.ElementsMatch(t, cfgCanceller.Signers, newConf.Signers) + require.Equal(t, cfgCanceller.Quorum, newConf.Quorum) + }) + } +} + +func TestChangeset_EVM_PartialTargets(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_90000001.Selector + + rt := newEVMRuntimeWithDeploy(t, selector) + mcmsState, chain := evmMCMSChainState(t, rt, selector) + + cfgProposer := cldftesthelpers.SingleGroupMCMS(t) + cfgProposer.Signers = append(cfgProposer.Signers, mcmsState.Timelock.Address()) + cfgProposer.Quorum = 2 + + err := rt.Exec( + runtime.ChangesetTask(setconfig.Changeset{}, setConfigInput( + []setconfig.ContractSetConfig{ + { + Ref: contractRef(selector, mcmscontracts.ProposerManyChainMultisig, ""), + Config: cfgProposer, + }, + }, + nil, + )), + ) + require.NoError(t, err) + + inspector := evm.NewInspector(chain.Client) + originalCfg := cldftesthelpers.SingleGroupMCMS(t) + + proposerConf, err := inspector.GetConfig(t.Context(), mcmsState.ProposerMcm.Address().Hex()) + require.NoError(t, err) + require.ElementsMatch(t, cfgProposer.Signers, proposerConf.Signers) + require.Equal(t, cfgProposer.Quorum, proposerConf.Quorum) + + cancellerConf, err := inspector.GetConfig(t.Context(), mcmsState.CancellerMcm.Address().Hex()) + require.NoError(t, err) + require.ElementsMatch(t, originalCfg.Signers, cancellerConf.Signers) + require.Equal(t, originalCfg.Quorum, cancellerConf.Quorum) + + bypasserConf, err := inspector.GetConfig(t.Context(), mcmsState.BypasserMcm.Address().Hex()) + require.NoError(t, err) + require.ElementsMatch(t, originalCfg.Signers, bypasserConf.Signers) + require.Equal(t, originalCfg.Quorum, bypasserConf.Quorum) +} + +func TestChangeset_EVM_Qualifier(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_90000001.Selector + selectorStr := strconv.FormatUint(selector, 10) + cllccipQualifier := "CLLCCIP" + rmnmcmsQualifier := "RMNMCMS" + + rt := newEVMRuntime(t, selector) + + cllccipConfig := cldftesthelpers.SingleGroupTimelockConfig(t) + cllccipConfig.Qualifier = &cllccipQualifier + rmnmcmsConfig := cldftesthelpers.SingleGroupTimelockConfig(t) + rmnmcmsConfig.Qualifier = &rmnmcmsQualifier + + err := rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cllccipConfig, + }), + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: rmnmcmsConfig, + }), + ) + require.NoError(t, err) + + cllccipState, err := evmstate.MaybeLoadMCMSWithTimelockStateWithQualifier(rt.Environment(), []uint64{selector}, cllccipQualifier) + require.NoError(t, err) + require.NotNil(t, cllccipState[selector]) + + cfgProposer := cldftesthelpers.SingleGroupMCMS(t) + cfgProposer.Signers = append(cfgProposer.Signers, cllccipState[selector].Timelock.Address()) + cfgProposer.Quorum = 2 + + for _, tt := range []struct { + name string + qualifier string + wantErr string + }{ + {name: "CLLCCIP qualifier", qualifier: cllccipQualifier}, + {name: "RMNMCMS qualifier", qualifier: rmnmcmsQualifier}, + { + name: "missing qualifier", + qualifier: "", + wantErr: fmt.Sprintf( + "validate timelock ref for chain %s: no addresses found for chain %s", + selectorStr, + selectorStr, + ), + }, + { + name: "unknown qualifier", + qualifier: "does-not-exist", + wantErr: fmt.Sprintf( + "validate timelock ref for chain %s: no addresses found for chain %s with qualifier \"does-not-exist\"", + selectorStr, + selectorStr, + ), + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + input := setConfigInput( + []setconfig.ContractSetConfig{ + { + Ref: contractRef(selector, mcmscontracts.ProposerManyChainMultisig, tt.qualifier), + Config: cfgProposer, + }, + }, + newMCMSInput("qualifier test", tt.qualifier), + ) + + err := setconfig.Changeset{}.VerifyPreconditions(rt.Environment(), input) + if tt.wantErr != "" { + require.Error(t, err) + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestChangeset_EVM_BuildsProposalWithoutExecute(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_90000001.Selector + rt := newEVMRuntimeWithDeployAndTransfer(t, selector) + + cfg := cldftesthelpers.SingleGroupMCMS(t) + taskID, err := runtime.ExecChangeset(rt, setconfig.Changeset{}, setConfigInput( + evmMCMSTargets(selector, cfg, cfg, cfg), + newMCMSInput("proposal only", ""), + )) + require.NoError(t, err) + + output, ok := rt.State().Outputs[taskID] + require.True(t, ok) + require.Len(t, output.MCMSTimelockProposals, 1) + require.NotEmpty(t, output.MCMSTimelockProposals[0].Operations) + require.NotEmpty(t, output.Reports) +} + +func TestChangeset_Solana(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_22222222222222222222222222222222222222222222.Selector + programsPath, programIDs, ab := soltestutils.PreloadMCMS(t, selector) + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithSolanaContainer(t, []uint64{selector}, programsPath, programIDs), + environment.WithAddressBook(ab), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + chain := rt.Environment().BlockChains.SolanaChains()[selector] + + err = rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cldftesthelpers.SingleGroupTimelockConfig(t), + }), + ) + require.NoError(t, err) + + addrs, err := rt.State().AddressBook.AddressesForChain(selector) + require.NoError(t, err) + mcmsState, err := solstate.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + soltestutils.FundSignerPDAs(t, chain, mcmsState) + + inspector := solana.NewInspector(chain.Client) + signer1Key, signer1Addr := createSolSigner(t) + _, signer2Addr := createSolSigner(t) + + newCfgProposer := cldftesthelpers.SingleGroupMCMS(t) + newCfgProposer.Signers = append(newCfgProposer.Signers, signer1Addr) + newCfgProposer.Quorum = 2 + newCfgCanceller := cldftesthelpers.SingleGroupMCMS(t) + newCfgBypasser := cldftesthelpers.SingleGroupMCMS(t) + newCfgBypasser.Signers = append(newCfgBypasser.Signers, signer1Addr) + newCfgBypasser.Quorum = 2 + + t.Run("direct send", func(t *testing.T) { //nolint:paralleltest // shared runtime state + err = rt.Exec( + runtime.ChangesetTask(setconfig.Changeset{}, setConfigInput( + solanaMCMSTargets(selector, newCfgProposer, newCfgCanceller, newCfgBypasser), + nil, + )), + ) + require.NoError(t, err) + + assertSolConfigEquals(t, inspector, mcmsState.McmProgram, mcmsState.ProposerMcmSeed, newCfgProposer) + assertSolConfigEquals(t, inspector, mcmsState.McmProgram, mcmsState.BypasserMcmSeed, newCfgBypasser) + assertSolConfigEquals(t, inspector, mcmsState.McmProgram, mcmsState.CancellerMcmSeed, newCfgCanceller) + }) + + t.Run("MCMS proposal", func(t *testing.T) { //nolint:paralleltest // shared runtime state + err = rt.Exec( + runtime.ChangesetTask(solchangesets.TransferMCMSToTimelockSolana{}, solchangesets.TransferMCMSToTimelockSolanaConfig{ + Chains: []uint64{selector}, + MCMSCfg: cldfproposalutils.TimelockConfig{MinDelay: time.Second}, + }), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner, signer1Key}), + ) + require.NoError(t, err) + + newCfgProposer.Signers = append(newCfgProposer.Signers, signer2Addr) + newCfgProposer.Quorum = 3 + newCfgBypasser.Signers = append(newCfgBypasser.Signers, signer2Addr) + newCfgBypasser.Quorum = 3 + + err = rt.Exec( + runtime.ChangesetTask(setconfig.Changeset{}, setConfigInput( + solanaMCMSTargets(selector, newCfgProposer, newCfgCanceller, newCfgBypasser), + newMCMSInput("solana set config", ""), + )), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner, signer1Key}), + ) + require.NoError(t, err) + + assertSolConfigEquals(t, inspector, mcmsState.McmProgram, mcmsState.ProposerMcmSeed, newCfgProposer) + assertSolConfigEquals(t, inspector, mcmsState.McmProgram, mcmsState.BypasserMcmSeed, newCfgBypasser) + assertSolConfigEquals(t, inspector, mcmsState.McmProgram, mcmsState.CancellerMcmSeed, newCfgCanceller) + }) +} + +func TestChangeset_VerifyPreconditions_zeroRefChainSelector(t *testing.T) { + t.Parallel() + + cfg := cldftesthelpers.SingleGroupMCMS(t) + input := setConfigInput( + []setconfig.ContractSetConfig{ + { + Ref: refkey.RefKey{ + ChainSelector: 0, + Type: contractRef(chain_selectors.TEST_90000001.Selector, mcmscontracts.ProposerManyChainMultisig, "").Type, + Version: &semvers.V1_0_0, + }, + Config: cfg, + }, + }, + nil, + ) + + err := setconfig.Changeset{}.VerifyPreconditions(cldf.Environment{}, input) + require.Error(t, err) + require.EqualError(t, err, "targets[0]: ref chain selector is required") +} + +func assertSolConfigEquals( + t *testing.T, inspector *solana.Inspector, programID solanago.PublicKey, seed solstate.PDASeed, want mcmstypes.Config, +) { + t.Helper() + + cfg, err := inspector.GetConfig(t.Context(), solana.ContractAddress(programID, solana.PDASeed(seed))) + require.NoError(t, err) + require.ElementsMatch(t, want.Signers, cfg.Signers) + require.Equal(t, want.Quorum, cfg.Quorum) +} + +func createSolSigner(t *testing.T) (*ecdsa.PrivateKey, common.Address) { + t.Helper() + + key, err := crypto.GenerateKey() + require.NoError(t, err) + publicKey := key.Public().(*ecdsa.PublicKey) + + return key, crypto.PubkeyToAddress(*publicKey) +} diff --git a/mcms/changesets/set-config/defaults.go b/mcms/changesets/set-config/defaults.go new file mode 100644 index 0000000..f5402f4 --- /dev/null +++ b/mcms/changesets/set-config/defaults.go @@ -0,0 +1,23 @@ +package set_config + +import "sync" + +var ( + defaultsRegistrar func() + ensureDefaultsOnce sync.Once +) + +// SetDefaultsRegistrar installs the function EnsureDefaults calls to register built-in +// families. The mcms package sets this in init; apps should import mcms to wire defaults. +func SetDefaultsRegistrar(fn func()) { + defaultsRegistrar = fn +} + +// EnsureDefaults registers built-in families when a registrar has been installed. +// Called automatically before dispatching to a family sequence. +func EnsureDefaults() { + if defaultsRegistrar == nil { + return + } + ensureDefaultsOnce.Do(defaultsRegistrar) +} diff --git a/mcms/changesets/set-config/helpers_test.go b/mcms/changesets/set-config/helpers_test.go new file mode 100644 index 0000000..e10c284 --- /dev/null +++ b/mcms/changesets/set-config/helpers_test.go @@ -0,0 +1,149 @@ +package set_config_test + +import ( + "crypto/ecdsa" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/cld-changesets/datastore/refkey" + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacymcms "github.com/smartcontractkit/cld-changesets/legacy/mcms/changesets" + evmstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/evm" + setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" +) + +func contractRef(chainSelector uint64, contractType cldf.ContractType, qualifier string) refkey.RefKey { + return refkey.New(chainSelector, cldfdatastore.ContractType(contractType), &semvers.V1_0_0, qualifier) +} + +func evmMCMSTargets( + chainSelector uint64, + proposer, canceller, bypasser mcmstypes.Config, +) []setconfig.ContractSetConfig { + return []setconfig.ContractSetConfig{ + {Ref: contractRef(chainSelector, mcmscontracts.ProposerManyChainMultisig, ""), Config: proposer}, + {Ref: contractRef(chainSelector, mcmscontracts.CancellerManyChainMultisig, ""), Config: canceller}, + {Ref: contractRef(chainSelector, mcmscontracts.BypasserManyChainMultisig, ""), Config: bypasser}, + } +} + +func solanaMCMSTargets( + chainSelector uint64, + proposer, canceller, bypasser mcmstypes.Config, +) []setconfig.ContractSetConfig { + return []setconfig.ContractSetConfig{ + {Ref: contractRef(chainSelector, mcmscontracts.ProposerManyChainMultisig, ""), Config: proposer}, + {Ref: contractRef(chainSelector, mcmscontracts.CancellerManyChainMultisig, ""), Config: canceller}, + {Ref: contractRef(chainSelector, mcmscontracts.BypasserManyChainMultisig, ""), Config: bypasser}, + } +} + +func newMCMSInput(description, qualifier string) *cldf.MCMSTimelockProposalInput { + return &cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(time.Second), + Qualifier: qualifier, + Description: description, + } +} + +func setConfigInput(targets []setconfig.ContractSetConfig, mcms *cldf.MCMSTimelockProposalInput) setconfig.Input { + return setconfig.Input{ + Cfg: setconfig.Config{Targets: targets}, + MCMS: mcms, + } +} + +func newEVMRuntime(t *testing.T, selectors ...uint64) *runtime.Runtime { + t.Helper() + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, selectors), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + return rt +} + +func deployEVMMCMSWithTimelock(t *testing.T, rt *runtime.Runtime, selectors ...uint64) { + t.Helper() + + cfg := cldftesthelpers.SingleGroupTimelockConfig(t) + configByChain := make(map[uint64]cldfproposalutils.MCMSWithTimelockConfig, len(selectors)) + for _, selector := range selectors { + configByChain[selector] = cfg + } + + err := rt.Exec( + // TODO: replace with new deploy changeset when available + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), configByChain), + ) + require.NoError(t, err) +} + +func newEVMRuntimeWithDeploy(t *testing.T, selectors ...uint64) *runtime.Runtime { + t.Helper() + + rt := newEVMRuntime(t, selectors...) + deployEVMMCMSWithTimelock(t, rt, selectors...) + + return rt +} + +func transferEVMMCMSToTimelock(t *testing.T, rt *runtime.Runtime, selector uint64) { + t.Helper() + + mcmsState, _ := evmMCMSChainState(t, rt, selector) + + err := rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.TransferToMCMSWithTimelockV2), legacymcms.TransferToMCMSWithTimelockConfig{ + ContractsByChain: map[uint64][]common.Address{ + selector: { + mcmsState.ProposerMcm.Address(), + mcmsState.BypasserMcm.Address(), + mcmsState.CancellerMcm.Address(), + }, + }, + }), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + ) + require.NoError(t, err) +} + +func newEVMRuntimeWithDeployAndTransfer(t *testing.T, selector uint64) *runtime.Runtime { + t.Helper() + + rt := newEVMRuntimeWithDeploy(t, selector) + transferEVMMCMSToTimelock(t, rt, selector) + + return rt +} + +func evmMCMSChainState(t *testing.T, rt *runtime.Runtime, selector uint64) (*evmstate.MCMSWithTimelockState, cldf_evm.Chain) { + t.Helper() + + chain := rt.Environment().BlockChains.EVMChains()[selector] + addrs, err := rt.State().AddressBook.AddressesForChain(selector) + require.NoError(t, err) + + mcmsState, err := evmstate.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + + return mcmsState, chain +} diff --git a/mcms/changesets/set-config/registry.go b/mcms/changesets/set-config/registry.go new file mode 100644 index 0000000..90a29f9 --- /dev/null +++ b/mcms/changesets/set-config/registry.go @@ -0,0 +1,100 @@ +package set_config + +import ( + "fmt" + "slices" + "strings" + "sync" + + chain_selectors "github.com/smartcontractkit/chain-selectors" +) + +var ( + registryMu sync.RWMutex + registry = make(map[string]Registration) +) + +// Register adds a family set-config implementation to the registry. +func Register(reg Registration) { + registryMu.Lock() + defer registryMu.Unlock() + + if reg.Family == "" { + panic("mcms set-config: family is required") + } + if reg.Sequence == nil { + panic(fmt.Sprintf("mcms set-config: sequence is required for family %q", reg.Family)) + } + if _, exists := registry[reg.Family]; exists { + panic(fmt.Sprintf("mcms set-config: family %q already registered", reg.Family)) + } + + registry[reg.Family] = reg +} + +// RegisterAll registers multiple family set-config implementations. +func RegisterAll(regs ...Registration) { + for _, reg := range regs { + Register(reg) + } +} + +func get(family string) (Registration, error) { + EnsureDefaults() + + registryMu.RLock() + defer registryMu.RUnlock() + + reg, ok := registry[family] + if !ok { + registered := registeredFamiliesLocked() + if len(registered) == 0 { + return Registration{}, fmt.Errorf( + "mcms set-config: no sequence registered for family %q (none registered — import github.com/smartcontractkit/cld-changesets/mcms)", + family, + ) + } + + return Registration{}, fmt.Errorf( + "mcms set-config: no sequence registered for family %q (registered: %s)", + family, + strings.Join(registered, ", "), + ) + } + + return reg, nil +} + +func sequenceByChainSelector(chainSelector uint64) (Sequence, error) { + family, err := chain_selectors.GetSelectorFamily(chainSelector) + if err != nil { + return nil, err + } + + reg, err := get(family) + if err != nil { + return nil, fmt.Errorf("chain selector %d: %w", chainSelector, err) + } + + return reg.Sequence, nil +} + +// RegisteredFamilies returns the sorted list of registered chain families. +func RegisteredFamilies() []string { + EnsureDefaults() + + registryMu.RLock() + defer registryMu.RUnlock() + + return registeredFamiliesLocked() +} + +func registeredFamiliesLocked() []string { + families := make([]string, 0, len(registry)) + for family := range registry { + families = append(families, family) + } + slices.Sort(families) + + return families +} diff --git a/mcms/changesets/set-config/types.go b/mcms/changesets/set-config/types.go new file mode 100644 index 0000000..06ddf95 --- /dev/null +++ b/mcms/changesets/set-config/types.go @@ -0,0 +1,51 @@ +package set_config + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/cld-changesets/datastore/refkey" +) + +// ContractSetConfig binds an MCMS config to a datastore contract reference. +type ContractSetConfig struct { + Ref refkey.RefKey `json:"ref"` + Config mcmstypes.Config `json:"config"` +} + +// ChainInput is the family-agnostic, per-chain request for a set-config sequence. +type ChainInput struct { + ChainSelector uint64 + Targets []ContractSetConfig + MCMS *cldf.MCMSTimelockProposalInput +} + +// ChainOutput is the family-agnostic result for a single chain. +type ChainOutput struct { + Reports []operations.Report[any, any] + Output sequenceutils.OnChainOutput +} + +// Sequence runs the family-specific set-config operations sequence for one chain. +type Sequence interface { + Verify(e cldf.Environment, in ChainInput) error + Run(e cldf.Environment, in ChainInput) (ChainOutput, error) +} + +// Registration describes one chain family's MCMS set-config implementation. +type Registration struct { + // Family is the chain-selectors family string (e.g. chainselectors.FamilyEVM). + Family string + // Sequence executes set-config for one chain. + Sequence Sequence +} + +// Config holds the set-config targets for the changeset. +type Config struct { + Targets []ContractSetConfig +} + +// Input is the changeset configuration with optional MCMS timelock proposal settings. +type Input = sequenceutils.WithMCMS[Config] diff --git a/mcms/changesets/set-config/validate.go b/mcms/changesets/set-config/validate.go new file mode 100644 index 0000000..955a96d --- /dev/null +++ b/mcms/changesets/set-config/validate.go @@ -0,0 +1,44 @@ +package set_config + +import ( + "fmt" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + mcmsreaders "github.com/smartcontractkit/cld-changesets/mcms/readers" +) + +// ValidateMCMSIfPresent checks that required MCMS timelock refs resolve when in.MCMS is set. +func ValidateMCMSIfPresent(e cldf.Environment, family string, in ChainInput) error { + if in.MCMS == nil { + return nil + } + + input := *in.MCMS + chainSelector := in.ChainSelector + + reader, err := mcmsreaders.ReaderForFamily(family) + if err != nil { + return err + } + + if _, err := reader.GetTimelockRef(e, chainSelector, input); err != nil { + return fmt.Errorf("validate timelock ref for chain %d: %w", chainSelector, err) + } + if _, err := reader.GetMCMSRef(e, chainSelector, input); err != nil { + return fmt.Errorf("validate mcms ref for chain %d: %w", chainSelector, err) + } + + if family == chainselectors.FamilyEVM { + evmReader, ok := reader.(mcmsreaders.EVMCallProxyReader) + if !ok { + return fmt.Errorf("validate call proxy ref for chain %d: reader for family %q does not support call proxy lookup", chainSelector, family) + } + if _, err := evmReader.GetCallProxyRef(e, chainSelector, input.Qualifier); err != nil { + return fmt.Errorf("validate call proxy ref for chain %d: %w", chainSelector, err) + } + } + + return nil +} diff --git a/mcms/evm/set-config/operation.go b/mcms/evm/set-config/operation.go new file mode 100644 index 0000000..c93dc31 --- /dev/null +++ b/mcms/evm/set-config/operation.go @@ -0,0 +1,99 @@ +package evmsetconfig + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + mcmsevm "github.com/smartcontractkit/mcms/sdk/evm" + mcmsbindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" + mcmstypes "github.com/smartcontractkit/mcms/types" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + evmops "github.com/smartcontractkit/cld-changesets/pkg/family/evm/operations" +) + +// MCMSetConfigTarget identifies one MCM contract and the config to apply. +type MCMSetConfigTarget struct { + Address common.Address `json:"address"` + Config mcmstypes.Config `json:"config"` + ContractType cldf.ContractType `json:"contractType"` +} + +// OpEVMSetConfigInput is the input for setting config on a single EVM MCM contract. +type OpEVMSetConfigInput struct { + Target MCMSetConfigTarget `json:"target"` + NoSend bool `json:"noSend"` + GasPrice uint64 `json:"gasPrice"` + GasLimit uint64 `json:"gasLimit"` +} + +// OpEVMSetConfigMCM sets MCMS config on an EVM MCM contract via the MCMS SDK configurer. +// TODO: this may be removed if we generate operations via operations gen tool. +var OpEVMSetConfigMCM = operations.NewOperation( + "evm-mcm-set-config", + semver.MustParse("1.0.0"), + "Sets MCMS config on an EVM MCM contract", + func(b operations.Bundle, deps cldf_evm.Chain, in OpEVMSetConfigInput) (evmops.EVMCallOutput, error) { + var opts *bind.TransactOpts + if in.NoSend { + opts = cldf.SimTransactOpts() + } else { + opts = evmops.CloneTransactOptsWithGas(deps.DeployerKey, in.GasLimit, in.GasPrice) + } + opts.Context = b.GetContext() + + configurer := mcmsevm.NewConfigurer(deps.Client, opts) + res, err := configurer.SetConfig(b.GetContext(), in.Target.Address.Hex(), &in.Target.Config, false) + if err != nil { + return evmops.EVMCallOutput{}, fmt.Errorf("failed to set config on %s: %w", in.Target.Address, err) + } + + tx, ok := res.RawData.(*types.Transaction) + if !ok { + return evmops.EVMCallOutput{}, fmt.Errorf("unexpected raw data type %T from SetConfig", res.RawData) + } + + confirmed := false + if !in.NoSend { + if _, err = cldf.ConfirmIfNoErrorWithABI(deps, tx, mcmsbindings.ManyChainMultiSigABI, err); err != nil { + return evmops.EVMCallOutput{}, fmt.Errorf("failed to confirm set config tx against %s: %w", in.Target.Address, err) + } + b.Logger.Infow("SetConfig tx confirmed", "txHash", res.Hash, "address", in.Target.Address.Hex()) + confirmed = true + } + + return evmops.EVMCallOutput{ + To: in.Target.Address, + Data: tx.Data(), + ContractType: in.Target.ContractType, + Confirmed: confirmed, + }, nil + }, +) + +// retrySetConfigWithGasBoost is an ExecuteOption that retries EVM set-config with gas boosting. +func retrySetConfigWithGasBoost(cfg *cldfproposalutils.GasBoostConfig) operations.ExecuteOption[OpEVMSetConfigInput, cldf_evm.Chain] { + if cfg == nil { + return operations.WithRetry[OpEVMSetConfigInput, cldf_evm.Chain]() + } + c := *cfg + + return operations.WithRetryInput(func(attempt uint, err error, in OpEVMSetConfigInput, deps cldf_evm.Chain) OpEVMSetConfigInput { + if in.NoSend { + return in + } + + gasLimit, gasPrice := evmops.GetBoostedGasForAttempt(c, attempt) + in.GasLimit = gasLimit + in.GasPrice = gasPrice + + return in + }) +} diff --git a/mcms/evm/set-config/operation_test.go b/mcms/evm/set-config/operation_test.go new file mode 100644 index 0000000..b84c54d --- /dev/null +++ b/mcms/evm/set-config/operation_test.go @@ -0,0 +1,79 @@ +package evmsetconfig + +import ( + "crypto/ecdsa" + "testing" + + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmsevm "github.com/smartcontractkit/mcms/sdk/evm" + mcmstypes "github.com/smartcontractkit/mcms/types" + + evmops "github.com/smartcontractkit/cld-changesets/pkg/family/evm/operations" +) + +func TestOpEVMSetConfigMCM(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + noSend bool + }{ + {name: "direct send", noSend: false}, + {name: "MCMS proposal", noSend: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt := newEVMSetConfigRuntime(t, selector) + refs := evmSetConfigRefs(t, rt.Environment(), selector) + chain := rt.Environment().BlockChains.EVMChains()[selector] + + if tt.noSend { + transferEVMMCMSToTimelock(t, rt, selector, refs) + } + + cfg := cldftesthelpers.SingleGroupMCMS(t) + cfg.Signers = append(cfg.Signers, refs.Timelock) + cfg.Quorum = 2 + + report, err := operations.ExecuteOperation( + rt.Environment().OperationsBundle, + OpEVMSetConfigMCM, + chain, + OpEVMSetConfigInput{ + Target: MCMSetConfigTarget{ + Address: refs.Canceller, + Config: cfg, + ContractType: mcmscontracts.CancellerManyChainMultisig, + }, + NoSend: tt.noSend, + }, + ) + require.NoError(t, err) + require.Equal(t, refs.Canceller, report.Output.To) + require.NotEmpty(t, report.Output.Data) + require.Equal(t, !tt.noSend, report.Output.Confirmed) + + if tt.noSend { + batch, err := evmCallOutputsToBatch(selector, []evmops.EVMCallOutput{report.Output}) + require.NoError(t, err) + require.Len(t, batch.Transactions, 1) + require.NoError(t, rt.Exec( + newTimelockProposalTask([]mcmstypes.BatchOperation{batch}, "evm set config operation test"), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + } + + assertEVMConfigEquals(t, mcmsevm.NewInspector(chain.Client), refs.Canceller, cfg) + }) + } +} diff --git a/mcms/evm/set-config/register.go b/mcms/evm/set-config/register.go new file mode 100644 index 0000000..b51164b --- /dev/null +++ b/mcms/evm/set-config/register.go @@ -0,0 +1,15 @@ +package evmsetconfig + +import ( + chainselectors "github.com/smartcontractkit/chain-selectors" + + setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" +) + +// EVMRegistration returns the EVM chain-family set-config registration. +func EVMRegistration() setconfig.Registration { + return setconfig.Registration{ + Family: chainselectors.FamilyEVM, + Sequence: SetConfigSequence{}, + } +} diff --git a/mcms/evm/set-config/sequence.go b/mcms/evm/set-config/sequence.go new file mode 100644 index 0000000..c8b0932 --- /dev/null +++ b/mcms/evm/set-config/sequence.go @@ -0,0 +1,170 @@ +package evmsetconfig + +import ( + "fmt" + "math/big" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmsTypes "github.com/smartcontractkit/mcms/types" + + setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" + evmops "github.com/smartcontractkit/cld-changesets/pkg/family/evm/operations" +) + +// SeqEVMSetConfigInput is the input for the generic EVM set-config sequence. +type SeqEVMSetConfigInput struct { + ChainSelector uint64 `json:"chainSelector"` + NoSend bool `json:"noSend"` + GasBoostConfig *cldfproposalutils.GasBoostConfig `json:"gasBoostConfig,omitempty"` + Targets []MCMSetConfigTarget `json:"targets"` +} + +// SeqEVMSetConfig sets config on each provided MCM contract via OpEVMSetConfigMCM. +var SeqEVMSetConfig = operations.NewSequence( + "seq-evm-mcm-set-config", + semver.MustParse("1.0.0"), + "Sets MCMS config on one or more MCM contracts", + func(b operations.Bundle, deps cldf_evm.Chain, in SeqEVMSetConfigInput) (sequenceutils.OnChainOutput, error) { + outs := make([]evmops.EVMCallOutput, 0, len(in.Targets)) + + for _, target := range in.Targets { + opReport, err := operations.ExecuteOperation( + b, + OpEVMSetConfigMCM, + deps, + OpEVMSetConfigInput{ + Target: target, + NoSend: in.NoSend, + }, + retrySetConfigWithGasBoost(in.GasBoostConfig), + ) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + out := opReport.Output + out.ContractType = target.ContractType + outs = append(outs, out) + } + + out := sequenceutils.OnChainOutput{} + if !in.NoSend { + return out, nil + } + + batch, err := evmCallOutputsToBatch(in.ChainSelector, outs) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + if len(batch.Transactions) > 0 { + out.BatchOps = []mcmsTypes.BatchOperation{batch} + } + + return out, nil + }, +) + +// SetConfigSequence runs SeqEVMSetConfig for EVM chains. +type SetConfigSequence struct{} + +var _ setconfig.Sequence = SetConfigSequence{} + +func (SetConfigSequence) Verify(e cldf.Environment, in setconfig.ChainInput) error { + if in.MCMS == nil { + return nil + } + + return setconfig.ValidateMCMSIfPresent(e, chainselectors.FamilyEVM, in) +} + +func (SetConfigSequence) Run(e cldf.Environment, in setconfig.ChainInput) (setconfig.ChainOutput, error) { + chain, ok := e.BlockChains.EVMChains()[in.ChainSelector] + if !ok { + return setconfig.ChainOutput{}, fmt.Errorf("chain %d not found in environment", in.ChainSelector) + } + if err := setconfig.ValidateMCMSIfPresent(e, chainselectors.FamilyEVM, in); err != nil { + return setconfig.ChainOutput{}, err + } + + targets, err := setConfigTargets(e, in.Targets) + if err != nil { + return setconfig.ChainOutput{}, err + } + + seqReport, err := operations.ExecuteSequence( + e.OperationsBundle, + SeqEVMSetConfig, + chain, + SeqEVMSetConfigInput{ + ChainSelector: in.ChainSelector, + NoSend: in.MCMS != nil, + Targets: targets, + }, + ) + out := setconfig.ChainOutput{ + Reports: seqReport.ExecutionReports, + Output: seqReport.Output, + } + if err != nil { + return out, fmt.Errorf("failed to execute EVM set config sequence: %w", err) + } + + return out, nil +} + +func setConfigTargets(e cldf.Environment, configs []setconfig.ContractSetConfig) ([]MCMSetConfigTarget, error) { + targets := make([]MCMSetConfigTarget, 0, len(configs)) + + for i, cfg := range configs { + ref, err := cfg.Ref.Resolve(e) + if err != nil { + return nil, fmt.Errorf("targets[%d]: %w", i, err) + } + if !common.IsHexAddress(ref.Address) { + return nil, fmt.Errorf("targets[%d]: invalid EVM address %q", i, ref.Address) + } + + targets = append(targets, MCMSetConfigTarget{ + Address: common.HexToAddress(ref.Address), + Config: cfg.Config, + ContractType: cldf.ContractType(ref.Type), + }) + } + + return targets, nil +} + +func evmCallOutputsToBatch(chainSelector uint64, outs []evmops.EVMCallOutput) (mcmsTypes.BatchOperation, error) { + result := mcmsTypes.BatchOperation{ + ChainSelector: mcmsTypes.ChainSelector(chainSelector), + Transactions: []mcmsTypes.Transaction{}, + } + + for _, out := range outs { + if out.Confirmed { + continue + } + + batchOperation, err := cldfproposalutils.BatchOperationForChain( + chainSelector, + out.To.Hex(), + out.Data, + big.NewInt(0), + string(out.ContractType), + []string{}, + ) + if err != nil { + return mcmsTypes.BatchOperation{}, fmt.Errorf("failed to create batch operation for chain %d: %w", chainSelector, err) + } + result.Transactions = append(result.Transactions, batchOperation.Transactions...) + } + + return result, nil +} diff --git a/mcms/evm/set-config/sequence_test.go b/mcms/evm/set-config/sequence_test.go new file mode 100644 index 0000000..a1bae37 --- /dev/null +++ b/mcms/evm/set-config/sequence_test.go @@ -0,0 +1,235 @@ +package evmsetconfig + +import ( + "crypto/ecdsa" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/segmentio/ksuid" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmsevm "github.com/smartcontractkit/mcms/sdk/evm" + mcmstypes "github.com/smartcontractkit/mcms/types" + + legacymcms "github.com/smartcontractkit/cld-changesets/legacy/mcms/changesets" + mcmsreaders "github.com/smartcontractkit/cld-changesets/mcms/readers" +) + +func TestSeqEVMSetConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + noSend bool + }{ + {name: "direct send", noSend: false}, + {name: "MCMS proposal", noSend: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt := newEVMSetConfigRuntime(t, selector) + refs := evmSetConfigRefs(t, rt.Environment(), selector) + chain := rt.Environment().BlockChains.EVMChains()[selector] + + if tt.noSend { + transferEVMMCMSToTimelock(t, rt, selector, refs) + } + + proposerCfg := cldftesthelpers.SingleGroupMCMS(t) + proposerCfg.Signers = append(proposerCfg.Signers, refs.Timelock) + proposerCfg.Quorum = 2 + + bypasserCfg := cldftesthelpers.SingleGroupMCMS(t) + bypasserCfg.Signers = append(bypasserCfg.Signers, refs.Proposer) + bypasserCfg.Quorum = 2 + + cancellerCfg := cldftesthelpers.SingleGroupMCMS(t) + cancellerCfg.Signers = append(cancellerCfg.Signers, refs.Bypasser) + cancellerCfg.Quorum = 2 + + targets := []MCMSetConfigTarget{ + { + Address: refs.Proposer, + Config: proposerCfg, + ContractType: mcmscontracts.ProposerManyChainMultisig, + }, + { + Address: refs.Bypasser, + Config: bypasserCfg, + ContractType: mcmscontracts.BypasserManyChainMultisig, + }, + } + if tt.noSend { + targets = []MCMSetConfigTarget{ + { + Address: refs.Canceller, + Config: cancellerCfg, + ContractType: mcmscontracts.CancellerManyChainMultisig, + }, + } + } + + report, err := operations.ExecuteSequence( + rt.Environment().OperationsBundle, + SeqEVMSetConfig, + chain, + SeqEVMSetConfigInput{ + ChainSelector: selector, + NoSend: tt.noSend, + Targets: targets, + }, + ) + require.NoError(t, err) + + if tt.noSend { + require.Len(t, report.Output.BatchOps, 1) + require.Len(t, report.Output.BatchOps[0].Transactions, 1) + require.NoError(t, rt.Exec( + newTimelockProposalTask(report.Output.BatchOps, "set config sequence test"), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + } else { + require.Empty(t, report.Output.BatchOps) + } + + inspector := mcmsevm.NewInspector(chain.Client) + if tt.noSend { + assertEVMConfigEquals(t, inspector, refs.Canceller, cancellerCfg) + } else { + assertEVMConfigEquals(t, inspector, refs.Proposer, proposerCfg) + assertEVMConfigEquals(t, inspector, refs.Bypasser, bypasserCfg) + } + }) + } +} + +func newEVMSetConfigRuntime(t *testing.T, selector uint64) *runtime.Runtime { + t.Helper() + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + err = rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cldftesthelpers.SingleGroupTimelockConfig(t), + }), + ) + require.NoError(t, err) + + return rt +} + +type evmMCMSRefs struct { + Timelock common.Address + Proposer common.Address + Canceller common.Address + Bypasser common.Address +} + +func evmSetConfigRefs(t *testing.T, env cldf.Environment, selector uint64) evmMCMSRefs { + t.Helper() + + reader := mcmsreaders.EVMReader{} + timelock, err := reader.GetTimelockRef(env, selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + proposer, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + }) + require.NoError(t, err) + canceller, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionCancel, + }) + require.NoError(t, err) + bypasser, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + }) + require.NoError(t, err) + + return evmMCMSRefs{ + Timelock: common.HexToAddress(timelock.Address), + Proposer: common.HexToAddress(proposer.Address), + Canceller: common.HexToAddress(canceller.Address), + Bypasser: common.HexToAddress(bypasser.Address), + } +} + +func transferEVMMCMSToTimelock(t *testing.T, rt *runtime.Runtime, selector uint64, refs evmMCMSRefs) { + t.Helper() + + err := rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.TransferToMCMSWithTimelockV2), legacymcms.TransferToMCMSWithTimelockConfig{ + ContractsByChain: map[uint64][]common.Address{ + selector: { + refs.Proposer, + refs.Bypasser, + refs.Canceller, + }, + }, + }), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + ) + require.NoError(t, err) +} + +type timelockProposalTask struct { + id string + batchOps []mcmstypes.BatchOperation + description string +} + +func newTimelockProposalTask(batchOps []mcmstypes.BatchOperation, description string) timelockProposalTask { + return timelockProposalTask{ + id: ksuid.New().String(), + batchOps: batchOps, + description: description, + } +} + +func (t timelockProposalTask) ID() string { + return t.id +} + +func (t timelockProposalTask) Run(e cldf.Environment, state *runtime.State) error { + mcmsreaders.RegisterDefault(cldf.GetMCMSReaderRegistry()) + + out, err := cldf.NewOutputBuilder(e, datastore.NewMemoryDataStore()). + WithTimelockProposal(cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(0), + Description: t.description, + }, t.batchOps). + Build() + if err != nil { + return err + } + + return state.MergeChangesetOutput(t.id, out) +} + +func assertEVMConfigEquals(t *testing.T, inspector *mcmsevm.Inspector, address common.Address, want mcmstypes.Config) { + t.Helper() + + got, err := inspector.GetConfig(t.Context(), address.Hex()) + require.NoError(t, err) + require.ElementsMatch(t, want.Signers, got.Signers) + require.Equal(t, want.Quorum, got.Quorum) +} diff --git a/mcms/readers/evm.go b/mcms/readers/evm.go new file mode 100644 index 0000000..3bdd5c5 --- /dev/null +++ b/mcms/readers/evm.go @@ -0,0 +1,116 @@ +package readers + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +// EVMCallProxyReader resolves the MCMS call proxy ref for EVM chains. +type EVMCallProxyReader interface { + GetCallProxyRef(e cldf.Environment, chainSelector uint64, qualifier string) (datastore.AddressRef, error) +} + +// EVMReader resolves MCMS timelock refs for EVM chains from the environment datastore. +type EVMReader struct{} + +var ( + _ cldf.MCMSReader = EVMReader{} + _ EVMCallProxyReader = EVMReader{} +) + +func (EVMReader) GetTimelockRef(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (datastore.AddressRef, error) { + return evmAddressRef(e, chainSelector, mcmscontracts.RBACTimelock, input.Qualifier) +} + +func (EVMReader) GetMCMSRef(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (datastore.AddressRef, error) { + return evmMCMSRef(e, chainSelector, input) +} + +func (EVMReader) GetCallProxyRef(e cldf.Environment, chainSelector uint64, qualifier string) (datastore.AddressRef, error) { + return evmAddressRef(e, chainSelector, mcmscontracts.CallProxy, qualifier) +} + +func (EVMReader) GetChainMetadata(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (mcmstypes.ChainMetadata, error) { + ref, err := evmMCMSRef(e, chainSelector, input) + if err != nil { + return mcmstypes.ChainMetadata{}, err + } + + inspector, err := cldfproposalutils.McmsInspectorForChain( + e, + chainSelector, + cldfproposalutils.WithTimelockAction(input.TimelockAction), + ) + if err != nil { + return mcmstypes.ChainMetadata{}, fmt.Errorf("build inspector for chain %d: %w", chainSelector, err) + } + + opCount, err := inspector.GetOpCount(e.GetContext(), ref.Address) + if err != nil { + return mcmstypes.ChainMetadata{}, fmt.Errorf("get op count for chain %d: %w", chainSelector, err) + } + + return mcmstypes.ChainMetadata{ + MCMAddress: ref.Address, + StartingOpCount: opCount, + }, nil +} + +func evmAddressRef(e cldf.Environment, chainSelector uint64, contractType cldf.ContractType, qualifier string) (datastore.AddressRef, error) { + if e.DataStore == nil { + return datastore.AddressRef{}, fmt.Errorf("datastore not available for chain %d", chainSelector) + } + + filters := []datastore.FilterFunc[datastore.AddressRefKey, datastore.AddressRef]{ + datastore.AddressRefByChainSelector(chainSelector), + datastore.AddressRefByType(datastore.ContractType(contractType)), + datastore.AddressRefByQualifier(qualifier), + } + + refs := e.DataStore.Addresses().Filter(filters...) + if len(refs) == 0 { + if qualifier != "" { + return datastore.AddressRef{}, fmt.Errorf("no addresses found for chain %d with qualifier %q", chainSelector, qualifier) + } + + return datastore.AddressRef{}, fmt.Errorf("no addresses found for chain %d", chainSelector) + } + if len(refs) > 1 { + ref := refs[0] + return datastore.AddressRef{}, fmt.Errorf( + "found more than one instance of contract %s v%s (labels=%s)", + contractType, + ref.Version.String(), + ref.Labels.String(), + ) + } + + return refs[0], nil +} + +func evmMCMSRef(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (datastore.AddressRef, error) { + contractType, err := evmMCMContractType(input.TimelockAction) + if err != nil { + return datastore.AddressRef{}, err + } + + return evmAddressRef(e, chainSelector, contractType, input.Qualifier) +} + +func evmMCMContractType(action mcmstypes.TimelockAction) (cldf.ContractType, error) { + switch action { + case mcmstypes.TimelockActionSchedule, "": + return mcmscontracts.ProposerManyChainMultisig, nil + case mcmstypes.TimelockActionCancel: + return mcmscontracts.CancellerManyChainMultisig, nil + case mcmstypes.TimelockActionBypass: + return mcmscontracts.BypasserManyChainMultisig, nil + default: + return "", fmt.Errorf("invalid timelock action %q", action) + } +} diff --git a/mcms/readers/evm_test.go b/mcms/readers/evm_test.go new file mode 100644 index 0000000..4cd623c --- /dev/null +++ b/mcms/readers/evm_test.go @@ -0,0 +1,109 @@ +package readers + +import ( + "context" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +func TestEVMReaderGetRefs(t *testing.T) { + t.Parallel() + + const selector uint64 = 90000001 + + version := semver.MustParse("1.0.0") + refs := map[datastore.ContractType]string{ + datastore.ContractType(mcmscontracts.RBACTimelock): "0x0000000000000000000000000000000000000100", + datastore.ContractType(mcmscontracts.ProposerManyChainMultisig): "0x0000000000000000000000000000000000000200", + datastore.ContractType(mcmscontracts.CancellerManyChainMultisig): "0x0000000000000000000000000000000000000300", + datastore.ContractType(mcmscontracts.BypasserManyChainMultisig): "0x0000000000000000000000000000000000000400", + datastore.ContractType(mcmscontracts.ManyChainMultisig): "0x0000000000000000000000000000000000000500", + datastore.ContractType(mcmscontracts.ProposerAccessControllerAccount): "0x0000000000000000000000000000000000000600", + } + + ds := datastore.NewMemoryDataStore() + for typ, address := range refs { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: address, + ChainSelector: selector, + Type: typ, + Version: version, + })) + } + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: "0x0000000000000000000000000000000000000aaa", + ChainSelector: selector, + Type: datastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + Version: version, + Qualifier: "qualified", + })) + + env := readerTestEnv(ds.Seal()) + reader := EVMReader{} + + gotTimelock, err := reader.GetTimelockRef(env, selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + require.Equal(t, refs[datastore.ContractType(mcmscontracts.RBACTimelock)], gotTimelock.Address) + require.Equal(t, datastore.ContractType(mcmscontracts.RBACTimelock), gotTimelock.Type) + + tests := []struct { + name string + action mcmstypes.TimelockAction + wantTyp datastore.ContractType + }{ + {name: "default action uses proposer", wantTyp: datastore.ContractType(mcmscontracts.ProposerManyChainMultisig)}, + {name: "schedule uses proposer", action: mcmstypes.TimelockActionSchedule, wantTyp: datastore.ContractType(mcmscontracts.ProposerManyChainMultisig)}, + {name: "cancel uses canceller", action: mcmstypes.TimelockActionCancel, wantTyp: datastore.ContractType(mcmscontracts.CancellerManyChainMultisig)}, + {name: "bypass uses bypasser", action: mcmstypes.TimelockActionBypass, wantTyp: datastore.ContractType(mcmscontracts.BypasserManyChainMultisig)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, refErr := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{TimelockAction: tt.action}) + require.NoError(t, refErr) + require.Equal(t, refs[tt.wantTyp], got.Address) + require.Equal(t, tt.wantTyp, got.Type) + }) + } + + gotQualified, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{Qualifier: "qualified"}) + require.NoError(t, err) + require.Equal(t, "0x0000000000000000000000000000000000000aaa", gotQualified.Address) +} + +func TestEVMReaderErrors(t *testing.T) { + t.Parallel() + + reader := EVMReader{} + + _, err := reader.GetMCMSRef(readerTestEnv(datastore.NewMemoryDataStore().Seal()), 1, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockAction("bad"), + }) + require.EqualError(t, err, `invalid timelock action "bad"`) + + _, err = reader.GetTimelockRef(cldf.Environment{GetContext: context.Background}, 1, cldf.MCMSTimelockProposalInput{}) + require.EqualError(t, err, "datastore not available for chain 1") + + _, err = reader.GetMCMSRef(readerTestEnv(datastore.NewMemoryDataStore().Seal()), 1, cldf.MCMSTimelockProposalInput{ + Qualifier: "missing", + }) + require.EqualError(t, err, `no addresses found for chain 1 with qualifier "missing"`) +} + +func readerTestEnv(ds datastore.DataStore) cldf.Environment { + return cldf.Environment{ + Logger: logger.Nop(), + ExistingAddresses: cldf.NewMemoryAddressBook(), + DataStore: ds, + GetContext: context.Background, + } +} diff --git a/mcms/readers/registry.go b/mcms/readers/registry.go new file mode 100644 index 0000000..a59b9eb --- /dev/null +++ b/mcms/readers/registry.go @@ -0,0 +1,52 @@ +package readers + +import ( + "fmt" + "sync" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +var ( + registerMu sync.Mutex + registerOnce = map[*cldf.MCMSReaderRegistry]*sync.Once{} +) + +// ReaderForFamily returns the registered MCMS reader for a chain family. +func ReaderForFamily(family string) (cldf.MCMSReader, error) { + reader, ok := cldf.GetMCMSReaderRegistry().Get(family) + if !ok { + return nil, fmt.Errorf("no MCMS reader registered for family %q", family) + } + + return reader, nil +} + +// RegisterDefault registers MCMS readers for supported chain families on reg. +// Safe to call multiple times; registration runs at most once per registry. +func RegisterDefault(reg *cldf.MCMSReaderRegistry) { + if reg == nil { + panic("register MCMS readers: registry is nil") + } + + registerMu.Lock() + once, ok := registerOnce[reg] + if !ok { + once = &sync.Once{} + registerOnce[reg] = once + } + registerMu.Unlock() + + once.Do(func() { + readers := map[string]cldf.MCMSReader{ + chain_selectors.FamilyEVM: EVMReader{}, + chain_selectors.FamilySolana: SolanaReader{}, + } + for family, reader := range readers { + if err := reg.Register(family, reader); err != nil { + panic(fmt.Sprintf("register MCMS reader for %q: %v", family, err)) + } + } + }) +} diff --git a/mcms/readers/solana.go b/mcms/readers/solana.go new file mode 100644 index 0000000..4811c02 --- /dev/null +++ b/mcms/readers/solana.go @@ -0,0 +1,168 @@ +package readers + +import ( + "fmt" + + solanago "github.com/gagliardetto/solana-go" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +// SolanaReader resolves MCMS timelock refs for Solana chains from the environment datastore. +type SolanaReader struct{} + +var _ cldf.MCMSReader = SolanaReader{} + +func (SolanaReader) GetTimelockRef(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (datastore.AddressRef, error) { + if err := requireSolanaChain(e, chainSelector); err != nil { + return datastore.AddressRef{}, err + } + + return solanaAddressRef(e, chainSelector, mcmscontracts.RBACTimelock, input.Qualifier) +} + +func (SolanaReader) GetMCMSRef(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (datastore.AddressRef, error) { + if err := requireSolanaChain(e, chainSelector); err != nil { + return datastore.AddressRef{}, err + } + + return solanaMCMSRef(e, chainSelector, input) +} + +func (SolanaReader) GetChainMetadata(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (mcmstypes.ChainMetadata, error) { + if err := requireSolanaChain(e, chainSelector); err != nil { + return mcmstypes.ChainMetadata{}, err + } + + mcmRef, err := solanaMCMSRef(e, chainSelector, input) + if err != nil { + return mcmstypes.ChainMetadata{}, err + } + + mcmProgram, mcmSeed, err := mcmssolanasdk.ParseContractAddress(mcmRef.Address) + if err != nil { + return mcmstypes.ChainMetadata{}, fmt.Errorf("parse MCMS address for chain %d: %w", chainSelector, err) + } + + proposerAccessController, err := solanaAccessControllerPubkey(e, chainSelector, mcmscontracts.ProposerAccessControllerAccount, input.Qualifier) + if err != nil { + return mcmstypes.ChainMetadata{}, err + } + cancellerAccessController, err := solanaAccessControllerPubkey(e, chainSelector, mcmscontracts.CancellerAccessControllerAccount, input.Qualifier) + if err != nil { + return mcmstypes.ChainMetadata{}, err + } + bypasserAccessController, err := solanaAccessControllerPubkey(e, chainSelector, mcmscontracts.BypasserAccessControllerAccount, input.Qualifier) + if err != nil { + return mcmstypes.ChainMetadata{}, err + } + + metadata, err := mcmssolanasdk.NewChainMetadata( + 0, + mcmProgram, + mcmSeed, + proposerAccessController, + cancellerAccessController, + bypasserAccessController, + ) + if err != nil { + return mcmstypes.ChainMetadata{}, fmt.Errorf("create chain metadata for chain %d: %w", chainSelector, err) + } + + inspector, err := cldfproposalutils.McmsInspectorForChain( + e, + chainSelector, + cldfproposalutils.WithTimelockAction(input.TimelockAction), + ) + if err != nil { + return mcmstypes.ChainMetadata{}, fmt.Errorf("build inspector for chain %d: %w", chainSelector, err) + } + + opCount, err := inspector.GetOpCount(e.GetContext(), metadata.MCMAddress) + if err != nil { + return mcmstypes.ChainMetadata{}, fmt.Errorf("get op count for chain %d: %w", chainSelector, err) + } + metadata.StartingOpCount = opCount + + return metadata, nil +} + +func requireSolanaChain(e cldf.Environment, chainSelector uint64) error { + if _, ok := e.BlockChains.SolanaChains()[chainSelector]; !ok { + return fmt.Errorf("chain %d not found", chainSelector) + } + + return nil +} + +func solanaAddressRef(e cldf.Environment, chainSelector uint64, contractType cldf.ContractType, qualifier string) (datastore.AddressRef, error) { + if e.DataStore == nil { + return datastore.AddressRef{}, fmt.Errorf("datastore not available for chain %d", chainSelector) + } + + filters := []datastore.FilterFunc[datastore.AddressRefKey, datastore.AddressRef]{ + datastore.AddressRefByChainSelector(chainSelector), + datastore.AddressRefByType(datastore.ContractType(contractType)), + datastore.AddressRefByQualifier(qualifier), + } + + refs := e.DataStore.Addresses().Filter(filters...) + if len(refs) == 0 { + if qualifier != "" { + return datastore.AddressRef{}, fmt.Errorf("no addresses found for chain %d with qualifier %q", chainSelector, qualifier) + } + + return datastore.AddressRef{}, fmt.Errorf("no addresses found for chain %d", chainSelector) + } + if len(refs) > 1 { + ref := refs[0] + return datastore.AddressRef{}, fmt.Errorf( + "found more than one instance of contract %s v%s (labels=%s)", + contractType, + ref.Version.String(), + ref.Labels.String(), + ) + } + + return refs[0], nil +} + +func solanaMCMSRef(e cldf.Environment, chainSelector uint64, input cldf.MCMSTimelockProposalInput) (datastore.AddressRef, error) { + contractType, err := solanaMCMContractType(input.TimelockAction) + if err != nil { + return datastore.AddressRef{}, err + } + + return solanaAddressRef(e, chainSelector, contractType, input.Qualifier) +} + +func solanaMCMContractType(action mcmstypes.TimelockAction) (cldf.ContractType, error) { + switch action { + case mcmstypes.TimelockActionSchedule, "": + return mcmscontracts.ProposerManyChainMultisig, nil + case mcmstypes.TimelockActionCancel: + return mcmscontracts.CancellerManyChainMultisig, nil + case mcmstypes.TimelockActionBypass: + return mcmscontracts.BypasserManyChainMultisig, nil + default: + return "", fmt.Errorf("invalid MCMS action %q", action) + } +} + +func solanaAccessControllerPubkey(e cldf.Environment, chainSelector uint64, contractType cldf.ContractType, qualifier string) (solanago.PublicKey, error) { + ref, err := solanaAddressRef(e, chainSelector, contractType, qualifier) + if err != nil { + return solanago.PublicKey{}, fmt.Errorf("resolve %s for chain %d: %w", contractType, chainSelector, err) + } + + pubkey, err := solanago.PublicKeyFromBase58(ref.Address) + if err != nil { + return solanago.PublicKey{}, fmt.Errorf("parse %s address for chain %d: %w", contractType, chainSelector, err) + } + + return pubkey, nil +} diff --git a/mcms/readers/solana_test.go b/mcms/readers/solana_test.go new file mode 100644 index 0000000..63ab2b7 --- /dev/null +++ b/mcms/readers/solana_test.go @@ -0,0 +1,139 @@ +package readers + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + solanago "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +func TestSolanaReaderGetRefs(t *testing.T) { + t.Parallel() + + const selector uint64 = 12463857294658392847 + + mcmProgram := solanago.NewWallet().PublicKey() + timelockProgram := solanago.NewWallet().PublicKey() + accessControllerProgram := solanago.NewWallet().PublicKey() + proposerSeed := testPDASeed(1) + cancellerSeed := testPDASeed(2) + bypasserSeed := testPDASeed(3) + timelockSeed := testPDASeed(4) + + ds := datastore.NewMemoryDataStore() + addSolanaRef(t, ds, mcmscontracts.ManyChainMultisigProgram, mcmProgram.String()) + addSolanaRef(t, ds, mcmscontracts.RBACTimelockProgram, timelockProgram.String()) + addSolanaRef(t, ds, mcmscontracts.AccessControllerProgram, accessControllerProgram.String()) + addSolanaRef(t, ds, mcmscontracts.ProposerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, proposerSeed)) + addSolanaRef(t, ds, mcmscontracts.CancellerManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, cancellerSeed)) + addSolanaRef(t, ds, mcmscontracts.BypasserManyChainMultisig, mcmssolana.ContractAddress(mcmProgram, bypasserSeed)) + addSolanaRef(t, ds, mcmscontracts.RBACTimelock, mcmssolana.ContractAddress(timelockProgram, timelockSeed)) + addSolanaRef(t, ds, mcmscontracts.ProposerAccessControllerAccount, solanago.NewWallet().PublicKey().String()) + addSolanaRef(t, ds, mcmscontracts.ExecutorAccessControllerAccount, solanago.NewWallet().PublicKey().String()) + addSolanaRef(t, ds, mcmscontracts.CancellerAccessControllerAccount, solanago.NewWallet().PublicKey().String()) + addSolanaRef(t, ds, mcmscontracts.BypasserAccessControllerAccount, solanago.NewWallet().PublicKey().String()) + + env := readerTestEnv(ds.Seal()) + env.BlockChains = chain.NewBlockChains(map[uint64]chain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }) + + reader := SolanaReader{} + gotTimelock, err := reader.GetTimelockRef(env, selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + require.Equal(t, mcmssolana.ContractAddress(timelockProgram, timelockSeed), gotTimelock.Address) + require.Equal(t, datastore.ContractType(mcmscontracts.RBACTimelock), gotTimelock.Type) + + tests := []struct { + name string + action mcmstypes.TimelockAction + want string + wantTyp datastore.ContractType + }{ + { + name: "default action uses proposer", + want: mcmssolana.ContractAddress(mcmProgram, proposerSeed), + wantTyp: datastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + }, + { + name: "schedule uses proposer", + action: mcmstypes.TimelockActionSchedule, + want: mcmssolana.ContractAddress(mcmProgram, proposerSeed), + wantTyp: datastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + }, + { + name: "cancel uses canceller", + action: mcmstypes.TimelockActionCancel, + want: mcmssolana.ContractAddress(mcmProgram, cancellerSeed), + wantTyp: datastore.ContractType(mcmscontracts.CancellerManyChainMultisig), + }, + { + name: "bypass uses bypasser", + action: mcmstypes.TimelockActionBypass, + want: mcmssolana.ContractAddress(mcmProgram, bypasserSeed), + wantTyp: datastore.ContractType(mcmscontracts.BypasserManyChainMultisig), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, refErr := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{TimelockAction: tt.action}) + require.NoError(t, refErr) + require.Equal(t, tt.want, got.Address) + require.Equal(t, tt.wantTyp, got.Type) + }) + } +} + +func TestSolanaReaderErrors(t *testing.T) { + t.Parallel() + + const selector uint64 = 12463857294658392847 + + reader := SolanaReader{} + + _, err := reader.GetMCMSRef(readerTestEnv(datastore.NewMemoryDataStore().Seal()), 1, cldf.MCMSTimelockProposalInput{}) + require.EqualError(t, err, "chain 1 not found") + + ds := datastore.NewMemoryDataStore() + env := readerTestEnv(ds.Seal()) + env.BlockChains = chain.NewBlockChains(map[uint64]chain.BlockChain{ + selector: cldfsol.Chain{Selector: selector}, + }) + _, err = reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockAction("bad"), + }) + require.EqualError(t, err, `invalid MCMS action "bad"`) +} + +func addSolanaRef(t *testing.T, ds *datastore.MemoryDataStore, typ cldf.ContractType, address string) { + t.Helper() + + const selector uint64 = 12463857294658392847 + version := semver.MustParse("1.0.0") + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: address, + ChainSelector: selector, + Type: datastore.ContractType(typ), + Version: version, + })) +} + +func testPDASeed(v byte) mcmssolana.PDASeed { + var seed mcmssolana.PDASeed + for i := range seed { + seed[i] = v + } + + return seed +} diff --git a/mcms/register.go b/mcms/register.go new file mode 100644 index 0000000..b19887b --- /dev/null +++ b/mcms/register.go @@ -0,0 +1,36 @@ +package mcms + +import ( + "sync" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" + evmsetconfig "github.com/smartcontractkit/cld-changesets/mcms/evm/set-config" + mcmsreaders "github.com/smartcontractkit/cld-changesets/mcms/readers" + solsetconfig "github.com/smartcontractkit/cld-changesets/mcms/solana/set-config" +) + +var registerDefaultsOnce sync.Once + +func init() { + // Runs when any code imports github.com/smartcontractkit/cld-changesets/mcms. + setconfig.SetDefaultsRegistrar(registerBuiltins) + registerBuiltins() +} + +func registerBuiltins() { + registerDefaultsOnce.Do(func() { + setconfig.RegisterAll( + evmsetconfig.EVMRegistration(), + solsetconfig.SolanaRegistration(), + ) + mcmsreaders.RegisterDefault(cldf.GetMCMSReaderRegistry()) + }) +} + +// RegisterDefaults registers built-in EVM and Solana set-config sequences and MCMS readers. +// mcms init already calls registerBuiltins(); this is the same call for explicit use (idempotent). +func RegisterDefaults() { + registerBuiltins() +} diff --git a/mcms/setconfig.go b/mcms/setconfig.go new file mode 100644 index 0000000..f8b4ce6 --- /dev/null +++ b/mcms/setconfig.go @@ -0,0 +1,9 @@ +package mcms + +import setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" + +// RegisterSetConfigFamily registers an additional chain-family set-config implementation. +// Can be used to add implementations from other repos outside cld-changesets. +func RegisterSetConfigFamily(reg setconfig.Registration) { + setconfig.Register(reg) +} diff --git a/mcms/solana/set-config/operation.go b/mcms/solana/set-config/operation.go new file mode 100644 index 0000000..9217a65 --- /dev/null +++ b/mcms/solana/set-config/operation.go @@ -0,0 +1,91 @@ +package solsetconfig + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + solanasdk "github.com/gagliardetto/solana-go" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// MCMSetConfigTarget identifies one MCM account and the config to apply. +type MCMSetConfigTarget struct { + Address string `json:"address"` + Config mcmstypes.Config `json:"config"` + ContractType string `json:"contractType"` +} + +// OpSolanaSetConfigInput is the input for setting config on a single Solana MCM account. +type OpSolanaSetConfigInput struct { + Target MCMSetConfigTarget `json:"target"` + NoSend bool `json:"noSend"` + AuthorityAccount solanasdk.PublicKey `json:"authorityAccount"` +} + +// OpSolanaSetConfigOutput is the output of a Solana set-config operation. +type OpSolanaSetConfigOutput struct { + Confirmed bool `json:"confirmed"` + BatchOperation mcmstypes.BatchOperation `json:"batchOperation"` +} + +// OpSolanaSetConfigMCM sets MCMS config on a Solana MCM account. +var OpSolanaSetConfigMCM = operations.NewOperation( + "solana-mcm-set-config", + semver.MustParse("1.0.0"), + "Sets MCMS config on a Solana MCM account", + func(b operations.Bundle, deps cldfsol.Chain, in OpSolanaSetConfigInput) (OpSolanaSetConfigOutput, error) { + var configurer *mcmssolana.Configurer + + if in.NoSend { + configurer = mcmssolana.NewConfigurer( + deps.Client, + *deps.DeployerKey, + mcmstypes.ChainSelector(deps.Selector), + mcmssolana.WithDoNotSendInstructionsOnChain(), + mcmssolana.WithAuthorityAccount(in.AuthorityAccount), + ) + } else { + configurer = mcmssolana.NewConfigurer( + deps.Client, + *deps.DeployerKey, + mcmstypes.ChainSelector(deps.Selector), + ) + } + + res, err := configurer.SetConfig(b.GetContext(), in.Target.Address, &in.Target.Config, false) + if err != nil { + return OpSolanaSetConfigOutput{}, fmt.Errorf("failed to set config on %s: %w", in.Target.Address, err) + } + + if in.NoSend { + instructions, ok := res.RawData.([]solanasdk.Instruction) + if !ok { + return OpSolanaSetConfigOutput{}, fmt.Errorf("unexpected raw data type %T from SetConfig", res.RawData) + } + + txs := make([]mcmstypes.Transaction, 0, len(instructions)) + for _, ix := range instructions { + tx, txErr := mcmssolana.NewTransactionFromInstruction(ix, in.Target.ContractType, []string{}) + if txErr != nil { + return OpSolanaSetConfigOutput{}, txErr + } + txs = append(txs, tx) + } + + return OpSolanaSetConfigOutput{ + BatchOperation: mcmstypes.BatchOperation{ + ChainSelector: mcmstypes.ChainSelector(deps.Selector), + Transactions: txs, + }, + }, nil + } + + b.Logger.Infow("SetConfig tx confirmed", "txHash", res.Hash, "address", in.Target.Address) + + return OpSolanaSetConfigOutput{Confirmed: true}, nil + }, +) diff --git a/mcms/solana/set-config/operation_test.go b/mcms/solana/set-config/operation_test.go new file mode 100644 index 0000000..9e22624 --- /dev/null +++ b/mcms/solana/set-config/operation_test.go @@ -0,0 +1,81 @@ +package solsetconfig + +import ( + "crypto/ecdsa" + "testing" + + "github.com/ethereum/go-ethereum/common" + solanago "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +func TestOpSolanaSetConfigMCM(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + noSend bool + }{ + {name: "direct send", noSend: false}, + {name: "MCMS proposal", noSend: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + rt := newSolanaSetConfigRuntime(t, selector) + chain := rt.Environment().BlockChains.SolanaChains()[selector] + refs := solanaSetConfigRefs(t, rt.Environment(), selector) + fundSolanaSignerPDAs(t, chain, refs) + + authorityAccount := solanago.PublicKey{} + if tt.noSend { + transferSolanaMCMSToTimelock(t, rt, selector) + fundSolanaSignerPDAs(t, chain, refs) + authorityAccount = refs.TimelockSigner + } + + cfg := cldftesthelpers.SingleGroupMCMS(t) + cfg.Signers = append(cfg.Signers, common.HexToAddress("0x0000000000000000000000000000000000000909")) + cfg.Quorum = 2 + + report, err := operations.ExecuteOperation( + rt.Environment().OperationsBundle, + OpSolanaSetConfigMCM, + chain, + OpSolanaSetConfigInput{ + Target: MCMSetConfigTarget{ + Address: refs.Canceller, + Config: cfg, + ContractType: string(mcmscontracts.CancellerManyChainMultisig), + }, + NoSend: tt.noSend, + AuthorityAccount: authorityAccount, + }, + ) + require.NoError(t, err) + require.Equal(t, !tt.noSend, report.Output.Confirmed) + + if tt.noSend { + require.Equal(t, mcmstypes.ChainSelector(selector), report.Output.BatchOperation.ChainSelector) + require.NotEmpty(t, report.Output.BatchOperation.Transactions) + require.NoError(t, rt.Exec( + newTimelockProposalTask([]mcmstypes.BatchOperation{report.Output.BatchOperation}, "solana set config operation test"), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + } + + assertSolanaConfigEquals(t, mcmssolana.NewInspector(chain.Client), refs.Canceller, cfg) + }) + } +} diff --git a/mcms/solana/set-config/register.go b/mcms/solana/set-config/register.go new file mode 100644 index 0000000..ce0e685 --- /dev/null +++ b/mcms/solana/set-config/register.go @@ -0,0 +1,15 @@ +package solsetconfig + +import ( + chainselectors "github.com/smartcontractkit/chain-selectors" + + setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" +) + +// SolanaRegistration returns the Solana chain-family set-config registration. +func SolanaRegistration() setconfig.Registration { + return setconfig.Registration{ + Family: chainselectors.FamilySolana, + Sequence: SetConfigSequence{}, + } +} diff --git a/mcms/solana/set-config/sequence.go b/mcms/solana/set-config/sequence.go new file mode 100644 index 0000000..9c933ba --- /dev/null +++ b/mcms/solana/set-config/sequence.go @@ -0,0 +1,152 @@ +package solsetconfig + +import ( + "fmt" + + solanago "github.com/gagliardetto/solana-go" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + setconfig "github.com/smartcontractkit/cld-changesets/mcms/changesets/set-config" + mcmsreaders "github.com/smartcontractkit/cld-changesets/mcms/readers" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" +) + +// SeqSolanaSetConfigInput is the input for the generic Solana set-config sequence. +type SeqSolanaSetConfigInput struct { + ChainSelector uint64 `json:"chainSelector"` + NoSend bool `json:"noSend"` + AuthorityAccount solanago.PublicKey `json:"authorityAccount"` + Targets []MCMSetConfigTarget `json:"targets"` +} + +// SeqSolanaSetConfig sets config on each provided Solana MCM account via OpSolanaSetConfigMCM. +// When NoSend is true, one batch operation is returned per target to respect Solana transaction size limits. +var SeqSolanaSetConfig = operations.NewSequence( + "seq-solana-mcm-set-config", + &semvers.V1_0_0, + "Sets MCMS config on one or more Solana MCM accounts", + func(b operations.Bundle, deps cldfsol.Chain, in SeqSolanaSetConfigInput) (sequenceutils.OnChainOutput, error) { + batchOps := make([]mcmstypes.BatchOperation, 0, len(in.Targets)) + + for _, target := range in.Targets { + opReport, err := operations.ExecuteOperation( + b, + OpSolanaSetConfigMCM, + deps, + OpSolanaSetConfigInput{ + Target: target, + NoSend: in.NoSend, + AuthorityAccount: in.AuthorityAccount, + }, + ) + if err != nil { + return sequenceutils.OnChainOutput{}, err + } + + if in.NoSend { + batchOps = append(batchOps, opReport.Output.BatchOperation) + } + } + + return sequenceutils.OnChainOutput{BatchOps: batchOps}, nil + }, +) + +// SetConfigSequence runs SeqSolanaSetConfig for Solana chains. +type SetConfigSequence struct{} + +var _ setconfig.Sequence = SetConfigSequence{} + +func (SetConfigSequence) Verify(e cldf.Environment, in setconfig.ChainInput) error { + if in.MCMS == nil { + return nil + } + + return setconfig.ValidateMCMSIfPresent(e, chainselectors.FamilySolana, in) +} + +func (SetConfigSequence) Run(e cldf.Environment, in setconfig.ChainInput) (setconfig.ChainOutput, error) { + chain, ok := e.BlockChains.SolanaChains()[in.ChainSelector] + if !ok { + return setconfig.ChainOutput{}, fmt.Errorf("chain %d not found in environment", in.ChainSelector) + } + useMCMS := in.MCMS != nil + authorityAccount := solanago.PublicKey{} + if useMCMS { + if err := setconfig.ValidateMCMSIfPresent(e, chainselectors.FamilySolana, in); err != nil { + return setconfig.ChainOutput{}, err + } + + reader, err := mcmsreaders.ReaderForFamily(chainselectors.FamilySolana) + if err != nil { + return setconfig.ChainOutput{}, err + } + + timelockRef, err := reader.GetTimelockRef(e, in.ChainSelector, *in.MCMS) + if err != nil { + return setconfig.ChainOutput{}, fmt.Errorf("resolve timelock ref for chain %d: %w", in.ChainSelector, err) + } + + timelockProgram, timelockSeed, err := mcmssolanasdk.ParseContractAddress(timelockRef.Address) + if err != nil { + return setconfig.ChainOutput{}, fmt.Errorf("parse timelock ref address for chain %d: %w", in.ChainSelector, err) + } + + var seed legacysolana.PDASeed + copy(seed[:], timelockSeed[:]) + authorityAccount = familysolana.GetTimelockSignerPDA(timelockProgram, seed) + } + + targets, err := setConfigTargets(e, in.Targets) + if err != nil { + return setconfig.ChainOutput{}, err + } + + seqReport, err := operations.ExecuteSequence( + e.OperationsBundle, + SeqSolanaSetConfig, + chain, + SeqSolanaSetConfigInput{ + ChainSelector: in.ChainSelector, + NoSend: useMCMS, + AuthorityAccount: authorityAccount, + Targets: targets, + }, + ) + out := setconfig.ChainOutput{ + Reports: seqReport.ExecutionReports, + Output: seqReport.Output, + } + if err != nil { + return out, fmt.Errorf("failed to execute Solana set config sequence: %w", err) + } + + return out, nil +} + +func setConfigTargets(e cldf.Environment, configs []setconfig.ContractSetConfig) ([]MCMSetConfigTarget, error) { + targets := make([]MCMSetConfigTarget, 0, len(configs)) + + for i, cfg := range configs { + ref, err := cfg.Ref.Resolve(e) + if err != nil { + return nil, fmt.Errorf("targets[%d]: %w", i, err) + } + + targets = append(targets, MCMSetConfigTarget{ + Address: ref.Address, + Config: cfg.Config, + ContractType: string(ref.Type), + }) + } + + return targets, nil +} diff --git a/mcms/solana/set-config/sequence_test.go b/mcms/solana/set-config/sequence_test.go new file mode 100644 index 0000000..c69c454 --- /dev/null +++ b/mcms/solana/set-config/sequence_test.go @@ -0,0 +1,278 @@ +package solsetconfig + +import ( + "crypto/ecdsa" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + solanago "github.com/gagliardetto/solana-go" + "github.com/segmentio/ksuid" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + legacymcms "github.com/smartcontractkit/cld-changesets/legacy/mcms/changesets" + solchangesets "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/changesets" + "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" + soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils" + mcmsreaders "github.com/smartcontractkit/cld-changesets/mcms/readers" +) + +func TestSeqSolanaSetConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + noSend bool + }{ + {name: "direct send", noSend: false}, + {name: "MCMS proposal", noSend: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + rt := newSolanaSetConfigRuntime(t, selector) + chain := rt.Environment().BlockChains.SolanaChains()[selector] + refs := solanaSetConfigRefs(t, rt.Environment(), selector) + fundSolanaSignerPDAs(t, chain, refs) + + authorityAccount := solanago.PublicKey{} + if tt.noSend { + transferSolanaMCMSToTimelock(t, rt, selector) + fundSolanaSignerPDAs(t, chain, refs) + authorityAccount = refs.TimelockSigner + } + + proposerCfg := cldftesthelpers.SingleGroupMCMS(t) + proposerCfg.Signers = append(proposerCfg.Signers, common.HexToAddress("0x0000000000000000000000000000000000000101")) + proposerCfg.Quorum = 2 + + cancellerCfg := cldftesthelpers.SingleGroupMCMS(t) + cancellerCfg.Signers = append(cancellerCfg.Signers, common.HexToAddress("0x0000000000000000000000000000000000000202")) + cancellerCfg.Quorum = 2 + + bypasserCfg := cldftesthelpers.SingleGroupMCMS(t) + bypasserCfg.Signers = append(bypasserCfg.Signers, common.HexToAddress("0x0000000000000000000000000000000000000303")) + bypasserCfg.Quorum = 2 + + targets := []MCMSetConfigTarget{ + { + Address: refs.Proposer, + Config: proposerCfg, + ContractType: string(mcmscontracts.ProposerManyChainMultisig), + }, + { + Address: refs.Canceller, + Config: cancellerCfg, + ContractType: string(mcmscontracts.CancellerManyChainMultisig), + }, + } + if tt.noSend { + targets = []MCMSetConfigTarget{ + { + Address: refs.Canceller, + Config: cancellerCfg, + ContractType: string(mcmscontracts.CancellerManyChainMultisig), + }, + } + } + + report, err := operations.ExecuteSequence( + rt.Environment().OperationsBundle, + SeqSolanaSetConfig, + chain, + SeqSolanaSetConfigInput{ + ChainSelector: selector, + NoSend: tt.noSend, + AuthorityAccount: authorityAccount, + Targets: targets, + }, + ) + require.NoError(t, err) + + if tt.noSend { + require.Len(t, report.Output.BatchOps, 1) + require.NotEmpty(t, report.Output.BatchOps[0].Transactions) + require.NoError(t, rt.Exec( + newTimelockProposalTask(report.Output.BatchOps, "solana set config sequence test"), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + } else { + require.Empty(t, report.Output.BatchOps) + } + + inspector := mcmssolana.NewInspector(chain.Client) + if tt.noSend { + assertSolanaConfigEquals(t, inspector, refs.Canceller, cancellerCfg) + } else { + assertSolanaConfigEquals(t, inspector, refs.Proposer, proposerCfg) + assertSolanaConfigEquals(t, inspector, refs.Canceller, cancellerCfg) + } + }) + } +} + +func newSolanaSetConfigRuntime(t *testing.T, selector uint64) *runtime.Runtime { + t.Helper() + + programsPath, programIDs, ab := soltestutils.PreloadMCMS(t, selector) + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithSolanaContainer(t, []uint64{selector}, programsPath, programIDs), + environment.WithAddressBook(ab), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + err = rt.Exec( + runtime.ChangesetTask(cldf.CreateLegacyChangeSet(legacymcms.DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cldftesthelpers.SingleGroupTimelockConfig(t), + }), + ) + require.NoError(t, err) + + return rt +} + +type solanaMCMSRefs struct { + Timelock string + Proposer string + Canceller string + Bypasser string + TimelockSigner solanago.PublicKey + ProposerSigner solanago.PublicKey + CancellerSigner solanago.PublicKey + BypasserSigner solanago.PublicKey +} + +func solanaSetConfigRefs(t *testing.T, env cldf.Environment, selector uint64) solanaMCMSRefs { + t.Helper() + + reader := mcmsreaders.SolanaReader{} + timelock, err := reader.GetTimelockRef(env, selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + proposer, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + }) + require.NoError(t, err) + canceller, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionCancel, + }) + require.NoError(t, err) + bypasser, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + }) + require.NoError(t, err) + + timelockProgram, timelockSeed, err := mcmssolana.ParseContractAddress(timelock.Address) + require.NoError(t, err) + timelockSigner, err := mcmssolana.FindTimelockSignerPDA(timelockProgram, timelockSeed) + require.NoError(t, err) + + return solanaMCMSRefs{ + Timelock: timelock.Address, + Proposer: proposer.Address, + Canceller: canceller.Address, + Bypasser: bypasser.Address, + TimelockSigner: timelockSigner, + ProposerSigner: solanaMCMSSignerPDA(t, proposer.Address), + CancellerSigner: solanaMCMSSignerPDA(t, canceller.Address), + BypasserSigner: solanaMCMSSignerPDA(t, bypasser.Address), + } +} + +func solanaMCMSSignerPDA(t *testing.T, address string) solanago.PublicKey { + t.Helper() + + program, seed, err := mcmssolana.ParseContractAddress(address) + require.NoError(t, err) + signer, err := mcmssolana.FindSignerPDA(program, seed) + require.NoError(t, err) + + return signer +} + +func fundSolanaSignerPDAs(t *testing.T, chain cldfsol.Chain, refs solanaMCMSRefs) { + t.Helper() + + err := solutils.FundAccounts(t.Context(), chain.Client, []solanago.PublicKey{ + refs.TimelockSigner, + refs.ProposerSigner, + refs.CancellerSigner, + refs.BypasserSigner, + }, 1) + require.NoError(t, err) +} + +func transferSolanaMCMSToTimelock(t *testing.T, rt *runtime.Runtime, selector uint64) { + t.Helper() + + err := rt.Exec( + runtime.ChangesetTask(solchangesets.TransferMCMSToTimelockSolana{}, solchangesets.TransferMCMSToTimelockSolanaConfig{ + Chains: []uint64{selector}, + MCMSCfg: cldfproposalutils.TimelockConfig{MinDelay: time.Second}, + }), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + ) + require.NoError(t, err) +} + +type timelockProposalTask struct { + id string + batchOps []mcmstypes.BatchOperation + description string +} + +func newTimelockProposalTask(batchOps []mcmstypes.BatchOperation, description string) timelockProposalTask { + return timelockProposalTask{ + id: ksuid.New().String(), + batchOps: batchOps, + description: description, + } +} + +func (t timelockProposalTask) ID() string { + return t.id +} + +func (t timelockProposalTask) Run(e cldf.Environment, state *runtime.State) error { + mcmsreaders.RegisterDefault(cldf.GetMCMSReaderRegistry()) + + out, err := cldf.NewOutputBuilder(e, datastore.NewMemoryDataStore()). + WithTimelockProposal(cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + ValidUntil: uint32(time.Now().Add(2 * time.Hour).UTC().Unix()), //nolint:gosec // test timestamp + TimelockDelay: mcmstypes.NewDuration(time.Second), + Description: t.description, + }, t.batchOps). + Build() + if err != nil { + return err + } + + return state.MergeChangesetOutput(t.id, out) +} + +func assertSolanaConfigEquals(t *testing.T, inspector *mcmssolana.Inspector, address string, want mcmstypes.Config) { + t.Helper() + + got, err := inspector.GetConfig(t.Context(), address) + require.NoError(t, err) + require.ElementsMatch(t, want.Signers, got.Signers) + require.Equal(t, want.Quorum, got.Quorum) +}