diff --git a/deployment/data-feeds/changeset/stellar/README.md b/deployment/data-feeds/changeset/stellar/README.md new file mode 100644 index 00000000000..a11683b7e74 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/README.md @@ -0,0 +1,113 @@ +### Stellar Data Feeds Changesets + +CLDF `ChangeSetV2` suite for the Soroban `DataFeedsCache` and `DataFeedsProxy` +contracts. Style follows the Solana suite (`ChangeSetV2` + operations); +functional coverage mirrors the EVM Data Feeds suite (deploy / configure / +admin / remove / ownership / proxy — `jd_*`, `migrate_feeds`, `import_address` +are EVM/JD-specific and deliberately have no Stellar equivalent here). + +#### Package layout + +``` +data-feeds/changeset/stellar/ +├── deploy_cache.go, deploy_proxy.go # instantiate contracts, record AddressRefs +├── configure_cache.go # SetFeedConfigs — descriptions + permissions +├── remove_feeds.go, feed_admin.go # remove feed configs, grant/revoke feed-admin +├── ownership.go # transfer / accept / renounce (cache + proxy) +├── upgrade_cache.go, recover_tokens.go, set_proxy_cache.go +├── deps.go, state.go # shared resolve-ref/build-deps + client loaders +├── validation.go, config.go # input validation, encoding helpers +├── operation/operation.go # one CLDF operation per on-chain call +└── testdata/ # e2e WASM fixtures — see testdata/README.md +``` + +Each changeset's `Apply` resolves a `datastore.AddressRef` (chain selector + +`ContractType` + version + qualifier) to a `stellarApplyDeps` bundle — the +contract ID plus the `stellardeps.StellarDeps` (Deploy + Invoker) needed to +call it — then executes one or more `operation.*` calls from +`operation/operation.go` through `env.OperationsBundle`. Operations are thin: +each wraps exactly one generated-binding call +(`chainlink-stellar/bindings/contracts/data_feeds_{cache,proxy}`) or one +`stellardeps.Deploy` call. `deps.go`'s `verifyContractRef` / +`resolveContractDeps` hold the chain-exists + version-parse + ref-exists + +build-deps skeleton shared by every changeset in this package. + +#### Permissions model — no standalone "set forwarder" + +There is no `SetForwarder`-style changeset here. A feed's writer allowlist is +part of its `FeedConfig` and is set via `SetFeedConfigs` +(`configure_cache.go`): each `FeedPermission` carries an `AllowedSender` — +the CRE forwarder contract address — plus `AllowedWorkflowOwner` and +`AllowedWorkflowName`, checked on-chain by `on_report`. This mirrors EVM's +`WorkflowMetadata{AllowedSender, AllowedWorkflowOwner, AllowedWorkflowName}` +gate. To point feeds at a new forwarder, re-run `SetFeedConfigs` with updated +`Permissions` — there's no separate on-chain "forwarder" slot to update. + +#### Cross-contract version coupling + +`DeployProxy` and `SetProxyCache` both resolve their cache `AddressRef` using +the *proxy's own* `req.Version`, not a separate cache-version field. This +suite's convention is that cross-contract datastore lookups share the acting +changeset's `Version` — so a cache recorded under a different version needs a +matching-version record before a proxy at that version can find it. An +optional `CacheVersion` field on these requests (to let the two diverge) is a +recorded follow-up, not implemented here. + +#### Regenerating testdata WASM + bindings + +The e2e test (`e2e_test.go`) deploys real WASM to a local devnet; the fixtures +in `testdata/` and the generated Go bindings in +`chainlink-stellar/bindings/contracts/data_feeds_{cache,proxy}` are a +**matched pair** — a contract ABI change requires regenerating both together, +or the e2e test will fail to compile or decode. See `testdata/README.md` for +checksums and the exact regeneration steps; in short: + +```sh +# 1. rebuild WASM from the contract source repo +cd /contracts/data-feeds +stellar contract build # stellar CLI 27.0.0 +cp target/wasm32v1-none/release/data_feeds_{cache,proxy}.wasm \ + /deployment/data-feeds/changeset/stellar/testdata/ +# (older stellar CLIs output to target/wasm32-unknown-unknown/release/ instead) + +# 2. regenerate bindings from the new WASM, in chainlink-stellar +./scripts/gen_bindings.sh +``` + +#### Running the e2e test + +`TestStellarDataFeedsE2E` boots a local CTF Soroban devnet, deploys both +contracts, and asserts 35 exported function calls succeed plus 2 +(`recover_tokens` on each contract) fail with the documented `InvalidAction` +host error (self-transfer re-entry — see the test's `recover_tokens` +comments). It needs Docker and does not run in CI (`skipInCI` skips when +`CI=true`). + +```sh +go test ./data-feeds/changeset/stellar/ -run TestStellarDataFeedsE2E -v -timeout 25m +``` + +The default path boots `stellar/quickstart:future` via CLDF's +`NewCTFChainProvider`, hardcoded by the CTF framework to `linux/amd64` — on a +native arm64 host without amd64 emulation this fails (`exec format error`). +Override with `STELLAR_E2E_RPC_URL` / `STELLAR_E2E_FRIENDBOT_URL` to attach to +a quickstart container started natively instead: + +```sh +docker run -d -p 8000:8000 stellar/quickstart:latest \ + --local --enable-soroban-rpc --protocol-version 26 +STELLAR_E2E_RPC_URL=http://127.0.0.1:8000/rpc \ +STELLAR_E2E_FRIENDBOT_URL=http://127.0.0.1:8000/friendbot \ +go test ./data-feeds/changeset/stellar/ -run TestStellarDataFeedsE2E -v -timeout 25m +``` + +See the doc comment on `newE2EChain` for why protocol 26 (not quickstart's +default 25) is required. + +#### Out of scope + +- **Durable-pipeline registration** in `chainlink-deployments` — follow-up + work, coordinate with PLEX-2923. +- **MCMS** (multi-chain multisig) integration — PLEX-owned. +- **Forwarder / timelock deployment** — PLEX-owned; this package only wires + an already-deployed forwarder's address into `FeedPermission.AllowedSender`. diff --git a/deployment/data-feeds/changeset/stellar/changeset_test.go b/deployment/data-feeds/changeset/stellar/changeset_test.go new file mode 100644 index 00000000000..d5976d410bf --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -0,0 +1,617 @@ +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" + "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" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +const testContractID = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + +// testChainSel is a real Stellar chain selector (from chain-selectors) so +// that ChainMetadata methods (Family, NetworkType, ...) resolve correctly. +var testChainSel = chainsel.STELLAR_TESTNET.Selector + +// newTestEnv returns an Environment with one fake Stellar chain and swapped-in fake deps. +// It swaps the package-level newStellarDeps seam to hand back fakeInvoker/fakeDeployer +// instead of talking to a real Soroban RPC endpoint, and restores the seam on cleanup. +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) (operation.StellarDeps, error) { + return operation.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 +} + +// contractRefSpec describes one AddressRef to seed via seedContractRefs. +type contractRefSpec struct { + contractType datastore.ContractType + address string + qualifier string + version string +} + +// seedContractRefs pre-seeds env.DataStore with the given AddressRefs (all in +// one MemoryDataStore) so changesets can resolve contracts by +// (ChainSel, Type, Version, Qualifier). Replaces any refs seeded by a prior +// call on the same env — call once per test with every ref the test needs +// (e.g. both a proxy and a cache ref for SetProxyCache). +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() +} + +// seedCacheRef pre-seeds env.DataStore with a single cache AddressRef so +// config changesets (SetFeedConfigs, RemoveFeedConfigs, Add/RemoveFeedAdmin) +// can resolve the cache contract by (ChainSel, CacheContract, Version, Qualifier). +func seedCacheRef(t *testing.T, env *cldf.Environment, address, qualifier, version string) { + t.Helper() + seedContractRefs(t, env, contractRefSpec{CacheContract, address, qualifier, version}) +} + +// seedProxyRef pre-seeds env.DataStore with a single proxy AddressRef so +// changesets can resolve the proxy contract by +// (ChainSel, ProxyContract, Version, Qualifier). +func seedProxyRef(t *testing.T, env *cldf.Environment, address, qualifier, version string) { + t.Helper() + seedContractRefs(t, env, contractRefSpec{ProxyContract, address, qualifier, version}) +} + +// writeDummyWasm writes a placeholder wasm file under t.TempDir() so +// VerifyPreconditions' filesystem check (os.Stat) has a real path to find. +// The contents are never read — deployment is faked via fakeDeployer. +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 +} + +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)) +} + +func TestDeployCacheChangeset_MissingChain(t *testing.T) { + env, _, _ := newTestEnv(t) + + req := &DeployCacheRequest{ + ChainSel: 999999, + WasmPath: writeDummyWasm(t, "data_feeds_cache.wasm"), + Qualifier: "test-cache", + Version: "1.0.0", + } + + require.Error(t, DeployCache{}.VerifyPreconditions(env, req)) +} + +// TestDeployCacheChangeset_ExplicitOwner pins that an explicit req.Owner +// seeds the deploy salt directly. deploy_cache.go's Apply only falls back to +// ch.Signer.Address() when req.Owner is empty; when it's set, the salt is +// GenerateDeterministicSalt(req.Owner, ...) -- not the chain signer's address. +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) + + // Seed the datastore with a cache AddressRef so DeployProxy can resolve it. + seedDS := datastore.NewMemoryDataStore() + require.NoError(t, seedDS.Addresses().Add(datastore.AddressRef{ + Address: "CCACHEFAKE0000000000000000000000000000000000000000000000", + ChainSelector: testChainSel, + Type: CacheContract, + Version: semver.MustParse("1.0.0"), + Qualifier: "test-cache", + })) + env.DataStore = seedDS.Seal() + + 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) +} + +func TestDeployProxyChangeset_MissingCache(t *testing.T) { + env, _, _ := newTestEnv(t) + + req := &DeployProxyRequest{ + ChainSel: testChainSel, + WasmPath: writeDummyWasm(t, "data_feeds_proxy.wasm"), + CacheQualifier: "does-not-exist", + Qualifier: "test-proxy", + Version: "1.0.0", + } + + require.Error(t, DeployProxy{}.VerifyPreconditions(env, req)) +} + +const testAdmin = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7" + +func TestSetFeedConfigsChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + + req := &SetFeedConfigsRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Admin: testAdmin, + DataIDs: []string{"0x018e16c39e00032000000"}, + Descriptions: []string{"BTC/USD"}, + Permissions: []FeedPermission{{ + AllowedSender: testContractID, // the forwarder + AllowedWorkflowOwner: "0x0102030405060708090a0b0c0d0e0f1011121314", + AllowedWorkflowName: "abc", + }}, + } + require.NoError(t, SetFeedConfigs{}.VerifyPreconditions(env, req)) + + _, 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) + + // Permissions apply to every feed in the batch: a single permission set + // with two DataIDs must still pass length validation. + multi := *req + multi.DataIDs = []string{"0x01", "0x02"} + multi.Descriptions = []string{"BTC/USD", "ETH/USD"} + require.NoError(t, SetFeedConfigs{}.VerifyPreconditions(env, &multi)) + + // 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)) +} + +func TestRemoveFeedConfigsChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + + req := &RemoveFeedConfigsRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + Admin: testAdmin, + DataIDs: []string{"0x018e16c39e00032000000"}, + } + require.NoError(t, RemoveFeedConfigs{}.VerifyPreconditions(env, req)) + + _, 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) + + // 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)) +} + +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)) +} + +// testProxyAddress is a second, distinct contract address used wherever a +// test needs to tell the proxy and cache refs apart (e.g. asserting Apply +// invoked the proxy, not the cache). +const testProxyAddress = "CBPROXY00000000000000000000000000000000000000000000000AA" + +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"}, + ) + + cacheReq := &OwnershipRequest{ + ChainSel: testChainSel, + Qualifier: "test-cache", + Version: "1.0.0", + Contract: CacheContract, + NewOwner: testAdmin, + LiveUntilLedger: 100, + } + + require.NoError(t, TransferOwnership{}.VerifyPreconditions(env, cacheReq)) + _, err := TransferOwnership{}.Apply(env, cacheReq) + 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, cacheReq)) + _, err = AcceptOwnership{}.Apply(env, cacheReq) + 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) + + require.NoError(t, RenounceOwnership{}.VerifyPreconditions(env, cacheReq)) + _, err = RenounceOwnership{}.Apply(env, cacheReq) + require.NoError(t, err) + require.Len(t, inv.calls, 3) + require.Equal(t, "renounce_ownership", inv.calls[2].Function) + require.Equal(t, testContractID, inv.calls[2].ContractID) + + // same three ops against the proxy: IsProxy selects the proxy client and + // the resolved contractID is the proxy's address, not the cache's. + proxyReq := *cacheReq + proxyReq.Qualifier = "test-proxy" + proxyReq.Contract = ProxyContract + + require.NoError(t, TransferOwnership{}.VerifyPreconditions(env, &proxyReq)) + _, err = TransferOwnership{}.Apply(env, &proxyReq) + require.NoError(t, err) + require.Equal(t, "transfer_ownership", inv.calls[3].Function) + require.Equal(t, testProxyAddress, inv.calls[3].ContractID) + + require.NoError(t, AcceptOwnership{}.VerifyPreconditions(env, &proxyReq)) + _, err = AcceptOwnership{}.Apply(env, &proxyReq) + require.NoError(t, err) + require.Equal(t, "accept_ownership", inv.calls[4].Function) + require.Equal(t, testProxyAddress, inv.calls[4].ContractID) + + require.NoError(t, RenounceOwnership{}.VerifyPreconditions(env, &proxyReq)) + _, err = RenounceOwnership{}.Apply(env, &proxyReq) + require.NoError(t, err) + require.Equal(t, "renounce_ownership", inv.calls[5].Function) + require.Equal(t, testProxyAddress, inv.calls[5].ContractID) + + // unknown Contract type must fail preconditions for all three changesets + badContract := *cacheReq + badContract.Contract = datastore.ContractType("SomethingElse") + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &badContract)) + require.Error(t, AcceptOwnership{}.VerifyPreconditions(env, &badContract)) + require.Error(t, RenounceOwnership{}.VerifyPreconditions(env, &badContract)) + + // TransferOwnership requires a valid NewOwner and nonzero LiveUntilLedger; + // Accept/Renounce don't touch those fields so the same request stays valid. + missingOwner := *cacheReq + missingOwner.NewOwner = "" + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &missingOwner)) + require.NoError(t, AcceptOwnership{}.VerifyPreconditions(env, &missingOwner)) + + badOwner := *cacheReq + badOwner.NewOwner = "not-a-key" + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &badOwner)) + + zeroLedger := *cacheReq + zeroLedger.LiveUntilLedger = 0 + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &zeroLedger)) + + // missing address ref must fail preconditions + badQualifier := *cacheReq + badQualifier.Qualifier = "does-not-exist" + require.Error(t, TransferOwnership{}.VerifyPreconditions(env, &badQualifier)) +} + +func TestUpgradeCacheChangeset(t *testing.T) { + env, inv, dep := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + + var wantHash xdr.Hash + for i := range wantHash { + wantHash[i] = byte(i + 1) + } + dep.wasmHash = wantHash + + req := &UpgradeCacheRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + WasmPath: writeDummyWasm(t, "new_cache.wasm"), + } + require.NoError(t, UpgradeCache{}.VerifyPreconditions(env, req)) + + _, err := UpgradeCache{}.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) + + // missing wasm path must fail preconditions + badPath := *req + badPath.WasmPath = "/does/not/exist.wasm" + require.Error(t, UpgradeCache{}.VerifyPreconditions(env, &badPath)) + + // missing cache ref must fail preconditions + badQualifier := *req + badQualifier.Qualifier = "does-not-exist" + require.Error(t, UpgradeCache{}.VerifyPreconditions(env, &badQualifier)) +} + +func TestRecoverTokensChangeset(t *testing.T) { + env, inv, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test", "1.0.0") + + req := &RecoverTokensRequest{ + ChainSel: testChainSel, + Qualifier: "test", + Version: "1.0.0", + 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) + + // 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)) +} + +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)) + + _, 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) + + // 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)) +} diff --git a/deployment/data-feeds/changeset/stellar/config.go b/deployment/data-feeds/changeset/stellar/config.go new file mode 100644 index 00000000000..a0d85714c7c --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/config.go @@ -0,0 +1,10 @@ +package stellar + +import "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + +const ( + // CacheContract is the datastore contract type for the DataFeedsCache Soroban contract. + CacheContract datastore.ContractType = "DataFeedsCache" + // ProxyContract is the datastore contract type for the DataFeedsProxy Soroban contract. + ProxyContract datastore.ContractType = "DataFeedsProxy" +) diff --git a/deployment/data-feeds/changeset/stellar/configure_cache.go b/deployment/data-feeds/changeset/stellar/configure_cache.go new file mode 100644 index 00000000000..2fb4e45421c --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/configure_cache.go @@ -0,0 +1,126 @@ +package stellar + +import ( + "errors" + "fmt" + + "github.com/Masterminds/semver/v3" + + 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" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// FeedPermission is the operator-facing shape of a workflow write-permission. +// AllowedSender is the on-chain caller permitted to invoke on_report — the CRE +// forwarder contract. Mirrors EVM WorkflowMetadata{AllowedSender, ...}. +type FeedPermission struct { + AllowedSender string // Stellar address (C... or G...) + AllowedWorkflowOwner string // 20-byte hex + AllowedWorkflowName string // <= 10 ASCII chars +} + +// SetFeedConfigsRequest sets descriptions + workflow permissions for a batch of +// feeds. Permissions apply to every feed in the batch (EVM-aligned semantics). +type SetFeedConfigsRequest struct { + ChainSel uint64 + Qualifier string + Version string + Admin string + DataIDs []string + Descriptions []string + Permissions []FeedPermission +} + +var _ cldf.ChangeSetV2[*SetFeedConfigsRequest] = SetFeedConfigs{} + +// SetFeedConfigs sets per-feed descriptions and shared workflow write-permissions +// on an already-deployed DataFeedsCache contract. +type SetFeedConfigs struct{} + +func (SetFeedConfigs) VerifyPreconditions(env cldf.Environment, req *SetFeedConfigsRequest) 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 + } + if len(req.DataIDs) == 0 { + return errors.New("DataIDs cannot be empty") + } + 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") + } + if _, err := dataIDsToBytes(req.DataIDs); err != nil { + return err + } + if _, err := req.permissions(); err != nil { + return err + } + return nil +} + +// permissions converts the operator shape to the generated binding shape. +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 +} + +func (SetFeedConfigs) Apply(env cldf.Environment, req *SetFeedConfigsRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + version := semver.MustParse(req.Version) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, 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, operation.SetFeedConfigs, d.deps, operation.SetFeedConfigsInput{ + ContractID: d.contractID, + Admin: req.Admin, + Entries: entries, + }) + return out, err +} diff --git a/deployment/data-feeds/changeset/stellar/deploy_cache.go b/deployment/data-feeds/changeset/stellar/deploy_cache.go new file mode 100644 index 00000000000..b3456d02e70 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deploy_cache.go @@ -0,0 +1,92 @@ +package stellar + +import ( + "fmt" + "os" + + "github.com/Masterminds/semver/v3" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// DeployCacheRequest configures a DataFeedsCache deployment. The real cache +// contract's __constructor(owner) takes a single argument — there is no +// RetentionTTLLedgers constructor input (that's a hardcoded on-chain constant). +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 uploads and instantiates the DataFeedsCache contract and records +// its address in the datastore under CacheContract. +type DeployCache struct{} + +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, ok := env.BlockChains.StellarChains()[req.ChainSel] + if !ok { + return out, fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + + deps, err := newStellarDeps(ch) + if err != nil { + return out, err + } + + owner := req.Owner + if owner == "" { + if ch.Signer == nil { + return out, fmt.Errorf("owner not set and chain has no signer") + } + owner = ch.Signer.Address() + } + salt := stellardeploy.GenerateDeterministicSalt(owner, "data_feeds_cache-"+req.Qualifier) + + report, err := operations.ExecuteOperation(env.OperationsBundle, operation.DeployCache, deps, operation.DeployCacheInput{ + WasmPath: req.WasmPath, + Salt: salt, + Owner: owner, + }) + if err != nil { + return out, err + } + + out.DataStore = datastore.NewMemoryDataStore() + return out, out.DataStore.Addresses().Add(datastore.AddressRef{ + Address: report.Output.ContractID, + ChainSelector: req.ChainSel, + Type: CacheContract, + Version: semver.MustParse(req.Version), + Qualifier: req.Qualifier, + Labels: req.LabelSet, + }) +} diff --git a/deployment/data-feeds/changeset/stellar/deploy_proxy.go b/deployment/data-feeds/changeset/stellar/deploy_proxy.go new file mode 100644 index 00000000000..dbae47623ac --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deploy_proxy.go @@ -0,0 +1,108 @@ +package stellar + +import ( + "fmt" + "os" + + "github.com/Masterminds/semver/v3" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// DeployProxyRequest configures a DataFeedsProxy deployment. The proxy's +// __constructor(owner, cache) resolves the cache address from the datastore +// by (ChainSel, CacheContract, Version, CacheQualifier) — the cache must +// already be deployed and recorded. The cache lookup reuses this request's +// own Version (there is no separate CacheVersion field): per this suite's +// convention, cross-contract lookups share the acting changeset's datastore +// Version, so a cache recorded under a different version needs a +// matching-version record (an optional CacheVersion field is a recorded +// follow-up if operators need split versions). +type DeployProxyRequest struct { + ChainSel uint64 + WasmPath string + Owner string // defaults to the chain's deployer address when empty + CacheQualifier string + Qualifier string + Version string + LabelSet datastore.LabelSet +} + +var _ cldf.ChangeSetV2[*DeployProxyRequest] = DeployProxy{} + +// DeployProxy uploads and instantiates the DataFeedsProxy contract and records +// its address in the datastore under ProxyContract. +type DeployProxy struct{} + +func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyRequest) error { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.Version); 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 fmt.Errorf("cache qualifier must be set") + } + return nil +} + +func (DeployProxy) Apply(env cldf.Environment, req *DeployProxyRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + ch, ok := env.BlockChains.StellarChains()[req.ChainSel] + if !ok { + return out, fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + + version := semver.MustParse(req.Version) + cacheRef, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.CacheQualifier), + ) + if err != nil { + return out, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.CacheQualifier, err) + } + + deps, err := newStellarDeps(ch) + if err != nil { + return out, err + } + + owner := req.Owner + if owner == "" { + if ch.Signer == nil { + return out, fmt.Errorf("owner not set and chain has no signer") + } + owner = ch.Signer.Address() + } + salt := stellardeploy.GenerateDeterministicSalt(owner, "data_feeds_proxy-"+req.Qualifier) + + report, err := operations.ExecuteOperation(env.OperationsBundle, operation.DeployProxy, deps, operation.DeployProxyInput{ + WasmPath: req.WasmPath, + Salt: salt, + Owner: owner, + Cache: cacheRef.Address, + }) + if err != nil { + return out, err + } + + out.DataStore = datastore.NewMemoryDataStore() + return out, out.DataStore.Addresses().Add(datastore.AddressRef{ + Address: report.Output.ContractID, + ChainSelector: req.ChainSel, + Type: ProxyContract, + Version: version, + Qualifier: req.Qualifier, + Labels: req.LabelSet, + }) +} diff --git a/deployment/data-feeds/changeset/stellar/deps.go b/deployment/data-feeds/changeset/stellar/deps.go new file mode 100644 index 00000000000..ab63c762f96 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deps.go @@ -0,0 +1,82 @@ +package stellar + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + cldfstellar "github.com/smartcontractkit/chainlink-deployments-framework/chain/stellar" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// newStellarDeps builds the deploy/invoke dependencies for a CLDF Stellar chain. +// Package-level var so unit tests can substitute fakes (the Invoker seam). +var newStellarDeps = func(ch cldfstellar.Chain) (operation.StellarDeps, error) { + d, err := stellardeploy.NewDeployerFromChain(ch) + if err != nil { + return operation.StellarDeps{}, err + } + return operation.StellarDeps{Deploy: d, Invoker: d}, nil +} + +// stellarApplyDeps bundles a resolved contract's address with the chain deps +// needed to invoke an operation against it — shared by every stellar +// changeset that acts on a single already-deployed contract. +type stellarApplyDeps struct { + deps operation.StellarDeps + contractID string +} + +// verifyContractRef checks that chainSel names a known Stellar chain, +// versionStr parses, and an AddressRef exists for (chainSel, contractType, +// version, qualifier). This is the chain/version/ref-existence skeleton +// shared by every changeset's VerifyPreconditions; only the extra checks +// differ. +func verifyContractRef(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, qualifier, versionStr string) error { + if _, ok := env.BlockChains.StellarChains()[chainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", chainSel) + } + version, err := semver.NewVersion(versionStr) + if err != nil { + return fmt.Errorf("invalid version %q: %w", versionStr, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(chainSel, contractType, version, qualifier), + ); err != nil { + return fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + } + return nil +} + +// resolveContractDeps looks up the AddressRef for (chainSel, contractType, +// version, qualifier) and bundles it with the chain's deploy/invoke deps, +// returning the AddressRef itself alongside for callers that need more than +// just its Address (e.g. LoadCacheClient/LoadProxyClient). version must +// already be parsed (see semver.MustParse in callers that have validated +// req.Version in VerifyPreconditions, typically via verifyContractRef). +func resolveContractDeps(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, qualifier string, version *semver.Version) (stellarApplyDeps, datastore.AddressRef, error) { + var zero stellarApplyDeps + var zeroRef datastore.AddressRef + ch, ok := env.BlockChains.StellarChains()[chainSel] + if !ok { + return zero, zeroRef, fmt.Errorf("stellar chain not found for chain selector %d", chainSel) + } + + ref, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(chainSel, contractType, version, qualifier), + ) + if err != nil { + return zero, zeroRef, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + } + + deps, err := newStellarDeps(ch) + if err != nil { + return zero, zeroRef, err + } + + return stellarApplyDeps{deps: deps, contractID: ref.Address}, ref, nil +} diff --git a/deployment/data-feeds/changeset/stellar/doc.go b/deployment/data-feeds/changeset/stellar/doc.go new file mode 100644 index 00000000000..88509c8608e --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/doc.go @@ -0,0 +1,4 @@ +// Package stellar contains CLD changesets for the Stellar (Soroban) Data Feeds +// cache and proxy contracts. Style follows the Solana suite (ChangeSetV2 + +// operations); functional coverage follows the EVM suite. +package stellar diff --git a/deployment/data-feeds/changeset/stellar/e2e_test.go b/deployment/data-feeds/changeset/stellar/e2e_test.go new file mode 100644 index 00000000000..cc6fd331ee2 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -0,0 +1,487 @@ +package stellar + +import ( + "bytes" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "sync" + "testing" + "time" + + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + cldfchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldfstellar "github.com/smartcontractkit/chainlink-deployments-framework/chain/stellar" + stellarprovider "github.com/smartcontractkit/chainlink-deployments-framework/chain/stellar/provider" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" +) + +// TestStellarDataFeedsE2E is the Definition-of-Done gate: it stands up a local +// CTF Soroban devnet, deploys both Data Feeds contracts, and invokes EVERY +// exported function on-chain — the 23 cache fns and the 14 proxy fns — through +// the real changesets/operations/bindings (no fakes). +// +// Function-coverage checklist. Outcome (a) = on-chain success; outcome (b) = a +// documented, asserted, expected contract-domain/host error (never ignored). +// +// DataFeedsCache (23): +// __constructor (a) DeployCache changeset +// upgrade (a) UpgradeCache changeset +// recover_tokens (b) RecoverTokens changeset — SAC transfer(from=self) +// re-enters the contract => host trap Error(Context, +// InvalidAction), asserted +// add_feed_admin (a) AddFeedAdmin changeset +// remove_feed_admin (a) RemoveFeedAdmin changeset +// set_feed_configs (a) SetFeedConfigs changeset +// remove_feed_configs (a) RemoveFeedConfigs changeset +// transfer_ownership (a) TransferOwnership changeset (self-transfer) +// accept_ownership (a) AcceptOwnership changeset +// renounce_ownership (a) RenounceOwnership changeset (LAST — irreversible) +// version (a) LoadCacheClient direct +// type_and_version (a) LoadCacheClient direct +// get_owner (a) LoadCacheClient direct +// decimals (a) LoadCacheClient direct +// description (a) LoadCacheClient direct +// is_feed_admin (a) LoadCacheClient direct +// has_permission (a) LoadCacheClient direct +// get_feed_permissions (a) LoadCacheClient direct +// on_report (a) LoadCacheClient direct — valid metadata+XDR report from the +// configured AllowedSender; stores a round +// latest_round (a) LoadCacheClient direct — returns the stored round +// get_round (a) LoadCacheClient direct — returns the stored round +// round_range (a) LoadCacheClient direct +// find_round (a) LoadCacheClient direct +// +// DataFeedsProxy (14): +// __constructor (a) DeployProxy changeset +// set_cache (a) SetProxyCache changeset +// transfer_ownership (a) TransferOwnership changeset (self-transfer) +// accept_ownership (a) AcceptOwnership changeset +// renounce_ownership (a) RenounceOwnership changeset (LAST — irreversible) +// version (a) LoadProxyClient direct +// type_and_version (a) LoadProxyClient direct +// get_owner (a) LoadProxyClient direct +// decimals (a) LoadProxyClient direct +// description (a) LoadProxyClient direct +// latest_round (a) LoadProxyClient direct — reads the round stored via on_report +// get_round (a) LoadProxyClient direct — reads the round stored via on_report +// upgrade (a) LoadProxyClient direct — fresh wasm hash (no changeset by design) +// recover_tokens (b) LoadProxyClient direct — SAC transfer(from=self) +// re-enters the contract => host trap Error(Context, +// InvalidAction), asserted +// +// The proxy deliberately has no upgrade/recover_tokens changeset (no operator +// need identified yet), so those two are driven through the generated proxy +// client directly. + +// e2eOnce guards the CTF container lifecycle for this suite (the provider +// requires a *sync.Once; one test => one Once). +var e2eOnce sync.Once + +const ( + e2eQualifier = "e2e" + e2eVersion = "1.0.0" + // e2eDataID is a canonical left-aligned feed id (BTC/USD-shaped). After + // dataIDsToBytes left-justifies it into [16]byte, byte[7] is 0x00, which is + // outside the cache's decimals window [0x20,0x60], so cache.decimals returns 0 + // (the else branch). Non-zero id => a valid feed id (is_valid_id passes). + e2eDataID = "0x018e16c39e00032000000" + e2eDescription = "BTC/USD" + e2eWorkflowName = "e2e" + // e2eWorkflowOwner is the 20-byte workflow owner shared by the feed config + // permission and the on_report metadata (they must match for the report to + // be accepted and stored). + e2eWorkflowOwner = "0x0102030405060708090a0b0c0d0e0f1011121314" + // e2eReportTS must be > 0 (the initial stored timestamp) so the report is + // recorded as a new round rather than rejected as stale. + e2eReportTS = uint64(1_700_000_000) +) + +func TestStellarDataFeedsE2E(t *testing.T) { + skipInCI(t) + + sel := chainsel.STELLAR_LOCALNET.Selector + ctx := t.Context() + + // --- stand up / attach to the local Soroban devnet --------------------- + bc := newE2EChain(t, sel) + + e, err := environment.New(ctx, + environment.WithChains(bc), + environment.WithLogger(logger.Test(t)), + ) + require.NoError(t, err) + env := *e + + ch, ok := env.BlockChains.StellarChains()[sel] + require.True(t, ok, "stellar chain %d must be present in the environment", sel) + deployer := ch.Signer.Address() + + // The random deployer key has no balance; friendbot funds it before any fee- + // bearing operation. Tolerates "already funded". + fundViaFriendbot(t, ch.FriendbotURL, deployer) + + const cacheWasm = "testdata/data_feeds_cache.wasm" + const proxyWasm = "testdata/data_feeds_proxy.wasm" + + // --- deploy + configure (changeset pipeline) --------------------------- + env = apply(t, env, DeployCache{}, &DeployCacheRequest{ + ChainSel: sel, WasmPath: cacheWasm, Qualifier: e2eQualifier, Version: e2eVersion, + }) + env = apply(t, env, DeployProxy{}, &DeployProxyRequest{ + ChainSel: sel, WasmPath: proxyWasm, CacheQualifier: e2eQualifier, + Qualifier: e2eQualifier, Version: e2eVersion, + }) + env = apply(t, env, AddFeedAdmin{}, &FeedAdminRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Admin: deployer, + }) + env = apply(t, env, SetFeedConfigs{}, &SetFeedConfigsRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Admin: deployer, + DataIDs: []string{e2eDataID}, Descriptions: []string{e2eDescription}, + Permissions: []FeedPermission{{ + AllowedSender: deployer, + AllowedWorkflowOwner: e2eWorkflowOwner, + AllowedWorkflowName: e2eWorkflowName, + }}, + }) + env = apply(t, env, SetProxyCache{}, &SetProxyCacheRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, CacheQualifier: e2eQualifier, + }) + + // Precompute the binding-typed feed id / permission tuple used by the + // direct calls below (reuse the package's own hex->bytes converters). + ids, err := dataIDsToBytes([]string{e2eDataID}) + require.NoError(t, err) + did := [16]byte(ids[0]) + + ownerBytes, err := workflowOwnerToBytes(e2eWorkflowOwner) + require.NoError(t, err) + nameBytes, err := workflowNameToBytes(e2eWorkflowName) + require.NoError(t, err) + + // --- cache: on_report (stores a round) + every read ------------------- + cacheClient, cacheRef, err := LoadCacheClient(env, sel, e2eQualifier, e2eVersion) + require.NoError(t, err) + cacheAddr := cacheRef.Address + + metadata := buildReportMetadata([10]byte(nameBytes), [20]byte(ownerBytes)) + report := buildReport(t, ids[0], big.NewInt(123456), e2eReportTS) + require.NoError(t, cacheClient.OnReport(ctx, deployer, metadata, report), + "on_report with a valid encoding from the configured AllowedSender must succeed") + + // Prove the report was actually stored (this is what makes the proxy + // latest_round/get_round reads return data, i.e. outcome (a)). + stored, err := cacheClient.LatestRound(ctx, did) + require.NoError(t, err) + require.NotNil(t, stored, "on_report should have stored a round") + require.Equalf(t, 0, stored.Answer.Cmp(big.NewInt(123456)), + "stored answer mismatch: got %v", stored.Answer) + require.Equal(t, uint64(1), stored.RoundId) + + mustCall(t, "cache.version", func() error { _, e := cacheClient.Version(ctx); return e }) + mustCall(t, "cache.type_and_version", func() error { _, e := cacheClient.TypeAndVersion(ctx); return e }) + mustCall(t, "cache.get_owner", func() error { _, e := cacheClient.GetOwner(ctx); return e }) + mustCall(t, "cache.decimals", func() error { _, e := cacheClient.Decimals(ctx, did); return e }) + mustCall(t, "cache.description", func() error { _, e := cacheClient.Description(ctx, did); return e }) + mustCall(t, "cache.is_feed_admin", func() error { _, e := cacheClient.IsFeedAdmin(ctx, deployer); return e }) + mustCall(t, "cache.has_permission", func() error { + _, e := cacheClient.HasPermission(ctx, did, deployer, [20]byte(ownerBytes), [10]byte(nameBytes)) + return e + }) + mustCall(t, "cache.get_feed_permissions", func() error { _, e := cacheClient.GetFeedPermissions(ctx, did); return e }) + mustCall(t, "cache.get_round", func() error { + r, e := cacheClient.GetRound(ctx, did, 1) + if e != nil { + return e + } + require.NotNil(t, r, "get_round(did,1) must return the stored round, not void") + return nil + }) + mustCall(t, "cache.round_range", func() error { + rounds, e := cacheClient.RoundRange(ctx, did, 1, 1) + if e != nil { + return e + } + require.NotEmpty(t, rounds, "round_range should return the stored round") + return nil + }) + mustCall(t, "cache.find_round", func() error { + r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.BoundAtOrBefore) + if e != nil { + return e + } + require.NotNil(t, r, "find_round must return the stored round, not void") + return nil + }) + + // --- proxy reads (read through to the cache's stored round) ----------- + proxyClient, _, err := LoadProxyClient(env, sel, e2eQualifier, e2eVersion) + require.NoError(t, err) + pdid := [16]byte(ids[0]) + + mustCall(t, "proxy.version", func() error { _, e := proxyClient.Version(ctx); return e }) + mustCall(t, "proxy.type_and_version", func() error { _, e := proxyClient.TypeAndVersion(ctx); return e }) + mustCall(t, "proxy.get_owner", func() error { _, e := proxyClient.GetOwner(ctx); return e }) + mustCall(t, "proxy.decimals", func() error { _, e := proxyClient.Decimals(ctx, pdid); return e }) + mustCall(t, "proxy.description", func() error { _, e := proxyClient.Description(ctx, pdid); return e }) + mustCall(t, "proxy.latest_round", func() error { _, e := proxyClient.LatestRound(ctx, pdid); return e }) + mustCall(t, "proxy.get_round", func() error { _, e := proxyClient.GetRound(ctx, pdid, 1); return e }) + + // --- proxy upgrade + recover_tokens (no changesets by design) --------- + deps, err := newStellarDeps(ch) + require.NoError(t, err) + proxyHash, err := deps.Deploy.UploadContractWASM(ctx, proxyWasm) + require.NoError(t, err) + require.NoError(t, proxyClient.Upgrade(ctx, [32]byte(proxyHash)), + "proxy upgrade to a freshly uploaded wasm hash must succeed") + + // recover_tokens invokes the SAC transfer(from=self, to, amount). We pass the + // contract's own address as the token, so the transfer sub-call re-enters the + // same contract; Soroban forbids contract re-entry and the host traps with + // Error(Context, InvalidAction) ("Contract re-entry is not allowed"). This is + // the documented (b) outcome: recover_tokens is really invoked (its three args + // are decoded and the transfer is dispatched — the tooling's encode/invoke path + // is what's under test) and the trap is a contract/host-domain error, not a + // transport/XDR/auth-plumbing failure. Standing up a funded native-XLM SAC + // balance for a success path is disproportionate for this gate. + proxyRecoverErr := proxyClient.RecoverTokens(ctx, proxyClient.ContractID(), deployer, 1) + require.Error(t, proxyRecoverErr, "proxy recover_tokens must fail: self-transfer re-enters the contract") + require.ErrorContains(t, proxyRecoverErr, "InvalidAction", "expected a contract re-entry host trap") + t.Logf("(b) proxy.recover_tokens error: %v", proxyRecoverErr) + + // --- proxy ownership: transfer -> accept -> renounce (renounce LAST) --- + proxyLiveUntil := nextLiveUntil(t, ch) + env = apply(t, env, TransferOwnership{}, &OwnershipRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, + Contract: ProxyContract, NewOwner: deployer, LiveUntilLedger: proxyLiveUntil, + }) + env = apply(t, env, AcceptOwnership{}, &OwnershipRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Contract: ProxyContract, + }) + env = apply(t, env, RenounceOwnership{}, &OwnershipRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Contract: ProxyContract, + }) + + // --- cache upgrade + recover + config/admin removal -------------------- + env = apply(t, env, UpgradeCache{}, &UpgradeCacheRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, WasmPath: cacheWasm, + }) + + // recover_tokens: same real-transfer semantics as the proxy. Token is the + // cache's own address, so the SAC transfer(from=self) re-enters the contract + // and the host traps with Error(Context, InvalidAction) — documented (b). Run + // while the deployer is still owner (ownership renounce happens last). + cacheRecoverErr := applyErr(t, env, RecoverTokens{}, &RecoverTokensRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, + Token: cacheAddr, To: deployer, Amount: 1, + }) + require.Error(t, cacheRecoverErr, "cache recover_tokens must fail: self-transfer re-enters the contract") + require.ErrorContains(t, cacheRecoverErr, "InvalidAction", "expected a contract re-entry host trap") + t.Logf("(b) cache.recover_tokens error: %v", cacheRecoverErr) + + env = apply(t, env, RemoveFeedConfigs{}, &RemoveFeedConfigsRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Admin: deployer, + DataIDs: []string{e2eDataID}, + }) + env = apply(t, env, RemoveFeedAdmin{}, &FeedAdminRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Admin: deployer, + }) + + // --- cache ownership: transfer -> accept -> renounce (renounce LAST) --- + cacheLiveUntil := nextLiveUntil(t, ch) + env = apply(t, env, TransferOwnership{}, &OwnershipRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, + Contract: CacheContract, NewOwner: deployer, LiveUntilLedger: cacheLiveUntil, + }) + env = apply(t, env, AcceptOwnership{}, &OwnershipRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Contract: CacheContract, + }) + env = apply(t, env, RenounceOwnership{}, &OwnershipRequest{ + ChainSel: sel, Qualifier: e2eQualifier, Version: e2eVersion, Contract: CacheContract, + }) +} + +// skipInCI mirrors the Solana suite convention: container-backed e2e tests do +// not run in CI (they need a local Docker daemon). +func skipInCI(t *testing.T) { + t.Helper() + if os.Getenv("CI") == "true" { + t.Skip("Skipping container-backed e2e test in CI") + } +} + +// newE2EChain provisions the Stellar devnet chain. +// +// The contracts under test are built with soroban-sdk 26.1.0, i.e. their wasm +// env-meta requires ledger protocol 26. quickstart:latest arms its local +// network at protocol 25 (uploads fail with `HostError: Error(Context, +// InternalError)`), so the CTF path pins stellar/quickstart:future, whose +// local network defaults to protocol 26. +// +// Default path: boot a CTF quickstart container via NewCTFChainProvider. +// +// Override path (STELLAR_E2E_RPC_URL set): attach to an externally started +// quickstart container via NewRPCChainProvider. This exists because the CTF +// framework hardcodes the container platform to linux/amd64 +// (chainlink-testing-framework framework/components/blockchain/stellar.go) and +// CLDF's CTFChainProviderConfig does not expose the ImagePlatform knob — so on +// a native linux/arm64 host without amd64 emulation the CTF path fails with +// "exec /start: exec format error" even though the quickstart images ship an +// arm64 variant. On such hosts run the same image natively and point the test +// at it: +// +// docker run -d -p 8000:8000 stellar/quickstart:latest \ +// --local --enable-soroban-rpc --protocol-version 26 +// STELLAR_E2E_RPC_URL=http://127.0.0.1:8000/rpc \ +// STELLAR_E2E_FRIENDBOT_URL=http://127.0.0.1:8000/friendbot \ +// go test ./data-feeds/changeset/stellar/ -run TestStellarDataFeedsE2E -v -timeout 25m +func newE2EChain(t *testing.T, sel uint64) cldfchain.BlockChain { + t.Helper() + + if rpcURL := os.Getenv("STELLAR_E2E_RPC_URL"); rpcURL != "" { + // Passphrase for the quickstart `--local` standalone network — the same + // one the CTF path uses (blockchain.DefaultStellarNetworkPassphrase). + const localPassphrase = "Standalone Network ; February 2017" + p := stellarprovider.NewRPCChainProvider(sel, stellarprovider.RPCChainProviderConfig{ + SorobanRPCURL: rpcURL, + NetworkPassphrase: localPassphrase, + FriendbotURL: os.Getenv("STELLAR_E2E_FRIENDBOT_URL"), + DeployerKeypairGen: stellarprovider.KeypairRandom(), + }) + bc, err := p.Initialize(t.Context()) + require.NoError(t, err) + return bc + } + + p := stellarprovider.NewCTFChainProvider(t, sel, stellarprovider.CTFChainProviderConfig{ + DeployerKeypairGen: stellarprovider.KeypairRandom(), + Once: &e2eOnce, + // protocol 26 local network; see doc comment above. + Image: "stellar/quickstart:future", + }) + bc, err := p.Initialize(t.Context()) + require.NoError(t, err) + return bc +} + +// apply configures and applies a single changeset, requiring success, and +// returns the merged environment (datastore + address book threaded forward). +func apply[C any](t *testing.T, env cldf.Environment, cs cldf.ChangeSetV2[C], cfg C) cldf.Environment { + t.Helper() + out, err := commonchangeset.Apply(t, env, commonchangeset.Configure(cs, cfg)) + require.NoError(t, err) + return out +} + +// applyErr configures and applies a single changeset expected to fail, and +// returns the error (the environment is unchanged on failure). +func applyErr[C any](t *testing.T, env cldf.Environment, cs cldf.ChangeSetV2[C], cfg C) error { + t.Helper() + _, err := commonchangeset.Apply(t, env, commonchangeset.Configure(cs, cfg)) + return err +} + +// mustCall runs a read/query closure and requires it to return no error. +func mustCall(t *testing.T, name string, fn func() error) { + t.Helper() + require.NoErrorf(t, fn(), "call %s", name) +} + +// nextLiveUntil returns a pending-transfer expiry ledger valid for the current +// chain: strictly greater than the current ledger and comfortably below +// max_live_until_ledger. u32::MAX would trip InvalidLiveUntilLedger. +func nextLiveUntil(t *testing.T, ch cldfstellar.Chain) uint32 { + t.Helper() + resp, err := ch.Client.GetLatestLedger(t.Context()) + require.NoError(t, err) + return resp.Sequence + 100_000 +} + +// buildReportMetadata lays out the 64-byte on_report metadata header the cache +// decodes: [0:32) workflow_cid | [32:42) workflow_name | [42:62) workflow_owner +// | [62:64) report_id. Only workflow_name and workflow_owner participate in the +// permission hash, so the cid/report_id bytes are left zero. +func buildReportMetadata(name [10]byte, owner [20]byte) []byte { + md := make([]byte, 64) + copy(md[32:42], name[:]) + copy(md[42:62], owner[:]) + return md +} + +// buildReport builds the on_report `report` payload: the XDR encoding of a +// Soroban `Vec` with a single entry. The cache truncates the +// 32-byte wire data id to its high 16 bytes, so wireHi16 is placed in the top +// half and the rest left zero. +func buildReport(t *testing.T, wireHi16 [16]byte, answer *big.Int, ts uint64) []byte { + t.Helper() + var wire [32]byte + copy(wire[0:16], wireHi16[:]) + + // #[contracttype] structs serialize as an ScMap with symbol keys sorted + // alphabetically (answer < data_id < timestamp) — BuildStructScVal sorts. + entry, err := scval.BuildStructScVal(map[string]xdr.ScVal{ + "data_id": scval.Bytes32ToScVal(wire), + "answer": scval.MustToScVal(scval.I256ToScVal(answer)), + "timestamp": scval.Uint64ToScVal(ts), + }) + require.NoError(t, err) + + vec := xdr.ScVec{entry} + vecPtr := &vec + reportVal := xdr.ScVal{Type: xdr.ScValTypeScvVec, Vec: &vecPtr} + b, err := reportVal.MarshalBinary() + require.NoError(t, err) + return b +} + +// fundViaFriendbot funds addr through the CTF chain's friendbot, tolerating an +// already-funded account. Friendbot may not be ready the instant the container +// starts, so it retries. +func fundViaFriendbot(t *testing.T, friendbotURL, addr string) { + t.Helper() + require.NotEmpty(t, friendbotURL, "friendbot URL must be set to fund the deployer") + + target := friendbotURL + "?addr=" + url.QueryEscape(addr) + var lastErr error + for i := 0; i < 15; i++ { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) //nolint:gosec // local CTF devnet URL + if err != nil { + lastErr = err + time.Sleep(2 * time.Second) + continue + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + lastErr = err + time.Sleep(2 * time.Second) + continue + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + // Friendbot returns a 400 with op_already_exists when the account is + // already funded — that's a success for our purposes. + if bytes.Contains(body, []byte("already")) || bytes.Contains(body, []byte("exists")) { + return + } + lastErr = fmt.Errorf("friendbot status %d: %s", resp.StatusCode, string(body)) + time.Sleep(2 * time.Second) + } + // Don't fail here — let the first fee-bearing deploy surface a precise + // underfunded error if funding genuinely failed. + t.Logf("warning: friendbot funding did not confirm success: %v", lastErr) +} 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..4d9ae66e254 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/feed_admin.go @@ -0,0 +1,81 @@ +package stellar + +import ( + "github.com/Masterminds/semver/v3" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// FeedAdminRequest grants or revokes feed-admin rights on an already-deployed +// DataFeedsCache contract. +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 +} + +// resolve looks up the cache contract's dependencies + address for req. +func (req *FeedAdminRequest) resolve(env cldf.Environment) (stellarApplyDeps, error) { + version := semver.MustParse(req.Version) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) + return d, err +} + +var ( + _ cldf.ChangeSetV2[*FeedAdminRequest] = AddFeedAdmin{} + _ cldf.ChangeSetV2[*FeedAdminRequest] = RemoveFeedAdmin{} +) + +// 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 := req.resolve(env) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.AddFeedAdmin, d.deps, operation.FeedAdminInput{ + ContractID: d.contractID, + Admin: req.Admin, + }) + return out, err +} + +// 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 := req.resolve(env) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RemoveFeedAdmin, d.deps, operation.FeedAdminInput{ + ContractID: d.contractID, + Admin: req.Admin, + }) + return out, err +} diff --git a/deployment/data-feeds/changeset/stellar/operation/operation.go b/deployment/data-feeds/changeset/stellar/operation/operation.go new file mode 100644 index 00000000000..13211cb1487 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/operation/operation.go @@ -0,0 +1,254 @@ +// Package operation contains the CLDF operations for the Stellar Data Feeds +// cache and proxy contracts. Each operation is one on-chain interaction; the +// changesets in the parent package compose and execute them. +package operation + +import ( + "context" + + "github.com/Masterminds/semver/v3" + + cldfops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "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" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + "github.com/stellar/go-stellar-sdk/xdr" +) + +var Version1_0_0 = semver.MustParse("1.0.0") + +// SorobanContractDeployer is the deploy-time surface these operations need +// from chainlink-stellar's concrete deployment.Deployer, defined locally so +// the upstream package does not have to export a widened interface. +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) +} + +// StellarDeps bundles deploy-time and runtime chain I/O for these operations. +// The same *deployment.Deployer satisfies both fields. +type StellarDeps struct { + Deploy SorobanContractDeployer + Invoker bindings.Invoker +} + +// Void is the output for operations that return no payload. +type Void struct{} + +// DeployOutput carries a deployed contract address (C... strkey). +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"` +} + +// DeployCache uploads the cache WASM and instantiates it via CreateContractV2 +// with __constructor(owner). The cache constructor takes a single argument; +// the data-retention TTL is a hardcoded on-chain constant, not a ctor input. +var DeployCache = cldfops.NewOperation( + "df-cache:deploy", Version1_0_0, + "Deploys the DataFeedsCache Soroban contract", + func(b cldfops.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 + }, +) + +type DeployProxyInput struct { + WasmPath string `json:"wasm_path"` + Salt [32]byte `json:"salt"` + Owner string `json:"owner"` + Cache string `json:"cache"` +} + +// DeployProxy instantiates the proxy via __constructor(owner, cache). +var DeployProxy = cldfops.NewOperation( + "df-proxy:deploy", Version1_0_0, + "Deploys the DataFeedsProxy Soroban contract", + func(b cldfops.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 + }, +) + +type SetFeedConfigsInput struct { + ContractID string `json:"contract_id"` + Admin string `json:"admin"` + Entries []cache.FeedConfigEntry `json:"entries"` +} + +var SetFeedConfigs = cldfops.NewOperation( + "df-cache:set-feed-configs", Version1_0_0, + "Sets per-feed descriptions and workflow write-permissions on the cache", + func(b cldfops.Bundle, d StellarDeps, in SetFeedConfigsInput) (Void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.SetFeedConfigs(b.GetContext(), in.Admin, in.Entries) + }, +) + +type RemoveFeedConfigsInput struct { + ContractID string `json:"contract_id"` + Admin string `json:"admin"` + DataIDs [][16]byte `json:"data_ids"` +} + +var RemoveFeedConfigs = cldfops.NewOperation( + "df-cache:remove-feed-configs", Version1_0_0, + "Removes feed configs from the cache", + func(b cldfops.Bundle, d StellarDeps, in RemoveFeedConfigsInput) (Void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.RemoveFeedConfigs(b.GetContext(), in.Admin, in.DataIDs) + }, +) + +type FeedAdminInput struct { + ContractID string `json:"contract_id"` + Admin string `json:"admin"` +} + +var AddFeedAdmin = cldfops.NewOperation( + "df-cache:add-feed-admin", Version1_0_0, + "Grants feed-admin rights on the cache", + func(b cldfops.Bundle, d StellarDeps, in FeedAdminInput) (Void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.AddFeedAdmin(b.GetContext(), in.Admin) + }, +) + +var RemoveFeedAdmin = cldfops.NewOperation( + "df-cache:remove-feed-admin", Version1_0_0, + "Revokes feed-admin rights on the cache", + func(b cldfops.Bundle, d StellarDeps, in FeedAdminInput) (Void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.RemoveFeedAdmin(b.GetContext(), in.Admin) + }, +) + +// Ownership ops work for both contracts: the generated cache and proxy clients +// expose identical two-step-ownership methods, selected by ContractID + IsProxy. +type OwnershipInput struct { + ContractID string `json:"contract_id"` + IsProxy bool `json:"is_proxy"` + NewOwner string `json:"new_owner"` + LiveUntilLedger uint32 `json:"live_until_ledger"` +} + +var TransferOwnership = cldfops.NewOperation( + "df:transfer-ownership", Version1_0_0, + "Begins two-step ownership transfer", + func(b cldfops.Bundle, d StellarDeps, in OwnershipInput) (Void, error) { + if in.IsProxy { + c := proxy.NewDataFeedsProxyClient(d.Invoker, in.ContractID) + return Void{}, c.TransferOwnership(b.GetContext(), in.NewOwner, in.LiveUntilLedger) + } + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.TransferOwnership(b.GetContext(), in.NewOwner, in.LiveUntilLedger) + }, +) + +var AcceptOwnership = cldfops.NewOperation( + "df:accept-ownership", Version1_0_0, + "Accepts a pending ownership transfer (caller must be the pending owner)", + func(b cldfops.Bundle, d StellarDeps, in OwnershipInput) (Void, error) { + if in.IsProxy { + c := proxy.NewDataFeedsProxyClient(d.Invoker, in.ContractID) + return Void{}, c.AcceptOwnership(b.GetContext()) + } + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.AcceptOwnership(b.GetContext()) + }, +) + +var RenounceOwnership = cldfops.NewOperation( + "df:renounce-ownership", Version1_0_0, + "Renounces ownership permanently", + func(b cldfops.Bundle, d StellarDeps, in OwnershipInput) (Void, error) { + if in.IsProxy { + c := proxy.NewDataFeedsProxyClient(d.Invoker, in.ContractID) + return Void{}, c.RenounceOwnership(b.GetContext()) + } + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.RenounceOwnership(b.GetContext()) + }, +) + +type UploadWASMInput struct { + WasmPath string `json:"wasm_path"` +} +type UploadWASMOutput struct { + WasmHash [32]byte `json:"wasm_hash"` +} + +var UploadWASM = cldfops.NewOperation( + "df:upload-wasm", Version1_0_0, + "Uploads a WASM blob and returns its code hash (for upgrades)", + func(b cldfops.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 + }, +) + +type UpgradeCacheInput struct { + ContractID string `json:"contract_id"` + NewWasmHash [32]byte `json:"new_wasm_hash"` +} + +var UpgradeCache = cldfops.NewOperation( + "df-cache:upgrade", Version1_0_0, + "Points the cache at a new WASM implementation", + func(b cldfops.Bundle, d StellarDeps, in UpgradeCacheInput) (Void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.Upgrade(b.GetContext(), in.NewWasmHash) + }, +) + +type RecoverTokensInput struct { + ContractID string `json:"contract_id"` + Token string `json:"token"` + To string `json:"to"` + Amount int64 `json:"amount"` +} + +var RecoverTokens = cldfops.NewOperation( + "df-cache:recover-tokens", Version1_0_0, + "Recovers tokens accidentally sent to the cache", + func(b cldfops.Bundle, d StellarDeps, in RecoverTokensInput) (Void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.RecoverTokens(b.GetContext(), in.Token, in.To, in.Amount) + }, +) + +type SetProxyCacheInput struct { + ContractID string `json:"contract_id"` + Cache string `json:"cache"` +} + +var SetProxyCache = cldfops.NewOperation( + "df-proxy:set-cache", Version1_0_0, + "Points the proxy at a cache contract", + func(b cldfops.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/ownership.go b/deployment/data-feeds/changeset/stellar/ownership.go new file mode 100644 index 00000000000..46e76f8961b --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/ownership.go @@ -0,0 +1,123 @@ +package stellar + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + "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/deployment/data-feeds/changeset/stellar/operation" +) + +// OwnershipRequest drives the two-step ownership changesets for either DF +// contract (CacheContract or ProxyContract) — the generated cache and proxy +// clients expose identical ownership methods. +type OwnershipRequest struct { + ChainSel uint64 + Qualifier string + Version string + Contract datastore.ContractType // CacheContract or ProxyContract + NewOwner string // TransferOwnership only + LiveUntilLedger uint32 // TransferOwnership only; pending-transfer expiry ledger +} + +func (req *OwnershipRequest) verifyPreconditions(env cldf.Environment) error { + if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + if req.Contract != CacheContract && req.Contract != ProxyContract { + return fmt.Errorf("unsupported contract type %q: must be %q or %q", req.Contract, CacheContract, ProxyContract) + } + if err := verifyContractRef(env, req.ChainSel, req.Contract, req.Qualifier, req.Version); err != nil { + return err + } + return nil +} + +// resolve looks up the target contract's dependencies + address for req. +func (req *OwnershipRequest) resolve(env cldf.Environment) (stellarApplyDeps, error) { + version := semver.MustParse(req.Version) + d, _, err := resolveContractDeps(env, req.ChainSel, req.Contract, req.Qualifier, version) + return d, err +} + +var ( + _ cldf.ChangeSetV2[*OwnershipRequest] = TransferOwnership{} + _ cldf.ChangeSetV2[*OwnershipRequest] = AcceptOwnership{} + _ cldf.ChangeSetV2[*OwnershipRequest] = RenounceOwnership{} +) + +// TransferOwnership begins a two-step ownership transfer on the cache or proxy. +type TransferOwnership struct{} + +func (TransferOwnership) VerifyPreconditions(env cldf.Environment, req *OwnershipRequest) 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 fmt.Errorf("LiveUntilLedger must be nonzero") + } + return nil +} + +func (TransferOwnership) Apply(env cldf.Environment, req *OwnershipRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, err := req.resolve(env) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.TransferOwnership, d.deps, operation.OwnershipInput{ + ContractID: d.contractID, + IsProxy: req.Contract == ProxyContract, + NewOwner: req.NewOwner, + LiveUntilLedger: req.LiveUntilLedger, + }) + return out, err +} + +// AcceptOwnership accepts a pending ownership transfer on the cache or proxy +// (the caller signing the underlying transaction 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 := req.resolve(env) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.AcceptOwnership, d.deps, operation.OwnershipInput{ + ContractID: d.contractID, + IsProxy: req.Contract == ProxyContract, + }) + return out, err +} + +// RenounceOwnership permanently renounces ownership of the cache or proxy. +type RenounceOwnership struct{} + +func (RenounceOwnership) VerifyPreconditions(env cldf.Environment, req *OwnershipRequest) error { + return req.verifyPreconditions(env) +} + +func (RenounceOwnership) Apply(env cldf.Environment, req *OwnershipRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + d, err := req.resolve(env) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RenounceOwnership, d.deps, operation.OwnershipInput{ + ContractID: d.contractID, + IsProxy: req.Contract == ProxyContract, + }) + return out, err +} 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..2619ea53e19 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/recover_tokens.go @@ -0,0 +1,60 @@ +package stellar + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// RecoverTokensRequest recovers tokens accidentally sent to an already-deployed +// DataFeedsCache contract. +type RecoverTokensRequest struct { + ChainSel uint64 + Qualifier string + Version string + Token string + To string + Amount int64 +} + +var _ cldf.ChangeSetV2[*RecoverTokensRequest] = RecoverTokens{} + +// RecoverTokens recovers tokens accidentally sent to the cache. +type RecoverTokens struct{} + +func (RecoverTokens) VerifyPreconditions(env cldf.Environment, req *RecoverTokensRequest) error { + if err := verifyContractRef(env, req.ChainSel, CacheContract, 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 fmt.Errorf("amount must be positive") + } + return nil +} + +func (RecoverTokens) Apply(env cldf.Environment, req *RecoverTokensRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + version := semver.MustParse(req.Version) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RecoverTokens, d.deps, operation.RecoverTokensInput{ + ContractID: d.contractID, + Token: req.Token, + To: req.To, + Amount: req.Amount, + }) + return out, err +} diff --git a/deployment/data-feeds/changeset/stellar/remove_feeds.go b/deployment/data-feeds/changeset/stellar/remove_feeds.go new file mode 100644 index 00000000000..40af6049681 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/remove_feeds.go @@ -0,0 +1,63 @@ +package stellar + +import ( + "errors" + + "github.com/Masterminds/semver/v3" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// RemoveFeedConfigsRequest removes a batch of feed configs from an +// already-deployed DataFeedsCache contract. +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{} + +func (RemoveFeedConfigs) VerifyPreconditions(env cldf.Environment, req *RemoveFeedConfigsRequest) 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 + } + if len(req.DataIDs) == 0 { + return errors.New("DataIDs cannot be empty") + } + if _, err := dataIDsToBytes(req.DataIDs); err != nil { + return err + } + return nil +} + +func (RemoveFeedConfigs) Apply(env cldf.Environment, req *RemoveFeedConfigsRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + version := semver.MustParse(req.Version) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) + if err != nil { + return out, err + } + + ids, err := dataIDsToBytes(req.DataIDs) + if err != nil { + return out, err + } + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RemoveFeedConfigs, d.deps, operation.RemoveFeedConfigsInput{ + ContractID: d.contractID, + Admin: req.Admin, + DataIDs: ids, + }) + return out, err +} diff --git a/deployment/data-feeds/changeset/stellar/report_encoding_test.go b/deployment/data-feeds/changeset/stellar/report_encoding_test.go new file mode 100644 index 00000000000..6213f6d8590 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/report_encoding_test.go @@ -0,0 +1,71 @@ +package stellar + +import ( + "math/big" + "testing" + + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" +) + +// TestBuildReportMetadataLayout pins buildReportMetadata's 64-byte on_report +// metadata header: [0:32) workflow_cid zero | [32:42) workflow_name | +// [42:62) workflow_owner | [62:64) report_id zero. +func TestBuildReportMetadataLayout(t *testing.T) { + var name [10]byte + for i := range name { + name[i] = byte(i + 1) // 0x01..0x0a, distinguishable from zero padding + } + var owner [20]byte + for i := range owner { + owner[i] = byte(i + 100) // 0x64..0x77 + } + + got := buildReportMetadata(name, owner) + require.Len(t, got, 64) + + want := make([]byte, 64) + copy(want[32:42], name[:]) + copy(want[42:62], owner[:]) + require.Equal(t, want, got) + + require.Equal(t, make([]byte, 32), got[0:32], "workflow_cid bytes must be zero") + require.Equal(t, name[:], got[32:42], "workflow_name occupies [32:42)") + require.Equal(t, owner[:], got[42:62], "workflow_owner occupies [42:62)") + require.Equal(t, []byte{0, 0}, got[62:64], "report_id bytes must be zero") +} + +// TestBuildReportScMapKeyOrder pins that buildReport's ScMap struct fields +// decode back in alphabetical key order (answer < data_id < timestamp), per +// scval.BuildStructScVal's sort.Strings over field names. +func TestBuildReportScMapKeyOrder(t *testing.T) { + var wireHi16 [16]byte + for i := range wireHi16 { + wireHi16[i] = byte(i + 1) + } + answer := big.NewInt(123456789) + const ts = uint64(1700000000) + + b := buildReport(t, wireHi16, answer, ts) + + var decoded xdr.ScVal + require.NoError(t, decoded.UnmarshalBinary(b)) + require.Equal(t, xdr.ScValTypeScvVec, decoded.Type) + require.NotNil(t, decoded.Vec) + vec := *decoded.Vec + require.Len(t, *vec, 1) + + entry := (*vec)[0] + require.Equal(t, xdr.ScValTypeScvMap, entry.Type) + require.NotNil(t, entry.Map) + scMap := *entry.Map + require.Len(t, *scMap, 3) + + keys := make([]string, len(*scMap)) + for i, e := range *scMap { + require.Equal(t, xdr.ScValTypeScvSymbol, e.Key.Type) + require.NotNil(t, e.Key.Sym) + keys[i] = string(*e.Key.Sym) + } + require.Equal(t, []string{"answer", "data_id", "timestamp"}, keys) +} 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..d57db3a5732 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go @@ -0,0 +1,66 @@ +package stellar + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + "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/deployment/data-feeds/changeset/stellar/operation" +) + +// SetProxyCacheRequest points an already-deployed DataFeedsProxy contract at a +// (possibly different) cache contract. Qualifier resolves the proxy being +// acted on (ChainSel, ProxyContract, Version, Qualifier); CacheQualifier +// resolves the new cache target (ChainSel, CacheContract, Version, +// CacheQualifier) — both lookups share this request's Version: per this +// suite's convention, cross-contract lookups use the acting changeset's +// datastore Version, so a cache recorded under a different version needs a +// matching-version record (an optional CacheVersion field is a recorded +// follow-up if operators need split versions). +type SetProxyCacheRequest struct { + ChainSel uint64 + Qualifier string + Version string + CacheQualifier string +} + +var _ cldf.ChangeSetV2[*SetProxyCacheRequest] = SetProxyCache{} + +// SetProxyCache points the proxy at a cache contract. +type SetProxyCache struct{} + +func (SetProxyCache) VerifyPreconditions(env cldf.Environment, req *SetProxyCacheRequest) error { + if err := verifyContractRef(env, req.ChainSel, ProxyContract, req.Qualifier, req.Version); err != nil { + return err + } + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.Version); err != nil { + return err + } + return nil +} + +func (SetProxyCache) Apply(env cldf.Environment, req *SetProxyCacheRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + version := semver.MustParse(req.Version) + + proxyDeps, _, err := resolveContractDeps(env, req.ChainSel, ProxyContract, req.Qualifier, version) + if err != nil { + return out, err + } + cacheRef, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.CacheQualifier), + ) + if err != nil { + return out, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.CacheQualifier, err) + } + + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.SetProxyCache, proxyDeps.deps, operation.SetProxyCacheInput{ + ContractID: proxyDeps.contractID, + Cache: cacheRef.Address, + }) + return out, err +} diff --git a/deployment/data-feeds/changeset/stellar/state.go b/deployment/data-feeds/changeset/stellar/state.go new file mode 100644 index 00000000000..461655f4d83 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/state.go @@ -0,0 +1,45 @@ +package stellar + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" + proxy "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_proxy" +) + +// loadContractClientDeps resolves the AddressRef for (chainSel, contractType, +// version, qualifier) and the chain's deploy/invoke deps (via +// resolveContractDeps, which chain ok-guards before touching the datastore +// or building deps). It is the shared lookup behind LoadCacheClient and +// LoadProxyClient. +func loadContractClientDeps(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, qualifier, version string) (stellarApplyDeps, datastore.AddressRef, error) { + v, err := semver.NewVersion(version) + if err != nil { + return stellarApplyDeps{}, datastore.AddressRef{}, fmt.Errorf("invalid version %q: %w", version, err) + } + return resolveContractDeps(env, chainSel, contractType, qualifier, v) +} + +// LoadCacheClient resolves the CacheContract AddressRef from env.DataStore +// for (chainSel, qualifier, version) and returns a generated cache client +// bound to its contract ID, along with the resolved AddressRef. +func LoadCacheClient(env cldf.Environment, chainSel uint64, qualifier, version string) (*cache.DataFeedsCacheClient, datastore.AddressRef, error) { + d, ref, err := loadContractClientDeps(env, chainSel, CacheContract, qualifier, version) + if err != nil { + return nil, datastore.AddressRef{}, err + } + return cache.NewDataFeedsCacheClient(d.deps.Invoker, d.contractID), ref, nil +} + +// LoadProxyClient is LoadCacheClient's counterpart for ProxyContract. +func LoadProxyClient(env cldf.Environment, chainSel uint64, qualifier, version string) (*proxy.DataFeedsProxyClient, datastore.AddressRef, error) { + d, ref, err := loadContractClientDeps(env, chainSel, ProxyContract, qualifier, version) + if err != nil { + return nil, datastore.AddressRef{}, err + } + return proxy.NewDataFeedsProxyClient(d.deps.Invoker, d.contractID), ref, nil +} diff --git a/deployment/data-feeds/changeset/stellar/state_test.go b/deployment/data-feeds/changeset/stellar/state_test.go new file mode 100644 index 00000000000..219480f4602 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/state_test.go @@ -0,0 +1,61 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadCacheClient(t *testing.T) { + env, _, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test-cache", "1.0.0") + + client, ref, err := LoadCacheClient(env, testChainSel, "test-cache", "1.0.0") + require.NoError(t, err) + require.NotNil(t, client) + require.Equal(t, testContractID, ref.Address) + require.Equal(t, testContractID, client.ContractID()) +} + +func TestLoadCacheClient_MissingRef(t *testing.T) { + env, _, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test-cache", "1.0.0") + + _, _, err := LoadCacheClient(env, testChainSel, "does-not-exist", "1.0.0") + require.Error(t, err) +} + +func TestLoadCacheClient_UnknownChain(t *testing.T) { + env, _, _ := newTestEnv(t) + seedCacheRef(t, &env, testContractID, "test-cache", "1.0.0") + + _, _, err := LoadCacheClient(env, 999999, "test-cache", "1.0.0") + require.Error(t, err) +} + +func TestLoadProxyClient(t *testing.T) { + env, _, _ := newTestEnv(t) + seedProxyRef(t, &env, testProxyAddress, "test-proxy", "1.0.0") + + client, ref, err := LoadProxyClient(env, testChainSel, "test-proxy", "1.0.0") + require.NoError(t, err) + require.NotNil(t, client) + require.Equal(t, testProxyAddress, ref.Address) + require.Equal(t, testProxyAddress, client.ContractID()) +} + +func TestLoadProxyClient_MissingRef(t *testing.T) { + env, _, _ := newTestEnv(t) + seedProxyRef(t, &env, testProxyAddress, "test-proxy", "1.0.0") + + _, _, err := LoadProxyClient(env, testChainSel, "does-not-exist", "1.0.0") + require.Error(t, err) +} + +func TestLoadProxyClient_UnknownChain(t *testing.T) { + env, _, _ := newTestEnv(t) + seedProxyRef(t, &env, testProxyAddress, "test-proxy", "1.0.0") + + _, _, err := LoadProxyClient(env, 999999, "test-proxy", "1.0.0") + require.Error(t, err) +} diff --git a/deployment/data-feeds/changeset/stellar/testdata/.gitignore b/deployment/data-feeds/changeset/stellar/testdata/.gitignore new file mode 100644 index 00000000000..391b5033818 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/testdata/.gitignore @@ -0,0 +1,5 @@ +# The repo-root .gitignore ignores *.wasm as build artifacts. These two files are +# committed test fixtures (the e2e test deploys them to a local Soroban devnet), +# so re-include them here. See README.md for regeneration. +!data_feeds_cache.wasm +!data_feeds_proxy.wasm diff --git a/deployment/data-feeds/changeset/stellar/testdata/README.md b/deployment/data-feeds/changeset/stellar/testdata/README.md new file mode 100644 index 00000000000..39fbabb0367 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/testdata/README.md @@ -0,0 +1,30 @@ +# Stellar Data Feeds e2e test fixtures + +`data_feeds_cache.wasm` and `data_feeds_proxy.wasm` are the compiled Soroban +contracts under test by `e2e_test.go` (`TestStellarDataFeedsE2E`). They are the +byte-for-byte artifacts produced by Task 1's `stellar contract build`. + +sha256 (must match the Task 1 contract inventory): + +- `data_feeds_cache.wasm` = `2d696c5e3def9337e29b109eee537c915dedbf89d09cb5d0ebc8f0a5d04b1d19` +- `data_feeds_proxy.wasm` = `08a1263807b7a83a342cfe97a792032ef02ac6a37f554e53e12c0bb8b3a588a7` + +## Regeneration + +These are committed binaries (the e2e test needs a real WASM to deploy to a +local CTF Soroban devnet). To regenerate after a contract change, rebuild from +the contract source repo and copy the artifacts back: + +```sh +cd /contracts/data-feeds +stellar contract build # stellar CLI 27.0.0 +cp target/wasm32v1-none/release/data_feeds_cache.wasm / +cp target/wasm32v1-none/release/data_feeds_proxy.wasm / +``` + +(Older stellar CLI versions output to `target/wasm32-unknown-unknown/release/` +instead — check which target triple your `stellar contract build` uses.) + +If the contract ABI changes, regenerate the Go bindings +(`chainlink-stellar/bindings/contracts/data_feeds_{cache,proxy}`) as well, or the +e2e test will fail to compile / decode. diff --git a/deployment/data-feeds/changeset/stellar/testdata/data_feeds_cache.wasm b/deployment/data-feeds/changeset/stellar/testdata/data_feeds_cache.wasm new file mode 100644 index 00000000000..cc53ad56d1b Binary files /dev/null and b/deployment/data-feeds/changeset/stellar/testdata/data_feeds_cache.wasm differ diff --git a/deployment/data-feeds/changeset/stellar/testdata/data_feeds_proxy.wasm b/deployment/data-feeds/changeset/stellar/testdata/data_feeds_proxy.wasm new file mode 100644 index 00000000000..367e0d4bbe2 Binary files /dev/null and b/deployment/data-feeds/changeset/stellar/testdata/data_feeds_proxy.wasm differ diff --git a/deployment/data-feeds/changeset/stellar/testutil_test.go b/deployment/data-feeds/changeset/stellar/testutil_test.go new file mode 100644 index 00000000000..a01380251da --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/testutil_test.go @@ -0,0 +1,61 @@ +package stellar + +import ( + "context" + + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" +) + +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 passed to UploadContractWASM, in call order +} + +func (f *fakeDeployer) DeployContract(ctx context.Context, wasmPath string, salt [32]byte) (string, error) { + return f.DeployContractWithArgs(ctx, wasmPath, salt, nil) +} + +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_cache.go b/deployment/data-feeds/changeset/stellar/upgrade_cache.go new file mode 100644 index 00000000000..59ba101ab1a --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/upgrade_cache.go @@ -0,0 +1,61 @@ +package stellar + +import ( + "fmt" + "os" + + "github.com/Masterminds/semver/v3" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// UpgradeCacheRequest points an already-deployed DataFeedsCache contract at a +// new WASM implementation. +type UpgradeCacheRequest struct { + ChainSel uint64 + Qualifier string + Version string + WasmPath string +} + +var _ cldf.ChangeSetV2[*UpgradeCacheRequest] = UpgradeCache{} + +// UpgradeCache uploads a new WASM blob and points the cache at it. Apply +// chains two operations: UploadWASM produces the code hash that UpgradeCache +// then applies on-chain. +type UpgradeCache struct{} + +func (UpgradeCache) VerifyPreconditions(env cldf.Environment, req *UpgradeCacheRequest) error { + if err := verifyContractRef(env, req.ChainSel, CacheContract, 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 (UpgradeCache) Apply(env cldf.Environment, req *UpgradeCacheRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + version := semver.MustParse(req.Version) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) + if err != nil { + return out, err + } + + uploadReport, err := operations.ExecuteOperation(env.OperationsBundle, operation.UploadWASM, d.deps, operation.UploadWASMInput{ + WasmPath: req.WasmPath, + }) + if err != nil { + return out, err + } + + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.UpgradeCache, d.deps, operation.UpgradeCacheInput{ + ContractID: d.contractID, + NewWasmHash: uploadReport.Output.WasmHash, + }) + return out, err +} diff --git a/deployment/data-feeds/changeset/stellar/validation.go b/deployment/data-feeds/changeset/stellar/validation.go new file mode 100644 index 00000000000..e3893a83887 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/validation.go @@ -0,0 +1,66 @@ +package stellar + +import ( + "encoding/hex" + "fmt" + "math/big" + "strings" + + "github.com/stellar/go-stellar-sdk/strkey" +) + +// validateAddress accepts a Stellar account (G...) or contract (C...) 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) +} + +// dataIDsToBytes converts 0x-prefixed hex feed IDs to 16-byte arrays, +// left-justified with trailing zero padding — byte-identical to the Solana +// suite's dataIDtoBytes (data IDs are canonically left-aligned, e.g. +// 0x018e16c39e00032000000000000000000 shorthand drops trailing zeros). +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 0x-prefixed 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/validation_test.go b/deployment/data-feeds/changeset/stellar/validation_test.go new file mode 100644 index 00000000000..972f911e8ac --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/validation_test.go @@ -0,0 +1,75 @@ +package stellar + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// mustHex16 decodes s (<=32 hex chars) and left-justifies it into a [16]byte, +// matching the layout dataIDsToBytes is expected to produce. +func mustHex16(t *testing.T, s string) [16]byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + require.LessOrEqual(t, len(b), 16) + var out [16]byte + copy(out[:], b) + return out +} + +func TestValidateAddress(t *testing.T) { + require.NoError(t, validateAddress("GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7")) // account + require.NoError(t, validateAddress("CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA")) // contract + require.Error(t, validateAddress("not-a-key")) + require.Error(t, validateAddress("")) +} + +func TestDataIDsToBytes(t *testing.T) { + // Full-width (exactly 16-byte) input: exact round-trip check. NOTE: for a + // value this wide, left-justified copy(b[:], v.Bytes()) and + // right-justified v.FillBytes(b[:]) produce byte-identical output, so + // this case alone cannot catch a regression to FillBytes — see the short + // case below for that. + got, err := dataIDsToBytes([]string{"0x018e16c39e0003200000000000000000"}) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, mustHex16(t, "018e16c39e0003200000000000000000"), got[0]) + + // Short (sub-16-byte) shorthand input: this is the discriminating case. + // Left-justified (copy), the nonzero prefix "18e16c39e0003200000" lands in + // the LEADING bytes b[0:10] with zero padding trailing at b[10:16]: + // 18 e1 6c 39 e0 00 32 00 00 00 00 00 00 00 00 00 + // Right-justified (FillBytes) would instead push the same nonzero bytes + // down to END at b[15], i.e.: + // 00 00 00 00 00 00 18 e1 6c 39 e0 00 32 00 00 00 + got, err = dataIDsToBytes([]string{"0x018e16c39e00032000000"}) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, byte(0x18), got[0][0]) + require.Equal(t, mustHex16(t, "18e16c39e00032000000000000000000"), got[0]) + + // >128-bit input (17 bytes) must be rejected. + _, err = dataIDsToBytes([]string{"0x01" + strings.Repeat("00", 16)}) + require.Error(t, err) + + // Invalid hex string must be rejected. + _, err = dataIDsToBytes([]string{"zzz"}) + require.Error(t, err) +} + +func TestWorkflowFieldConversions(t *testing.T) { + n, err := workflowNameToBytes("abc") + require.NoError(t, err) + require.Equal(t, byte('a'), n[0]) + _, err = workflowNameToBytes("elevenchars!") + require.Error(t, err) + + o, err := workflowOwnerToBytes("0x0102030405060708090a0b0c0d0e0f1011121314") + require.NoError(t, err) + require.Equal(t, byte(0x14), o[19]) + _, err = workflowOwnerToBytes("0x01") + require.Error(t, err) +} diff --git a/deployment/go.mod b/deployment/go.mod index 15032859439..f9882e0d3a0 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.20260723224154-a5c793895953 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-20260723224154-a5c793895953 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..bd25cfb028e 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.20260723224154-a5c793895953 h1:n/Q0hHFQRxYEalqDEeg01osPd6SYVdrwSbu7Kp0VBdA= +github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260723224154-a5c793895953/go.mod h1:J8D0u8K/a2+3YuJx0F8nf4Fd7h1UZqeWGYszp7aCbdA= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260723224154-a5c793895953 h1:vMQkCPE3ClKukommFC52jKTwPwI5WbM8vo+DMvzG39Q= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260723224154-a5c793895953/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=