Skip to content

Commit d9e4444

Browse files
committed
evm-gateway-demo: multi-replica demo infra, namespace filtering, block DB retention
- Add namespace filtering to Chain (NewChain gains namespace param) - Add SQLite PRAGMA temp_store=MEMORY to fix crash on scratch images - Add TruncateBlocks to storage.Store with configurable retention window - Wire cfg.Network.Namespace and cfg.Gateway.SyncTimeout through app.go - Add run-demo.sh Ansible-based demo runner script - Add USDC_deployment.md multi-replica deployment guide - Extend replay_json_dataset_test.go with wrap-around support - Add chain_test.go namespace-filter tests and store_test.go truncation tests Signed-off-by: Eyal Kushnir <eyal.kushnir@ibm.com>
1 parent 2efed97 commit d9e4444

13 files changed

Lines changed: 858 additions & 53 deletions

File tree

Makefile

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ build-release:
2424
-o release/linux-$$arch/fxevm ./cmd/fxevm || exit 1; \
2525
done
2626

27+
IMAGE_TAG ?= dev
28+
2729
.PHONY: build-image
2830
build-image: build-release
2931
$(DOCKER) buildx build \
@@ -32,7 +34,7 @@ build-image: build-release
3234
--build-arg VERSION=dev \
3335
--build-arg CREATED=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) \
3436
--build-arg REVISION=$(shell git rev-parse HEAD) \
35-
--tag fabric-x-evm:dev \
37+
--tag fabric-x-evm:$(IMAGE_TAG) \
3638
.
3739

3840
.PHONY: checks
@@ -193,3 +195,16 @@ hardhat-tests:
193195
.PHONY: perf-tests
194196
perf-tests: pre-pull-images
195197
@VERBOSE=$(VERBOSE) FABRIC_VERSION=$(FABRIC_VERSION) ./scripts/run_perf_test.sh
198+
199+
# STAGING_HOST, SSH_USER, EVM_BRANCH, and DEMO_ARGS may be overridden on the command line.
200+
STAGING_HOST ?= dectrust8.vpc.cloud9.ibm.com
201+
SSH_USER ?= root
202+
EVM_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
203+
204+
.PHONY: run-demo
205+
run-demo:
206+
scripts/run-demo.sh \
207+
--staging-host $(STAGING_HOST) \
208+
--ssh-user $(SSH_USER) \
209+
--evm-branch $(EVM_BRANCH) \
210+
$(DEMO_ARGS)

gateway/app/app.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,13 @@ func buildApp(ctx context.Context, cfg config.Config, gwSigner sdk.Signer, logge
114114
return nil, fmt.Errorf("failed to create submitter: %w", err)
115115
}
116116

117-
chain, err := core.NewChain(cfg.Gateway.Database.ConnString, cfg.Gateway.Database.TriePath, false)
117+
chain, err := core.NewChain(core.ChainOpts{
118+
DBConnString: cfg.Gateway.Database.ConnString,
119+
TriePath: cfg.Gateway.Database.TriePath,
120+
Namespace: cfg.Network.Namespace,
121+
BlockRetention: cfg.Gateway.BlockRetention,
122+
BlockTruncationInterval: cfg.Gateway.BlockTruncationInterval,
123+
})
118124
if err != nil {
119125
return nil, fmt.Errorf("failed to create chain: %w", err)
120126
}
@@ -203,8 +209,12 @@ func (a *App) Run(ctx context.Context) error {
203209
g.Go(func() error { return a.gwSync.Start(gctx) })
204210

205211
// Wait for initial sync before serving traffic
212+
syncTimeout := a.cfg.Gateway.SyncTimeout
213+
if syncTimeout == 0 {
214+
syncTimeout = 10 * time.Second
215+
}
206216
for i, sync := range a.endorserSyncs {
207-
if err := waitUntilSynced(gctx, sync, 10*time.Second); err != nil {
217+
if err := waitUntilSynced(gctx, sync, syncTimeout); err != nil {
208218
return err
209219
}
210220
appLogger.Debugf("endorser %d synced", i)

gateway/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type Gateway struct {
3737
EnableTestRPC bool `mapstructure:"enable-test-rpc" yaml:"enable-test-rpc"` // Enable test-only RPC methods (eth_accounts, eth_sendTransaction) - UNSAFE for production
3838

3939
WorkerCount int `mapstructure:"worker-count" yaml:"worker-count"` // number of worker goroutines; defaults to 1 if not set
40+
41+
BlockRetention int `mapstructure:"block-retention" yaml:"block-retention"` // number of recent blocks to keep on the gateway DB; 0 uses storage default
42+
BlockTruncationInterval uint64 `mapstructure:"block-truncation-interval" yaml:"block-truncation-interval"` // run truncation every N blocks; 0 uses storage default
4043
}
4144

4245
// DB holds the database paths for the gateway.

gateway/core/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ func (g *Gateway) Stop() error {
394394

395395
total, invalid := g.TxQueue.Stats()
396396
if total > 0 {
397-
fmt.Println("gw stats:", total, invalid, float64(invalid)/float64(total))
397+
logger.Infof("gw stats: total=%d invalid=%d invalid_ratio=%.4f", total, invalid, float64(invalid)/float64(total))
398398
}
399399

400400
return err

gateway/core/chain.go

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,49 @@ import (
2828
// (for block ingestion) and core.Store (via the embedded *storage.Store, for API queries).
2929
type Chain struct {
3030
*storage.Store
31-
db *sql.DB
32-
ts *trie.Store
33-
prevHash common.Hash // Ethereum hash of last committed block; seeded from DB on startup
31+
db *sql.DB
32+
ts *trie.Store
33+
prevHash common.Hash // Ethereum hash of last committed block; seeded from DB on startup
34+
namespace string // Fabric namespace to filter; empty means accept all
35+
}
36+
37+
// ChainOpts is the parameter bundle for NewChain.
38+
//
39+
// DBConnString uses the modernc SQLite DSN format.
40+
// TriePath is the directory for the PebbleDB trie; empty = in-memory (and ignored when WithTrie is false).
41+
// Namespace filters delivered blocks to only those containing transactions for that Fabric
42+
// namespace; empty accepts all namespaces.
43+
// BlockRetention and BlockTruncationInterval are forwarded to the underlying Store; zero values
44+
// use the storage package defaults (storage.DefaultBlockRetention / storage.DefaultBlockTruncationInterval).
45+
type ChainOpts struct {
46+
DBConnString string
47+
TriePath string
48+
Namespace string
49+
WithTrie bool
50+
BlockRetention int
51+
BlockTruncationInterval uint64
3452
}
3553

3654
// NewChain opens the SQLite database and trie store, seeds state from the latest committed
37-
// block, and returns a ready Chain. dbConnStr uses the modernc SQLite DSN format;
38-
// triePath is the directory for the PebbleDB trie (empty string = in-memory).
55+
// block, and returns a ready Chain.
3956
// The caller must register the SQLite driver (e.g. _ "modernc.org/sqlite") before calling.
40-
func NewChain(dbConnStr, triePath string, withTrie bool) (*Chain, error) {
41-
db, err := sqlite.Open(dbConnStr)
57+
func NewChain(opts ChainOpts) (*Chain, error) {
58+
db, err := sqlite.Open(opts.DBConnString)
4259
if err != nil {
4360
return nil, fmt.Errorf("open db: %w", err)
4461
}
62+
// Store SQLite temp objects in memory. The release image is built from
63+
// scratch and has no /tmp, so file-based temp storage would fail with
64+
// SQLITE_IOERR_GETTEMPPATH (6410) during WAL operations like DELETE.
65+
if _, err := db.Exec("PRAGMA temp_store=MEMORY"); err != nil {
66+
db.Close()
67+
return nil, fmt.Errorf("set temp_store pragma: %w", err)
68+
}
4569

46-
blockStore := storage.NewStore(db)
70+
blockStore := storage.NewStore(db, storage.StoreOpts{
71+
BlockRetention: opts.BlockRetention,
72+
BlockTruncationInterval: opts.BlockTruncationInterval,
73+
})
4774
if err := blockStore.Init(); err != nil {
4875
db.Close()
4976
return nil, fmt.Errorf("init block store: %w", err)
@@ -58,21 +85,39 @@ func NewChain(dbConnStr, triePath string, withTrie bool) (*Chain, error) {
5885
}
5986

6087
var ts *trie.Store
61-
if withTrie {
62-
ts, err = trie.New(triePath, initialRoot)
88+
if opts.WithTrie {
89+
ts, err = trie.New(opts.TriePath, initialRoot)
6390
if err != nil {
6491
db.Close()
6592
return nil, fmt.Errorf("open trie store: %w", err)
6693
}
6794
}
6895

69-
return &Chain{Store: blockStore, db: db, ts: ts, prevHash: prevHash}, nil
96+
return &Chain{Store: blockStore, db: db, ts: ts, prevHash: prevHash, namespace: opts.Namespace}, nil
97+
}
98+
99+
// filterAndConvert applies namespace filtering then delegates to the package-level ConvertToDomain.
100+
func (c *Chain) filterAndConvert(b blocks.Block) domain.Block {
101+
if c.namespace == "" {
102+
return ConvertToDomain(b)
103+
}
104+
filtered := b
105+
filtered.Transactions = filtered.Transactions[:0:0]
106+
for _, tx := range b.Transactions {
107+
for _, nrws := range tx.NsRWS {
108+
if nrws.Namespace == c.namespace {
109+
filtered.Transactions = append(filtered.Transactions, tx)
110+
break
111+
}
112+
}
113+
}
114+
return ConvertToDomain(filtered)
70115
}
71116

72117
// Handle implements blocks.BlockHandler. It commits the block's write sets to the trie,
73118
// then persists the block and its transactions to the database.
74119
func (c *Chain) Handle(ctx context.Context, b blocks.Block) error {
75-
ebl := ConvertToDomain(b)
120+
ebl := c.filterAndConvert(b)
76121

77122
ebl.ParentHash = c.prevHash.Bytes()
78123
if c.ts != nil {
@@ -90,6 +135,12 @@ func (c *Chain) Handle(ctx context.Context, b blocks.Block) error {
90135
return err
91136
}
92137

138+
if c.Store.BlockRetention > 0 && c.Store.BlockTruncationInterval > 0 && ebl.BlockNumber%c.Store.BlockTruncationInterval == 0 {
139+
if err := c.Store.TruncateBlocks(ctx, c.Store.BlockRetention); err != nil {
140+
logger.Warnf("block DB truncation failed at block %d: %v", ebl.BlockNumber, err)
141+
}
142+
}
143+
93144
return nil
94145
}
95146

@@ -115,8 +166,6 @@ func ConvertToDomain(b blocks.Block) domain.Block {
115166

116167
logIndex := int64(0) // logIndex is the index of the log in the block
117168
for _, tx := range b.Transactions {
118-
// TODO: filter on namespace?
119-
120169
// retrieve the Ethereum transaction from the chaincode invocation
121170
if len(tx.InputArgs) < 2 || !bytes.Equal(tx.InputArgs[0], []byte{byte(fc.ProposalTypeEVMTx)}) {
122171
// skip non-eth tx

gateway/core/chain_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,49 @@ func TestConvertToDomain_EmptyBlock(t *testing.T) {
129129
assert.Len(t, got.Transactions, 0)
130130
}
131131

132+
func TestConvertToDomain_NamespaceFilter(t *testing.T) {
133+
key, err := crypto.GenerateKey()
134+
require.NoError(t, err)
135+
ethb := marshaledEthTx(t, key, common.HexToAddress("0x1111111111111111111111111111111111111111"), big.NewInt(1))
136+
137+
matchTx := blocks.Transaction{
138+
ID: "tx-match",
139+
Valid: true,
140+
InputArgs: [][]byte{{byte(co.ProposalTypeEVMTx)}, ethb},
141+
NsRWS: []blocks.NsReadWriteSet{{Namespace: "my-ns"}},
142+
}
143+
foreignTx := blocks.Transaction{
144+
ID: "tx-foreign",
145+
Valid: true,
146+
InputArgs: [][]byte{{byte(co.ProposalTypeEVMTx)}, ethb},
147+
NsRWS: []blocks.NsReadWriteSet{{Namespace: "other-ns"}},
148+
}
149+
150+
t.Run("filters out foreign namespace", func(t *testing.T) {
151+
c := &Chain{namespace: "my-ns"}
152+
got := c.filterAndConvert(blocks.Block{Number: 1, Transactions: []blocks.Transaction{matchTx, foreignTx}})
153+
require.Len(t, got.Transactions, 1)
154+
assert.Equal(t, "tx-match", got.Transactions[0].FabricTxID)
155+
})
156+
157+
t.Run("no-op when namespace is empty", func(t *testing.T) {
158+
c := &Chain{}
159+
got := c.filterAndConvert(blocks.Block{Number: 1, Transactions: []blocks.Transaction{matchTx, foreignTx}})
160+
assert.Len(t, got.Transactions, 2)
161+
})
162+
163+
t.Run("skips tx with no NsRWS when namespace set", func(t *testing.T) {
164+
noNsTx := blocks.Transaction{
165+
ID: "tx-no-ns",
166+
Valid: true,
167+
InputArgs: [][]byte{{byte(co.ProposalTypeEVMTx)}, ethb},
168+
}
169+
c := &Chain{namespace: "my-ns"}
170+
got := c.filterAndConvert(blocks.Block{Number: 1, Transactions: []blocks.Transaction{noNsTx}})
171+
assert.Len(t, got.Transactions, 0)
172+
})
173+
}
174+
132175
// --- convertTransaction ---
133176

134177
func TestConvertTransaction_RegularTransfer(t *testing.T) {

gateway/core/txqueue_helpers.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,13 @@ func senderForTx(tx *types.Transaction) (common.Address, bool) {
4343
return sender, true
4444
}
4545

46-
// recipientForTx extracts the recipient address from a transaction.
47-
// For ERC20 transfers, it extracts the recipient from the calldata.
48-
// Returns (address, true) if successful, (zero address, false) otherwise.
46+
// erc20TransferSelector is the 4-byte function selector for ERC20 transfer(address,uint256).
47+
var erc20TransferSelector = [4]byte{0xa9, 0x05, 0x9c, 0xbb}
48+
49+
// recipientForTx extracts the recipient address from an ERC20 transfer(address,uint256) call.
50+
// Returns (address, true) only when the calldata matches the ERC20 transfer selector and is the
51+
// expected length. Without the selector check, any call with >=68 bytes of data would be decoded
52+
// as a transfer and create false-positive dependency edges between unrelated transactions.
4953
func recipientForTx(tx *types.Transaction) (common.Address, bool) {
5054
if tx.To() == nil {
5155
return common.Address{}, false
@@ -55,6 +59,10 @@ func recipientForTx(tx *types.Transaction) (common.Address, bool) {
5559
if len(data) < 4+32+32 {
5660
return common.Address{}, false
5761
}
62+
if data[0] != erc20TransferSelector[0] || data[1] != erc20TransferSelector[1] ||
63+
data[2] != erc20TransferSelector[2] || data[3] != erc20TransferSelector[3] {
64+
return common.Address{}, false
65+
}
5866

5967
// Extract recipient from ERC20 transfer calldata (offset 4 + 12 bytes)
6068
recipientOffset := 4 + 12

gateway/storage/store.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,37 @@ import (
2121
var ddl string
2222

2323
type Store struct {
24-
queries *Queries
25-
DB *sql.DB
26-
CachedBlockNumber atomic.Uint64 // cached block number for fast reads
24+
queries *Queries
25+
DB *sql.DB
26+
CachedBlockNumber atomic.Uint64 // cached block number for fast reads
27+
BlockRetention int // number of recent blocks to keep; 0 disables truncation
28+
BlockTruncationInterval uint64 // run truncation every N blocks; 0 disables truncation
2729
}
2830

29-
func NewStore(db *sql.DB) *Store {
31+
// StoreOpts carries optional tuning for NewStore. Zero values use built-in defaults
32+
// (DefaultBlockRetention / DefaultBlockTruncationInterval).
33+
type StoreOpts struct {
34+
BlockRetention int
35+
BlockTruncationInterval uint64
36+
}
37+
38+
const (
39+
DefaultBlockRetention = 10000
40+
DefaultBlockTruncationInterval = 1000
41+
)
42+
43+
func NewStore(db *sql.DB, opts StoreOpts) *Store {
44+
if opts.BlockRetention == 0 {
45+
opts.BlockRetention = DefaultBlockRetention
46+
}
47+
if opts.BlockTruncationInterval == 0 {
48+
opts.BlockTruncationInterval = DefaultBlockTruncationInterval
49+
}
3050
return &Store{
31-
queries: New(db),
32-
DB: db,
51+
queries: New(db),
52+
DB: db,
53+
BlockRetention: opts.BlockRetention,
54+
BlockTruncationInterval: opts.BlockTruncationInterval,
3355
}
3456
}
3557

@@ -451,6 +473,34 @@ func (s *Store) GetLogs(ctx context.Context, filter domain.LogFilter) ([]domain.
451473
return logs, nil
452474
}
453475

476+
// TruncateBlocks deletes blocks older than the last keepLastN blocks, along with
477+
// their associated transactions and logs. All three tables are deleted in a single
478+
// transaction to maintain referential integrity (logs and transactions have foreign
479+
// keys on block_number with no CASCADE, so they must be deleted first).
480+
// Returns nil immediately if there is nothing to truncate.
481+
func (s *Store) TruncateBlocks(ctx context.Context, keepLastN int) error {
482+
cutoff := int64(s.CachedBlockNumber.Load()) - int64(keepLastN)
483+
if cutoff <= 0 {
484+
return nil
485+
}
486+
sqlTx, err := s.DB.BeginTx(ctx, nil)
487+
if err != nil {
488+
return err
489+
}
490+
defer sqlTx.Rollback() //nolint:errcheck
491+
492+
for _, stmt := range []string{
493+
`DELETE FROM logs WHERE block_number <= ?`,
494+
`DELETE FROM transactions WHERE block_number <= ?`,
495+
`DELETE FROM blocks WHERE block_number <= ?`,
496+
} {
497+
if _, err := sqlTx.Exec(stmt, cutoff); err != nil {
498+
return err
499+
}
500+
}
501+
return sqlTx.Commit()
502+
}
503+
454504
// GetLogsByTxHash retrieves all logs for a specific transaction.
455505
func (s *Store) GetLogsByTxHash(ctx context.Context, txHash []byte) ([]domain.Log, error) {
456506
rows, err := s.queries.GetLogsByTxHash(ctx, txHash)

0 commit comments

Comments
 (0)