Skip to content

Commit 0bd5b91

Browse files
committed
feat: add integration test for EV Node posting to DA
1 parent 86d745a commit 0bd5b91

File tree

2 files changed

+363
-1
lines changed

2 files changed

+363
-1
lines changed
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"io"
8+
"net"
9+
"net/http"
10+
"os"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
cosmosmath "cosmossdk.io/math"
16+
tastoradocker "github.com/celestiaorg/tastora/framework/docker"
17+
"github.com/celestiaorg/tastora/framework/docker/container"
18+
tastoracosmos "github.com/celestiaorg/tastora/framework/docker/cosmos"
19+
tastorada "github.com/celestiaorg/tastora/framework/docker/dataavailability"
20+
"github.com/celestiaorg/tastora/framework/docker/evstack"
21+
"github.com/celestiaorg/tastora/framework/testutil/wait"
22+
tastoratypes "github.com/celestiaorg/tastora/framework/types"
23+
sdk "github.com/cosmos/cosmos-sdk/types"
24+
"github.com/cosmos/cosmos-sdk/types/module/testutil"
25+
"github.com/cosmos/cosmos-sdk/x/auth"
26+
"github.com/cosmos/cosmos-sdk/x/bank"
27+
banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
28+
"github.com/cosmos/cosmos-sdk/x/gov"
29+
"github.com/cosmos/ibc-go/v8/modules/apps/transfer"
30+
coreda "github.com/evstack/ev-node/core/da"
31+
"github.com/evstack/ev-node/da/jsonrpc"
32+
seqcommon "github.com/evstack/ev-node/sequencers/common"
33+
"github.com/rs/zerolog"
34+
"github.com/stretchr/testify/require"
35+
)
36+
37+
// TestEvNode_PostsToDA spins up celestia-app, a celestia bridge node and an
38+
// EV Node (aggregator) via tastora, then verifies the EV Node actually posts
39+
// data to DA by confirming blobs exist in the ev-data namespace via the DA
40+
// JSON-RPC client.
41+
func TestEvNode_PostsToDA(t *testing.T) {
42+
if testing.Short() {
43+
t.Skip("skip integration in short mode")
44+
}
45+
46+
configurePrefixOnce.Do(configureCelestiaBech32Prefix)
47+
48+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
49+
defer cancel()
50+
51+
uniqueTestName := fmt.Sprintf("%s-%d", t.Name(), time.Now().UnixNano())
52+
53+
dockerClient, networkID := tastoradocker.Setup(t)
54+
t.Cleanup(tastoradocker.Cleanup(t, dockerClient))
55+
56+
encCfg := testutil.MakeTestEncodingConfig(auth.AppModuleBasic{}, bank.AppModuleBasic{}, transfer.AppModuleBasic{}, gov.AppModuleBasic{})
57+
58+
// 1) Start celestia-app chain
59+
chainImage := container.Image{
60+
Repository: "ghcr.io/celestiaorg/celestia-app",
61+
Version: "v5.0.10",
62+
UIDGID: "10001:10001",
63+
}
64+
65+
chainBuilder := tastoracosmos.NewChainBuilderWithTestName(t, uniqueTestName).
66+
WithDockerClient(dockerClient).
67+
WithDockerNetworkID(networkID).
68+
WithImage(chainImage).
69+
WithEncodingConfig(&encCfg).
70+
WithAdditionalStartArgs(
71+
"--force-no-bbr",
72+
"--grpc.enable",
73+
"--grpc.address", "0.0.0.0:9090",
74+
"--rpc.grpc_laddr=tcp://0.0.0.0:9098",
75+
"--rpc.laddr=tcp://0.0.0.0:26657",
76+
"--timeout-commit", "1s",
77+
"--minimum-gas-prices", "0utia",
78+
).
79+
WithNode(tastoracosmos.NewChainNodeConfigBuilder().Build())
80+
81+
chain, err := chainBuilder.Build(ctx)
82+
require.NoError(t, err, "build celestia-app chain")
83+
require.NoError(t, chain.Start(ctx), "start celestia-app chain")
84+
85+
chainID := chain.GetChainID()
86+
genesisHash, err := fetchGenesisHash(ctx, chain)
87+
require.NoError(t, err, "genesis hash")
88+
89+
chainNetInfo, err := chain.GetNodes()[0].GetNetworkInfo(ctx)
90+
require.NoError(t, err, "chain network info")
91+
coreHost := chainNetInfo.Internal.Hostname
92+
93+
// 2) Start celestia-node (bridge)
94+
daImage := container.Image{
95+
Repository: "ghcr.io/celestiaorg/celestia-node",
96+
Version: "v0.28.4-mocha",
97+
UIDGID: "10001:10001",
98+
}
99+
100+
daNetwork, err := tastorada.NewNetworkBuilderWithTestName(t, uniqueTestName).
101+
WithDockerClient(dockerClient).
102+
WithDockerNetworkID(networkID).
103+
WithImage(daImage).
104+
WithNodes(tastorada.NewNodeBuilder().WithNodeType(tastoratypes.BridgeNode).Build()).
105+
Build(ctx)
106+
require.NoError(t, err, "build da network")
107+
108+
bridge := daNetwork.GetBridgeNodes()[0]
109+
err = bridge.Start(ctx,
110+
tastorada.WithChainID(chainID),
111+
tastorada.WithAdditionalStartArguments(
112+
"--p2p.network", chainID,
113+
"--core.ip", coreHost,
114+
"--rpc.addr", "0.0.0.0",
115+
),
116+
tastorada.WithEnvironmentVariables(map[string]string{
117+
"CELESTIA_CUSTOM": tastoratypes.BuildCelestiaCustomEnvVar(chainID, genesisHash, ""),
118+
"P2P_NETWORK": chainID,
119+
}),
120+
)
121+
require.NoError(t, err, "start bridge node")
122+
123+
bridgeWallet, err := bridge.GetWallet()
124+
require.NoError(t, err, "bridge wallet")
125+
126+
// 3) Wait for chain to be live then fund bridge wallet
127+
validatorNode := chain.GetNodes()[0].(*tastoracosmos.ChainNode)
128+
129+
err = wait.ForCondition(ctx, 2*time.Minute, time.Second, func() (bool, error) {
130+
c, err := validatorNode.GetRPCClient()
131+
if err != nil {
132+
return false, nil
133+
}
134+
if _, err := c.Status(ctx); err != nil {
135+
return false, nil
136+
}
137+
h, err := validatorNode.Height(ctx)
138+
if err != nil {
139+
return false, nil
140+
}
141+
return h >= 3, nil
142+
})
143+
require.NoError(t, err, "validator RPC ready")
144+
145+
// fund the bridge wallet via CLI to avoid JSON-RPC decoding issues
146+
faucetKey := "faucet"
147+
sendAmt := sdk.NewInt64Coin(chain.Config.Denom, 5_000_000_000)
148+
rpcNode := fmt.Sprintf("tcp://%s:26657", coreHost)
149+
150+
cmd := []string{
151+
validatorNode.BinaryName,
152+
"tx", "bank", "send",
153+
faucetKey,
154+
bridgeWallet.FormattedAddress,
155+
sendAmt.String(),
156+
"--chain-id", chainID,
157+
"--home", validatorNode.HomeDir(),
158+
"--keyring-backend", "test",
159+
"--node", rpcNode,
160+
"--fees", fmt.Sprintf("1000%s", chain.Config.Denom),
161+
"--broadcast-mode", "sync",
162+
"--yes",
163+
}
164+
stdout, stderr, err := validatorNode.Exec(ctx, cmd, nil)
165+
require.NoErrorf(t, err, "fund bridge wallet via CLI: %s", string(stderr))
166+
require.Contains(t, string(stdout), "code: 0", "bank send succeeded")
167+
168+
bankQuery := banktypes.NewQueryClient(chain.GetNode().GrpcConn)
169+
err = wait.ForCondition(ctx, 2*time.Minute, time.Second, func() (bool, error) {
170+
bal, err := bankQuery.Balance(ctx, &banktypes.QueryBalanceRequest{
171+
Address: bridgeWallet.FormattedAddress,
172+
Denom: chain.Config.Denom,
173+
})
174+
if err != nil {
175+
return false, nil
176+
}
177+
return bal.Balance != nil && bal.Balance.Amount.GT(cosmosmath.NewInt(0)), nil
178+
})
179+
require.NoError(t, err, "bridge wallet funded")
180+
181+
bridgeNetInfo, err := bridge.GetNetworkInfo(ctx)
182+
require.NoError(t, err, "bridge network info")
183+
184+
// wait for celestia-node RPC port to become reachable
185+
err = wait.ForCondition(ctx, 2*time.Minute, time.Second, func() (bool, error) {
186+
hostPort := fmt.Sprintf("127.0.0.1:%s", bridgeNetInfo.External.Ports.RPC)
187+
conn, err := net.DialTimeout("tcp", hostPort, 2*time.Second)
188+
if err != nil {
189+
return false, nil
190+
}
191+
_ = conn.Close()
192+
return true, nil
193+
})
194+
require.NoError(t, err, "bridge RPC reachable")
195+
196+
// 4) Start EV Node (aggregator) pointing at DA
197+
evNodeChain, err := evstack.NewChainBuilderWithTestName(t, uniqueTestName).
198+
WithChainID("evchain-test").
199+
WithBinaryName("testapp").
200+
WithAggregatorPassphrase("12345678").
201+
WithImage(getEvNodeImage()).
202+
WithDockerClient(dockerClient).
203+
WithDockerNetworkID(networkID).
204+
WithNode(evstack.NewNodeBuilder().WithAggregator(true).Build()).
205+
Build(ctx)
206+
require.NoError(t, err, "build ev node chain")
207+
208+
evNode := evNodeChain.GetNodes()[0]
209+
require.NoError(t, evNode.Init(ctx), "ev node init")
210+
211+
authToken, err := bridge.GetAuthToken()
212+
require.NoError(t, err, "bridge auth token")
213+
214+
daAddress := fmt.Sprintf("http://%s", bridgeNetInfo.Internal.RPCAddress())
215+
headerNamespaceStr := "ev-header"
216+
dataNamespaceStr := "ev-data"
217+
dataNamespace := coreda.NamespaceFromString(dataNamespaceStr)
218+
219+
require.NoError(t, evNode.Start(ctx,
220+
"--evnode.da.address", daAddress,
221+
"--evnode.da.auth_token", authToken,
222+
"--evnode.rpc.address", "0.0.0.0:7331",
223+
"--evnode.da.namespace", headerNamespaceStr,
224+
"--evnode.da.data_namespace", dataNamespaceStr,
225+
"--kv-endpoint", "0.0.0.0:8080",
226+
), "start ev node")
227+
228+
evNetInfo, err := evNode.GetNetworkInfo(ctx)
229+
require.NoError(t, err, "ev node network info")
230+
httpAddr := evNetInfo.External.HTTPAddress()
231+
require.NotEmpty(t, httpAddr)
232+
parts := strings.Split(httpAddr, ":")
233+
require.Len(t, parts, 2)
234+
host, port := parts[0], parts[1]
235+
if host == "0.0.0.0" {
236+
host = "localhost"
237+
}
238+
cli, err := newHTTPClient(host, port)
239+
require.NoError(t, err)
240+
241+
// 5) Submit a tx to ev-node to trigger block production + DA posting
242+
key, value := "da-key", "da-value"
243+
_, err = cli.Post(ctx, "/tx", key, value)
244+
require.NoError(t, err)
245+
246+
require.Eventually(t, func() bool {
247+
res, err := cli.Get(ctx, "/kv?key="+key)
248+
if err != nil {
249+
return false
250+
}
251+
return string(res) == value
252+
}, 30*time.Second, time.Second, "ev-node should serve the kv value")
253+
254+
// 6) Assert data landed on DA via celestia-node blob RPC (namespace ev-data)
255+
daRPCAddr := fmt.Sprintf("http://127.0.0.1:%s", bridgeNetInfo.External.Ports.RPC)
256+
daClient, err := jsonrpc.NewClient(ctx, zerolog.Nop(), daRPCAddr, authToken, seqcommon.AbsoluteMaxBlobSize)
257+
require.NoError(t, err, "new da client")
258+
defer daClient.Close()
259+
260+
validator := chain.GetNodes()[0].(*tastoracosmos.ChainNode)
261+
tmRPC, err := validator.GetRPCClient()
262+
require.NoError(t, err, "tm rpc client")
263+
264+
var pfbHeight int64
265+
require.Eventually(t, func() bool {
266+
res, err := tmRPC.TxSearch(ctx, "message.action='/celestia.blob.v1.MsgPayForBlobs'", false, nil, nil, "desc")
267+
if err != nil || len(res.Txs) == 0 {
268+
return false
269+
}
270+
dataNSB64 := base64.StdEncoding.EncodeToString(dataNamespace.Bytes())
271+
for _, tx := range res.Txs {
272+
if tx.TxResult.Code != 0 {
273+
continue
274+
}
275+
for _, ev := range tx.TxResult.Events {
276+
if ev.Type != "celestia.blob.v1.EventPayForBlobs" {
277+
continue
278+
}
279+
for _, attr := range ev.Attributes {
280+
if string(attr.Key) == "namespaces" && strings.Contains(string(attr.Value), dataNSB64) {
281+
pfbHeight = tx.Height
282+
return true
283+
}
284+
}
285+
}
286+
}
287+
return false
288+
}, 2*time.Minute, 5*time.Second, "expected a PayForBlobs tx on celestia-app")
289+
290+
require.Eventually(t, func() bool {
291+
if pfbHeight == 0 {
292+
return false
293+
}
294+
for h := pfbHeight; h <= pfbHeight+10; h++ {
295+
ids, err := daClient.DA.GetIDs(ctx, uint64(h), dataNamespace.Bytes())
296+
if err != nil {
297+
t.Logf("GetIDs data height=%d err=%v", h, err)
298+
continue
299+
}
300+
if ids != nil && len(ids.IDs) > 0 {
301+
return true
302+
}
303+
}
304+
return false
305+
}, 6*time.Minute, 5*time.Second, "expected blob in DA for namespace ev-data")
306+
}
307+
308+
// newHTTPClient is a small helper to avoid importing the docker_e2e client.
309+
func newHTTPClient(host, port string) (*httpClient, error) {
310+
return &httpClient{baseURL: fmt.Sprintf("http://%s:%s", host, port)}, nil
311+
}
312+
313+
type httpClient struct {
314+
baseURL string
315+
}
316+
317+
func (c *httpClient) Get(ctx context.Context, path string) ([]byte, error) {
318+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
319+
if err != nil {
320+
return nil, err
321+
}
322+
resp, err := http.DefaultClient.Do(req)
323+
if err != nil {
324+
return nil, err
325+
}
326+
defer resp.Body.Close()
327+
if resp.StatusCode != http.StatusOK {
328+
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
329+
}
330+
return io.ReadAll(resp.Body)
331+
}
332+
333+
func (c *httpClient) Post(ctx context.Context, path, key, value string) ([]byte, error) {
334+
body := strings.NewReader(fmt.Sprintf("%s=%s", key, value))
335+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body)
336+
if err != nil {
337+
return nil, err
338+
}
339+
req.Header.Set("Content-Type", "text/plain")
340+
resp, err := http.DefaultClient.Do(req)
341+
if err != nil {
342+
return nil, err
343+
}
344+
defer resp.Body.Close()
345+
return io.ReadAll(resp.Body)
346+
}
347+
348+
// getEvNodeImage resolves the EV Node image to use for the test.
349+
// Falls back to EV_NODE_IMAGE_REPO:EV_NODE_IMAGE_TAG or evstack:local-dev.
350+
func getEvNodeImage() container.Image {
351+
repo := strings.TrimSpace(getEnvDefault("EV_NODE_IMAGE_REPO", "evstack"))
352+
tag := strings.TrimSpace(getEnvDefault("EV_NODE_IMAGE_TAG", "local-dev"))
353+
return container.NewImage(repo, tag, "10001:10001")
354+
}
355+
356+
func getEnvDefault(key, def string) string {
357+
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
358+
return v
359+
}
360+
return def
361+
}

test/e2e/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ require (
1212
github.com/cosmos/ibc-go/v8 v8.7.0
1313
github.com/ethereum/go-ethereum v1.16.7
1414
github.com/evstack/ev-node v1.0.0-beta.10
15+
github.com/evstack/ev-node/da v0.0.0-00010101000000-000000000000
1516
github.com/evstack/ev-node/execution/evm v0.0.0-20250602130019-2a732cf903a5
1617
github.com/evstack/ev-node/execution/evm/test v0.0.0-00010101000000-000000000000
1718
github.com/libp2p/go-libp2p v0.45.0
19+
github.com/rs/zerolog v1.34.0
1820
github.com/stretchr/testify v1.11.1
1921
)
2022

@@ -195,7 +197,6 @@ require (
195197
github.com/rivo/uniseg v0.2.0 // indirect
196198
github.com/rogpeppe/go-internal v1.14.1 // indirect
197199
github.com/rs/cors v1.11.1 // indirect
198-
github.com/rs/zerolog v1.34.0 // indirect
199200
github.com/sagikazarmark/locafero v0.11.0 // indirect
200201
github.com/sasha-s/go-deadlock v0.3.5 // indirect
201202
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect

0 commit comments

Comments
 (0)