Skip to content

Commit 8019439

Browse files
authored
feat: introduce Solana CTF provider (#164)
Adds a new provider for the Solana blockchain that leverages CTF to launch a Solana container and returns a Solana Chain instance backed by the CTF container.
1 parent 662acb2 commit 8019439

File tree

9 files changed

+571
-34
lines changed

9 files changed

+571
-34
lines changed

.changeset/wild-kids-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": patch
3+
---
4+
5+
Adds a Solana CTF Chain Provider which returns a chain backend by a testing container

chain/aptos/provider/ctf_provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func NewCTFChainProvider(
7777
// generating a deployer signer account, and constructing the chain instance.
7878
func (p *CTFChainProvider) Initialize() (chain.BlockChain, error) {
7979
if p.chain != nil {
80-
return p.chain, nil // Already initialized
80+
return *p.chain, nil // Already initialized
8181
}
8282

8383
if err := p.config.validate(); err != nil {

chain/solana/provider/ctf_provider.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"path/filepath"
8+
"runtime"
9+
"strconv"
10+
"sync"
11+
"testing"
12+
"time"
13+
14+
"github.com/avast/retry-go/v4"
15+
sollib "github.com/gagliardetto/solana-go"
16+
solrpc "github.com/gagliardetto/solana-go/rpc"
17+
chain_selectors "github.com/smartcontractkit/chain-selectors"
18+
solCommonUtil "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common"
19+
"github.com/smartcontractkit/chainlink-testing-framework/framework"
20+
"github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain"
21+
"github.com/smartcontractkit/freeport"
22+
"github.com/stretchr/testify/require"
23+
"github.com/testcontainers/testcontainers-go"
24+
25+
"github.com/smartcontractkit/chainlink-deployments-framework/chain"
26+
"github.com/smartcontractkit/chainlink-deployments-framework/chain/solana"
27+
"github.com/smartcontractkit/chainlink-deployments-framework/chain/solana/provider/rpcclient"
28+
)
29+
30+
// CTFChainProviderConfig holds the configuration to initialize the CTFChainProvider.
31+
type CTFChainProviderConfig struct {
32+
// Required: A generator for the deployer key. Use PrivateKeyFromRaw to create a deployer
33+
// key from a private key.
34+
DeployerKeyGen PrivateKeyGenerator
35+
// Required: The absolute path to the directory containing the Solana CLI binaries.
36+
ProgramsPath string
37+
// Required: A map of program names to their program IDs. You may set this as an empty map if
38+
// you do not have any programs to deploy.
39+
ProgramIDs map[string]string
40+
// Required: A sync.Once instance to ensure that the CTF framework only sets up the new
41+
// DefaultNetwork once
42+
Once *sync.Once
43+
// Optional: WaitDelayAfterContainerStart is the duration to wait after starting the CTF
44+
// container. This is useful to ensure the container is fully initialized before attempting to
45+
// interact with it.
46+
//
47+
// Default: 0s (no delay)
48+
WaitDelayAfterContainerStart time.Duration
49+
}
50+
51+
// validate checks if the RPCChainProviderConfig is valid.
52+
func (c CTFChainProviderConfig) validate() error {
53+
if c.DeployerKeyGen == nil {
54+
return errors.New("deployer key generator is required")
55+
}
56+
if c.ProgramsPath == "" {
57+
return errors.New("programs path is required")
58+
}
59+
if c.ProgramIDs == nil {
60+
return errors.New("program ids is required")
61+
}
62+
if err := isValidFilepath(c.ProgramsPath); err != nil {
63+
return err
64+
}
65+
66+
return nil
67+
}
68+
69+
var _ chain.Provider = (*CTFChainProvider)(nil)
70+
71+
// CTFChainProvider manages an Solana chain instance running inside a Chainlink Testing Framework
72+
// (CTF) Docker container.
73+
//
74+
// This provider requires Docker to be installed and operational. Spinning up a new container
75+
// can be slow, so it is recommended to initialize the provider only once per test suite or parent
76+
// test to optimize performance.
77+
type CTFChainProvider struct {
78+
t *testing.T
79+
selector uint64
80+
config CTFChainProviderConfig
81+
82+
chain *solana.Chain
83+
}
84+
85+
// NewCTFChainProvider creates a new CTFChainProvider with the given selector and configuration.
86+
func NewCTFChainProvider(
87+
t *testing.T, selector uint64, config CTFChainProviderConfig,
88+
) *CTFChainProvider {
89+
t.Helper()
90+
91+
p := &CTFChainProvider{
92+
t: t,
93+
selector: selector,
94+
config: config,
95+
}
96+
97+
return p
98+
}
99+
100+
// Initialize sets up the Solana chain by validating the configuration, starting a CTF container,
101+
// generating a deployer key, and constructing the chain instance.
102+
func (p *CTFChainProvider) Initialize() (chain.BlockChain, error) {
103+
if p.chain != nil {
104+
return *p.chain, nil // Already initialized
105+
}
106+
107+
if err := p.config.validate(); err != nil {
108+
return nil, fmt.Errorf("failed to validate provider config: %w", err)
109+
}
110+
111+
// Get the Solana Chain ID
112+
chainID, err := chain_selectors.GetChainIDFromSelector(p.selector)
113+
if err != nil {
114+
return nil, fmt.Errorf("failed to get chain ID from selector %d: %w", p.selector, err)
115+
}
116+
117+
// Generate the deployer keypair
118+
privKey, err := p.config.DeployerKeyGen.Generate()
119+
if err != nil {
120+
return nil, fmt.Errorf("failed to generate deployer keypair: %w", err)
121+
}
122+
123+
// Persist the deployer keypair to a temporary for the Solana CLI to use
124+
keypairDir := p.t.TempDir()
125+
126+
keypairPath := filepath.Join(keypairDir, "solana-keypair.json")
127+
if err = writePrivateKeyToPath(keypairPath, privKey); err != nil {
128+
return nil, fmt.Errorf("failed to write deployer keypair to file: %w", err)
129+
}
130+
131+
// Start the CTF Container
132+
httpURL, wsURL := p.startContainer(chainID, privKey.PublicKey())
133+
134+
// Initialize the Solana client with the container HTTP URL
135+
client := rpcclient.New(solrpc.New(httpURL), privKey)
136+
137+
// Initialize the Solana chain instance with the provided configuration
138+
p.chain = &solana.Chain{
139+
Selector: p.selector,
140+
Client: client.Client,
141+
URL: httpURL,
142+
WSURL: wsURL,
143+
DeployerKey: &privKey,
144+
ProgramsPath: p.config.ProgramsPath,
145+
KeypairPath: keypairPath,
146+
SendAndConfirm: func(
147+
ctx context.Context, instructions []sollib.Instruction, txMods ...rpcclient.TxModifier,
148+
) error {
149+
_, err := client.SendAndConfirmTx(ctx, instructions,
150+
rpcclient.WithTxModifiers(txMods...),
151+
rpcclient.WithRetry(500, 50*time.Millisecond),
152+
)
153+
154+
return err
155+
},
156+
Confirm: func(instructions []sollib.Instruction, opts ...solCommonUtil.TxModifier) error {
157+
emptyLookupTables := map[sollib.PublicKey]sollib.PublicKeySlice{}
158+
_, err := solCommonUtil.SendAndConfirmWithLookupTablesAndRetries(
159+
context.Background(),
160+
client.Client,
161+
instructions,
162+
privKey,
163+
solrpc.CommitmentConfirmed,
164+
emptyLookupTables,
165+
opts...,
166+
)
167+
168+
return err
169+
},
170+
}
171+
172+
return *p.chain, nil
173+
}
174+
175+
// Name returns the name of the CTFChainProvider.
176+
func (*CTFChainProvider) Name() string {
177+
return "Solana CTF Chain Provider"
178+
}
179+
180+
// ChainSelector returns the chain selector of the Solana chain managed by this provider.
181+
func (p *CTFChainProvider) ChainSelector() uint64 {
182+
return p.selector
183+
}
184+
185+
// BlockChain returns the Solana chain instance managed by this provider. You must call Initialize
186+
// before using this method to ensure the chain is properly set up.
187+
func (p *CTFChainProvider) BlockChain() chain.BlockChain {
188+
return p.chain
189+
}
190+
191+
// startContainer starts a CTF container for the Solana chain.
192+
func (p *CTFChainProvider) startContainer(
193+
chainID string,
194+
adminPubKey sollib.PublicKey,
195+
) (string, string) {
196+
var (
197+
attempts = uint(10)
198+
httpURL, wsURL string
199+
)
200+
201+
// initialize the docker network used by CTF
202+
err := framework.DefaultNetwork(p.config.Once)
203+
require.NoError(p.t, err)
204+
205+
err = retry.Do(func() error {
206+
// solana requires 2 ports, one for http and one for ws, but only allows one to be specified
207+
// the other is +1 of the first one
208+
// must reserve 2 to avoid port conflicts in the freeport library with other tests
209+
// https://github.com/smartcontractkit/chainlink-testing-framework/blob/e109695d311e6ed42ca3194907571ce6454fae8d/framework/components/blockchain/blockchain.go#L39
210+
ports := freeport.GetN(p.t, 2)
211+
212+
image := ""
213+
if runtime.GOOS == "linux" {
214+
image = "solanalabs/solana:v1.18.26" // workaround on linux to load a separate image
215+
}
216+
217+
input := &blockchain.Input{
218+
Image: image,
219+
Type: "solana",
220+
ChainID: chainID,
221+
PublicKey: adminPubKey.String(),
222+
Port: strconv.Itoa(ports[0]),
223+
ContractsDir: p.config.ProgramsPath, // Programs are contracts in the context of CTF
224+
SolanaPrograms: p.config.ProgramIDs,
225+
}
226+
227+
output, rerr := blockchain.NewBlockchainNetwork(input)
228+
if rerr != nil {
229+
// Return the ports to freeport to avoid leaking them during retries
230+
freeport.Return(ports)
231+
232+
return rerr
233+
}
234+
235+
testcontainers.CleanupContainer(p.t, output.Container)
236+
237+
httpURL = output.Nodes[0].ExternalHTTPUrl
238+
wsURL = output.Nodes[0].ExternalWSUrl
239+
240+
return nil
241+
},
242+
retry.Context(p.t.Context()),
243+
retry.Attempts(attempts),
244+
retry.Delay(1*time.Second),
245+
retry.DelayType(retry.FixedDelay),
246+
)
247+
require.NoError(p.t, err, "Failed to start CTF Solana container after %d attempts", attempts)
248+
249+
checkSolanaNodeHealth(p.t, httpURL)
250+
251+
// Wait for the configured delay after starting the container to ensure the chain is fully booted.
252+
if p.config.WaitDelayAfterContainerStart > 0 {
253+
time.Sleep(p.config.WaitDelayAfterContainerStart)
254+
}
255+
256+
return httpURL, wsURL
257+
}
258+
259+
// checkSolanaNodeHealth checks the health of the Solana node by querying its health endpoint.
260+
// We expect that node will be available within 30 seconds, with a 1 second delay between attempts,
261+
// however this is an assumption.
262+
func checkSolanaNodeHealth(t *testing.T, httpURL string) {
263+
t.Helper()
264+
265+
solclient := solrpc.New(httpURL)
266+
err := retry.Do(func() error {
267+
out, rerr := solclient.GetHealth(t.Context())
268+
if rerr != nil {
269+
return rerr
270+
}
271+
if out != solrpc.HealthOk {
272+
return fmt.Errorf("API server not healthy yet: %s", out)
273+
}
274+
275+
return nil
276+
},
277+
retry.Context(t.Context()),
278+
retry.Attempts(30),
279+
retry.Delay(1*time.Second),
280+
retry.DelayType(retry.FixedDelay),
281+
)
282+
require.NoError(t, err, "API server is not healthy")
283+
}

0 commit comments

Comments
 (0)