diff --git a/deployment/data-feeds/changeset/stellar/chain_util.go b/deployment/data-feeds/changeset/stellar/chain_util.go new file mode 100644 index 00000000000..59cc456347f --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/chain_util.go @@ -0,0 +1,159 @@ +package stellar + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/stellar/go-stellar-sdk/xdr" + + cldfstellar "github.com/smartcontractkit/chainlink-deployments-framework/chain/stellar" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-stellar/bindings" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" + proxy "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_proxy" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" +) + +type StellarDeps struct { + Deploy SorobanContractDeployer + Invoker bindings.Invoker +} + +type SorobanContractDeployer interface { + DeployContractWithArgs(ctx context.Context, wasmPath string, salt [32]byte, ctorArgs []xdr.ScVal) (string, error) + UploadContractWASM(ctx context.Context, wasmPath string) (xdr.Hash, error) +} + +type void struct{} + +var opVersion = semver.MustParse("1.0.0") + +// generated cache and proxy clients. +type contractAdmin interface { + TransferOwnership(ctx context.Context, newOwner string, liveUntilLedger uint32) error + AcceptOwnership(ctx context.Context) error + Upgrade(ctx context.Context, newWasmHash [32]byte) error + RecoverTokens(ctx context.Context, token, to string, amount int64) error +} + +func adminClient(d StellarDeps, contractID string, isProxy bool) contractAdmin { + if isProxy { + return proxy.NewDataFeedsProxyClient(d.Invoker, contractID) + } + return cache.NewDataFeedsCacheClient(d.Invoker, contractID) +} + +var newStellarDeps = func(ch cldfstellar.Chain) (StellarDeps, error) { + d, err := stellardeploy.NewDeployerFromChain(ch) + if err != nil { + return StellarDeps{}, err + } + return StellarDeps{Deploy: d, Invoker: d}, nil +} + +func chainDeps(env cldf.Environment, chainSel uint64) (cldfstellar.Chain, StellarDeps, error) { + ch, ok := env.BlockChains.StellarChains()[chainSel] + if !ok { + return cldfstellar.Chain{}, StellarDeps{}, fmt.Errorf("stellar chain not found for chain selector %d", chainSel) + } + deps, err := newStellarDeps(ch) + return ch, deps, err +} + +func ownerOrSigner(ch cldfstellar.Chain, owner string) (string, error) { + if owner != "" { + return owner, nil + } + if ch.Signer == nil { + return "", errors.New("owner not set and chain has no signer") + } + return ch.Signer.Address(), nil +} + +type stellarApplyDeps struct { + deps StellarDeps + contractID string +} + +func verifyContractRef(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, qualifier, version string) error { + if _, ok := env.BlockChains.StellarChains()[chainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", chainSel) + } + _, err := getAddressRef(env, chainSel, contractType, qualifier, version) + return err +} + +func getAddressRef(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, qualifier, version string) (datastore.AddressRef, error) { + v, err := semver.NewVersion(version) + if err != nil { + return datastore.AddressRef{}, fmt.Errorf("invalid version %q: %w", version, err) + } + ref, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(chainSel, contractType, v, qualifier), + ) + if err != nil { + return datastore.AddressRef{}, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + } + return ref, nil +} + +func resolveContractDeps(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, qualifier, version string) (stellarApplyDeps, datastore.AddressRef, error) { + _, deps, err := chainDeps(env, chainSel) + if err != nil { + return stellarApplyDeps{}, datastore.AddressRef{}, err + } + ref, err := getAddressRef(env, chainSel, contractType, qualifier, version) + if err != nil { + return stellarApplyDeps{}, datastore.AddressRef{}, err + } + return stellarApplyDeps{deps: deps, contractID: ref.Address}, ref, nil +} + +// dataIDsToBytes converts hex feed IDs to [16]byte. Data IDs are canonically +// left-aligned, so short values are left-justified with trailing zero padding. +func dataIDsToBytes(ids []string) ([][16]byte, error) { + out := make([][16]byte, 0, len(ids)) + for _, id := range ids { + v, ok := new(big.Int).SetString(id, 0) + if !ok { + return nil, fmt.Errorf("invalid data_id: %q", id) + } + if v.BitLen() > 128 { + return nil, fmt.Errorf("data_id too long: %q (%d bits)", id, v.BitLen()) + } + var b [16]byte + copy(b[:], v.Bytes()) + out = append(out, b) + } + return out, nil +} + +// workflowNameToBytes right-pads an ASCII workflow name into [10]byte. +func workflowNameToBytes(s string) ([10]byte, error) { + var out [10]byte + if len(s) > len(out) { + return out, fmt.Errorf("workflow name %q exceeds %d bytes", s, len(out)) + } + copy(out[:], s) + return out, nil +} + +// workflowOwnerToBytes decodes a 20-byte hex workflow owner. +func workflowOwnerToBytes(hexStr string) ([20]byte, error) { + var out [20]byte + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return out, fmt.Errorf("invalid workflow owner %q: %w", hexStr, err) + } + if len(b) != len(out) { + return out, fmt.Errorf("workflow owner must be %d bytes, got %d", len(out), len(b)) + } + copy(out[:], b) + return out, nil +} diff --git a/deployment/data-feeds/changeset/stellar/config.go b/deployment/data-feeds/changeset/stellar/config.go new file mode 100644 index 00000000000..b2e391ce515 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/config.go @@ -0,0 +1,9 @@ +package stellar + +import "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + +// Datastore contract types for the two DF contracts. +const ( + CacheContract datastore.ContractType = "DataFeedsCache" + ProxyContract datastore.ContractType = "DataFeedsProxy" +) diff --git a/deployment/data-feeds/changeset/stellar/deploy.go b/deployment/data-feeds/changeset/stellar/deploy.go new file mode 100644 index 00000000000..5624b8e4b30 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deploy.go @@ -0,0 +1,223 @@ +package stellar + +import ( + "errors" + "fmt" + "os" + + "github.com/Masterminds/semver/v3" + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" +) + +// DeployCacheRequest configures a DataFeedsCache deployment. +type DeployCacheRequest struct { + ChainSel uint64 + WasmPath string + Owner string // defaults to the chain's deployer address when empty + Qualifier string + Version string + LabelSet datastore.LabelSet +} + +var _ cldf.ChangeSetV2[*DeployCacheRequest] = DeployCache{} + +// DeployCache deploys the cache contract and records its address. +type DeployCache struct{} + +type deployOutput struct { + ContractID string `json:"contract_id"` +} + +type deployCacheInput struct { + WasmPath string `json:"wasm_path"` + Salt [32]byte `json:"salt"` + Owner string `json:"owner"` +} + +func (DeployCache) VerifyPreconditions(env cldf.Environment, req *DeployCacheRequest) error { + if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + if _, err := semver.NewVersion(req.Version); err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if _, err := os.Stat(req.WasmPath); err != nil { + return fmt.Errorf("wasm path: %w", err) + } + if req.Owner != "" { + if err := validateAddress(req.Owner); err != nil { + return err + } + } + return nil +} + +func (DeployCache) Apply(env cldf.Environment, req *DeployCacheRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + ch, deps, err := chainDeps(env, req.ChainSel) + if err != nil { + return out, err + } + owner, err := ownerOrSigner(ch, req.Owner) + if err != nil { + return out, err + } + + salt := stellardeploy.GenerateDeterministicSalt(owner, "data_feeds_cache-"+req.Qualifier) + report, err := operations.ExecuteOperation(env.OperationsBundle, deployCacheOp, deps, deployCacheInput{ + WasmPath: req.WasmPath, + Salt: salt, + Owner: owner, + }) + if err != nil { + return out, err + } + return recordAddress(report.Output.ContractID, req.ChainSel, CacheContract, req.Qualifier, req.Version, req.LabelSet) +} + +// The cache constructor takes only the owner; the data-retention TTL is an +// on-chain constant. +func recordAddress(address string, chainSel uint64, contractType datastore.ContractType, qualifier, version string, labels datastore.LabelSet) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + v, err := semver.NewVersion(version) + if err != nil { + return out, fmt.Errorf("invalid version %q: %w", version, err) + } + out.DataStore = datastore.NewMemoryDataStore() + return out, out.DataStore.Addresses().Add(datastore.AddressRef{ + Address: address, + ChainSelector: chainSel, + Type: contractType, + Version: v, + Qualifier: qualifier, + Labels: labels, + }) +} + +var deployCacheOp = operations.NewOperation( + "df-cache:deploy", opVersion, + "Deploys the DataFeedsCache Soroban contract", + func(b operations.Bundle, d StellarDeps, in deployCacheInput) (deployOutput, error) { + args := []xdr.ScVal{ + scval.AddressToScVal(in.Owner), + } + cid, err := d.Deploy.DeployContractWithArgs(b.GetContext(), in.WasmPath, in.Salt, args) + if err != nil { + return deployOutput{}, err + } + return deployOutput{ContractID: cid}, nil + }, +) + +// DeployProxyRequest configures a DataFeedsProxy deployment. The cache is +// resolved from the datastore by CacheQualifier and must already be recorded. +type DeployProxyRequest struct { + ChainSel uint64 + WasmPath string + Owner string // defaults to the chain's deployer address when empty + CacheQualifier string + CacheVersion string // cache's datastore version; defaults to Version + Qualifier string + Version string + LabelSet datastore.LabelSet +} + +// cacheVersion defaults an empty CacheVersion to Version: cache and proxy +// usually share a release. +func (req *DeployProxyRequest) cacheVersion() string { + if req.CacheVersion != "" { + return req.CacheVersion + } + return req.Version +} + +var _ cldf.ChangeSetV2[*DeployProxyRequest] = DeployProxy{} + +// DeployProxy deploys the proxy contract and records its address. +type DeployProxy struct{} + +type deployProxyInput struct { + WasmPath string `json:"wasm_path"` + Salt [32]byte `json:"salt"` + Owner string `json:"owner"` + Cache string `json:"cache"` +} + +func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyRequest) error { + if _, err := semver.NewVersion(req.Version); err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.cacheVersion()); err != nil { + return err + } + if _, err := os.Stat(req.WasmPath); err != nil { + return fmt.Errorf("wasm path: %w", err) + } + if req.Owner != "" { + if err := validateAddress(req.Owner); err != nil { + return err + } + } + if req.CacheQualifier == "" { + return errors.New("cache qualifier must be set") + } + return nil +} + +func (DeployProxy) Apply(env cldf.Environment, req *DeployProxyRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + ch, deps, err := chainDeps(env, req.ChainSel) + if err != nil { + return out, err + } + cacheRef, err := getAddressRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.cacheVersion()) + if err != nil { + return out, err + } + owner, err := ownerOrSigner(ch, req.Owner) + if err != nil { + return out, err + } + + salt := stellardeploy.GenerateDeterministicSalt(owner, "data_feeds_proxy-"+req.Qualifier) + report, err := operations.ExecuteOperation(env.OperationsBundle, deployProxyOp, deps, deployProxyInput{ + WasmPath: req.WasmPath, + Salt: salt, + Owner: owner, + Cache: cacheRef.Address, + }) + if err != nil { + return out, err + } + out, err = recordAddress(report.Output.ContractID, req.ChainSel, ProxyContract, req.Qualifier, req.Version, req.LabelSet) + if err != nil { + return out, err + } + return out, out.DataStore.ContractMetadata().Upsert(datastore.ContractMetadata{ + ChainSelector: req.ChainSel, + Address: report.Output.ContractID, + Metadata: ContractMetadata{Cache: cacheRef.Address}, + }) +} + +var deployProxyOp = operations.NewOperation( + "df-proxy:deploy", opVersion, + "Deploys the DataFeedsProxy Soroban contract", + func(b operations.Bundle, d StellarDeps, in deployProxyInput) (deployOutput, error) { + args := []xdr.ScVal{ + scval.AddressToScVal(in.Owner), + scval.AddressToScVal(in.Cache), + } + cid, err := d.Deploy.DeployContractWithArgs(b.GetContext(), in.WasmPath, in.Salt, args) + if err != nil { + return deployOutput{}, err + } + return deployOutput{ContractID: cid}, nil + }, +) diff --git a/deployment/data-feeds/changeset/stellar/deploy_test.go b/deployment/data-feeds/changeset/stellar/deploy_test.go new file mode 100644 index 00000000000..e7d5e04bd4a --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deploy_test.go @@ -0,0 +1,157 @@ +package stellar + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" +) + +func TestDeployCacheChangeset(t *testing.T) { + env, _, dep := newTestEnv(t) + + req := &DeployCacheRequest{ + ChainSel: testChainSel, + WasmPath: writeDummyWasm(t, "data_feeds_cache.wasm"), + Qualifier: "test-cache", + Version: "1.0.0", + } + + require.NoError(t, DeployCache{}.VerifyPreconditions(env, req)) + + out, err := DeployCache{}.Apply(env, req) + require.NoError(t, err) + require.NotNil(t, out.DataStore) + + require.Len(t, dep.deploys, 1) + require.Len(t, dep.deploys[0].Args, 1) // owner ctor arg + + owner := env.BlockChains.StellarChains()[testChainSel].Signer.Address() + wantSalt := stellardeploy.GenerateDeterministicSalt(owner, "data_feeds_cache-"+req.Qualifier) + require.Equal(t, wantSalt, dep.deploys[0].Salt) + + key := datastore.NewAddressRefKey(testChainSel, CacheContract, semver.MustParse("1.0.0"), "test-cache") + ref, err := out.DataStore.Addresses().Get(key) + require.NoError(t, err) + require.Equal(t, testContractID, ref.Address) + + // invalid explicit Owner must fail preconditions + badOwner := *req + badOwner.Owner = "not-a-key" + require.Error(t, DeployCache{}.VerifyPreconditions(env, &badOwner)) + + // unknown chain must fail preconditions + badChain := *req + badChain.ChainSel = 999999 + require.Error(t, DeployCache{}.VerifyPreconditions(env, &badChain)) + + // invalid version must fail preconditions + badVersion := *req + badVersion.Version = "not-semver" + require.Error(t, DeployCache{}.VerifyPreconditions(env, &badVersion)) + + // missing wasm file must fail preconditions + badWasm := *req + badWasm.WasmPath = "/does/not/exist.wasm" + require.Error(t, DeployCache{}.VerifyPreconditions(env, &badWasm)) +} + +// An explicit Owner seeds the deploy salt; the chain signer is only the +// fallback when Owner is empty. +func TestDeployCacheChangeset_ExplicitOwner(t *testing.T) { + env, _, dep := newTestEnv(t) + + req := &DeployCacheRequest{ + ChainSel: testChainSel, + WasmPath: writeDummyWasm(t, "data_feeds_cache.wasm"), + Owner: testAdmin, + Qualifier: "test-cache-owner", + Version: "1.0.0", + } + + require.NoError(t, DeployCache{}.VerifyPreconditions(env, req)) + + out, err := DeployCache{}.Apply(env, req) + require.NoError(t, err) + require.NotNil(t, out.DataStore) + + require.Len(t, dep.deploys, 1) + require.Len(t, dep.deploys[0].Args, 1) // owner ctor arg + + wantSalt := stellardeploy.GenerateDeterministicSalt(testAdmin, "data_feeds_cache-"+req.Qualifier) + require.Equal(t, wantSalt, dep.deploys[0].Salt) +} + +func TestDeployProxyChangeset(t *testing.T) { + env, _, dep := newTestEnv(t) + + cacheAddr := "CCACHEFAKE0000000000000000000000000000000000000000000000" + seedCacheRef(t, &env, cacheAddr, "test-cache", "1.0.0") + + req := &DeployProxyRequest{ + ChainSel: testChainSel, + WasmPath: writeDummyWasm(t, "data_feeds_proxy.wasm"), + CacheQualifier: "test-cache", + Qualifier: "test-proxy", + Version: "1.0.0", + } + + require.NoError(t, DeployProxy{}.VerifyPreconditions(env, req)) + + out, err := DeployProxy{}.Apply(env, req) + require.NoError(t, err) + require.NotNil(t, out.DataStore) + + require.Len(t, dep.deploys, 1) + require.Len(t, dep.deploys[0].Args, 2) // owner + cache ctor args + + owner := env.BlockChains.StellarChains()[testChainSel].Signer.Address() + wantSalt := stellardeploy.GenerateDeterministicSalt(owner, "data_feeds_proxy-"+req.Qualifier) + require.Equal(t, wantSalt, dep.deploys[0].Salt) + + key := datastore.NewAddressRefKey(testChainSel, ProxyContract, semver.MustParse("1.0.0"), "test-proxy") + ref, err := out.DataStore.Addresses().Get(key) + require.NoError(t, err) + require.Equal(t, testContractID, ref.Address) + + // the metadata mirror records which cache the proxy points at + meta := outputMetadata(t, out, testContractID) + require.Equal(t, cacheAddr, meta.Cache) + + // missing cache ref must fail preconditions + missing := *req + missing.CacheQualifier = "does-not-exist" + require.Error(t, DeployProxy{}.VerifyPreconditions(env, &missing)) +} + +// CacheVersion resolves a cache recorded under a different version than the +// proxy being deployed; empty CacheVersion falls back to Version (covered above). +func TestDeployProxyChangeset_CrossVersion(t *testing.T) { + env, _, _ := newTestEnv(t) + + cacheAddr := "CCACHEFAKE0000000000000000000000000000000000000000000000" + seedCacheRef(t, &env, cacheAddr, "test-cache", "1.0.0") + + req := &DeployProxyRequest{ + ChainSel: testChainSel, + WasmPath: writeDummyWasm(t, "data_feeds_proxy.wasm"), + CacheQualifier: "test-cache", + CacheVersion: "1.0.0", + Qualifier: "test-proxy", + Version: "1.1.0", + } + require.NoError(t, DeployProxy{}.VerifyPreconditions(env, req)) + + out, err := DeployProxy{}.Apply(env, req) + require.NoError(t, err) + meta := outputMetadata(t, out, testContractID) + require.Equal(t, cacheAddr, meta.Cache) + + // without CacheVersion the cache ref must not resolve at the proxy's version + noCacheVersion := *req + noCacheVersion.CacheVersion = "" + require.Error(t, DeployProxy{}.VerifyPreconditions(env, &noCacheVersion)) +} diff --git a/deployment/data-feeds/changeset/stellar/feed_admin.go b/deployment/data-feeds/changeset/stellar/feed_admin.go new file mode 100644 index 00000000000..f6d8557feb5 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/feed_admin.go @@ -0,0 +1,93 @@ +package stellar + +import ( + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" +) + +// FeedAdminRequest grants or revokes feed-admin rights on the cache. +type FeedAdminRequest struct { + ChainSel uint64 + Qualifier string + Version string + Admin string +} + +func (req *FeedAdminRequest) verifyPreconditions(env cldf.Environment) error { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.Qualifier, req.Version); err != nil { + return err + } + if err := validateAddress(req.Admin); err != nil { + return err + } + return nil +} + +var ( + _ cldf.ChangeSetV2[*FeedAdminRequest] = AddFeedAdmin{} + _ cldf.ChangeSetV2[*FeedAdminRequest] = RemoveFeedAdmin{} +) + +type feedAdminInput struct { + ContractID string `json:"contract_id"` + Admin string `json:"admin"` +} + +// AddFeedAdmin grants feed-admin rights on the cache. +type AddFeedAdmin struct{} + +func (AddFeedAdmin) VerifyPreconditions(env cldf.Environment, req *FeedAdminRequest) error { + return req.verifyPreconditions(env) +} + +func (AddFeedAdmin) Apply(env cldf.Environment, req *FeedAdminRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, addFeedAdminOp, d.deps, feedAdminInput{ + ContractID: d.contractID, + Admin: req.Admin, + }) + return out, err +} + +var addFeedAdminOp = operations.NewOperation( + "df-cache:add-feed-admin", opVersion, + "Grants feed-admin rights on the cache", + func(b operations.Bundle, d StellarDeps, in feedAdminInput) (void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return void{}, c.AddFeedAdmin(b.GetContext(), in.Admin) + }, +) + +// RemoveFeedAdmin revokes feed-admin rights on the cache. +type RemoveFeedAdmin struct{} + +func (RemoveFeedAdmin) VerifyPreconditions(env cldf.Environment, req *FeedAdminRequest) error { + return req.verifyPreconditions(env) +} + +func (RemoveFeedAdmin) Apply(env cldf.Environment, req *FeedAdminRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, removeFeedAdminOp, d.deps, feedAdminInput{ + ContractID: d.contractID, + Admin: req.Admin, + }) + return out, err +} + +var removeFeedAdminOp = operations.NewOperation( + "df-cache:remove-feed-admin", opVersion, + "Revokes feed-admin rights on the cache", + func(b operations.Bundle, d StellarDeps, in feedAdminInput) (void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return void{}, c.RemoveFeedAdmin(b.GetContext(), in.Admin) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/feed_admin_test.go b/deployment/data-feeds/changeset/stellar/feed_admin_test.go new file mode 100644 index 00000000000..d33f28a152e --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/feed_admin_test.go @@ -0,0 +1,45 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFeedAdminChangesets(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + + req := &FeedAdminRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Admin: testAdmin, + } + + require.NoError(t, AddFeedAdmin{}.VerifyPreconditions(env, req)) + _, err := AddFeedAdmin{}.Apply(env, req) + require.NoError(t, err) + require.Len(t, inv.calls, 1) + require.Equal(t, "add_feed_admin", inv.calls[0].Function) + require.Equal(t, testContractID, inv.calls[0].ContractID) + + require.NoError(t, RemoveFeedAdmin{}.VerifyPreconditions(env, req)) + _, err = RemoveFeedAdmin{}.Apply(env, req) + require.NoError(t, err) + require.Len(t, inv.calls, 2) + require.Equal(t, "remove_feed_admin", inv.calls[1].Function) + require.Equal(t, testContractID, inv.calls[1].ContractID) + + // invalid admin address must fail preconditions for both changesets + badAdmin := *req + badAdmin.Admin = "not-a-key" + require.Error(t, AddFeedAdmin{}.VerifyPreconditions(env, &badAdmin)) + require.Error(t, RemoveFeedAdmin{}.VerifyPreconditions(env, &badAdmin)) + + // missing cache ref must fail preconditions for both changesets + badQualifier := *req + badQualifier.Qualifier = "does-not-exist" + require.Error(t, AddFeedAdmin{}.VerifyPreconditions(env, &badQualifier)) + require.Error(t, RemoveFeedAdmin{}.VerifyPreconditions(env, &badQualifier)) +} diff --git a/deployment/data-feeds/changeset/stellar/metadata.go b/deployment/data-feeds/changeset/stellar/metadata.go new file mode 100644 index 00000000000..a62e6a4111d --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/metadata.go @@ -0,0 +1,63 @@ +package stellar + +import ( + "encoding/hex" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +// ContractMetadata mirrors a cache contract's feed configuration so it is +// queryable off-chain. On-chain state stays authoritative; only the feed +// config changesets mutate this. +type ContractMetadata struct { + Cache string `json:"cache,omitempty"` // proxy only: current cache target + Feeds map[string]FeedMetadata `json:"feeds,omitempty"` // cache only, keyed by 0x data id +} + +// FeedMetadata mirrors one configured feed. +type FeedMetadata struct { + Description string `json:"description"` + Decimals uint32 `json:"decimals"` + Permissions []FeedPermission `json:"permissions"` +} + +// dataIDHex canonicalizes a feed id to full-width 0x hex. +func dataIDHex(id [16]byte) string { + return "0x" + hex.EncodeToString(id[:]) +} + +// decimalsFromID mirrors the contract: byte 7 in [0x20,0x60] encodes decimals. +func decimalsFromID(id [16]byte) uint32 { + if b := id[7]; b >= 0x20 && b <= 0x60 { + return uint32(b - 0x20) + } + return 0 +} + +// metadataOutput applies mutate to the contract's latest stored metadata and +// returns a ChangesetOutput carrying the updated record. +func metadataOutput(env cldf.Environment, chainSel uint64, address string, mutate func(*ContractMetadata)) (cldf.ChangesetOutput, error) { + meta := loadMetadata(env, chainSel, address) + mutate(&meta) + var out cldf.ChangesetOutput + out.DataStore = datastore.NewMemoryDataStore() + return out, out.DataStore.ContractMetadata().Upsert(datastore.ContractMetadata{ + ChainSelector: chainSel, + Address: address, + Metadata: meta, + }) +} + +// loadMetadata returns the contract's stored metadata, or a zero value. +func loadMetadata(env cldf.Environment, chainSel uint64, address string) ContractMetadata { + rec, err := env.DataStore.ContractMetadata().Get(datastore.NewContractMetadataKey(chainSel, address)) + if err != nil { + return ContractMetadata{} + } + meta, err := datastore.As[ContractMetadata](rec.Metadata) + if err != nil { + return ContractMetadata{} + } + return meta +} diff --git a/deployment/data-feeds/changeset/stellar/ownership.go b/deployment/data-feeds/changeset/stellar/ownership.go new file mode 100644 index 00000000000..7e39f0b5ca8 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/ownership.go @@ -0,0 +1,113 @@ +package stellar + +import ( + "errors" + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// OwnershipRequest identifies the cache or proxy whose ownership changes. +type OwnershipRequest struct { + ChainSel uint64 + Qualifier string + Version string + Contract datastore.ContractType // CacheContract or ProxyContract +} + +// TransferOwnershipRequest begins a two-step ownership transfer to NewOwner. +// The pending transfer expires at LiveUntilLedger unless accepted first. +type TransferOwnershipRequest struct { + OwnershipRequest + NewOwner string + LiveUntilLedger uint32 // pending-transfer expiry ledger +} + +func (req *OwnershipRequest) verifyPreconditions(env cldf.Environment) error { + if err := validateContract(req.Contract); err != nil { + return err + } + return verifyContractRef(env, req.ChainSel, req.Contract, req.Qualifier, req.Version) +} + +var ( + _ cldf.ChangeSetV2[*TransferOwnershipRequest] = TransferOwnership{} + _ cldf.ChangeSetV2[*OwnershipRequest] = AcceptOwnership{} +) + +type ownershipInput struct { + ContractID string `json:"contract_id"` + IsProxy bool `json:"is_proxy"` + NewOwner string `json:"new_owner"` + LiveUntilLedger uint32 `json:"live_until_ledger"` +} + +// TransferOwnership begins a two-step ownership transfer. +type TransferOwnership struct{} + +func (TransferOwnership) VerifyPreconditions(env cldf.Environment, req *TransferOwnershipRequest) error { + if err := req.verifyPreconditions(env); err != nil { + return err + } + if err := validateAddress(req.NewOwner); err != nil { + return fmt.Errorf("new owner: %w", err) + } + if req.LiveUntilLedger == 0 { + return errors.New("LiveUntilLedger must be nonzero") + } + return nil +} + +func (TransferOwnership) Apply(env cldf.Environment, req *TransferOwnershipRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, req.Contract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, transferOwnershipOp, d.deps, ownershipInput{ + ContractID: d.contractID, + IsProxy: req.Contract == ProxyContract, + NewOwner: req.NewOwner, + LiveUntilLedger: req.LiveUntilLedger, + }) + return out, err +} + +var transferOwnershipOp = operations.NewOperation( + "df:transfer-ownership", opVersion, + "Begins two-step ownership transfer", + func(b operations.Bundle, d StellarDeps, in ownershipInput) (void, error) { + return void{}, adminClient(d, in.ContractID, in.IsProxy).TransferOwnership(b.GetContext(), in.NewOwner, in.LiveUntilLedger) + }, +) + +// AcceptOwnership accepts a pending ownership transfer. The transaction +// signer must be the pending owner. +type AcceptOwnership struct{} + +func (AcceptOwnership) VerifyPreconditions(env cldf.Environment, req *OwnershipRequest) error { + return req.verifyPreconditions(env) +} + +func (AcceptOwnership) Apply(env cldf.Environment, req *OwnershipRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, req.Contract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, acceptOwnershipOp, d.deps, ownershipInput{ + ContractID: d.contractID, + IsProxy: req.Contract == ProxyContract, + }) + return out, err +} + +var acceptOwnershipOp = operations.NewOperation( + "df:accept-ownership", opVersion, + "Accepts a pending ownership transfer (caller must be the pending owner)", + func(b operations.Bundle, d StellarDeps, in ownershipInput) (void, error) { + return void{}, adminClient(d, in.ContractID, in.IsProxy).AcceptOwnership(b.GetContext()) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/ownership_test.go b/deployment/data-feeds/changeset/stellar/ownership_test.go new file mode 100644 index 00000000000..279285166ad --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/ownership_test.go @@ -0,0 +1,96 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" +) + +func TestOwnershipChangesets(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedContractRefs(t, &env, + contractRefSpec{CacheContract, testContractID, "test-cache", "1.0.0"}, + contractRefSpec{ProxyContract, testProxyAddress, "test-proxy", "1.0.0"}, + ) + + base := OwnershipRequest{ + ChainSel: testChainSel, + Qualifier: "test-cache", + Version: "1.0.0", + Contract: CacheContract, + } + transferReq := &TransferOwnershipRequest{ + OwnershipRequest: base, + NewOwner: testAdmin, + LiveUntilLedger: 100, + } + + require.NoError(t, TransferOwnership{}.VerifyPreconditions(env, transferReq)) + _, err := TransferOwnership{}.Apply(env, transferReq) + require.NoError(t, err) + require.Len(t, inv.calls, 1) + require.Equal(t, "transfer_ownership", inv.calls[0].Function) + require.Equal(t, testContractID, inv.calls[0].ContractID) + require.Len(t, inv.calls[0].Args, 2) + + require.NoError(t, AcceptOwnership{}.VerifyPreconditions(env, &base)) + _, err = AcceptOwnership{}.Apply(env, &base) + require.NoError(t, err) + require.Len(t, inv.calls, 2) + require.Equal(t, "accept_ownership", inv.calls[1].Function) + require.Equal(t, testContractID, inv.calls[1].ContractID) + require.Empty(t, inv.calls[1].Args) + + // same ops against the proxy: IsProxy selects the proxy client and the + // resolved contractID is the proxy's address, not the cache's. + proxyBase := base + proxyBase.Qualifier = "test-proxy" + proxyBase.Contract = ProxyContract + proxyTransfer := &TransferOwnershipRequest{ + OwnershipRequest: proxyBase, + NewOwner: testAdmin, + LiveUntilLedger: 100, + } + + require.NoError(t, TransferOwnership{}.VerifyPreconditions(env, proxyTransfer)) + _, err = TransferOwnership{}.Apply(env, proxyTransfer) + require.NoError(t, err) + require.Equal(t, "transfer_ownership", inv.calls[2].Function) + require.Equal(t, testProxyAddress, inv.calls[2].ContractID) + + require.NoError(t, AcceptOwnership{}.VerifyPreconditions(env, &proxyBase)) + _, err = AcceptOwnership{}.Apply(env, &proxyBase) + require.NoError(t, err) + require.Equal(t, "accept_ownership", inv.calls[3].Function) + require.Equal(t, testProxyAddress, inv.calls[3].ContractID) + + // unknown Contract type must fail preconditions for both changesets + badContract := base + badContract.Contract = datastore.ContractType("SomethingElse") + require.Error(t, AcceptOwnership{}.VerifyPreconditions(env, &badContract)) + badTransfer := *transferReq + badTransfer.Contract = badContract.Contract + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &badTransfer)) + + // TransferOwnership requires a valid NewOwner and nonzero LiveUntilLedger; + // AcceptOwnership's request doesn't carry those fields at all. + missingOwner := *transferReq + missingOwner.NewOwner = "" + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &missingOwner)) + require.NoError(t, AcceptOwnership{}.VerifyPreconditions(env, &missingOwner.OwnershipRequest)) + + badOwner := *transferReq + badOwner.NewOwner = "not-a-key" + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &badOwner)) + + zeroLedger := *transferReq + zeroLedger.LiveUntilLedger = 0 + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &zeroLedger)) + + // missing address ref must fail preconditions + badQualifier := *transferReq + badQualifier.Qualifier = "does-not-exist" + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &badQualifier)) +} diff --git a/deployment/data-feeds/changeset/stellar/recover_tokens.go b/deployment/data-feeds/changeset/stellar/recover_tokens.go new file mode 100644 index 00000000000..b7ad825e252 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/recover_tokens.go @@ -0,0 +1,77 @@ +package stellar + +import ( + "errors" + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// RecoverTokensRequest recovers tokens accidentally sent to the cache or proxy. +type RecoverTokensRequest struct { + ChainSel uint64 + Qualifier string + Version string + Contract datastore.ContractType // CacheContract or ProxyContract + Token string + To string + Amount int64 +} + +var _ cldf.ChangeSetV2[*RecoverTokensRequest] = RecoverTokens{} + +// RecoverTokens recovers tokens accidentally sent to the cache or proxy. +type RecoverTokens struct{} + +type recoverTokensInput struct { + ContractID string `json:"contract_id"` + IsProxy bool `json:"is_proxy"` + Token string `json:"token"` + To string `json:"to"` + Amount int64 `json:"amount"` +} + +func (RecoverTokens) VerifyPreconditions(env cldf.Environment, req *RecoverTokensRequest) error { + if err := validateContract(req.Contract); err != nil { + return err + } + if err := verifyContractRef(env, req.ChainSel, req.Contract, req.Qualifier, req.Version); err != nil { + return err + } + if err := validateAddress(req.Token); err != nil { + return fmt.Errorf("token: %w", err) + } + if err := validateAddress(req.To); err != nil { + return fmt.Errorf("to: %w", err) + } + if req.Amount <= 0 { + return errors.New("amount must be positive") + } + return nil +} + +func (RecoverTokens) Apply(env cldf.Environment, req *RecoverTokensRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, req.Contract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, recoverTokensOp, d.deps, recoverTokensInput{ + ContractID: d.contractID, + IsProxy: req.Contract == ProxyContract, + Token: req.Token, + To: req.To, + Amount: req.Amount, + }) + return out, err +} + +var recoverTokensOp = operations.NewOperation( + "df:recover-tokens", opVersion, + "Recovers tokens accidentally sent to the contract", + func(b operations.Bundle, d StellarDeps, in recoverTokensInput) (void, error) { + return void{}, adminClient(d, in.ContractID, in.IsProxy).RecoverTokens(b.GetContext(), in.Token, in.To, in.Amount) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/recover_tokens_test.go b/deployment/data-feeds/changeset/stellar/recover_tokens_test.go new file mode 100644 index 00000000000..22f7c9121cc --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/recover_tokens_test.go @@ -0,0 +1,69 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRecoverTokensChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedContractRefs(t, &env, + contractRefSpec{CacheContract, testContractID, "test", "1.0.0"}, + contractRefSpec{ProxyContract, testProxyAddress, "test-proxy", "1.0.0"}, + ) + + req := &RecoverTokensRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Contract: CacheContract, + Token: testContractID, + To: testAdmin, + Amount: 1000, + } + require.NoError(t, RecoverTokens{}.VerifyPreconditions(env, req)) + + _, err := RecoverTokens{}.Apply(env, req) + require.NoError(t, err) + require.Len(t, inv.calls, 1) + require.Equal(t, "recover_tokens", inv.calls[0].Function) + require.Equal(t, testContractID, inv.calls[0].ContractID) + require.Len(t, inv.calls[0].Args, 3) + + // proxy path targets the proxy ref + proxyReq := *req + proxyReq.Qualifier = "test-proxy" + proxyReq.Contract = ProxyContract + require.NoError(t, RecoverTokens{}.VerifyPreconditions(env, &proxyReq)) + _, err = RecoverTokens{}.Apply(env, &proxyReq) + require.NoError(t, err) + require.Len(t, inv.calls, 2) + require.Equal(t, "recover_tokens", inv.calls[1].Function) + require.Equal(t, testProxyAddress, inv.calls[1].ContractID) + + // unsupported contract type must fail preconditions + badContract := *req + badContract.Contract = "NotAContract" + require.Error(t, RecoverTokens{}.VerifyPreconditions(env, &badContract)) + + // bad token address must fail preconditions + badToken := *req + badToken.Token = "not-a-key" + require.Error(t, RecoverTokens{}.VerifyPreconditions(env, &badToken)) + + // bad recipient address must fail preconditions + badTo := *req + badTo.To = "not-a-key" + require.Error(t, RecoverTokens{}.VerifyPreconditions(env, &badTo)) + + // non-positive amount must fail preconditions + badAmount := *req + badAmount.Amount = 0 + require.Error(t, RecoverTokens{}.VerifyPreconditions(env, &badAmount)) + + // missing cache ref must fail preconditions + badQualifier := *req + badQualifier.Qualifier = "does-not-exist" + require.Error(t, RecoverTokens{}.VerifyPreconditions(env, &badQualifier)) +} diff --git a/deployment/data-feeds/changeset/stellar/registry_test.go b/deployment/data-feeds/changeset/stellar/registry_test.go new file mode 100644 index 00000000000..59ba40b77fc --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/registry_test.go @@ -0,0 +1,34 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" + + cldregistry "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" +) + +// Registers every changeset the way a chainlink-deployments +// domain will and lists the keys, mirroring cld durable-pipeline list. +func TestChangesetRegistryKeys(t *testing.T) { + r := cldregistry.NewChangesetsRegistry() + + r.Add("0001_deploy_cache", cldregistry.Configure(DeployCache{}).WithEnvInput()) + r.Add("0002_deploy_proxy", cldregistry.Configure(DeployProxy{}).WithEnvInput()) + r.Add("0003_set_feed_configs", cldregistry.Configure(SetFeedConfigs{}).WithEnvInput()) + r.Add("0004_remove_feed_configs", cldregistry.Configure(RemoveFeedConfigs{}).WithEnvInput()) + r.Add("0005_set_feed_frozen", cldregistry.Configure(SetFeedFrozen{}).WithEnvInput()) + r.Add("0006_add_feed_admin", cldregistry.Configure(AddFeedAdmin{}).WithEnvInput()) + r.Add("0007_remove_feed_admin", cldregistry.Configure(RemoveFeedAdmin{}).WithEnvInput()) + r.Add("0008_transfer_ownership", cldregistry.Configure(TransferOwnership{}).WithEnvInput()) + r.Add("0009_accept_ownership", cldregistry.Configure(AcceptOwnership{}).WithEnvInput()) + r.Add("0010_upgrade", cldregistry.Configure(Upgrade{}).WithEnvInput()) + r.Add("0011_recover_tokens", cldregistry.Configure(RecoverTokens{}).WithEnvInput()) + r.Add("0012_set_proxy_cache", cldregistry.Configure(SetProxyCache{}).WithEnvInput()) + + keys := r.ListKeys() + require.Len(t, keys, 12) + for _, k := range keys { + t.Logf("registered: %s", k) + } +} diff --git a/deployment/data-feeds/changeset/stellar/remove_feed_configs.go b/deployment/data-feeds/changeset/stellar/remove_feed_configs.go new file mode 100644 index 00000000000..b2a8711ae86 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/remove_feed_configs.go @@ -0,0 +1,66 @@ +package stellar + +import ( + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" +) + +// RemoveFeedConfigsRequest removes a batch of feed configs from the cache. +type RemoveFeedConfigsRequest struct { + ChainSel uint64 + Qualifier string + Version string + Admin string + DataIDs []string +} + +var _ cldf.ChangeSetV2[*RemoveFeedConfigsRequest] = RemoveFeedConfigs{} + +// RemoveFeedConfigs removes feed configs from the cache. +type RemoveFeedConfigs struct{} + +type removeFeedConfigsInput struct { + ContractID string `json:"contract_id"` + Admin string `json:"admin"` + DataIDs [][16]byte `json:"data_ids"` +} + +func (RemoveFeedConfigs) VerifyPreconditions(env cldf.Environment, req *RemoveFeedConfigsRequest) error { + return verifyFeedPreconditions(env, req.ChainSel, req.Qualifier, req.Version, req.Admin, req.DataIDs) +} + +func (RemoveFeedConfigs) Apply(env cldf.Environment, req *RemoveFeedConfigsRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + + ids, err := dataIDsToBytes(req.DataIDs) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, removeFeedConfigsOp, d.deps, removeFeedConfigsInput{ + ContractID: d.contractID, + Admin: req.Admin, + DataIDs: ids, + }) + if err != nil { + return out, err + } + return metadataOutput(env, req.ChainSel, d.contractID, func(m *ContractMetadata) { + for _, id := range ids { + delete(m.Feeds, dataIDHex(id)) + } + }) +} + +var removeFeedConfigsOp = operations.NewOperation( + "df-cache:remove-feed-configs", opVersion, + "Removes feed configs from the cache", + func(b operations.Bundle, d StellarDeps, in removeFeedConfigsInput) (void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return void{}, c.RemoveFeedConfigs(b.GetContext(), in.Admin, in.DataIDs) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/remove_feed_configs_test.go b/deployment/data-feeds/changeset/stellar/remove_feed_configs_test.go new file mode 100644 index 00000000000..5ddadd02388 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/remove_feed_configs_test.go @@ -0,0 +1,53 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRemoveFeedConfigsChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + seedContractMetadata(t, &env, testContractID, ContractMetadata{ + Feeds: map[string]FeedMetadata{ + "0x018e16c39e0003320000000000000000": {Description: "BTC/USD"}, + "0x01c50f0e2106d5fd0000000000000000": {Description: "SOL/USD"}, + }, + }) + + req := &RemoveFeedConfigsRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Admin: testAdmin, + DataIDs: []string{"0x018e16c39e0003320000000000000000"}, + } + require.NoError(t, RemoveFeedConfigs{}.VerifyPreconditions(env, req)) + + out, err := RemoveFeedConfigs{}.Apply(env, req) + require.NoError(t, err) + require.Len(t, inv.calls, 1) + require.Equal(t, "remove_feed_configs", inv.calls[0].Function) + require.Equal(t, testContractID, inv.calls[0].ContractID) + + // only the removed feed leaves the metadata mirror + meta := outputMetadata(t, out, testContractID) + require.NotContains(t, meta.Feeds, "0x018e16c39e0003320000000000000000") + require.Equal(t, "SOL/USD", meta.Feeds["0x01c50f0e2106d5fd0000000000000000"].Description) + + // empty DataIDs must fail preconditions + empty := *req + empty.DataIDs = nil + require.Error(t, RemoveFeedConfigs{}.VerifyPreconditions(env, &empty)) + + // invalid admin address must fail preconditions + badAdmin := *req + badAdmin.Admin = "not-a-key" + require.Error(t, RemoveFeedConfigs{}.VerifyPreconditions(env, &badAdmin)) + + // missing cache ref must fail preconditions + badQualifier := *req + badQualifier.Qualifier = "does-not-exist" + require.Error(t, RemoveFeedConfigs{}.VerifyPreconditions(env, &badQualifier)) +} diff --git a/deployment/data-feeds/changeset/stellar/set_feed_configs.go b/deployment/data-feeds/changeset/stellar/set_feed_configs.go new file mode 100644 index 00000000000..68e23265969 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_feed_configs.go @@ -0,0 +1,138 @@ +package stellar + +import ( + "errors" + "fmt" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" +) + +// FeedPermission is a workflow write-permission. AllowedSender is the caller +// allowed to invoke on_report, i.e. the CRE forwarder contract. +type FeedPermission struct { + AllowedSender string // C... or G... address + AllowedWorkflowOwner string // 20-byte hex + AllowedWorkflowName string // up to 10 ASCII chars +} + +// SetFeedConfigsRequest configures a batch of feeds. Permissions apply to +// every feed in the batch. +type SetFeedConfigsRequest struct { + ChainSel uint64 + Qualifier string + Version string + Admin string + DataIDs []string + Descriptions []string + Permissions []FeedPermission +} + +func (req *SetFeedConfigsRequest) permissions() ([]cache.WorkflowPermission, error) { + out := make([]cache.WorkflowPermission, 0, len(req.Permissions)) + for _, p := range req.Permissions { + if err := validateAddress(p.AllowedSender); err != nil { + return nil, fmt.Errorf("allowed sender: %w", err) + } + owner, err := workflowOwnerToBytes(p.AllowedWorkflowOwner) + if err != nil { + return nil, err + } + name, err := workflowNameToBytes(p.AllowedWorkflowName) + if err != nil { + return nil, err + } + out = append(out, cache.WorkflowPermission{ + AllowedSender: p.AllowedSender, + AllowedWorkflowOwner: owner, + AllowedWorkflowName: name, + }) + } + return out, nil +} + +var _ cldf.ChangeSetV2[*SetFeedConfigsRequest] = SetFeedConfigs{} + +// SetFeedConfigs sets per-feed descriptions and workflow write-permissions on +// the cache. +type SetFeedConfigs struct{} + +type setFeedConfigsInput struct { + ContractID string `json:"contract_id"` + Admin string `json:"admin"` + Entries []cache.FeedConfigEntry `json:"entries"` +} + +func (SetFeedConfigs) VerifyPreconditions(env cldf.Environment, req *SetFeedConfigsRequest) error { + if err := verifyFeedPreconditions(env, req.ChainSel, req.Qualifier, req.Version, req.Admin, req.DataIDs); err != nil { + return err + } + if len(req.DataIDs) != len(req.Descriptions) { + return errors.New("DataIDs and Descriptions must have the same length") + } + if len(req.Permissions) == 0 { + return errors.New("Permissions cannot be empty") + } + _, err := req.permissions() + return err +} + +func (SetFeedConfigs) Apply(env cldf.Environment, req *SetFeedConfigsRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + + ids, err := dataIDsToBytes(req.DataIDs) + if err != nil { + return out, err + } + perms, err := req.permissions() + if err != nil { + return out, err + } + + entries := make([]cache.FeedConfigEntry, len(ids)) + for i, id := range ids { + entries[i] = cache.FeedConfigEntry{ + DataId: id, + Config: cache.FeedConfig{ + Description: req.Descriptions[i], + WorkflowPermissions: perms, + }, + } + } + + _, err = operations.ExecuteOperation(env.OperationsBundle, setFeedConfigsOp, d.deps, setFeedConfigsInput{ + ContractID: d.contractID, + Admin: req.Admin, + Entries: entries, + }) + if err != nil { + return out, err + } + return metadataOutput(env, req.ChainSel, d.contractID, func(m *ContractMetadata) { + if m.Feeds == nil { + m.Feeds = map[string]FeedMetadata{} + } + for i, id := range ids { + key := dataIDHex(id) + m.Feeds[key] = FeedMetadata{ + Description: req.Descriptions[i], + Decimals: decimalsFromID(id), + Permissions: req.Permissions, + } + } + }) +} + +var setFeedConfigsOp = operations.NewOperation( + "df-cache:set-feed-configs", opVersion, + "Sets per-feed descriptions and workflow write-permissions on the cache", + func(b operations.Bundle, d StellarDeps, in setFeedConfigsInput) (void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return void{}, c.SetFeedConfigs(b.GetContext(), in.Admin, in.Entries) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/set_feed_configs_test.go b/deployment/data-feeds/changeset/stellar/set_feed_configs_test.go new file mode 100644 index 00000000000..6b8ed63623c --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_feed_configs_test.go @@ -0,0 +1,86 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetFeedConfigsChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + seedContractMetadata(t, &env, testContractID, ContractMetadata{ + Feeds: map[string]FeedMetadata{ + "0x01c50f0e2106d5fd0000000000000000": {Description: "SOL/USD"}, + }, + }) + + req := &SetFeedConfigsRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Admin: testAdmin, + DataIDs: []string{"0x018e16c39e0003320000000000000000", "0x018e16c39e0003990000000000000000"}, + Descriptions: []string{"BTC/USD", "ETH/USD"}, + Permissions: []FeedPermission{{ + AllowedSender: testContractID, // the forwarder + AllowedWorkflowOwner: "0x0102030405060708090a0b0c0d0e0f1011121314", + AllowedWorkflowName: "abc", + }}, + } + require.NoError(t, SetFeedConfigs{}.VerifyPreconditions(env, req)) + + out, err := SetFeedConfigs{}.Apply(env, req) + require.NoError(t, err) + require.Len(t, inv.calls, 1) + require.Equal(t, "set_feed_configs", inv.calls[0].Function) + require.Equal(t, testContractID, inv.calls[0].ContractID) + + // the metadata mirror keeps the pre-existing feed and adds both new ones, + // each with its own description and id-derived decimals + meta := outputMetadata(t, out, testContractID) + require.Len(t, meta.Feeds, 3) + require.Equal(t, "SOL/USD", meta.Feeds["0x01c50f0e2106d5fd0000000000000000"].Description) + btc := meta.Feeds["0x018e16c39e0003320000000000000000"] + require.Equal(t, "BTC/USD", btc.Description) + require.Equal(t, uint32(18), btc.Decimals) + require.Equal(t, req.Permissions, btc.Permissions) + eth := meta.Feeds["0x018e16c39e0003990000000000000000"] + require.Equal(t, "ETH/USD", eth.Description) + require.Equal(t, uint32(0), eth.Decimals) // byte 7 outside the decimals range + + // mismatched lengths must fail preconditions + bad := *req + bad.Descriptions = nil + require.Error(t, SetFeedConfigs{}.VerifyPreconditions(env, &bad)) + + // empty DataIDs must fail preconditions + empty := *req + empty.DataIDs = nil + empty.Descriptions = nil + require.Error(t, SetFeedConfigs{}.VerifyPreconditions(env, &empty)) + + // invalid admin address must fail preconditions + badAdmin := *req + badAdmin.Admin = "not-a-key" + require.Error(t, SetFeedConfigs{}.VerifyPreconditions(env, &badAdmin)) + + // missing cache ref must fail preconditions + badQualifier := *req + badQualifier.Qualifier = "does-not-exist" + require.Error(t, SetFeedConfigs{}.VerifyPreconditions(env, &badQualifier)) + + // invalid permission fields must fail preconditions + badPerm := *req + badPerm.Permissions = []FeedPermission{{ + AllowedSender: "not-a-key", + AllowedWorkflowOwner: "0x0102030405060708090a0b0c0d0e0f1011121314", + AllowedWorkflowName: "abc", + }} + require.Error(t, SetFeedConfigs{}.VerifyPreconditions(env, &badPerm)) + + // empty Permissions must fail preconditions + emptyPerm := *req + emptyPerm.Permissions = nil + require.Error(t, SetFeedConfigs{}.VerifyPreconditions(env, &emptyPerm)) +} diff --git a/deployment/data-feeds/changeset/stellar/set_feed_frozen.go b/deployment/data-feeds/changeset/stellar/set_feed_frozen.go new file mode 100644 index 00000000000..496e0794155 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_feed_frozen.go @@ -0,0 +1,63 @@ +package stellar + +import ( + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" +) + +// SetFeedFrozenRequest freezes or unfreezes a batch of feeds. +type SetFeedFrozenRequest struct { + ChainSel uint64 + Qualifier string + Version string + Admin string + DataIDs []string + Frozen bool +} + +var _ cldf.ChangeSetV2[*SetFeedFrozenRequest] = SetFeedFrozen{} + +// SetFeedFrozen freezes or unfreezes feeds. Reads on a frozen feed fail with +// FeedFrozen; freezing a feed with no recorded round fails with NoFeedState. +type SetFeedFrozen struct{} + +type setFeedFrozenInput struct { + ContractID string `json:"contract_id"` + Admin string `json:"admin"` + DataIDs [][16]byte `json:"data_ids"` + Frozen bool `json:"frozen"` +} + +func (SetFeedFrozen) VerifyPreconditions(env cldf.Environment, req *SetFeedFrozenRequest) error { + return verifyFeedPreconditions(env, req.ChainSel, req.Qualifier, req.Version, req.Admin, req.DataIDs) +} + +func (SetFeedFrozen) Apply(env cldf.Environment, req *SetFeedFrozenRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + + ids, err := dataIDsToBytes(req.DataIDs) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, setFeedFrozenOp, d.deps, setFeedFrozenInput{ + ContractID: d.contractID, + Admin: req.Admin, + DataIDs: ids, + Frozen: req.Frozen, + }) + return out, err +} + +var setFeedFrozenOp = operations.NewOperation( + "df-cache:set-feed-frozen", opVersion, + "Freezes or unfreezes feeds on the cache", + func(b operations.Bundle, d StellarDeps, in setFeedFrozenInput) (void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return void{}, c.SetFeedFrozen(b.GetContext(), in.Admin, in.DataIDs, in.Frozen) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/set_feed_frozen_test.go b/deployment/data-feeds/changeset/stellar/set_feed_frozen_test.go new file mode 100644 index 00000000000..c47240e8137 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_feed_frozen_test.go @@ -0,0 +1,43 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetFeedFrozenChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + + req := &SetFeedFrozenRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Admin: testAdmin, + DataIDs: []string{"0x018e16c39e00032000000"}, + Frozen: true, + } + require.NoError(t, SetFeedFrozen{}.VerifyPreconditions(env, req)) + + _, err := SetFeedFrozen{}.Apply(env, req) + require.NoError(t, err) + require.Len(t, inv.calls, 1) + require.Equal(t, "set_feed_frozen", inv.calls[0].Function) + require.Equal(t, testContractID, inv.calls[0].ContractID) + + // empty DataIDs must fail preconditions + empty := *req + empty.DataIDs = nil + require.Error(t, SetFeedFrozen{}.VerifyPreconditions(env, &empty)) + + // invalid admin address must fail preconditions + badAdmin := *req + badAdmin.Admin = "not-a-key" + require.Error(t, SetFeedFrozen{}.VerifyPreconditions(env, &badAdmin)) + + // missing cache ref must fail preconditions + badQualifier := *req + badQualifier.Qualifier = "does-not-exist" + require.Error(t, SetFeedFrozen{}.VerifyPreconditions(env, &badQualifier)) +} diff --git a/deployment/data-feeds/changeset/stellar/set_proxy_cache.go b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go new file mode 100644 index 00000000000..eb784dc57f4 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go @@ -0,0 +1,75 @@ +package stellar + +import ( + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + proxy "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_proxy" +) + +// SetProxyCacheRequest points the proxy at a new cache. Qualifier resolves +// the proxy; CacheQualifier resolves the cache. +type SetProxyCacheRequest struct { + ChainSel uint64 + Qualifier string + Version string + CacheQualifier string + CacheVersion string // cache's datastore version; defaults to Version +} + +// cacheVersion defaults an empty CacheVersion to Version: cache and proxy +// usually share a release. +func (req *SetProxyCacheRequest) cacheVersion() string { + if req.CacheVersion != "" { + return req.CacheVersion + } + return req.Version +} + +var _ cldf.ChangeSetV2[*SetProxyCacheRequest] = SetProxyCache{} + +// SetProxyCache points the proxy at a cache contract. +type SetProxyCache struct{} + +type setProxyCacheInput struct { + ContractID string `json:"contract_id"` + Cache string `json:"cache"` +} + +func (SetProxyCache) VerifyPreconditions(env cldf.Environment, req *SetProxyCacheRequest) error { + if err := verifyContractRef(env, req.ChainSel, ProxyContract, req.Qualifier, req.Version); err != nil { + return err + } + return verifyContractRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.cacheVersion()) +} + +func (SetProxyCache) Apply(env cldf.Environment, req *SetProxyCacheRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + proxyDeps, _, err := resolveContractDeps(env, req.ChainSel, ProxyContract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + cacheRef, err := getAddressRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.cacheVersion()) + if err != nil { + return out, err + } + + _, err = operations.ExecuteOperation(env.OperationsBundle, setProxyCacheOp, proxyDeps.deps, setProxyCacheInput{ + ContractID: proxyDeps.contractID, + Cache: cacheRef.Address, + }) + if err != nil { + return out, err + } + return metadataOutput(env, req.ChainSel, proxyDeps.contractID, func(m *ContractMetadata) { + m.Cache = cacheRef.Address + }) +} + +var setProxyCacheOp = operations.NewOperation( + "df-proxy:set-cache", opVersion, + "Points the proxy at a cache contract", + func(b operations.Bundle, d StellarDeps, in setProxyCacheInput) (void, error) { + c := proxy.NewDataFeedsProxyClient(d.Invoker, in.ContractID) + return void{}, c.SetCache(b.GetContext(), in.Cache) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/set_proxy_cache_test.go b/deployment/data-feeds/changeset/stellar/set_proxy_cache_test.go new file mode 100644 index 00000000000..f10f9ce4779 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_proxy_cache_test.go @@ -0,0 +1,75 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetProxyCacheChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedContractRefs(t, &env, + contractRefSpec{ProxyContract, testProxyAddress, "test-proxy", "1.0.0"}, + contractRefSpec{CacheContract, testContractID, "test-cache", "1.0.0"}, + ) + + req := &SetProxyCacheRequest{ + ChainSel: testChainSel, + Qualifier: "test-proxy", + Version: "1.0.0", + CacheQualifier: "test-cache", + } + require.NoError(t, SetProxyCache{}.VerifyPreconditions(env, req)) + + out, err := SetProxyCache{}.Apply(env, req) + require.NoError(t, err) + require.Len(t, inv.calls, 1) + require.Equal(t, "set_cache", inv.calls[0].Function) + require.Equal(t, testProxyAddress, inv.calls[0].ContractID) + require.Len(t, inv.calls[0].Args, 1) + + // the metadata mirror records the proxy's new cache target + meta := outputMetadata(t, out, testProxyAddress) + require.Equal(t, testContractID, meta.Cache) + + // missing cache ref must fail preconditions (proxy ref alone isn't enough) + proxyOnly, _, _ := newTestEnv(t) + seedProxyRef(t, &proxyOnly, testProxyAddress, "test-proxy", "1.0.0") + require.Error(t, SetProxyCache{}.VerifyPreconditions(proxyOnly, req)) + + // missing proxy ref must fail preconditions (cache ref alone isn't enough) + cacheOnly, _, _ := newTestEnv(t) + seedCacheRef(t, &cacheOnly, testContractID, "test-cache", "1.0.0") + require.Error(t, SetProxyCache{}.VerifyPreconditions(cacheOnly, req)) +} + +// CacheVersion resolves a cache recorded under a different version than the +// proxy; empty CacheVersion falls back to Version. +func TestSetProxyCacheChangeset_CrossVersion(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedContractRefs(t, &env, + contractRefSpec{ProxyContract, testProxyAddress, "test-proxy", "1.1.0"}, + contractRefSpec{CacheContract, testContractID, "test-cache", "1.0.0"}, + ) + + req := &SetProxyCacheRequest{ + ChainSel: testChainSel, + Qualifier: "test-proxy", + Version: "1.1.0", + CacheQualifier: "test-cache", + CacheVersion: "1.0.0", + } + require.NoError(t, SetProxyCache{}.VerifyPreconditions(env, req)) + + out, err := SetProxyCache{}.Apply(env, req) + require.NoError(t, err) + require.Equal(t, "set_cache", inv.calls[0].Function) + + meta := outputMetadata(t, out, testProxyAddress) + require.Equal(t, testContractID, meta.Cache) + + // without CacheVersion the cache ref must not resolve at the proxy's version + noCacheVersion := *req + noCacheVersion.CacheVersion = "" + require.Error(t, SetProxyCache{}.VerifyPreconditions(env, &noCacheVersion)) +} diff --git a/deployment/data-feeds/changeset/stellar/test_util.go b/deployment/data-feeds/changeset/stellar/test_util.go new file mode 100644 index 00000000000..4deee6000db --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/test_util.go @@ -0,0 +1,179 @@ +package stellar + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Masterminds/semver/v3" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stellar/go-stellar-sdk/keypair" + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" + + cldfchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfstellar "github.com/smartcontractkit/chainlink-deployments-framework/chain/stellar" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr" + cldflogger "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +const ( + testContractID = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + testAdmin = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7" + // testProxyAddress is distinct so tests can tell proxy and cache refs apart. + testProxyAddress = "CBPROXY00000000000000000000000000000000000000000000000AA" +) + +var testChainSel = chainsel.STELLAR_TESTNET.Selector + +func newTestEnv(t *testing.T) (cldf.Environment, *fakeInvoker, *fakeDeployer) { + t.Helper() + + kp, err := keypair.Random() + require.NoError(t, err) + + chain := cldfstellar.Chain{ + ChainMetadata: cldfstellar.ChainMetadata{Selector: testChainSel}, + Signer: cldfstellar.NewStellarKeypairSigner(kp), + } + + invoker := &fakeInvoker{} + deployer := &fakeDeployer{contractID: testContractID} + + origDeps := newStellarDeps + t.Cleanup(func() { newStellarDeps = origDeps }) + newStellarDeps = func(_ cldfstellar.Chain) (StellarDeps, error) { + return StellarDeps{Deploy: deployer, Invoker: invoker}, nil + } + + blockChains := cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ + testChainSel: chain, + }) + + env := cldf.NewEnvironment( + "test", + cldflogger.Test(t), + nil, // ExistingAddresses: unused by these changesets, DataStore is authoritative + datastore.NewMemoryDataStore().Seal(), + nil, + nil, + func() context.Context { return context.Background() }, + ocr.OCRSecrets{}, + blockChains, + ) + + return *env, invoker, deployer +} + +type contractRefSpec struct { + contractType datastore.ContractType + address string + qualifier string + version string +} + +func seedContractRefs(t *testing.T, env *cldf.Environment, refs ...contractRefSpec) { + t.Helper() + ds := datastore.NewMemoryDataStore() + for _, r := range refs { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: r.address, + ChainSelector: testChainSel, + Type: r.contractType, + Version: semver.MustParse(r.version), + Qualifier: r.qualifier, + })) + } + env.DataStore = ds.Seal() +} + +func seedCacheRef(t *testing.T, env *cldf.Environment, address, qualifier, version string) { + t.Helper() + seedContractRefs(t, env, contractRefSpec{CacheContract, address, qualifier, version}) +} + +func seedProxyRef(t *testing.T, env *cldf.Environment, address, qualifier, version string) { + t.Helper() + seedContractRefs(t, env, contractRefSpec{ProxyContract, address, qualifier, version}) +} + +func seedContractMetadata(t *testing.T, env *cldf.Environment, address string, meta ContractMetadata) { + t.Helper() + ds := datastore.NewMemoryDataStore() + require.NoError(t, ds.Merge(env.DataStore)) + require.NoError(t, ds.ContractMetadata().Upsert(datastore.ContractMetadata{ + ChainSelector: testChainSel, + Address: address, + Metadata: meta, + })) + env.DataStore = ds.Seal() +} + +func outputMetadata(t *testing.T, out cldf.ChangesetOutput, address string) ContractMetadata { + t.Helper() + rec, err := out.DataStore.Seal().ContractMetadata().Get(datastore.NewContractMetadataKey(testChainSel, address)) + require.NoError(t, err) + meta, err := datastore.As[ContractMetadata](rec.Metadata) + require.NoError(t, err) + return meta +} + +func writeDummyWasm(t *testing.T, name string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte("dummy wasm"), 0o600)) + return path +} + +type invocation struct { + ContractID string + Function string + Args []xdr.ScVal +} + +type fakeInvoker struct { + calls []invocation +} + +func (f *fakeInvoker) InvokeContract(_ context.Context, contractID, fn string, args []xdr.ScVal) (*xdr.ScVal, error) { + f.calls = append(f.calls, invocation{contractID, fn, args}) + v := xdr.ScVal{Type: xdr.ScValTypeScvVoid} + return &v, nil +} + +func (f *fakeInvoker) SimulateContract(_ context.Context, contractID, fn string, args []xdr.ScVal) (*xdr.ScVal, error) { + f.calls = append(f.calls, invocation{contractID, fn, args}) + v := xdr.ScVal{Type: xdr.ScValTypeScvVoid} + return &v, nil +} + +func (f *fakeInvoker) GetEvents(_ context.Context, _ string, _ uint32, _ []string) ([]protocolrpc.EventInfo, error) { + return nil, nil +} + +type deployCall struct { + WasmPath string + Salt [32]byte + Args []xdr.ScVal +} + +type fakeDeployer struct { + deploys []deployCall + contractID string + wasmHash xdr.Hash + uploads []string // wasm paths, in call order +} + +func (f *fakeDeployer) DeployContractWithArgs(_ context.Context, wasmPath string, salt [32]byte, args []xdr.ScVal) (string, error) { + f.deploys = append(f.deploys, deployCall{wasmPath, salt, args}) + return f.contractID, nil +} + +func (f *fakeDeployer) UploadContractWASM(_ context.Context, wasmPath string) (xdr.Hash, error) { + f.uploads = append(f.uploads, wasmPath) + return f.wasmHash, nil +} diff --git a/deployment/data-feeds/changeset/stellar/upgrade.go b/deployment/data-feeds/changeset/stellar/upgrade.go new file mode 100644 index 00000000000..44271e3e176 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/upgrade.go @@ -0,0 +1,93 @@ +package stellar + +import ( + "fmt" + "os" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// UpgradeRequest points the cache or proxy at a new WASM implementation. +type UpgradeRequest struct { + ChainSel uint64 + Qualifier string + Version string + Contract datastore.ContractType // CacheContract or ProxyContract + WasmPath string +} + +var _ cldf.ChangeSetV2[*UpgradeRequest] = Upgrade{} + +// Upgrade uploads a new WASM blob and points the contract at it. +type Upgrade struct{} + +type uploadWASMInput struct { + WasmPath string `json:"wasm_path"` +} + +type uploadWASMOutput struct { + WasmHash [32]byte `json:"wasm_hash"` +} + +type upgradeContractInput struct { + ContractID string `json:"contract_id"` + IsProxy bool `json:"is_proxy"` + NewWasmHash [32]byte `json:"new_wasm_hash"` +} + +func (Upgrade) VerifyPreconditions(env cldf.Environment, req *UpgradeRequest) error { + if err := validateContract(req.Contract); err != nil { + return err + } + if err := verifyContractRef(env, req.ChainSel, req.Contract, req.Qualifier, req.Version); err != nil { + return err + } + if _, err := os.Stat(req.WasmPath); err != nil { + return fmt.Errorf("wasm path: %w", err) + } + return nil +} + +func (Upgrade) Apply(env cldf.Environment, req *UpgradeRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, _, err := resolveContractDeps(env, req.ChainSel, req.Contract, req.Qualifier, req.Version) + if err != nil { + return out, err + } + + uploadReport, err := operations.ExecuteOperation(env.OperationsBundle, uploadWASMOp, d.deps, uploadWASMInput{ + WasmPath: req.WasmPath, + }) + if err != nil { + return out, err + } + + _, err = operations.ExecuteOperation(env.OperationsBundle, upgradeContractOp, d.deps, upgradeContractInput{ + ContractID: d.contractID, + IsProxy: req.Contract == ProxyContract, + NewWasmHash: uploadReport.Output.WasmHash, + }) + return out, err +} + +var uploadWASMOp = operations.NewOperation( + "df:upload-wasm", opVersion, + "Uploads a WASM blob and returns its code hash (for upgrades)", + func(b operations.Bundle, d StellarDeps, in uploadWASMInput) (uploadWASMOutput, error) { + h, err := d.Deploy.UploadContractWASM(b.GetContext(), in.WasmPath) + if err != nil { + return uploadWASMOutput{}, err + } + return uploadWASMOutput{WasmHash: [32]byte(h)}, nil + }, +) + +var upgradeContractOp = operations.NewOperation( + "df:upgrade", opVersion, + "Points the contract at a new WASM implementation", + func(b operations.Bundle, d StellarDeps, in upgradeContractInput) (void, error) { + return void{}, adminClient(d, in.ContractID, in.IsProxy).Upgrade(b.GetContext(), in.NewWasmHash) + }, +) diff --git a/deployment/data-feeds/changeset/stellar/upgrade_test.go b/deployment/data-feeds/changeset/stellar/upgrade_test.go new file mode 100644 index 00000000000..e528791ebf0 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/upgrade_test.go @@ -0,0 +1,76 @@ +package stellar + +import ( + "testing" + + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" +) + +func TestUpgradeChangeset(t *testing.T) { + env, inv, dep := newTestEnv(t) + seedContractRefs(t, &env, + contractRefSpec{CacheContract, testContractID, "test", "1.0.0"}, + contractRefSpec{ProxyContract, testProxyAddress, "test-proxy", "1.0.0"}, + ) + + var wantHash xdr.Hash + for i := range wantHash { + wantHash[i] = byte(i + 1) + } + dep.wasmHash = wantHash + + req := &UpgradeRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Contract: CacheContract, + WasmPath: writeDummyWasm(t, "new_cache.wasm"), + } + require.NoError(t, Upgrade{}.VerifyPreconditions(env, req)) + + _, err := Upgrade{}.Apply(env, req) + require.NoError(t, err) + + // the upload happened, against the requested wasm path + require.Len(t, dep.uploads, 1) + require.Equal(t, req.WasmPath, dep.uploads[0]) + + // the upgrade invocation carried the fake's uploaded hash through + require.Len(t, inv.calls, 1) + require.Equal(t, "upgrade", inv.calls[0].Function) + require.Equal(t, testContractID, inv.calls[0].ContractID) + require.Len(t, inv.calls[0].Args, 1) + + gotHash, err := scval.Bytes32FromScVal(inv.calls[0].Args[0]) + require.NoError(t, err) + require.Equal(t, [32]byte(wantHash), gotHash) + + // proxy path targets the proxy ref + proxyReq := *req + proxyReq.Qualifier = "test-proxy" + proxyReq.Contract = ProxyContract + require.NoError(t, Upgrade{}.VerifyPreconditions(env, &proxyReq)) + _, err = Upgrade{}.Apply(env, &proxyReq) + require.NoError(t, err) + require.Len(t, inv.calls, 2) + require.Equal(t, "upgrade", inv.calls[1].Function) + require.Equal(t, testProxyAddress, inv.calls[1].ContractID) + + // unsupported contract type must fail preconditions + badContract := *req + badContract.Contract = "NotAContract" + require.Error(t, Upgrade{}.VerifyPreconditions(env, &badContract)) + + // missing wasm path must fail preconditions + badPath := *req + badPath.WasmPath = "/does/not/exist.wasm" + require.Error(t, Upgrade{}.VerifyPreconditions(env, &badPath)) + + // missing cache ref must fail preconditions + badQualifier := *req + badQualifier.Qualifier = "does-not-exist" + require.Error(t, Upgrade{}.VerifyPreconditions(env, &badQualifier)) +} diff --git a/deployment/data-feeds/changeset/stellar/validation.go b/deployment/data-feeds/changeset/stellar/validation.go new file mode 100644 index 00000000000..9c4ba3706d7 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/validation.go @@ -0,0 +1,45 @@ +package stellar + +import ( + "errors" + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/stellar/go-stellar-sdk/strkey" +) + +// validateContract restricts a request's target to the two DF contract types. +func validateContract(t datastore.ContractType) error { + if t != CacheContract && t != ProxyContract { + return fmt.Errorf("unsupported contract type %q: must be %q or %q", t, CacheContract, ProxyContract) + } + return nil +} + +// validateAddress accepts a G... account or C... contract strkey. +func validateAddress(s string) error { + if strkey.IsValidEd25519PublicKey(s) { + return nil + } + if _, err := strkey.Decode(strkey.VersionByteContract, s); err == nil { + return nil + } + return fmt.Errorf("%q is not a valid Stellar account or contract address", s) +} + +// verifyFeedPreconditions checks the preconditions shared by the cache feed +// changesets: cache address ref, valid admin, non-empty parseable DataIDs. +func verifyFeedPreconditions(env cldf.Environment, chainSel uint64, qualifier, version, admin string, dataIDs []string) error { + if err := verifyContractRef(env, chainSel, CacheContract, qualifier, version); err != nil { + return err + } + if err := validateAddress(admin); err != nil { + return err + } + if len(dataIDs) == 0 { + return errors.New("DataIDs cannot be empty") + } + _, err := dataIDsToBytes(dataIDs) + return err +} diff --git a/deployment/go.mod b/deployment/go.mod index 15032859439..2e6bc8589f4 100644 --- a/deployment/go.mod +++ b/deployment/go.mod @@ -60,7 +60,7 @@ require ( github.com/smartcontractkit/chainlink-protos/orchestrator v0.11.0 github.com/smartcontractkit/chainlink-solana v1.3.1-0.20260605202330-b5a89c32fdc1 github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260513123719-d347eaf314e1 - github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260721074545-ef8526aebfcf + github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260804133734-971e21aec752 github.com/smartcontractkit/chainlink-sui v0.0.0-20260714190119-005bb9a612c3 github.com/smartcontractkit/chainlink-sui/codec v0.0.0-20260720132736-e99278bfdc96 github.com/smartcontractkit/chainlink-sui/deployment v0.0.0-20260714190119-005bb9a612c3 @@ -93,6 +93,21 @@ require ( gotest.tools/v3 v3.5.2 ) +require ( + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect +) + require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cosmossdk.io/api v0.7.6 // indirect @@ -260,17 +275,6 @@ require ( github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // 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.3 // indirect @@ -458,7 +462,7 @@ require ( github.com/smartcontractkit/chainlink-protos/storage-service v0.3.0 // indirect github.com/smartcontractkit/chainlink-protos/svr v1.3.0 // indirect github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260716141634-c0cc05ed05d8 // indirect - github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260721074545-ef8526aebfcf // indirect + github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260804133734-971e21aec752 github.com/smartcontractkit/chainlink-testing-framework/parrot v0.6.2 // indirect github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5 // indirect github.com/smartcontractkit/chainlink-ton/cciplib v0.1.1-0.20260716214810-db5ecc877490 // indirect @@ -472,7 +476,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/stellar/go-stellar-sdk v0.5.0 // indirect + github.com/stellar/go-stellar-sdk v0.5.0 github.com/stellar/go-xdr v0.0.0-20260423131911-a87d4d0789c3 // indirect github.com/stephenlacy/go-ethereum-hdwallet v0.0.0-20230913225845-a4fa94429863 // indirect github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect @@ -571,7 +575,6 @@ require ( sigs.k8s.io/kustomize/api v0.17.2 // indirect sigs.k8s.io/kustomize/kyaml v0.17.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/deployment/go.sum b/deployment/go.sum index 6bf68057548..23986392020 100644 --- a/deployment/go.sum +++ b/deployment/go.sum @@ -1480,10 +1480,10 @@ github.com/smartcontractkit/chainlink-solana v1.3.1-0.20260605202330-b5a89c32fdc github.com/smartcontractkit/chainlink-solana v1.3.1-0.20260605202330-b5a89c32fdc1/go.mod h1:wi1QdXqhSJnADt9YRaRtEWomqknLcrdkTS0JotupuOQ= github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260513123719-d347eaf314e1 h1:/xvuNFI7DwOoTQnmAdYPDdY+sConn3RgZ2rMy/8AXlo= github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260513123719-d347eaf314e1/go.mod h1:lQK+YvR9Ox0ft72k0se7DlA+kujVWyjFQXG3DLbEZ/4= -github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260721074545-ef8526aebfcf h1:AUZU1Lnnf7aOh9G5t+Sk0pjQklsT+QMg/an0Quhglns= -github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260721074545-ef8526aebfcf/go.mod h1:J8D0u8K/a2+3YuJx0F8nf4Fd7h1UZqeWGYszp7aCbdA= -github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260721074545-ef8526aebfcf h1:K0og2/DvEL1/rOc6TF4RuhHJ1fUEGdrZ2NMdYRvB/S8= -github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260721074545-ef8526aebfcf/go.mod h1:uZEMU0BN48tmRP/Hu+RdnM70g17pgAv4s/Nux7asr2U= +github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260804133734-971e21aec752 h1:b3cQr1oDv0S+sdjm3zy+ejmA0v3uTXPEF1IyCc7NQsw= +github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260804133734-971e21aec752/go.mod h1:J8D0u8K/a2+3YuJx0F8nf4Fd7h1UZqeWGYszp7aCbdA= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260804133734-971e21aec752 h1:kxAF1oaJMql/b4Pa3nX965cPFBfTM18I8cLlJnPgaSM= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260804133734-971e21aec752/go.mod h1:IYBZArxwOQRlcx0Udx/VfFjqJpAs4NrjKsBoNITKAMg= github.com/smartcontractkit/chainlink-sui v0.0.0-20260714190119-005bb9a612c3 h1:bI9pXgfGOhS+tPGjPkpos3nuGE/5Xfw8X0MQHi+0EcU= github.com/smartcontractkit/chainlink-sui v0.0.0-20260714190119-005bb9a612c3/go.mod h1:6FWUSAXA58d0c9AyOi/1zymX40/67czcDR1SGZt/BKg= github.com/smartcontractkit/chainlink-sui/codec v0.0.0-20260720132736-e99278bfdc96 h1:3R9f6pguDIjei+nKx5dUT9j6fX8urGmlZA7Ad3x9UBs=