From caf1fadce6cd6da48329be2dce463d0d93b6af7b Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 23 Jul 2026 00:48:28 +0100 Subject: [PATCH 01/21] feat(deployment): add chainlink-stellar dependency and stellar changeset package stub --- .../changeset/stellar/deps_spike_test.go | 37 +++++++++++++++++++ .../data-feeds/changeset/stellar/doc.go | 4 ++ deployment/go.mod | 4 +- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 deployment/data-feeds/changeset/stellar/deps_spike_test.go create mode 100644 deployment/data-feeds/changeset/stellar/doc.go diff --git a/deployment/data-feeds/changeset/stellar/deps_spike_test.go b/deployment/data-feeds/changeset/stellar/deps_spike_test.go new file mode 100644 index 00000000000..9b51fe13c50 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deps_spike_test.go @@ -0,0 +1,37 @@ +package stellar + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" + "github.com/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" +) + +// TestImports is a throwaway spike proving the chainlink-stellar dependency +// (root module + bindings submodule) resolves and links from inside the +// chainlink deployment module. A later task replaces this with real +// changesets. +func TestImports(t *testing.T) { + // GenerateDeterministicSalt lives in the root chainlink-stellar + // deployment package. + salt := stellardeploy.GenerateDeterministicSalt("GABCDEF", "DataFeedsCache") + if salt == ([32]byte{}) { + t.Fatal("expected non-zero deterministic salt") + } + + // FromDeployer(nil) proves stellardeps links against both the root + // module (deployment.Deployer) and the bindings submodule + // (bindings.Invoker) it embeds. + deps := stellardeps.FromDeployer(nil) + if deps.Deploy != nil || deps.Invoker != nil { + t.Fatal("expected zero-value StellarDeps for nil deployer") + } + + // NewDataFeedsCacheClient proves the bindings submodule's generated + // contract client package resolves. + client := data_feeds_cache.NewDataFeedsCacheClient(nil, "CONTRACTID") + if client == nil { + t.Fatal("expected non-nil client") + } +} 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/go.mod b/deployment/go.mod index 15032859439..0fa80906c0b 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.20260722234304-681eb03502fb 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 @@ -458,7 +458,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-20260722234304-681eb03502fb // indirect 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 From 0c2c0b0faa2976afef8231497ae7982b5ddeb4f2 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 2 Jul 2026 23:36:48 +0100 Subject: [PATCH 02/21] =?UTF-8?q?feat(data-feeds/stellar):=20package=20sca?= =?UTF-8?q?ffold=20=E2=80=94=20config,=20validation,=20deps=20seam,=20test?= =?UTF-8?q?=20fakes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data-feeds/changeset/stellar/config.go | 10 +++ .../data-feeds/changeset/stellar/deps.go | 17 +++++ .../changeset/stellar/testutil_test.go | 63 ++++++++++++++++++ .../changeset/stellar/validation.go | 64 +++++++++++++++++++ .../changeset/stellar/validation_test.go | 35 ++++++++++ 5 files changed, 189 insertions(+) create mode 100644 deployment/data-feeds/changeset/stellar/config.go create mode 100644 deployment/data-feeds/changeset/stellar/deps.go create mode 100644 deployment/data-feeds/changeset/stellar/testutil_test.go create mode 100644 deployment/data-feeds/changeset/stellar/validation.go create mode 100644 deployment/data-feeds/changeset/stellar/validation_test.go 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/deps.go b/deployment/data-feeds/changeset/stellar/deps.go new file mode 100644 index 00000000000..0a1bae47008 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deps.go @@ -0,0 +1,17 @@ +package stellar + +import ( + cldfstellar "github.com/smartcontractkit/chainlink-deployments-framework/chain/stellar" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" + "github.com/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" +) + +// 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) (stellardeps.StellarDeps, error) { + d, err := stellardeploy.NewDeployerFromChain(ch) + if err != nil { + return stellardeps.StellarDeps{}, err + } + return stellardeps.FromDeployer(d), nil +} 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..7c4006eb9a2 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/testutil_test.go @@ -0,0 +1,63 @@ +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 + simResults map[string]xdr.ScVal // keyed by function name +} + +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}) + if v, ok := f.simResults[fn]; ok { + return &v, nil + } + 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 +} + +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, _ string) (xdr.Hash, error) { + return f.wasmHash, nil +} diff --git a/deployment/data-feeds/changeset/stellar/validation.go b/deployment/data-feeds/changeset/stellar/validation.go new file mode 100644 index 00000000000..0e53ce2d35b --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/validation.go @@ -0,0 +1,64 @@ +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 +// (same semantics as the Solana suite's dataIDsToBytes). +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 + v.FillBytes(b[:]) + 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..8c668130f59 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/validation_test.go @@ -0,0 +1,35 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +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) { + got, err := dataIDsToBytes([]string{"0x018e16c39e00032000000"}) + require.NoError(t, err) + require.Len(t, got, 1) + require.Error(t, func() error { _, err := dataIDsToBytes([]string{"zzz"}); return 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) +} From a66f115f4f7aa43c93132c2bc6c5c05b30af2c8e Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 2 Jul 2026 23:40:15 +0100 Subject: [PATCH 03/21] fix(data-feeds/stellar): left-justify data IDs to match Solana suite semantics --- deployment/data-feeds/changeset/stellar/validation.go | 8 +++++--- .../data-feeds/changeset/stellar/validation_test.go | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/validation.go b/deployment/data-feeds/changeset/stellar/validation.go index 0e53ce2d35b..e3893a83887 100644 --- a/deployment/data-feeds/changeset/stellar/validation.go +++ b/deployment/data-feeds/changeset/stellar/validation.go @@ -20,8 +20,10 @@ func validateAddress(s string) error { 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 -// (same semantics as the Solana suite's dataIDsToBytes). +// 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 { @@ -33,7 +35,7 @@ func dataIDsToBytes(ids []string) ([][16]byte, error) { return nil, fmt.Errorf("data_id too long: %q (%d bits)", id, v.BitLen()) } var b [16]byte - v.FillBytes(b[:]) + copy(b[:], v.Bytes()) out = append(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 index 8c668130f59..6efff29bea4 100644 --- a/deployment/data-feeds/changeset/stellar/validation_test.go +++ b/deployment/data-feeds/changeset/stellar/validation_test.go @@ -14,9 +14,12 @@ func TestValidateAddress(t *testing.T) { } func TestDataIDsToBytes(t *testing.T) { - got, err := dataIDsToBytes([]string{"0x018e16c39e00032000000"}) + got, err := dataIDsToBytes([]string{"0x018e16c39e0003200000000000000000"}) require.NoError(t, err) require.Len(t, got, 1) + // left-justified: leading 0x01 schema byte stays in b[0], zeros pad the tail + require.Equal(t, byte(0x01), got[0][0]) + require.Equal(t, byte(0x00), got[0][15]) require.Error(t, func() error { _, err := dataIDsToBytes([]string{"zzz"}); return err }()) } From 638f8086694214306d3f82c945748b98e071b033 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 2 Jul 2026 23:45:14 +0100 Subject: [PATCH 04/21] test(data-feeds/stellar): pin left-justified data-ID layout with discriminating input --- .../changeset/stellar/validation_test.go | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/validation_test.go b/deployment/data-feeds/changeset/stellar/validation_test.go index 6efff29bea4..972f911e8ac 100644 --- a/deployment/data-feeds/changeset/stellar/validation_test.go +++ b/deployment/data-feeds/changeset/stellar/validation_test.go @@ -1,11 +1,25 @@ 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 @@ -14,13 +28,36 @@ func TestValidateAddress(t *testing.T) { } 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) - // left-justified: leading 0x01 schema byte stays in b[0], zeros pad the tail - require.Equal(t, byte(0x01), got[0][0]) - require.Equal(t, byte(0x00), got[0][15]) - require.Error(t, func() error { _, err := dataIDsToBytes([]string{"zzz"}); return err }()) + 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) { From ba73611d7039641e94200e700884023414819bc0 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 2 Jul 2026 23:49:43 +0100 Subject: [PATCH 05/21] feat(data-feeds/stellar): CLDF operations for cache and proxy --- .../changeset/stellar/operation/operation.go | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 deployment/data-feeds/changeset/stellar/operation/operation.go 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..047f3172101 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/operation/operation.go @@ -0,0 +1,237 @@ +// 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 ( + "github.com/Masterminds/semver/v3" + + cldfops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + 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/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" + "github.com/stellar/go-stellar-sdk/xdr" +) + +var Version1_0_0 = semver.MustParse("1.0.0") + +// 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.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.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.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 []cache.DataId `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.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.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.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,omitempty"` + LiveUntilLedger uint32 `json:"live_until_ledger,omitempty"` +} + +var TransferOwnership = cldfops.NewOperation( + "df:transfer-ownership", Version1_0_0, + "Begins two-step ownership transfer", + func(b cldfops.Bundle, d stellardeps.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.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.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.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.StellarDeps, in UpgradeCacheInput) (Void, error) { + c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) + return Void{}, c.Upgrade(b.GetContext(), cache.WasmHash(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.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.StellarDeps, in SetProxyCacheInput) (Void, error) { + c := proxy.NewDataFeedsProxyClient(d.Invoker, in.ContractID) + return Void{}, c.SetCache(b.GetContext(), in.Cache) + }, +) From 50629ad6953211af38cc6123b71cd61bc0fdd667 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 00:03:11 +0100 Subject: [PATCH 06/21] feat(data-feeds/stellar): DeployCache and DeployProxy changesets --- .../changeset/stellar/changeset_test.go | 156 ++++++++++++++++++ .../changeset/stellar/deploy_cache.go | 88 ++++++++++ .../changeset/stellar/deploy_proxy.go | 108 ++++++++++++ .../changeset/stellar/deps_spike_test.go | 37 ----- 4 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 deployment/data-feeds/changeset/stellar/changeset_test.go create mode 100644 deployment/data-feeds/changeset/stellar/deploy_cache.go create mode 100644 deployment/data-feeds/changeset/stellar/deploy_proxy.go delete mode 100644 deployment/data-feeds/changeset/stellar/deps_spike_test.go 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..86487d19726 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -0,0 +1,156 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/Masterminds/semver/v3" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stellar/go-stellar-sdk/keypair" + "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/deployment/operations/stellardeps" +) + +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) (stellardeps.StellarDeps, error) { + return stellardeps.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 +} + +func TestDeployCacheChangeset(t *testing.T) { + env, _, dep := newTestEnv(t) + + req := &DeployCacheRequest{ + ChainSel: testChainSel, + WasmPath: "/tmp/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 + + 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) +} + +func TestDeployCacheChangeset_MissingChain(t *testing.T) { + env, _, _ := newTestEnv(t) + + req := &DeployCacheRequest{ + ChainSel: 999999, + WasmPath: "/tmp/data_feeds_cache.wasm", + Qualifier: "test-cache", + Version: "1.0.0", + } + + require.Error(t, DeployCache{}.VerifyPreconditions(env, req)) +} + +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: "/tmp/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 + + 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: "/tmp/data_feeds_proxy.wasm", + CacheQualifier: "does-not-exist", + Qualifier: "test-proxy", + Version: "1.0.0", + } + + require.Error(t, DeployProxy{}.VerifyPreconditions(env, req)) +} 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..253f3796231 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deploy_cache.go @@ -0,0 +1,88 @@ +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" + 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 req.WasmPath == "" { + return fmt.Errorf("wasm path must be set") + } + 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 := env.BlockChains.StellarChains()[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..effc197ea11 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/deploy_proxy.go @@ -0,0 +1,108 @@ +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" + 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. +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 _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if req.WasmPath == "" { + return fmt.Errorf("wasm path must be set") + } + if req.Owner != "" { + if err := validateAddress(req.Owner); err != nil { + return err + } + } + if req.CacheQualifier == "" { + return fmt.Errorf("cache qualifier must be set") + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.CacheQualifier), + ); err != nil { + return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.CacheQualifier, err) + } + return nil +} + +func (DeployProxy) Apply(env cldf.Environment, req *DeployProxyRequest) (cldf.ChangesetOutput, error) { + var out cldf.ChangesetOutput + ch := env.BlockChains.StellarChains()[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_spike_test.go b/deployment/data-feeds/changeset/stellar/deps_spike_test.go deleted file mode 100644 index 9b51fe13c50..00000000000 --- a/deployment/data-feeds/changeset/stellar/deps_spike_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package stellar - -import ( - "testing" - - "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" - stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" - "github.com/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" -) - -// TestImports is a throwaway spike proving the chainlink-stellar dependency -// (root module + bindings submodule) resolves and links from inside the -// chainlink deployment module. A later task replaces this with real -// changesets. -func TestImports(t *testing.T) { - // GenerateDeterministicSalt lives in the root chainlink-stellar - // deployment package. - salt := stellardeploy.GenerateDeterministicSalt("GABCDEF", "DataFeedsCache") - if salt == ([32]byte{}) { - t.Fatal("expected non-zero deterministic salt") - } - - // FromDeployer(nil) proves stellardeps links against both the root - // module (deployment.Deployer) and the bindings submodule - // (bindings.Invoker) it embeds. - deps := stellardeps.FromDeployer(nil) - if deps.Deploy != nil || deps.Invoker != nil { - t.Fatal("expected zero-value StellarDeps for nil deployer") - } - - // NewDataFeedsCacheClient proves the bindings submodule's generated - // contract client package resolves. - client := data_feeds_cache.NewDataFeedsCacheClient(nil, "CONTRACTID") - if client == nil { - t.Fatal("expected non-nil client") - } -} From 933d1d717192520d8fcdc5f30f2ccd13888c350b Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 00:10:31 +0100 Subject: [PATCH 07/21] fix(data-feeds/stellar): Apply-side chain guard, stat wasm path, assert deterministic salt --- .../changeset/stellar/changeset_test.go | 29 ++++++++++++++++--- .../changeset/stellar/deploy_cache.go | 10 +++++-- .../changeset/stellar/deploy_proxy.go | 10 +++++-- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/changeset_test.go b/deployment/data-feeds/changeset/stellar/changeset_test.go index 86487d19726..d626557ecb4 100644 --- a/deployment/data-feeds/changeset/stellar/changeset_test.go +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -2,6 +2,8 @@ package stellar import ( "context" + "os" + "path/filepath" "testing" "github.com/Masterminds/semver/v3" @@ -15,6 +17,7 @@ import ( 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" + stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" "github.com/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" ) @@ -66,12 +69,22 @@ func newTestEnv(t *testing.T) (cldf.Environment, *fakeInvoker, *fakeDeployer) { return *env, invoker, deployer } +// 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: "/tmp/data_feeds_cache.wasm", + WasmPath: writeDummyWasm(t, "data_feeds_cache.wasm"), Qualifier: "test-cache", Version: "1.0.0", } @@ -85,6 +98,10 @@ func TestDeployCacheChangeset(t *testing.T) { 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) @@ -96,7 +113,7 @@ func TestDeployCacheChangeset_MissingChain(t *testing.T) { req := &DeployCacheRequest{ ChainSel: 999999, - WasmPath: "/tmp/data_feeds_cache.wasm", + WasmPath: writeDummyWasm(t, "data_feeds_cache.wasm"), Qualifier: "test-cache", Version: "1.0.0", } @@ -120,7 +137,7 @@ func TestDeployProxyChangeset(t *testing.T) { req := &DeployProxyRequest{ ChainSel: testChainSel, - WasmPath: "/tmp/data_feeds_proxy.wasm", + WasmPath: writeDummyWasm(t, "data_feeds_proxy.wasm"), CacheQualifier: "test-cache", Qualifier: "test-proxy", Version: "1.0.0", @@ -135,6 +152,10 @@ func TestDeployProxyChangeset(t *testing.T) { 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) @@ -146,7 +167,7 @@ func TestDeployProxyChangeset_MissingCache(t *testing.T) { req := &DeployProxyRequest{ ChainSel: testChainSel, - WasmPath: "/tmp/data_feeds_proxy.wasm", + WasmPath: writeDummyWasm(t, "data_feeds_proxy.wasm"), CacheQualifier: "does-not-exist", Qualifier: "test-proxy", Version: "1.0.0", diff --git a/deployment/data-feeds/changeset/stellar/deploy_cache.go b/deployment/data-feeds/changeset/stellar/deploy_cache.go index 253f3796231..b3456d02e70 100644 --- a/deployment/data-feeds/changeset/stellar/deploy_cache.go +++ b/deployment/data-feeds/changeset/stellar/deploy_cache.go @@ -2,6 +2,7 @@ package stellar import ( "fmt" + "os" "github.com/Masterminds/semver/v3" @@ -38,8 +39,8 @@ func (DeployCache) VerifyPreconditions(env cldf.Environment, req *DeployCacheReq if _, err := semver.NewVersion(req.Version); err != nil { return fmt.Errorf("invalid version %q: %w", req.Version, err) } - if req.WasmPath == "" { - return fmt.Errorf("wasm path must be set") + 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 { @@ -51,7 +52,10 @@ func (DeployCache) VerifyPreconditions(env cldf.Environment, req *DeployCacheReq func (DeployCache) Apply(env cldf.Environment, req *DeployCacheRequest) (cldf.ChangesetOutput, error) { var out cldf.ChangesetOutput - ch := env.BlockChains.StellarChains()[req.ChainSel] + 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 { diff --git a/deployment/data-feeds/changeset/stellar/deploy_proxy.go b/deployment/data-feeds/changeset/stellar/deploy_proxy.go index effc197ea11..723810b405a 100644 --- a/deployment/data-feeds/changeset/stellar/deploy_proxy.go +++ b/deployment/data-feeds/changeset/stellar/deploy_proxy.go @@ -2,6 +2,7 @@ package stellar import ( "fmt" + "os" "github.com/Masterminds/semver/v3" @@ -41,8 +42,8 @@ func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyReq if err != nil { return fmt.Errorf("invalid version %q: %w", req.Version, err) } - if req.WasmPath == "" { - return fmt.Errorf("wasm path must be set") + 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 { @@ -62,7 +63,10 @@ func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyReq func (DeployProxy) Apply(env cldf.Environment, req *DeployProxyRequest) (cldf.ChangesetOutput, error) { var out cldf.ChangesetOutput - ch := env.BlockChains.StellarChains()[req.ChainSel] + 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( From 996029603eb294d26379ef6d6001deb18b84e87e Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 00:16:07 +0100 Subject: [PATCH 08/21] feat(data-feeds/stellar): feed config, removal, and feed-admin changesets --- .../changeset/stellar/changeset_test.go | 154 ++++++++++++++++++ .../changeset/stellar/configure_cache.go | 145 +++++++++++++++++ .../changeset/stellar/feed_admin.go | 119 ++++++++++++++ .../changeset/stellar/remove_feeds.go | 92 +++++++++++ 4 files changed, 510 insertions(+) create mode 100644 deployment/data-feeds/changeset/stellar/configure_cache.go create mode 100644 deployment/data-feeds/changeset/stellar/feed_admin.go create mode 100644 deployment/data-feeds/changeset/stellar/remove_feeds.go diff --git a/deployment/data-feeds/changeset/stellar/changeset_test.go b/deployment/data-feeds/changeset/stellar/changeset_test.go index d626557ecb4..ab67bb78022 100644 --- a/deployment/data-feeds/changeset/stellar/changeset_test.go +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -69,6 +69,22 @@ func newTestEnv(t *testing.T) (cldf.Environment, *fakeInvoker, *fakeDeployer) { return *env, invoker, deployer } +// seedCacheRef pre-seeds env.DataStore with a 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() + ds := datastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + Address: address, + ChainSelector: testChainSel, + Type: CacheContract, + Version: semver.MustParse(version), + Qualifier: qualifier, + })) + env.DataStore = ds.Seal() +} + // 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. @@ -175,3 +191,141 @@ func TestDeployProxyChangeset_MissingCache(t *testing.T) { 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)) +} + +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)) +} 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..236737e8684 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/configure_cache.go @@ -0,0 +1,145 @@ +package stellar + +import ( + "errors" + "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" + 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 _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ); err != nil { + return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, 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 _, 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: cache.WorkflowOwner(owner), + AllowedWorkflowName: cache.WorkflowName(name), + }) + } + return out, nil +} + +func (SetFeedConfigs) Apply(env cldf.Environment, req *SetFeedConfigsRequest) (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) + ref, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ) + if err != nil { + return out, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + } + + deps, err := newStellarDeps(ch) + 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: cache.DataId(id), + Config: cache.FeedConfig{ + Description: req.Descriptions[i], + WorkflowPermissions: perms, + }, + } + } + + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.SetFeedConfigs, deps, operation.SetFeedConfigsInput{ + ContractID: ref.Address, + Admin: req.Admin, + Entries: entries, + }) + return out, err +} 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..01cd0bd6536 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/feed_admin.go @@ -0,0 +1,119 @@ +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-stellar/deployment/operations/stellardeps" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" +) + +// stellarApplyDeps bundles the resolved cache contract ID with the chain deps +// needed to invoke an operation against it — shared between AddFeedAdmin and +// RemoveFeedAdmin's Apply, which are otherwise identical apart from the op. +type stellarApplyDeps struct { + deps stellardeps.StellarDeps + contractID string +} + +// 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 _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ); err != nil { + return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, 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) { + var zero stellarApplyDeps + ch, ok := env.BlockChains.StellarChains()[req.ChainSel] + if !ok { + return zero, fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + + version := semver.MustParse(req.Version) + ref, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ) + if err != nil { + return zero, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + } + + deps, err := newStellarDeps(ch) + if err != nil { + return zero, err + } + + return stellarApplyDeps{deps: deps, contractID: ref.Address}, nil +} + +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/remove_feeds.go b/deployment/data-feeds/changeset/stellar/remove_feeds.go new file mode 100644 index 00000000000..be036976b30 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/remove_feeds.go @@ -0,0 +1,92 @@ +package stellar + +import ( + "errors" + "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" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" + + "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 _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ); err != nil { + return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, 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 + 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) + ref, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ) + if err != nil { + return out, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + } + + deps, err := newStellarDeps(ch) + if err != nil { + return out, err + } + + ids, err := dataIDsToBytes(req.DataIDs) + if err != nil { + return out, err + } + dataIDs := make([]cache.DataId, len(ids)) + for i, id := range ids { + dataIDs[i] = cache.DataId(id) + } + + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RemoveFeedConfigs, deps, operation.RemoveFeedConfigsInput{ + ContractID: ref.Address, + Admin: req.Admin, + DataIDs: dataIDs, + }) + return out, err +} From f55d11463c81a6f813275a9e77118792453a8660 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 00:28:57 +0100 Subject: [PATCH 09/21] feat(data-feeds/stellar): ownership, upgrade, recover-tokens, set-proxy-cache changesets --- .../changeset/stellar/changeset_test.go | 269 +++++++++++++++++- .../data-feeds/changeset/stellar/deps.go | 40 +++ .../changeset/stellar/feed_admin.go | 29 +- .../data-feeds/changeset/stellar/ownership.go | 128 +++++++++ .../changeset/stellar/recover_tokens.go | 70 +++++ .../changeset/stellar/set_proxy_cache.go | 72 +++++ .../changeset/stellar/testutil_test.go | 4 +- .../changeset/stellar/upgrade_cache.go | 71 +++++ 8 files changed, 643 insertions(+), 40 deletions(-) create mode 100644 deployment/data-feeds/changeset/stellar/ownership.go create mode 100644 deployment/data-feeds/changeset/stellar/recover_tokens.go create mode 100644 deployment/data-feeds/changeset/stellar/set_proxy_cache.go create mode 100644 deployment/data-feeds/changeset/stellar/upgrade_cache.go diff --git a/deployment/data-feeds/changeset/stellar/changeset_test.go b/deployment/data-feeds/changeset/stellar/changeset_test.go index ab67bb78022..10e869831a4 100644 --- a/deployment/data-feeds/changeset/stellar/changeset_test.go +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -17,6 +18,7 @@ import ( 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" + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" "github.com/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" ) @@ -69,22 +71,50 @@ func newTestEnv(t *testing.T) (cldf.Environment, *fakeInvoker, *fakeDeployer) { return *env, invoker, deployer } -// seedCacheRef pre-seeds env.DataStore with a 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) { +// 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() - require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ - Address: address, - ChainSelector: testChainSel, - Type: CacheContract, - Version: semver.MustParse(version), - Qualifier: qualifier, - })) + 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. @@ -329,3 +359,220 @@ func TestFeedAdminChangesets(t *testing.T) { 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 := cache.WasmHashFromScVal(inv.calls[0].Args[0]) + require.NoError(t, err) + require.Equal(t, cache.WasmHash(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/deps.go b/deployment/data-feeds/changeset/stellar/deps.go index 0a1bae47008..1a563b2b055 100644 --- a/deployment/data-feeds/changeset/stellar/deps.go +++ b/deployment/data-feeds/changeset/stellar/deps.go @@ -1,7 +1,13 @@ 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-stellar/deployment/operations/stellardeps" ) @@ -15,3 +21,37 @@ var newStellarDeps = func(ch cldfstellar.Chain) (stellardeps.StellarDeps, error) } return stellardeps.FromDeployer(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 stellardeps.StellarDeps + contractID string +} + +// resolveContractDeps looks up the AddressRef for (chainSel, contractType, +// version, qualifier) and bundles it with the chain's deploy/invoke deps. +// version must already be parsed (see semver.MustParse in callers that have +// validated req.Version in VerifyPreconditions). +func resolveContractDeps(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, version *semver.Version, qualifier string) (stellarApplyDeps, error) { + var zero stellarApplyDeps + ch, ok := env.BlockChains.StellarChains()[chainSel] + if !ok { + return zero, 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, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + } + + deps, err := newStellarDeps(ch) + if err != nil { + return zero, err + } + + return stellarApplyDeps{deps: deps, contractID: ref.Address}, nil +} diff --git a/deployment/data-feeds/changeset/stellar/feed_admin.go b/deployment/data-feeds/changeset/stellar/feed_admin.go index 01cd0bd6536..97080c9ef02 100644 --- a/deployment/data-feeds/changeset/stellar/feed_admin.go +++ b/deployment/data-feeds/changeset/stellar/feed_admin.go @@ -8,19 +8,10 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/operations" - "github.com/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" ) -// stellarApplyDeps bundles the resolved cache contract ID with the chain deps -// needed to invoke an operation against it — shared between AddFeedAdmin and -// RemoveFeedAdmin's Apply, which are otherwise identical apart from the op. -type stellarApplyDeps struct { - deps stellardeps.StellarDeps - contractID string -} - // FeedAdminRequest grants or revokes feed-admin rights on an already-deployed // DataFeedsCache contract. type FeedAdminRequest struct { @@ -51,26 +42,8 @@ func (req *FeedAdminRequest) verifyPreconditions(env cldf.Environment) error { // resolve looks up the cache contract's dependencies + address for req. func (req *FeedAdminRequest) resolve(env cldf.Environment) (stellarApplyDeps, error) { - var zero stellarApplyDeps - ch, ok := env.BlockChains.StellarChains()[req.ChainSel] - if !ok { - return zero, fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) - } - version := semver.MustParse(req.Version) - ref, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ) - if err != nil { - return zero, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) - } - - deps, err := newStellarDeps(ch) - if err != nil { - return zero, err - } - - return stellarApplyDeps{deps: deps, contractID: ref.Address}, nil + return resolveContractDeps(env, req.ChainSel, CacheContract, version, req.Qualifier) } var ( diff --git a/deployment/data-feeds/changeset/stellar/ownership.go b/deployment/data-feeds/changeset/stellar/ownership.go new file mode 100644 index 00000000000..17acd407063 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/ownership.go @@ -0,0 +1,128 @@ +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) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + 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 := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, req.Contract, version, req.Qualifier), + ); err != nil { + return fmt.Errorf("%s address ref not found for qualifier %q: %w", req.Contract, req.Qualifier, 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) + return resolveContractDeps(env, req.ChainSel, req.Contract, version, req.Qualifier) +} + +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..535516f0c8c --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/recover_tokens.go @@ -0,0 +1,70 @@ +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" +) + +// 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 _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ); err != nil { + return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, 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, version, req.Qualifier) + 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/set_proxy_cache.go b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go new file mode 100644 index 00000000000..a0e6dcc4297 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go @@ -0,0 +1,72 @@ +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; CacheQualifier resolves the new cache target — the two must +// resolve to distinct AddressRefs when repointing the proxy. +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 _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, ProxyContract, version, req.Qualifier), + ); err != nil { + return fmt.Errorf("proxy address ref not found for qualifier %q: %w", req.Qualifier, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.CacheQualifier), + ); err != nil { + return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.CacheQualifier, 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, version, req.Qualifier) + 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/testutil_test.go b/deployment/data-feeds/changeset/stellar/testutil_test.go index 7c4006eb9a2..285b61ea436 100644 --- a/deployment/data-feeds/changeset/stellar/testutil_test.go +++ b/deployment/data-feeds/changeset/stellar/testutil_test.go @@ -47,6 +47,7 @@ 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) { @@ -58,6 +59,7 @@ func (f *fakeDeployer) DeployContractWithArgs(_ context.Context, wasmPath string return f.contractID, nil } -func (f *fakeDeployer) UploadContractWASM(_ context.Context, _ string) (xdr.Hash, error) { +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..015747d01b3 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/upgrade_cache.go @@ -0,0 +1,71 @@ +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" + + "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 _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { + return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + } + version, err := semver.NewVersion(req.Version) + if err != nil { + return fmt.Errorf("invalid version %q: %w", req.Version, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), + ); err != nil { + return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, 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, version, req.Qualifier) + 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 +} From 35010be6f46416ef4de1eaebe5a236fe618cc573 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 00:30:07 +0100 Subject: [PATCH 10/21] chore(deployment): mark go-stellar-sdk as direct dependency --- deployment/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/go.mod b/deployment/go.mod index 0fa80906c0b..c7ee0c0e426 100644 --- a/deployment/go.mod +++ b/deployment/go.mod @@ -472,7 +472,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 From c53ed55d58090723478c4f12181d5fc18796852e Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 00:34:34 +0100 Subject: [PATCH 11/21] feat(data-feeds/stellar): datastore-backed cache and proxy client loaders --- .../data-feeds/changeset/stellar/state.go | 60 ++++++++++++++++++ .../changeset/stellar/state_test.go | 61 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 deployment/data-feeds/changeset/stellar/state.go create mode 100644 deployment/data-feeds/changeset/stellar/state_test.go diff --git a/deployment/data-feeds/changeset/stellar/state.go b/deployment/data-feeds/changeset/stellar/state.go new file mode 100644 index 00000000000..e9a7822e3f9 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/state.go @@ -0,0 +1,60 @@ +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) { + var zeroRef datastore.AddressRef + + v, err := semver.NewVersion(version) + if err != nil { + return stellarApplyDeps{}, zeroRef, fmt.Errorf("invalid version %q: %w", version, err) + } + + d, err := resolveContractDeps(env, chainSel, contractType, v, qualifier) + if err != nil { + return stellarApplyDeps{}, zeroRef, err + } + + ref, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(chainSel, contractType, v, qualifier), + ) + if err != nil { + return stellarApplyDeps{}, zeroRef, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + } + + return d, ref, nil +} + +// 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) +} From b36dbb4458b562c98b46553fd4a8497341211501 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 09:12:43 +0100 Subject: [PATCH 12/21] test(data-feeds/stellar): e2e on local CTF devnet covering every cache and proxy function --- .../data-feeds/changeset/stellar/e2e_test.go | 463 ++++++++++++++++++ .../changeset/stellar/testdata/.gitignore | 5 + .../changeset/stellar/testdata/README.md | 27 + .../stellar/testdata/data_feeds_cache.wasm | Bin 0 -> 19526 bytes .../stellar/testdata/data_feeds_proxy.wasm | Bin 0 -> 10365 bytes deployment/go.mod | 15 + 6 files changed, 510 insertions(+) create mode 100644 deployment/data-feeds/changeset/stellar/e2e_test.go create mode 100644 deployment/data-feeds/changeset/stellar/testdata/.gitignore create mode 100644 deployment/data-feeds/changeset/stellar/testdata/README.md create mode 100644 deployment/data-feeds/changeset/stellar/testdata/data_feeds_cache.wasm create mode 100644 deployment/data-feeds/changeset/stellar/testdata/data_feeds_proxy.wasm 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..a802783e093 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -0,0 +1,463 @@ +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" + + 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" + proxy "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_proxy" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + 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 has no upgrade/recover_tokens changeset (controller decision), 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 := cache.DataId(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(cache.WorkflowName(nameBytes), cache.WorkflowOwner(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, cache.WorkflowOwner(ownerBytes), cache.WorkflowName(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 { _, e := cacheClient.GetRound(ctx, did, 1); return e }) + mustCall(t, "cache.round_range", func() error { _, e := cacheClient.RoundRange(ctx, did, 1, 1); return e }) + mustCall(t, "cache.find_round", func() error { + _, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.Bound{AtOrBefore: &cache.BoundAtOrBefore{}}) + return e + }) + + // --- proxy reads (read through to the cache's stored round) ----------- + proxyClient, _, err := LoadProxyClient(env, sel, e2eQualifier, e2eVersion) + require.NoError(t, err) + pdid := proxy.DataId(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, proxy.WasmHash(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.Contains(t, proxyRecoverErr.Error(), "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.Contains(t, cacheRecoverErr.Error(), "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 cache.WorkflowName, owner cache.WorkflowOwner) []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++ { + resp, err := http.Get(target) //nolint:gosec // local CTF devnet URL + 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/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..bde65331e79 --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/testdata/README.md @@ -0,0 +1,27 @@ +# 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/wasm32-unknown-unknown/release/data_feeds_cache.wasm / +cp target/wasm32-unknown-unknown/release/data_feeds_proxy.wasm / +``` + +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 0000000000000000000000000000000000000000..022a219c3ee7940b603ab8f94cfe1e65dff26ad5 GIT binary patch literal 19526 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 ln+V+|>w$ Date: Fri, 3 Jul 2026 09:52:02 +0100 Subject: [PATCH 13/21] docs(data-feeds/stellar): package README One-page overview of the Stellar Data Feeds changeset package: layout (changesets -> operations -> chainlink-stellar bindings/deployer), the permissions model (CRE forwarder flows into FeedPermission.AllowedSender via SetFeedConfigs, no standalone "set forwarder"), how to regenerate testdata WASM + bindings together, how to run the container-backed e2e test including the arm64 STELLAR_E2E_RPC_URL override, and what's explicitly out of scope (durable-pipeline registration, MCMS, forwarder deployment). --- .../data-feeds/changeset/stellar/README.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 deployment/data-feeds/changeset/stellar/README.md diff --git a/deployment/data-feeds/changeset/stellar/README.md b/deployment/data-feeds/changeset/stellar/README.md new file mode 100644 index 00000000000..40464c929de --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/README.md @@ -0,0 +1,100 @@ +### 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. + +#### 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/wasm32-unknown-unknown/release/data_feeds_{cache,proxy}.wasm \ + /deployment/data-feeds/changeset/stellar/testdata/ + +# 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 invokes every exported function (success path + one +documented error path each). 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`. From 240b2922f742b70d43bca800f083f9d8cb6d1ece Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 09:52:17 +0100 Subject: [PATCH 14/21] refactor(data-feeds/stellar): quality pass - review minors - e2e_test.go: assert require.NotNil on get_round/find_round results so the test actually fails if the round comes back void instead of the stored value. - state.go/deps.go: replace the duplicated Addresses().Get(...) + error-string pattern in loadContractClientDeps with a shared resolveContractDeps that also returns the AddressRef for callers that need more than its Address. - operation/operation.go: drop the misleading omitempty from OwnershipInput's NewOwner/LiveUntilLedger json tags (zero values are valid input, not "absent"). - deps.go: extract the chain-exists + version-parse + AddressRef-exists skeleton (duplicated verbatim across every changeset's VerifyPreconditions) into verifyContractRef; update all nine changeset files to call it instead of repeating the checks inline. --- .../changeset/stellar/configure_cache.go | 32 +++----------- .../changeset/stellar/deploy_proxy.go | 13 +----- .../data-feeds/changeset/stellar/deps.go | 42 +++++++++++++++---- .../data-feeds/changeset/stellar/e2e_test.go | 19 +++++++-- .../changeset/stellar/feed_admin.go | 19 ++------- .../changeset/stellar/operation/operation.go | 4 +- .../data-feeds/changeset/stellar/ownership.go | 16 ++----- .../changeset/stellar/recover_tokens.go | 16 ++----- .../changeset/stellar/remove_feeds.go | 33 +++------------ .../changeset/stellar/set_proxy_cache.go | 21 +++------- .../data-feeds/changeset/stellar/state.go | 19 +-------- .../changeset/stellar/upgrade_cache.go | 16 ++----- 12 files changed, 84 insertions(+), 166 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/configure_cache.go b/deployment/data-feeds/changeset/stellar/configure_cache.go index 236737e8684..5c67e79654b 100644 --- a/deployment/data-feeds/changeset/stellar/configure_cache.go +++ b/deployment/data-feeds/changeset/stellar/configure_cache.go @@ -6,7 +6,6 @@ import ( "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" cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" @@ -42,17 +41,8 @@ var _ cldf.ChangeSetV2[*SetFeedConfigsRequest] = SetFeedConfigs{} type SetFeedConfigs struct{} func (SetFeedConfigs) VerifyPreconditions(env cldf.Environment, req *SetFeedConfigsRequest) error { - if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { - return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) - } - version, err := semver.NewVersion(req.Version) - if err != nil { - return fmt.Errorf("invalid version %q: %w", req.Version, err) - } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ); err != nil { - return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + return err } if err := validateAddress(req.Admin); err != nil { return err @@ -98,20 +88,8 @@ func (req *SetFeedConfigsRequest) permissions() ([]cache.WorkflowPermission, err func (SetFeedConfigs) Apply(env cldf.Environment, req *SetFeedConfigsRequest) (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) - ref, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ) - if err != nil { - return out, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) - } - - deps, err := newStellarDeps(ch) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, version, req.Qualifier) if err != nil { return out, err } @@ -136,8 +114,8 @@ func (SetFeedConfigs) Apply(env cldf.Environment, req *SetFeedConfigsRequest) (c } } - _, err = operations.ExecuteOperation(env.OperationsBundle, operation.SetFeedConfigs, deps, operation.SetFeedConfigsInput{ - ContractID: ref.Address, + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.SetFeedConfigs, d.deps, operation.SetFeedConfigsInput{ + ContractID: d.contractID, Admin: req.Admin, Entries: entries, }) diff --git a/deployment/data-feeds/changeset/stellar/deploy_proxy.go b/deployment/data-feeds/changeset/stellar/deploy_proxy.go index 723810b405a..084d8d761e0 100644 --- a/deployment/data-feeds/changeset/stellar/deploy_proxy.go +++ b/deployment/data-feeds/changeset/stellar/deploy_proxy.go @@ -35,13 +35,6 @@ var _ cldf.ChangeSetV2[*DeployProxyRequest] = DeployProxy{} type DeployProxy struct{} func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyRequest) error { - if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { - return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) - } - version, err := semver.NewVersion(req.Version) - if 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) } @@ -53,10 +46,8 @@ func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyReq if req.CacheQualifier == "" { return fmt.Errorf("cache qualifier must be set") } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.CacheQualifier), - ); err != nil { - return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.CacheQualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.CacheQualifier); err != nil { + return err } return nil } diff --git a/deployment/data-feeds/changeset/stellar/deps.go b/deployment/data-feeds/changeset/stellar/deps.go index 1a563b2b055..a80816b6156 100644 --- a/deployment/data-feeds/changeset/stellar/deps.go +++ b/deployment/data-feeds/changeset/stellar/deps.go @@ -30,28 +30,54 @@ type stellarApplyDeps struct { contractID string } +// verifyContractRef checks that chainSel names a known Stellar chain, +// versionStr parses, and an AddressRef exists for (chainSel, contractType, +// version, qualifier). It returns the parsed version so VerifyPreconditions +// callers can validate further request-specific fields without re-parsing, +// and so Apply can reuse it via semver.MustParse (VerifyPreconditions always +// runs first). 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, versionStr, qualifier string) (*semver.Version, error) { + if _, ok := env.BlockChains.StellarChains()[chainSel]; !ok { + return nil, fmt.Errorf("stellar chain not found for chain selector %d", chainSel) + } + version, err := semver.NewVersion(versionStr) + if err != nil { + return nil, fmt.Errorf("invalid version %q: %w", versionStr, err) + } + if _, err := env.DataStore.Addresses().Get( + datastore.NewAddressRefKey(chainSel, contractType, version, qualifier), + ); err != nil { + return nil, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + } + return version, nil +} + // resolveContractDeps looks up the AddressRef for (chainSel, contractType, -// version, qualifier) and bundles it with the chain's deploy/invoke deps. -// version must already be parsed (see semver.MustParse in callers that have -// validated req.Version in VerifyPreconditions). -func resolveContractDeps(env cldf.Environment, chainSel uint64, contractType datastore.ContractType, version *semver.Version, qualifier string) (stellarApplyDeps, error) { +// 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, version *semver.Version, qualifier string) (stellarApplyDeps, datastore.AddressRef, error) { var zero stellarApplyDeps + var zeroRef datastore.AddressRef ch, ok := env.BlockChains.StellarChains()[chainSel] if !ok { - return zero, fmt.Errorf("stellar chain not found for chain selector %d", chainSel) + 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, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + 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, err + return zero, zeroRef, err } - return stellarApplyDeps{deps: deps, contractID: ref.Address}, nil + return stellarApplyDeps{deps: deps, contractID: ref.Address}, ref, nil } diff --git a/deployment/data-feeds/changeset/stellar/e2e_test.go b/deployment/data-feeds/changeset/stellar/e2e_test.go index a802783e093..10e0e6a14b3 100644 --- a/deployment/data-feeds/changeset/stellar/e2e_test.go +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -16,6 +16,7 @@ import ( "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" @@ -24,7 +25,6 @@ import ( 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/smartcontractkit/chainlink-common/pkg/logger" commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" ) @@ -201,11 +201,22 @@ func TestStellarDataFeedsE2E(t *testing.T) { return e }) mustCall(t, "cache.get_feed_permissions", func() error { _, e := cacheClient.GetFeedPermissions(ctx, did); return e }) - mustCall(t, "cache.get_round", func() error { _, e := cacheClient.GetRound(ctx, did, 1); 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 { _, e := cacheClient.RoundRange(ctx, did, 1, 1); return e }) mustCall(t, "cache.find_round", func() error { - _, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.Bound{AtOrBefore: &cache.BoundAtOrBefore{}}) - return e + r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.Bound{AtOrBefore: &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) ----------- diff --git a/deployment/data-feeds/changeset/stellar/feed_admin.go b/deployment/data-feeds/changeset/stellar/feed_admin.go index 97080c9ef02..e65b555c28c 100644 --- a/deployment/data-feeds/changeset/stellar/feed_admin.go +++ b/deployment/data-feeds/changeset/stellar/feed_admin.go @@ -1,11 +1,8 @@ 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" @@ -22,17 +19,8 @@ type FeedAdminRequest struct { } func (req *FeedAdminRequest) 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) - } - version, err := semver.NewVersion(req.Version) - if err != nil { - return fmt.Errorf("invalid version %q: %w", req.Version, err) - } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ); err != nil { - return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + return err } if err := validateAddress(req.Admin); err != nil { return err @@ -43,7 +31,8 @@ func (req *FeedAdminRequest) verifyPreconditions(env cldf.Environment) error { // 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) - return resolveContractDeps(env, req.ChainSel, CacheContract, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, version, req.Qualifier) + return d, err } var ( diff --git a/deployment/data-feeds/changeset/stellar/operation/operation.go b/deployment/data-feeds/changeset/stellar/operation/operation.go index 047f3172101..a9d2691e7bb 100644 --- a/deployment/data-feeds/changeset/stellar/operation/operation.go +++ b/deployment/data-feeds/changeset/stellar/operation/operation.go @@ -130,8 +130,8 @@ var RemoveFeedAdmin = cldfops.NewOperation( type OwnershipInput struct { ContractID string `json:"contract_id"` IsProxy bool `json:"is_proxy"` - NewOwner string `json:"new_owner,omitempty"` - LiveUntilLedger uint32 `json:"live_until_ledger,omitempty"` + NewOwner string `json:"new_owner"` + LiveUntilLedger uint32 `json:"live_until_ledger"` } var TransferOwnership = cldfops.NewOperation( diff --git a/deployment/data-feeds/changeset/stellar/ownership.go b/deployment/data-feeds/changeset/stellar/ownership.go index 17acd407063..ed9321f309a 100644 --- a/deployment/data-feeds/changeset/stellar/ownership.go +++ b/deployment/data-feeds/changeset/stellar/ownership.go @@ -25,20 +25,11 @@ type OwnershipRequest struct { } 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) - } - version, err := semver.NewVersion(req.Version) - if err != nil { - return fmt.Errorf("invalid version %q: %w", req.Version, err) - } 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 := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, req.Contract, version, req.Qualifier), - ); err != nil { - return fmt.Errorf("%s address ref not found for qualifier %q: %w", req.Contract, req.Qualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, req.Contract, req.Version, req.Qualifier); err != nil { + return err } return nil } @@ -46,7 +37,8 @@ func (req *OwnershipRequest) verifyPreconditions(env cldf.Environment) error { // 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) - return resolveContractDeps(env, req.ChainSel, req.Contract, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, req.Contract, version, req.Qualifier) + return d, err } var ( diff --git a/deployment/data-feeds/changeset/stellar/recover_tokens.go b/deployment/data-feeds/changeset/stellar/recover_tokens.go index 535516f0c8c..77184125d17 100644 --- a/deployment/data-feeds/changeset/stellar/recover_tokens.go +++ b/deployment/data-feeds/changeset/stellar/recover_tokens.go @@ -5,7 +5,6 @@ import ( "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" @@ -29,17 +28,8 @@ var _ cldf.ChangeSetV2[*RecoverTokensRequest] = RecoverTokens{} type RecoverTokens struct{} func (RecoverTokens) VerifyPreconditions(env cldf.Environment, req *RecoverTokensRequest) error { - if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { - return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) - } - version, err := semver.NewVersion(req.Version) - if err != nil { - return fmt.Errorf("invalid version %q: %w", req.Version, err) - } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ); err != nil { - return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + return err } if err := validateAddress(req.Token); err != nil { return fmt.Errorf("token: %w", err) @@ -56,7 +46,7 @@ func (RecoverTokens) VerifyPreconditions(env cldf.Environment, req *RecoverToken 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, version, req.Qualifier) if err != nil { return out, err } diff --git a/deployment/data-feeds/changeset/stellar/remove_feeds.go b/deployment/data-feeds/changeset/stellar/remove_feeds.go index be036976b30..81e7691c6b4 100644 --- a/deployment/data-feeds/changeset/stellar/remove_feeds.go +++ b/deployment/data-feeds/changeset/stellar/remove_feeds.go @@ -2,11 +2,9 @@ package stellar import ( "errors" - "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" cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" @@ -30,17 +28,8 @@ var _ cldf.ChangeSetV2[*RemoveFeedConfigsRequest] = RemoveFeedConfigs{} type RemoveFeedConfigs struct{} func (RemoveFeedConfigs) VerifyPreconditions(env cldf.Environment, req *RemoveFeedConfigsRequest) error { - if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { - return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) - } - version, err := semver.NewVersion(req.Version) - if err != nil { - return fmt.Errorf("invalid version %q: %w", req.Version, err) - } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ); err != nil { - return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + return err } if err := validateAddress(req.Admin); err != nil { return err @@ -56,20 +45,8 @@ func (RemoveFeedConfigs) VerifyPreconditions(env cldf.Environment, req *RemoveFe func (RemoveFeedConfigs) Apply(env cldf.Environment, req *RemoveFeedConfigsRequest) (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) - ref, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ) - if err != nil { - return out, fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) - } - - deps, err := newStellarDeps(ch) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, version, req.Qualifier) if err != nil { return out, err } @@ -83,8 +60,8 @@ func (RemoveFeedConfigs) Apply(env cldf.Environment, req *RemoveFeedConfigsReque dataIDs[i] = cache.DataId(id) } - _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RemoveFeedConfigs, deps, operation.RemoveFeedConfigsInput{ - ContractID: ref.Address, + _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RemoveFeedConfigs, d.deps, operation.RemoveFeedConfigsInput{ + ContractID: d.contractID, Admin: req.Admin, DataIDs: dataIDs, }) diff --git a/deployment/data-feeds/changeset/stellar/set_proxy_cache.go b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go index a0e6dcc4297..56b1932be76 100644 --- a/deployment/data-feeds/changeset/stellar/set_proxy_cache.go +++ b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go @@ -29,22 +29,11 @@ var _ cldf.ChangeSetV2[*SetProxyCacheRequest] = SetProxyCache{} type SetProxyCache struct{} func (SetProxyCache) VerifyPreconditions(env cldf.Environment, req *SetProxyCacheRequest) error { - if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { - return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) + if _, err := verifyContractRef(env, req.ChainSel, ProxyContract, req.Version, req.Qualifier); err != nil { + return err } - version, err := semver.NewVersion(req.Version) - if err != nil { - return fmt.Errorf("invalid version %q: %w", req.Version, err) - } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, ProxyContract, version, req.Qualifier), - ); err != nil { - return fmt.Errorf("proxy address ref not found for qualifier %q: %w", req.Qualifier, err) - } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.CacheQualifier), - ); err != nil { - return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.CacheQualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.CacheQualifier); err != nil { + return err } return nil } @@ -53,7 +42,7 @@ func (SetProxyCache) Apply(env cldf.Environment, req *SetProxyCacheRequest) (cld var out cldf.ChangesetOutput version := semver.MustParse(req.Version) - proxyDeps, err := resolveContractDeps(env, req.ChainSel, ProxyContract, version, req.Qualifier) + proxyDeps, _, err := resolveContractDeps(env, req.ChainSel, ProxyContract, version, req.Qualifier) if err != nil { return out, err } diff --git a/deployment/data-feeds/changeset/stellar/state.go b/deployment/data-feeds/changeset/stellar/state.go index e9a7822e3f9..b0c5259d2a0 100644 --- a/deployment/data-feeds/changeset/stellar/state.go +++ b/deployment/data-feeds/changeset/stellar/state.go @@ -17,26 +17,11 @@ import ( // 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) { - var zeroRef datastore.AddressRef - v, err := semver.NewVersion(version) if err != nil { - return stellarApplyDeps{}, zeroRef, fmt.Errorf("invalid version %q: %w", version, err) - } - - d, err := resolveContractDeps(env, chainSel, contractType, v, qualifier) - if err != nil { - return stellarApplyDeps{}, zeroRef, err + return stellarApplyDeps{}, datastore.AddressRef{}, fmt.Errorf("invalid version %q: %w", version, err) } - - ref, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(chainSel, contractType, v, qualifier), - ) - if err != nil { - return stellarApplyDeps{}, zeroRef, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) - } - - return d, ref, nil + return resolveContractDeps(env, chainSel, contractType, v, qualifier) } // LoadCacheClient resolves the CacheContract AddressRef from env.DataStore diff --git a/deployment/data-feeds/changeset/stellar/upgrade_cache.go b/deployment/data-feeds/changeset/stellar/upgrade_cache.go index 015747d01b3..d260361b7f4 100644 --- a/deployment/data-feeds/changeset/stellar/upgrade_cache.go +++ b/deployment/data-feeds/changeset/stellar/upgrade_cache.go @@ -6,7 +6,6 @@ import ( "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" @@ -30,17 +29,8 @@ var _ cldf.ChangeSetV2[*UpgradeCacheRequest] = UpgradeCache{} type UpgradeCache struct{} func (UpgradeCache) VerifyPreconditions(env cldf.Environment, req *UpgradeCacheRequest) error { - if _, ok := env.BlockChains.StellarChains()[req.ChainSel]; !ok { - return fmt.Errorf("stellar chain not found for chain selector %d", req.ChainSel) - } - version, err := semver.NewVersion(req.Version) - if err != nil { - return fmt.Errorf("invalid version %q: %w", req.Version, err) - } - if _, err := env.DataStore.Addresses().Get( - datastore.NewAddressRefKey(req.ChainSel, CacheContract, version, req.Qualifier), - ); err != nil { - return fmt.Errorf("cache address ref not found for qualifier %q: %w", req.Qualifier, err) + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + return err } if _, err := os.Stat(req.WasmPath); err != nil { return fmt.Errorf("wasm path: %w", err) @@ -51,7 +41,7 @@ func (UpgradeCache) VerifyPreconditions(env cldf.Environment, req *UpgradeCacheR 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, version, req.Qualifier) if err != nil { return out, err } From f6c94c963750b3ae6c8460a4443a9de20389e1f2 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 10:03:50 +0100 Subject: [PATCH 15/21] fix(data-feeds/stellar): restore precondition check order, assert round_range non-empty --- deployment/data-feeds/changeset/stellar/deploy_proxy.go | 6 +++--- deployment/data-feeds/changeset/stellar/e2e_test.go | 9 ++++++++- deployment/data-feeds/changeset/stellar/ownership.go | 3 +++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/deploy_proxy.go b/deployment/data-feeds/changeset/stellar/deploy_proxy.go index 084d8d761e0..0c5ce1f6407 100644 --- a/deployment/data-feeds/changeset/stellar/deploy_proxy.go +++ b/deployment/data-feeds/changeset/stellar/deploy_proxy.go @@ -35,6 +35,9 @@ var _ cldf.ChangeSetV2[*DeployProxyRequest] = DeployProxy{} type DeployProxy struct{} func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyRequest) error { + if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.CacheQualifier); err != nil { + return err + } if _, err := os.Stat(req.WasmPath); err != nil { return fmt.Errorf("wasm path: %w", err) } @@ -46,9 +49,6 @@ func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyReq if req.CacheQualifier == "" { return fmt.Errorf("cache qualifier must be set") } - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.CacheQualifier); err != nil { - return err - } return nil } diff --git a/deployment/data-feeds/changeset/stellar/e2e_test.go b/deployment/data-feeds/changeset/stellar/e2e_test.go index 10e0e6a14b3..9945d55559b 100644 --- a/deployment/data-feeds/changeset/stellar/e2e_test.go +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -209,7 +209,14 @@ func TestStellarDataFeedsE2E(t *testing.T) { require.NotNil(t, r, "get_round(did,1) must return the stored round, not void") return nil }) - mustCall(t, "cache.round_range", func() error { _, e := cacheClient.RoundRange(ctx, did, 1, 1); return e }) + 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.Bound{AtOrBefore: &cache.BoundAtOrBefore{}}) if e != nil { diff --git a/deployment/data-feeds/changeset/stellar/ownership.go b/deployment/data-feeds/changeset/stellar/ownership.go index ed9321f309a..0933bbc158b 100644 --- a/deployment/data-feeds/changeset/stellar/ownership.go +++ b/deployment/data-feeds/changeset/stellar/ownership.go @@ -25,6 +25,9 @@ type OwnershipRequest struct { } 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) } From 7eeff5f3204a6fc71b72dd75732f2c0edbb1c6dc Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 10:55:12 +0100 Subject: [PATCH 16/21] =?UTF-8?q?fix(data-feeds/stellar):=20final-review?= =?UTF-8?q?=20fixes=20=E2=80=94=20lint,=20docs,=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e2e_test.go fundViaFriendbot: use NewRequestWithContext (noctx) - README/testdata README: correct wasm output path to wasm32v1-none - document cross-contract version coupling (DeployProxy/SetProxyCache resolve cache refs at the proxy's own Version, not a separate field) - verifyContractRef: drop unused *semver.Version return - unify verifyContractRef/resolveContractDeps param order to (qualifier, version) across all call sites - testutil_test.go: drop unused fakeInvoker.simResults - configure_cache.go: reject empty Permissions in VerifyPreconditions - README: correct e2e test coverage description (35 calls, 2 InvalidAction error paths) - e2e_test.go: require.Contains -> require.ErrorContains - fix stale cache.Bound struct-literal usage left by a bindings regeneration elsewhere in the workspace (kept e2e compiling) --- .../data-feeds/changeset/stellar/README.md | 21 +++++++++++++++---- .../changeset/stellar/changeset_test.go | 5 +++++ .../changeset/stellar/configure_cache.go | 7 +++++-- .../changeset/stellar/deploy_proxy.go | 9 ++++++-- .../data-feeds/changeset/stellar/deps.go | 20 ++++++++---------- .../data-feeds/changeset/stellar/e2e_test.go | 14 +++++++++---- .../changeset/stellar/feed_admin.go | 4 ++-- .../data-feeds/changeset/stellar/ownership.go | 4 ++-- .../changeset/stellar/recover_tokens.go | 4 ++-- .../changeset/stellar/remove_feeds.go | 4 ++-- .../changeset/stellar/set_proxy_cache.go | 15 ++++++++----- .../data-feeds/changeset/stellar/state.go | 2 +- .../changeset/stellar/testdata/README.md | 7 +++++-- .../changeset/stellar/testutil_test.go | 6 +----- .../changeset/stellar/upgrade_cache.go | 4 ++-- 15 files changed, 80 insertions(+), 46 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/README.md b/deployment/data-feeds/changeset/stellar/README.md index 40464c929de..a11683b7e74 100644 --- a/deployment/data-feeds/changeset/stellar/README.md +++ b/deployment/data-feeds/changeset/stellar/README.md @@ -43,6 +43,16 @@ the CRE forwarder contract address — plus `AllowedWorkflowOwner` and 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 @@ -56,8 +66,9 @@ checksums and the exact regeneration steps; in short: # 1. rebuild WASM from the contract source repo cd /contracts/data-feeds stellar contract build # stellar CLI 27.0.0 -cp target/wasm32-unknown-unknown/release/data_feeds_{cache,proxy}.wasm \ +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 @@ -66,9 +77,11 @@ cp target/wasm32-unknown-unknown/release/data_feeds_{cache,proxy}.wasm \ #### Running the e2e test `TestStellarDataFeedsE2E` boots a local CTF Soroban devnet, deploys both -contracts, and invokes every exported function (success path + one -documented error path each). It needs Docker and does not run in CI -(`skipInCI` skips when `CI=true`). +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 diff --git a/deployment/data-feeds/changeset/stellar/changeset_test.go b/deployment/data-feeds/changeset/stellar/changeset_test.go index 10e869831a4..f8c54c55f8e 100644 --- a/deployment/data-feeds/changeset/stellar/changeset_test.go +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -285,6 +285,11 @@ func TestSetFeedConfigsChangeset(t *testing.T) { 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) { diff --git a/deployment/data-feeds/changeset/stellar/configure_cache.go b/deployment/data-feeds/changeset/stellar/configure_cache.go index 5c67e79654b..b57f1dfad39 100644 --- a/deployment/data-feeds/changeset/stellar/configure_cache.go +++ b/deployment/data-feeds/changeset/stellar/configure_cache.go @@ -41,7 +41,7 @@ var _ cldf.ChangeSetV2[*SetFeedConfigsRequest] = SetFeedConfigs{} type SetFeedConfigs struct{} func (SetFeedConfigs) VerifyPreconditions(env cldf.Environment, req *SetFeedConfigsRequest) error { - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.Qualifier, req.Version); err != nil { return err } if err := validateAddress(req.Admin); err != nil { @@ -53,6 +53,9 @@ func (SetFeedConfigs) VerifyPreconditions(env cldf.Environment, req *SetFeedConf 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 } @@ -89,7 +92,7 @@ func (req *SetFeedConfigsRequest) permissions() ([]cache.WorkflowPermission, err 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) if err != nil { return out, err } diff --git a/deployment/data-feeds/changeset/stellar/deploy_proxy.go b/deployment/data-feeds/changeset/stellar/deploy_proxy.go index 0c5ce1f6407..dbae47623ac 100644 --- a/deployment/data-feeds/changeset/stellar/deploy_proxy.go +++ b/deployment/data-feeds/changeset/stellar/deploy_proxy.go @@ -17,7 +17,12 @@ import ( // 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. +// 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 @@ -35,7 +40,7 @@ var _ cldf.ChangeSetV2[*DeployProxyRequest] = DeployProxy{} type DeployProxy struct{} func (DeployProxy) VerifyPreconditions(env cldf.Environment, req *DeployProxyRequest) error { - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.CacheQualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.Version); err != nil { return err } if _, err := os.Stat(req.WasmPath); err != nil { diff --git a/deployment/data-feeds/changeset/stellar/deps.go b/deployment/data-feeds/changeset/stellar/deps.go index a80816b6156..29a7468ea02 100644 --- a/deployment/data-feeds/changeset/stellar/deps.go +++ b/deployment/data-feeds/changeset/stellar/deps.go @@ -32,25 +32,23 @@ type stellarApplyDeps struct { // verifyContractRef checks that chainSel names a known Stellar chain, // versionStr parses, and an AddressRef exists for (chainSel, contractType, -// version, qualifier). It returns the parsed version so VerifyPreconditions -// callers can validate further request-specific fields without re-parsing, -// and so Apply can reuse it via semver.MustParse (VerifyPreconditions always -// runs first). 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, versionStr, qualifier string) (*semver.Version, error) { +// 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 nil, fmt.Errorf("stellar chain not found for chain selector %d", chainSel) + return fmt.Errorf("stellar chain not found for chain selector %d", chainSel) } version, err := semver.NewVersion(versionStr) if err != nil { - return nil, fmt.Errorf("invalid version %q: %w", versionStr, err) + return fmt.Errorf("invalid version %q: %w", versionStr, err) } if _, err := env.DataStore.Addresses().Get( datastore.NewAddressRefKey(chainSel, contractType, version, qualifier), ); err != nil { - return nil, fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) + return fmt.Errorf("%s address ref not found for qualifier %q: %w", contractType, qualifier, err) } - return version, nil + return nil } // resolveContractDeps looks up the AddressRef for (chainSel, contractType, @@ -59,7 +57,7 @@ func verifyContractRef(env cldf.Environment, chainSel uint64, contractType datas // 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, version *semver.Version, qualifier string) (stellarApplyDeps, datastore.AddressRef, error) { +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] diff --git a/deployment/data-feeds/changeset/stellar/e2e_test.go b/deployment/data-feeds/changeset/stellar/e2e_test.go index 9945d55559b..d3520920a41 100644 --- a/deployment/data-feeds/changeset/stellar/e2e_test.go +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -218,7 +218,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { return nil }) mustCall(t, "cache.find_round", func() error { - r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.Bound{AtOrBefore: &cache.BoundAtOrBefore{}}) + r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.BoundAtOrBefore) if e != nil { return e } @@ -258,7 +258,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { // 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.Contains(t, proxyRecoverErr.Error(), "InvalidAction", "expected a contract re-entry host trap") + 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) --- @@ -288,7 +288,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { Token: cacheAddr, To: deployer, Amount: 1, }) require.Error(t, cacheRecoverErr, "cache recover_tokens must fail: self-transfer re-enters the contract") - require.Contains(t, cacheRecoverErr.Error(), "InvalidAction", "expected a contract re-entry host trap") + 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{ @@ -456,7 +456,13 @@ func fundViaFriendbot(t *testing.T, friendbotURL, addr string) { target := friendbotURL + "?addr=" + url.QueryEscape(addr) var lastErr error for i := 0; i < 15; i++ { - resp, err := http.Get(target) //nolint:gosec // local CTF devnet URL + 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) diff --git a/deployment/data-feeds/changeset/stellar/feed_admin.go b/deployment/data-feeds/changeset/stellar/feed_admin.go index e65b555c28c..4d9ae66e254 100644 --- a/deployment/data-feeds/changeset/stellar/feed_admin.go +++ b/deployment/data-feeds/changeset/stellar/feed_admin.go @@ -19,7 +19,7 @@ type FeedAdminRequest struct { } func (req *FeedAdminRequest) verifyPreconditions(env cldf.Environment) error { - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.Qualifier, req.Version); err != nil { return err } if err := validateAddress(req.Admin); err != nil { @@ -31,7 +31,7 @@ func (req *FeedAdminRequest) verifyPreconditions(env cldf.Environment) error { // 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) return d, err } diff --git a/deployment/data-feeds/changeset/stellar/ownership.go b/deployment/data-feeds/changeset/stellar/ownership.go index 0933bbc158b..46e76f8961b 100644 --- a/deployment/data-feeds/changeset/stellar/ownership.go +++ b/deployment/data-feeds/changeset/stellar/ownership.go @@ -31,7 +31,7 @@ func (req *OwnershipRequest) verifyPreconditions(env cldf.Environment) error { 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.Version, req.Qualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, req.Contract, req.Qualifier, req.Version); err != nil { return err } return nil @@ -40,7 +40,7 @@ func (req *OwnershipRequest) verifyPreconditions(env cldf.Environment) error { // 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, req.Contract, req.Qualifier, version) return d, err } diff --git a/deployment/data-feeds/changeset/stellar/recover_tokens.go b/deployment/data-feeds/changeset/stellar/recover_tokens.go index 77184125d17..2619ea53e19 100644 --- a/deployment/data-feeds/changeset/stellar/recover_tokens.go +++ b/deployment/data-feeds/changeset/stellar/recover_tokens.go @@ -28,7 +28,7 @@ var _ cldf.ChangeSetV2[*RecoverTokensRequest] = RecoverTokens{} type RecoverTokens struct{} func (RecoverTokens) VerifyPreconditions(env cldf.Environment, req *RecoverTokensRequest) error { - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.Qualifier, req.Version); err != nil { return err } if err := validateAddress(req.Token); err != nil { @@ -46,7 +46,7 @@ func (RecoverTokens) VerifyPreconditions(env cldf.Environment, req *RecoverToken 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) if err != nil { return out, err } diff --git a/deployment/data-feeds/changeset/stellar/remove_feeds.go b/deployment/data-feeds/changeset/stellar/remove_feeds.go index 81e7691c6b4..8aa87d9029b 100644 --- a/deployment/data-feeds/changeset/stellar/remove_feeds.go +++ b/deployment/data-feeds/changeset/stellar/remove_feeds.go @@ -28,7 +28,7 @@ var _ cldf.ChangeSetV2[*RemoveFeedConfigsRequest] = RemoveFeedConfigs{} type RemoveFeedConfigs struct{} func (RemoveFeedConfigs) VerifyPreconditions(env cldf.Environment, req *RemoveFeedConfigsRequest) error { - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.Qualifier, req.Version); err != nil { return err } if err := validateAddress(req.Admin); err != nil { @@ -46,7 +46,7 @@ func (RemoveFeedConfigs) VerifyPreconditions(env cldf.Environment, req *RemoveFe 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) if err != nil { return out, err } diff --git a/deployment/data-feeds/changeset/stellar/set_proxy_cache.go b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go index 56b1932be76..d57db3a5732 100644 --- a/deployment/data-feeds/changeset/stellar/set_proxy_cache.go +++ b/deployment/data-feeds/changeset/stellar/set_proxy_cache.go @@ -14,8 +14,13 @@ import ( // SetProxyCacheRequest points an already-deployed DataFeedsProxy contract at a // (possibly different) cache contract. Qualifier resolves the proxy being -// acted on; CacheQualifier resolves the new cache target — the two must -// resolve to distinct AddressRefs when repointing the proxy. +// 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 @@ -29,10 +34,10 @@ var _ cldf.ChangeSetV2[*SetProxyCacheRequest] = SetProxyCache{} type SetProxyCache struct{} func (SetProxyCache) VerifyPreconditions(env cldf.Environment, req *SetProxyCacheRequest) error { - if _, err := verifyContractRef(env, req.ChainSel, ProxyContract, req.Version, req.Qualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, ProxyContract, req.Qualifier, req.Version); err != nil { return err } - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.CacheQualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.CacheQualifier, req.Version); err != nil { return err } return nil @@ -42,7 +47,7 @@ func (SetProxyCache) Apply(env cldf.Environment, req *SetProxyCacheRequest) (cld var out cldf.ChangesetOutput version := semver.MustParse(req.Version) - proxyDeps, _, err := resolveContractDeps(env, req.ChainSel, ProxyContract, version, req.Qualifier) + proxyDeps, _, err := resolveContractDeps(env, req.ChainSel, ProxyContract, req.Qualifier, version) if err != nil { return out, err } diff --git a/deployment/data-feeds/changeset/stellar/state.go b/deployment/data-feeds/changeset/stellar/state.go index b0c5259d2a0..461655f4d83 100644 --- a/deployment/data-feeds/changeset/stellar/state.go +++ b/deployment/data-feeds/changeset/stellar/state.go @@ -21,7 +21,7 @@ func loadContractClientDeps(env cldf.Environment, chainSel uint64, contractType if err != nil { return stellarApplyDeps{}, datastore.AddressRef{}, fmt.Errorf("invalid version %q: %w", version, err) } - return resolveContractDeps(env, chainSel, contractType, v, qualifier) + return resolveContractDeps(env, chainSel, contractType, qualifier, v) } // LoadCacheClient resolves the CacheContract AddressRef from env.DataStore diff --git a/deployment/data-feeds/changeset/stellar/testdata/README.md b/deployment/data-feeds/changeset/stellar/testdata/README.md index bde65331e79..39fbabb0367 100644 --- a/deployment/data-feeds/changeset/stellar/testdata/README.md +++ b/deployment/data-feeds/changeset/stellar/testdata/README.md @@ -18,10 +18,13 @@ the contract source repo and copy the artifacts back: ```sh cd /contracts/data-feeds stellar contract build # stellar CLI 27.0.0 -cp target/wasm32-unknown-unknown/release/data_feeds_cache.wasm / -cp target/wasm32-unknown-unknown/release/data_feeds_proxy.wasm / +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/testutil_test.go b/deployment/data-feeds/changeset/stellar/testutil_test.go index 285b61ea436..a01380251da 100644 --- a/deployment/data-feeds/changeset/stellar/testutil_test.go +++ b/deployment/data-feeds/changeset/stellar/testutil_test.go @@ -14,8 +14,7 @@ type invocation struct { } type fakeInvoker struct { - calls []invocation - simResults map[string]xdr.ScVal // keyed by function name + calls []invocation } func (f *fakeInvoker) InvokeContract(_ context.Context, contractID, fn string, args []xdr.ScVal) (*xdr.ScVal, error) { @@ -26,9 +25,6 @@ func (f *fakeInvoker) InvokeContract(_ context.Context, contractID, fn string, a func (f *fakeInvoker) SimulateContract(_ context.Context, contractID, fn string, args []xdr.ScVal) (*xdr.ScVal, error) { f.calls = append(f.calls, invocation{contractID, fn, args}) - if v, ok := f.simResults[fn]; ok { - return &v, nil - } v := xdr.ScVal{Type: xdr.ScValTypeScvVoid} return &v, nil } diff --git a/deployment/data-feeds/changeset/stellar/upgrade_cache.go b/deployment/data-feeds/changeset/stellar/upgrade_cache.go index d260361b7f4..59ba101ab1a 100644 --- a/deployment/data-feeds/changeset/stellar/upgrade_cache.go +++ b/deployment/data-feeds/changeset/stellar/upgrade_cache.go @@ -29,7 +29,7 @@ var _ cldf.ChangeSetV2[*UpgradeCacheRequest] = UpgradeCache{} type UpgradeCache struct{} func (UpgradeCache) VerifyPreconditions(env cldf.Environment, req *UpgradeCacheRequest) error { - if _, err := verifyContractRef(env, req.ChainSel, CacheContract, req.Version, req.Qualifier); err != nil { + if err := verifyContractRef(env, req.ChainSel, CacheContract, req.Qualifier, req.Version); err != nil { return err } if _, err := os.Stat(req.WasmPath); err != nil { @@ -41,7 +41,7 @@ func (UpgradeCache) VerifyPreconditions(env cldf.Environment, req *UpgradeCacheR 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, version, req.Qualifier) + d, _, err := resolveContractDeps(env, req.ChainSel, CacheContract, req.Qualifier, version) if err != nil { return out, err } From 4562c66b765345233d58ec14b63a5a1b5b9d360c Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 10:55:22 +0100 Subject: [PATCH 17/21] test(data-feeds/stellar): pin report byte layout, owner-path cases - report_encoding_test.go: pin buildReportMetadata's 64-byte on_report header layout and buildReport's ScMap alphabetical key order - changeset_test.go: DeployCache invalid-owner precondition-rejection case, plus TestDeployCacheChangeset_ExplicitOwner pinning that an explicit req.Owner (not the chain signer) seeds the deploy salt --- .../changeset/stellar/changeset_test.go | 33 +++++++++ .../changeset/stellar/report_encoding_test.go | 73 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 deployment/data-feeds/changeset/stellar/report_encoding_test.go diff --git a/deployment/data-feeds/changeset/stellar/changeset_test.go b/deployment/data-feeds/changeset/stellar/changeset_test.go index f8c54c55f8e..5cdfce133e6 100644 --- a/deployment/data-feeds/changeset/stellar/changeset_test.go +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -152,6 +152,11 @@ func TestDeployCacheChangeset(t *testing.T) { 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) { @@ -167,6 +172,34 @@ func TestDeployCacheChangeset_MissingChain(t *testing.T) { 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) 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..f916d48e5ab --- /dev/null +++ b/deployment/data-feeds/changeset/stellar/report_encoding_test.go @@ -0,0 +1,73 @@ +package stellar + +import ( + "math/big" + "testing" + + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" + + cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" +) + +// 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 cache.WorkflowName + for i := range name { + name[i] = byte(i + 1) // 0x01..0x0a, distinguishable from zero padding + } + var owner cache.WorkflowOwner + 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) +} From cb4f129bd8ef2329a7ec134aaf81bde8ce7807f9 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 11:04:35 +0100 Subject: [PATCH 18/21] fix(data-feeds/stellar): construct Bound as symbol union in e2e find_round --- deployment/data-feeds/changeset/stellar/e2e_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/data-feeds/changeset/stellar/e2e_test.go b/deployment/data-feeds/changeset/stellar/e2e_test.go index d3520920a41..7db2a8a48f2 100644 --- a/deployment/data-feeds/changeset/stellar/e2e_test.go +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -218,7 +218,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { return nil }) mustCall(t, "cache.find_round", func() error { - r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.BoundAtOrBefore) + r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.Bound{AtOrBefore: &cache.BoundAtOrBefore{}}) if e != nil { return e } From 7940a5e305f8434400af6ba98f345092e3360956 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 3 Jul 2026 13:52:23 +0100 Subject: [PATCH 19/21] docs(data-feeds/stellar): reword e2e coverage comment --- deployment/data-feeds/changeset/stellar/e2e_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/e2e_test.go b/deployment/data-feeds/changeset/stellar/e2e_test.go index 7db2a8a48f2..c4e3efe2ba6 100644 --- a/deployment/data-feeds/changeset/stellar/e2e_test.go +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -83,8 +83,9 @@ import ( // re-enters the contract => host trap Error(Context, // InvalidAction), asserted // -// The proxy has no upgrade/recover_tokens changeset (controller decision), so -// those two are driven through the generated proxy client directly. +// 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). From 71cd76c56590c42d2af4dad38fef2032e30788c6 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 23 Jul 2026 00:57:02 +0100 Subject: [PATCH 20/21] Tidy deployment module for chainlink-stellar pins --- deployment/go.mod | 14 +------------- deployment/go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/deployment/go.mod b/deployment/go.mod index 70f4517f4ca..e8d214f5811 100644 --- a/deployment/go.mod +++ b/deployment/go.mod @@ -275,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 @@ -473,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-20260722234304-681eb03502fb // indirect + github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260722234304-681eb03502fb 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 @@ -586,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..7f625320681 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.20260722234304-681eb03502fb h1:i+sCNRlblWoKiLlgLdhl9ut3BUYQLRzJYehd+7WNVyI= +github.com/smartcontractkit/chainlink-stellar v0.0.3-0.20260722234304-681eb03502fb/go.mod h1:J8D0u8K/a2+3YuJx0F8nf4Fd7h1UZqeWGYszp7aCbdA= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260722234304-681eb03502fb h1:hOl0Yd4NjvKmDbE4uQdRqJR1c7PWtuIa7qxW7TmAxIw= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260722234304-681eb03502fb/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= From a57fdd36dd194be30940562182aed68dd6a3bf23 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 24 Jul 2026 22:36:43 +0100 Subject: [PATCH 21/21] Re-pin chainlink-stellar to a5c7938 and adapt to final bindings - Bump chainlink-stellar + /bindings pseudo-versions to a5c7938 (I256 completeness, #[topic] event decoding, deployer resource-fee cushion, hardened generation scripts) - Define SorobanContractDeployer + StellarDeps locally in the operation package; upstream no longer exports a widened deployer interface - Replace removed alias types (DataId, WasmHash, WorkflowOwner, WorkflowName) with their literal array types - Bound is now an integer enum: pass cache.BoundAtOrBefore directly - Rebuild testdata wasms from the current contracts (old ones predate Bound's explicit discriminants and trap on find_round) --- .../changeset/stellar/changeset_test.go | 13 +++-- .../changeset/stellar/configure_cache.go | 6 +- .../data-feeds/changeset/stellar/deps.go | 11 ++-- .../data-feeds/changeset/stellar/e2e_test.go | 15 +++-- .../changeset/stellar/operation/operation.go | 53 ++++++++++++------ .../changeset/stellar/remove_feeds.go | 8 +-- .../changeset/stellar/report_encoding_test.go | 6 +- .../stellar/testdata/data_feeds_cache.wasm | Bin 19526 -> 21500 bytes .../stellar/testdata/data_feeds_proxy.wasm | Bin 10365 -> 9463 bytes deployment/go.mod | 4 +- deployment/go.sum | 8 +-- 11 files changed, 67 insertions(+), 57 deletions(-) diff --git a/deployment/data-feeds/changeset/stellar/changeset_test.go b/deployment/data-feeds/changeset/stellar/changeset_test.go index 5cdfce133e6..d5976d410bf 100644 --- a/deployment/data-feeds/changeset/stellar/changeset_test.go +++ b/deployment/data-feeds/changeset/stellar/changeset_test.go @@ -18,9 +18,10 @@ import ( 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" - cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" stellardeploy "github.com/smartcontractkit/chainlink-stellar/deployment" - "github.com/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" + + "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" ) const testContractID = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" @@ -48,8 +49,8 @@ func newTestEnv(t *testing.T) (cldf.Environment, *fakeInvoker, *fakeDeployer) { origDeps := newStellarDeps t.Cleanup(func() { newStellarDeps = origDeps }) - newStellarDeps = func(_ cldfstellar.Chain) (stellardeps.StellarDeps, error) { - return stellardeps.StellarDeps{Deploy: deployer, Invoker: invoker}, nil + newStellarDeps = func(_ cldfstellar.Chain) (operation.StellarDeps, error) { + return operation.StellarDeps{Deploy: deployer, Invoker: invoker}, nil } blockChains := cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ @@ -525,9 +526,9 @@ func TestUpgradeCacheChangeset(t *testing.T) { require.Equal(t, testContractID, inv.calls[0].ContractID) require.Len(t, inv.calls[0].Args, 1) - gotHash, err := cache.WasmHashFromScVal(inv.calls[0].Args[0]) + gotHash, err := scval.Bytes32FromScVal(inv.calls[0].Args[0]) require.NoError(t, err) - require.Equal(t, cache.WasmHash(wantHash), *gotHash) + require.Equal(t, [32]byte(wantHash), gotHash) // missing wasm path must fail preconditions badPath := *req diff --git a/deployment/data-feeds/changeset/stellar/configure_cache.go b/deployment/data-feeds/changeset/stellar/configure_cache.go index b57f1dfad39..2fb4e45421c 100644 --- a/deployment/data-feeds/changeset/stellar/configure_cache.go +++ b/deployment/data-feeds/changeset/stellar/configure_cache.go @@ -82,8 +82,8 @@ func (req *SetFeedConfigsRequest) permissions() ([]cache.WorkflowPermission, err } out = append(out, cache.WorkflowPermission{ AllowedSender: p.AllowedSender, - AllowedWorkflowOwner: cache.WorkflowOwner(owner), - AllowedWorkflowName: cache.WorkflowName(name), + AllowedWorkflowOwner: owner, + AllowedWorkflowName: name, }) } return out, nil @@ -109,7 +109,7 @@ func (SetFeedConfigs) Apply(env cldf.Environment, req *SetFeedConfigsRequest) (c entries := make([]cache.FeedConfigEntry, len(ids)) for i, id := range ids { entries[i] = cache.FeedConfigEntry{ - DataId: cache.DataId(id), + DataId: id, Config: cache.FeedConfig{ Description: req.Descriptions[i], WorkflowPermissions: perms, diff --git a/deployment/data-feeds/changeset/stellar/deps.go b/deployment/data-feeds/changeset/stellar/deps.go index 29a7468ea02..ab63c762f96 100644 --- a/deployment/data-feeds/changeset/stellar/deps.go +++ b/deployment/data-feeds/changeset/stellar/deps.go @@ -9,24 +9,25 @@ import ( "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-stellar/deployment/operations/stellardeps" + + "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) (stellardeps.StellarDeps, error) { +var newStellarDeps = func(ch cldfstellar.Chain) (operation.StellarDeps, error) { d, err := stellardeploy.NewDeployerFromChain(ch) if err != nil { - return stellardeps.StellarDeps{}, err + return operation.StellarDeps{}, err } - return stellardeps.FromDeployer(d), nil + 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 stellardeps.StellarDeps + deps operation.StellarDeps contractID string } diff --git a/deployment/data-feeds/changeset/stellar/e2e_test.go b/deployment/data-feeds/changeset/stellar/e2e_test.go index c4e3efe2ba6..cc6fd331ee2 100644 --- a/deployment/data-feeds/changeset/stellar/e2e_test.go +++ b/deployment/data-feeds/changeset/stellar/e2e_test.go @@ -23,7 +23,6 @@ import ( 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" - proxy "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_proxy" "github.com/smartcontractkit/chainlink-stellar/bindings/scval" commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" @@ -165,7 +164,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { // direct calls below (reuse the package's own hex->bytes converters). ids, err := dataIDsToBytes([]string{e2eDataID}) require.NoError(t, err) - did := cache.DataId(ids[0]) + did := [16]byte(ids[0]) ownerBytes, err := workflowOwnerToBytes(e2eWorkflowOwner) require.NoError(t, err) @@ -177,7 +176,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { require.NoError(t, err) cacheAddr := cacheRef.Address - metadata := buildReportMetadata(cache.WorkflowName(nameBytes), cache.WorkflowOwner(ownerBytes)) + 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") @@ -198,7 +197,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { 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, cache.WorkflowOwner(ownerBytes), cache.WorkflowName(nameBytes)) + _, 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 }) @@ -219,7 +218,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { return nil }) mustCall(t, "cache.find_round", func() error { - r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.Bound{AtOrBefore: &cache.BoundAtOrBefore{}}) + r, e := cacheClient.FindRound(ctx, did, e2eReportTS, cache.BoundAtOrBefore) if e != nil { return e } @@ -230,7 +229,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { // --- proxy reads (read through to the cache's stored round) ----------- proxyClient, _, err := LoadProxyClient(env, sel, e2eQualifier, e2eVersion) require.NoError(t, err) - pdid := proxy.DataId(ids[0]) + 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 }) @@ -245,7 +244,7 @@ func TestStellarDataFeedsE2E(t *testing.T) { require.NoError(t, err) proxyHash, err := deps.Deploy.UploadContractWASM(ctx, proxyWasm) require.NoError(t, err) - require.NoError(t, proxyClient.Upgrade(ctx, proxy.WasmHash(proxyHash)), + 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 @@ -414,7 +413,7 @@ func nextLiveUntil(t *testing.T, ch cldfstellar.Chain) uint32 { // 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 cache.WorkflowName, owner cache.WorkflowOwner) []byte { +func buildReportMetadata(name [10]byte, owner [20]byte) []byte { md := make([]byte, 64) copy(md[32:42], name[:]) copy(md[42:62], owner[:]) diff --git a/deployment/data-feeds/changeset/stellar/operation/operation.go b/deployment/data-feeds/changeset/stellar/operation/operation.go index a9d2691e7bb..13211cb1487 100644 --- a/deployment/data-feeds/changeset/stellar/operation/operation.go +++ b/deployment/data-feeds/changeset/stellar/operation/operation.go @@ -4,18 +4,35 @@ 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/smartcontractkit/chainlink-stellar/deployment/operations/stellardeps" "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{} @@ -36,7 +53,7 @@ type DeployCacheInput struct { var DeployCache = cldfops.NewOperation( "df-cache:deploy", Version1_0_0, "Deploys the DataFeedsCache Soroban contract", - func(b cldfops.Bundle, d stellardeps.StellarDeps, in DeployCacheInput) (DeployOutput, error) { + func(b cldfops.Bundle, d StellarDeps, in DeployCacheInput) (DeployOutput, error) { args := []xdr.ScVal{ scval.AddressToScVal(in.Owner), } @@ -59,7 +76,7 @@ type DeployProxyInput struct { var DeployProxy = cldfops.NewOperation( "df-proxy:deploy", Version1_0_0, "Deploys the DataFeedsProxy Soroban contract", - func(b cldfops.Bundle, d stellardeps.StellarDeps, in DeployProxyInput) (DeployOutput, error) { + func(b cldfops.Bundle, d StellarDeps, in DeployProxyInput) (DeployOutput, error) { args := []xdr.ScVal{ scval.AddressToScVal(in.Owner), scval.AddressToScVal(in.Cache), @@ -81,22 +98,22 @@ type SetFeedConfigsInput struct { 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.StellarDeps, in SetFeedConfigsInput) (Void, error) { + 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 []cache.DataId `json:"data_ids"` + 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.StellarDeps, in RemoveFeedConfigsInput) (Void, error) { + 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) }, @@ -110,7 +127,7 @@ type FeedAdminInput struct { 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.StellarDeps, in FeedAdminInput) (Void, error) { + 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) }, @@ -119,7 +136,7 @@ var AddFeedAdmin = cldfops.NewOperation( 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.StellarDeps, in FeedAdminInput) (Void, error) { + 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) }, @@ -137,7 +154,7 @@ type OwnershipInput struct { var TransferOwnership = cldfops.NewOperation( "df:transfer-ownership", Version1_0_0, "Begins two-step ownership transfer", - func(b cldfops.Bundle, d stellardeps.StellarDeps, in OwnershipInput) (Void, error) { + 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) @@ -150,7 +167,7 @@ var TransferOwnership = cldfops.NewOperation( 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.StellarDeps, in OwnershipInput) (Void, error) { + 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()) @@ -163,7 +180,7 @@ var AcceptOwnership = cldfops.NewOperation( var RenounceOwnership = cldfops.NewOperation( "df:renounce-ownership", Version1_0_0, "Renounces ownership permanently", - func(b cldfops.Bundle, d stellardeps.StellarDeps, in OwnershipInput) (Void, error) { + 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()) @@ -183,7 +200,7 @@ type UploadWASMOutput struct { 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.StellarDeps, in UploadWASMInput) (UploadWASMOutput, error) { + 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 @@ -200,9 +217,9 @@ type UpgradeCacheInput struct { var UpgradeCache = cldfops.NewOperation( "df-cache:upgrade", Version1_0_0, "Points the cache at a new WASM implementation", - func(b cldfops.Bundle, d stellardeps.StellarDeps, in UpgradeCacheInput) (Void, error) { + func(b cldfops.Bundle, d StellarDeps, in UpgradeCacheInput) (Void, error) { c := cache.NewDataFeedsCacheClient(d.Invoker, in.ContractID) - return Void{}, c.Upgrade(b.GetContext(), cache.WasmHash(in.NewWasmHash)) + return Void{}, c.Upgrade(b.GetContext(), in.NewWasmHash) }, ) @@ -216,7 +233,7 @@ type RecoverTokensInput struct { var RecoverTokens = cldfops.NewOperation( "df-cache:recover-tokens", Version1_0_0, "Recovers tokens accidentally sent to the cache", - func(b cldfops.Bundle, d stellardeps.StellarDeps, in RecoverTokensInput) (Void, error) { + 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) }, @@ -230,7 +247,7 @@ type SetProxyCacheInput struct { var SetProxyCache = cldfops.NewOperation( "df-proxy:set-cache", Version1_0_0, "Points the proxy at a cache contract", - func(b cldfops.Bundle, d stellardeps.StellarDeps, in SetProxyCacheInput) (Void, error) { + 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/remove_feeds.go b/deployment/data-feeds/changeset/stellar/remove_feeds.go index 8aa87d9029b..40af6049681 100644 --- a/deployment/data-feeds/changeset/stellar/remove_feeds.go +++ b/deployment/data-feeds/changeset/stellar/remove_feeds.go @@ -7,7 +7,6 @@ import ( cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/operations" - cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/stellar/operation" ) @@ -55,15 +54,10 @@ func (RemoveFeedConfigs) Apply(env cldf.Environment, req *RemoveFeedConfigsReque if err != nil { return out, err } - dataIDs := make([]cache.DataId, len(ids)) - for i, id := range ids { - dataIDs[i] = cache.DataId(id) - } - _, err = operations.ExecuteOperation(env.OperationsBundle, operation.RemoveFeedConfigs, d.deps, operation.RemoveFeedConfigsInput{ ContractID: d.contractID, Admin: req.Admin, - DataIDs: dataIDs, + 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 index f916d48e5ab..6213f6d8590 100644 --- a/deployment/data-feeds/changeset/stellar/report_encoding_test.go +++ b/deployment/data-feeds/changeset/stellar/report_encoding_test.go @@ -6,19 +6,17 @@ import ( "github.com/stellar/go-stellar-sdk/xdr" "github.com/stretchr/testify/require" - - cache "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/data_feeds_cache" ) // 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 cache.WorkflowName + var name [10]byte for i := range name { name[i] = byte(i + 1) // 0x01..0x0a, distinguishable from zero padding } - var owner cache.WorkflowOwner + var owner [20]byte for i := range owner { owner[i] = byte(i + 100) // 0x64..0x77 } diff --git a/deployment/data-feeds/changeset/stellar/testdata/data_feeds_cache.wasm b/deployment/data-feeds/changeset/stellar/testdata/data_feeds_cache.wasm index 022a219c3ee7940b603ab8f94cfe1e65dff26ad5..cc53ad56d1bac6b3744db8f05cc9f2a29d452d9c 100644 GIT binary patch literal 21500 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�NRPz6VFk9fLZyyQ1dR+dlBD+|19>pzWX$& z-etWmOaw7xW$YWrmRx=Tf$%ksBp5)A<6SzE!eww8a&#xMt?q<)5wObVpdxbCi@;pt zh#P@K8WNpXxe>xdmz>0pVA?j*WMc$$O*SS)!Mva(zi2nWeG9GcT19K5Nh4ZA{kBoN z-^CYn$il)C2@4}(jkE!DCL|cjXi$U#E0Hv-I64Q8-efttAH9R_1g2o_YUU2#pcT4! z0rcCXKeDeY)l00g@O_O1?PT>b92$$6v>Pf0lwj6-L+2B zO7<*v53AFpH$s2;;!Ts@C|z=3rtn-rn2haVtH4^<;l06-blaO+lsgcK%B?M&8{dkZ_x;WW)c>dqAsYkhuPzrKs)6+f^$S7y?3V|DX-qjoa?K5_7#b^KUJFordTVMUWLR5-b z42FLN`wkss2BV8sd!1NxfrG55gD*HZSbo~Ww?447%vXQ0t1rMvB^22DsibV!V zSRFYq6}&&W-;vu98IbUmxG&fJ@=@>y9XKj{2SNp3avomVH}&X_-z@k)yvO~)sIC3G z${m0F+|0M0v$d}O^yL5P9{TI!wpRW2XV*>UzVS!4w&jce_r0%e`Se*^d;Dv!{NTrb zwK#8U2R{0)=Y}8p%AeZWzh8g(7ryd4|LAXQ?QgGqpT+sfF)Pe(}?{1-AD4 z?)Qd&Nxk`dw)WWiHPUMi{IQ<^$aHaoTrY#UJF zpTKJfUUHAus;9?Gjq&;N^zm{{YK`i6qcLAUffqjb76{7fPn1^ON~t&S4DkFRo}GBM zz9@1UZ=_UDE7gOyCfK(b&orKRedBbwX5o+5%TFxT@J37RtbOsLGTo>wV3pFs(p}Mb zqzF|G^A6#e!Si7}vv@v+X9u1w@4=j|*5+pBv4Q5>EOqs9j3bVpz;hGF^YxF)w2I+> z4()^+!y{=zzHY<9J@U57k>oWRwmsay>o7;Q>ioF$P(h2Qqkj zr;fKx%C)$bV7XXYC|}iRU(iwNF2Ev*e;Usl@EphUMm&kZBTLvsdD^~&Gv27z@d6Q+ zY1GmGDU2goekFPW+xhZQd0i)YdFadNBMI9{)7>gL`EvdwN>h~;tw&B@@_4ne*Jsw;XrFOc1qJYpnCmI-?CRfp6jvpVZPpV1PoDGJT7JKL6O2~=yb;eR`sT8b-Y<+N2YgdyK@j)B>rS;_4@Rj?cTm~a2s^YEjxzxluEmI z>>3!H*)_atV9!u#cxrlPYIyhL;PCXFyLJo??w*<2wYwa{*dQ>*>r3US@%o9<9Oxxo z&Q3laUK^?O6wkq8wSDIhRog9lN<&k7X39f@dv*;>&g|Z~dwBQIP-$v-$Ih9lp*=%8 zb`MV8IXpGBTd6_e)S@8baY0*~#}0h1x2KLo z8`-g|_SLGO&>i=+Zy3D=$Ka8b|+d>bf}Euey-Kd0V(^cr;Oc*zZ~CAC!N5Jly5Zd zTI~e(cHpjW(!U8ff_=R{!oSuzwBvr$HIzMJaIe?vkI(R0YY|4;8X^`I=@);xlXDd< z%4U&Ex&-m3D;Y!6EUhwz?gu~IOZ-V*Fhv9L=BI0wRgxx|Z=~4#lf04|HF18tH#W8M zZ4CPwd-ndq&K2IwiAT4uR>S+m@#p!Y<;Ka{V%SDRA8%8!aSuQV{#I|h9hj4{H z9pbgo{}pi8q#1i#-g1_~u?}2|zp^xp)b|)ke}RLq4_$ZN);GC1H(vKz zd%l*KEOca($h>y(1d7z4|Y>$8=03>S?tsa8TQe`K#xxVK-u!9N7L_ z0&I)>JE*s=o-exPt@yS5*8>PQ)@5tajs0uM&|B%}>*;3hBi;+)xBn^u+hU$d#lZpE zVqN1#{1eyHHjv;n2_2Z_%fZO-$7g<&BKg zs`KT<;U5_}it4fGM-XK6$jF1$D4>YjCmx3?J~)$u4z87{+!w0}#xb7M(IwfBV7%{C ztjHVHrOH$t045$QBo-n;pcy$79-n~Pr`nUTNgOj`A|vzo1PFKa&ss#(YdzRzC6K{)$um1fA&bB<&>Km@{;}*#{7TR%r8|yny zAB}znqu#QKX>uMRl0ibj%;0A= z_`4u=bDVhsGkG>Qam!=X#rd-~z%6ZaReg#v@EKS;|r*atE!tV9ug4Q^CR5oB*Yx zvM7XJD!tS7BFh5H$K2Z5%f-eJ2gpIOR8r*X2&7EO>MHQfsS0w5!cr5trexe={(geH z){g>FV&iy0{2P%YE61^&Uw;+L+Wy#ou{HP!R^!?qHG)!>A24cx->&yLFB|25V5DfH zT5~{e%Oz}j~AJ1 zdy8DqjaQ9J6pY}+yi#i}F#Cx8K{B!*Y0+LM;%}8dB8^&McCWt3AP9lIR(rhnwc;Pn zYmb50wpJWO9DH_0pY>P8Beo+Rv;L~!MEbM89L>LRZM(+a>_3gh+z^dfZHuHc`^jj` zx?^!nbY_Vz9p`@ve_}j1th3cSKs}~OfcDtOL~|Bb0rJ@p!)&^jgGP#z@GL za=sf9C`ni*o$-D;npa8E9@{%su4N0^_g3~W=D*w4*js`Ut$#C3NU-wRknAT8cz8sX zUpFR`@xy}lM**cX)*08@PmIq`MC;f?ZlQH=f?G`Mdy?z17{{BNI5y&+AWT3-`{3AX H(bxU|6yro| literal 19526 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