Skip to content

Commit 58fe455

Browse files
feat: implement multi-account support with round-robin address selection (#2785)
<!-- Please read and fill out this form before submitting your PR. Please make sure you have reviewed our contributors guide before submitting your first PR. NOTE: PR titles should follow semantic commits: https://www.conventionalcommits.org/en/v1.0.0/ --> ## Overview <!-- Please provide an explanation of the PR, including the appropriate context, background, goal, and rationale. If there is an issue with this information, please provide a tl;dr and link the issue. Ex: Closes #<issue number> --> Closes #2627 --------- Co-authored-by: Marko <[email protected]>
1 parent 3d49dfc commit 58fe455

File tree

11 files changed

+470
-19
lines changed

11 files changed

+470
-19
lines changed

apps/evm/single/entrypoint.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ if [ -n "$DA_NAMESPACE" ]; then
8888
default_flags="$default_flags --rollkit.da.namespace $DA_NAMESPACE"
8989
fi
9090

91+
if [ -n "$DA_SIGNING_ADDRESSES" ]; then
92+
default_flags="$default_flags --rollkit.da.signing_addresses $DA_SIGNING_ADDRESSES"
93+
fi
94+
9195
# If no arguments passed, show help
9296
if [ $# -eq 0 ]; then
9397
exec evm-single

block/internal/submitting/da_submitter.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package submitting
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"fmt"
78
"time"
89

@@ -13,6 +14,7 @@ import (
1314
"github.com/evstack/ev-node/block/internal/common"
1415
coreda "github.com/evstack/ev-node/core/da"
1516
"github.com/evstack/ev-node/pkg/config"
17+
pkgda "github.com/evstack/ev-node/pkg/da"
1618
"github.com/evstack/ev-node/pkg/genesis"
1719
"github.com/evstack/ev-node/pkg/rpc/server"
1820
"github.com/evstack/ev-node/pkg/signer"
@@ -124,6 +126,9 @@ type DASubmitter struct {
124126
// calculate namespaces bytes once and reuse them
125127
namespaceBz []byte
126128
namespaceDataBz []byte
129+
130+
// address selector for multi-account support
131+
addressSelector pkgda.AddressSelector
127132
}
128133

129134
// NewDASubmitter creates a new DA submitter
@@ -147,6 +152,17 @@ func NewDASubmitter(
147152
metrics = common.NopMetrics()
148153
}
149154

155+
// Create address selector based on configuration
156+
var addressSelector pkgda.AddressSelector
157+
if len(config.DA.SigningAddresses) > 0 {
158+
addressSelector = pkgda.NewRoundRobinSelector(config.DA.SigningAddresses)
159+
daSubmitterLogger.Info().
160+
Int("num_addresses", len(config.DA.SigningAddresses)).
161+
Msg("initialized round-robin address selector for multi-account DA submissions")
162+
} else {
163+
addressSelector = pkgda.NewNoOpSelector()
164+
}
165+
150166
return &DASubmitter{
151167
da: da,
152168
config: config,
@@ -156,6 +172,7 @@ func NewDASubmitter(
156172
logger: daSubmitterLogger,
157173
namespaceBz: coreda.NamespaceFromString(config.DA.GetNamespace()).Bytes(),
158174
namespaceDataBz: coreda.NamespaceFromString(config.DA.GetDataNamespace()).Bytes(),
175+
addressSelector: addressSelector,
159176
}
160177
}
161178

@@ -235,7 +252,6 @@ func (s *DASubmitter) SubmitHeaders(ctx context.Context, cache cache.Manager) er
235252
"header",
236253
s.namespaceBz,
237254
[]byte(s.config.DA.SubmitOptions),
238-
cache,
239255
func() uint64 { return cache.NumPendingHeaders() },
240256
)
241257
}
@@ -279,7 +295,6 @@ func (s *DASubmitter) SubmitData(ctx context.Context, cache cache.Manager, signe
279295
"data",
280296
s.namespaceDataBz,
281297
[]byte(s.config.DA.SubmitOptions),
282-
cache,
283298
func() uint64 { return cache.NumPendingData() },
284299
)
285300
}
@@ -340,6 +355,44 @@ func (s *DASubmitter) createSignedData(dataList []*types.SignedData, signer sign
340355
return signedDataList, nil
341356
}
342357

358+
// mergeSubmitOptions merges the base submit options with a signing address.
359+
// If the base options are valid JSON, the signing address is added to the JSON object.
360+
// Otherwise, a new JSON object is created with just the signing address.
361+
// Returns the base options unchanged if no signing address is provided.
362+
func mergeSubmitOptions(baseOptions []byte, signingAddress string) ([]byte, error) {
363+
if signingAddress == "" {
364+
return baseOptions, nil
365+
}
366+
367+
var optionsMap map[string]interface{}
368+
369+
// If base options are provided, try to parse them as JSON
370+
if len(baseOptions) > 0 {
371+
// Try to unmarshal existing options, ignoring errors for non-JSON input
372+
if err := json.Unmarshal(baseOptions, &optionsMap); err != nil {
373+
// Not valid JSON - start with empty map
374+
optionsMap = make(map[string]interface{})
375+
}
376+
}
377+
378+
// Ensure map is initialized even if unmarshal returned nil
379+
if optionsMap == nil {
380+
optionsMap = make(map[string]interface{})
381+
}
382+
383+
// Add or override the signing address
384+
// Note: Uses "signer_address" to match Celestia's TxConfig JSON schema
385+
optionsMap["signer_address"] = signingAddress
386+
387+
// Marshal back to JSON
388+
mergedOptions, err := json.Marshal(optionsMap)
389+
if err != nil {
390+
return nil, fmt.Errorf("failed to marshal submit options: %w", err)
391+
}
392+
393+
return mergedOptions, nil
394+
}
395+
343396
// submitToDA is a generic helper for submitting items to the DA layer with retry, backoff, and gas price logic.
344397
func submitToDA[T any](
345398
s *DASubmitter,
@@ -350,7 +403,6 @@ func submitToDA[T any](
350403
itemType string,
351404
namespace []byte,
352405
options []byte,
353-
cache cache.Manager,
354406
getTotalPendingFn func() uint64,
355407
) error {
356408
marshaled, err := marshalItems(ctx, items, marshalFn, itemType)
@@ -397,12 +449,24 @@ func submitToDA[T any](
397449
return err
398450
}
399451

452+
// Select signing address and merge with options
453+
signingAddress := s.addressSelector.Next()
454+
mergedOptions, err := mergeSubmitOptions(options, signingAddress)
455+
if err != nil {
456+
s.logger.Error().Err(err).Msg("failed to merge submit options with signing address")
457+
return fmt.Errorf("failed to merge submit options: %w", err)
458+
}
459+
460+
if signingAddress != "" {
461+
s.logger.Debug().Str("signingAddress", signingAddress).Msg("using signing address for DA submission")
462+
}
463+
400464
submitCtx, cancel := context.WithTimeout(ctx, submissionTimeout)
401465
defer cancel()
402466

403467
// Perform submission
404468
start := time.Now()
405-
res := types.SubmitWithHelpers(submitCtx, s.da, s.logger, marshaled, rs.GasPrice, namespace, options)
469+
res := types.SubmitWithHelpers(submitCtx, s.da, s.logger, marshaled, rs.GasPrice, namespace, mergedOptions)
406470
s.logger.Debug().Int("attempts", rs.Attempt).Dur("elapsed", time.Since(start)).Uint64("code", uint64(res.Code)).Msg("got SubmitWithHelpers response from celestia")
407471

408472
// Record submission result for observability

block/internal/submitting/da_submitter_mocks_test.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ func TestSubmitToDA_MempoolRetry_IncreasesGasAndSucceeds(t *testing.T) {
8686
nsBz,
8787
opts,
8888
nil,
89-
nil,
9089
)
9190
assert.NoError(t, err)
9291

@@ -138,7 +137,6 @@ func TestSubmitToDA_UnknownError_RetriesSameGasThenSucceeds(t *testing.T) {
138137
nsBz,
139138
opts,
140139
nil,
141-
nil,
142140
)
143141
assert.NoError(t, err)
144142
assert.Equal(t, []float64{5.5, 5.5}, usedGas)
@@ -195,7 +193,6 @@ func TestSubmitToDA_TooBig_HalvesBatch(t *testing.T) {
195193
nsBz,
196194
opts,
197195
nil,
198-
nil,
199196
)
200197
assert.NoError(t, err)
201198
assert.Equal(t, []int{4, 2}, batchSizes)
@@ -245,7 +242,6 @@ func TestSubmitToDA_SentinelNoGas_PreservesGasAcrossRetries(t *testing.T) {
245242
nsBz,
246243
opts,
247244
nil,
248-
nil,
249245
)
250246
assert.NoError(t, err)
251247
assert.Equal(t, []float64{-1, -1}, usedGas)
@@ -286,7 +282,6 @@ func TestSubmitToDA_PartialSuccess_AdvancesWindow(t *testing.T) {
286282
nsBz,
287283
opts,
288284
nil,
289-
nil,
290285
)
291286
assert.NoError(t, err)
292287
assert.Equal(t, 3, totalSubmitted)
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package submitting
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestMergeSubmitOptions_NoSigningAddress(t *testing.T) {
12+
baseOptions := []byte(`{"key":"value"}`)
13+
14+
result, err := mergeSubmitOptions(baseOptions, "")
15+
require.NoError(t, err)
16+
assert.Equal(t, baseOptions, result, "should return unchanged options when no signing address")
17+
}
18+
19+
func TestMergeSubmitOptions_EmptyBaseOptions(t *testing.T) {
20+
signingAddress := "celestia1abc123"
21+
22+
result, err := mergeSubmitOptions([]byte{}, signingAddress)
23+
require.NoError(t, err)
24+
25+
var resultMap map[string]interface{}
26+
err = json.Unmarshal(result, &resultMap)
27+
require.NoError(t, err)
28+
29+
assert.Equal(t, signingAddress, resultMap["signer_address"])
30+
}
31+
32+
func TestMergeSubmitOptions_ValidJSON(t *testing.T) {
33+
baseOptions := []byte(`{"existing":"option","number":42}`)
34+
signingAddress := "celestia1def456"
35+
36+
result, err := mergeSubmitOptions(baseOptions, signingAddress)
37+
require.NoError(t, err)
38+
39+
var resultMap map[string]interface{}
40+
err = json.Unmarshal(result, &resultMap)
41+
require.NoError(t, err)
42+
43+
assert.Equal(t, "option", resultMap["existing"])
44+
assert.Equal(t, float64(42), resultMap["number"]) // JSON numbers are float64
45+
assert.Equal(t, signingAddress, resultMap["signer_address"])
46+
}
47+
48+
func TestMergeSubmitOptions_InvalidJSON(t *testing.T) {
49+
baseOptions := []byte(`not-json-content`)
50+
signingAddress := "celestia1ghi789"
51+
52+
result, err := mergeSubmitOptions(baseOptions, signingAddress)
53+
require.NoError(t, err)
54+
55+
var resultMap map[string]interface{}
56+
err = json.Unmarshal(result, &resultMap)
57+
require.NoError(t, err)
58+
59+
// Should create new JSON object with just the signing address
60+
assert.Equal(t, signingAddress, resultMap["signer_address"])
61+
assert.Len(t, resultMap, 1, "should only contain signing address when base options are invalid JSON")
62+
}
63+
64+
func TestMergeSubmitOptions_OverrideExistingAddress(t *testing.T) {
65+
baseOptions := []byte(`{"signer_address":"old-address","other":"data"}`)
66+
newAddress := "celestia1new456"
67+
68+
result, err := mergeSubmitOptions(baseOptions, newAddress)
69+
require.NoError(t, err)
70+
71+
var resultMap map[string]interface{}
72+
err = json.Unmarshal(result, &resultMap)
73+
require.NoError(t, err)
74+
75+
assert.Equal(t, newAddress, resultMap["signer_address"], "should override existing signing address")
76+
assert.Equal(t, "data", resultMap["other"])
77+
}
78+
79+
func TestMergeSubmitOptions_NilBaseOptions(t *testing.T) {
80+
signingAddress := "celestia1jkl012"
81+
82+
result, err := mergeSubmitOptions(nil, signingAddress)
83+
require.NoError(t, err)
84+
85+
var resultMap map[string]interface{}
86+
err = json.Unmarshal(result, &resultMap)
87+
require.NoError(t, err)
88+
89+
assert.Equal(t, signingAddress, resultMap["signer_address"])
90+
}
91+
92+
func TestMergeSubmitOptions_ComplexJSON(t *testing.T) {
93+
baseOptions := []byte(`{
94+
"nested": {
95+
"key": "value"
96+
},
97+
"array": [1, 2, 3],
98+
"bool": true
99+
}`)
100+
signingAddress := "celestia1complex"
101+
102+
result, err := mergeSubmitOptions(baseOptions, signingAddress)
103+
require.NoError(t, err)
104+
105+
var resultMap map[string]interface{}
106+
err = json.Unmarshal(result, &resultMap)
107+
require.NoError(t, err)
108+
109+
// Check nested structure is preserved
110+
nested, ok := resultMap["nested"].(map[string]interface{})
111+
require.True(t, ok)
112+
assert.Equal(t, "value", nested["key"])
113+
114+
// Check array is preserved
115+
array, ok := resultMap["array"].([]interface{})
116+
require.True(t, ok)
117+
assert.Len(t, array, 3)
118+
119+
// Check bool is preserved
120+
assert.Equal(t, true, resultMap["bool"])
121+
122+
// Check signing address was added
123+
assert.Equal(t, signingAddress, resultMap["signer_address"])
124+
}
125+
126+
func TestMergeSubmitOptions_NullJSON(t *testing.T) {
127+
base := []byte("null")
128+
merged, err := mergeSubmitOptions(base, `{"signer_address": "abc"}`)
129+
require.NoError(t, err)
130+
require.NotNil(t, merged)
131+
require.Contains(t, string(merged), "signer_address")
132+
}

core/da/dummy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ func (d *DummyDA) SubmitWithOptions(ctx context.Context, blobs []Blob, gasPrice
221221

222222
d.blobs[idStr] = blob
223223
d.commitments[idStr] = commitment
224-
d.proofs[idStr] = commitment // Simple proof
224+
d.proofs[idStr] = commitment // Simple proof
225225
d.namespaceByID[idStr] = namespace // Store namespace for this blob
226226

227227
ids = append(ids, id)

0 commit comments

Comments
 (0)