Skip to content

Commit 33bb441

Browse files
feat(evm): add force inclusion command (#2888)
Add force inclusion command for evm app. Similar to evstack/ev-abci#295 ~~Blocked on #2797 --------- Co-authored-by: Marko <[email protected]>
1 parent 38e5579 commit 33bb441

File tree

24 files changed

+1602
-28
lines changed

24 files changed

+1602
-28
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Implement forced inclusion and based sequencing ([#2797](https://github.com/evstack/ev-node/pull/2797))
1515
This changes requires to add a `da_epoch_forced_inclusion` field in `genesis.json` file.
1616
To enable this feature, set the force inclusion namespace in the `evnode.yaml`.
17+
- Added `post-tx` command and force inclusion server to submit transaction directly to the DA layer. ([#2888](https://github.com/evstack/ev-node/pull/2888))
18+
Additionally, modified the core package to support marking transactions as forced included transactions.
19+
The execution client ought to perform basic validation on those transactions as they have skipped the execution client's mempool.
1720

1821
### Changed
1922

apps/evm/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,69 @@ If you'd ever like to restart a fresh node, make sure to remove the originally c
103103
```bash
104104
rm -rf ~/.evm-full-node
105105
```
106+
107+
## Force Inclusion API
108+
109+
The EVM app includes a Force Inclusion API server that exposes an Ethereum-compatible JSON-RPC endpoint for submitting transactions directly to the DA layer for force inclusion.
110+
111+
When enabling this server, the node operator will be paying for the gas costs of the transactions submitted through the API on the DA layer. The application costs are still paid by the transaction signer.
112+
113+
### Enabling the Force Inclusion API
114+
115+
To enable the Force Inclusion API server, add the following flag when starting the node:
116+
117+
```bash
118+
./evm start \
119+
--force-inclusion-server="127.0.0.1:8547" \
120+
... other flags ...
121+
```
122+
123+
### Configuration Flag
124+
125+
- `--force-inclusion-server`: Address for the force inclusion API server (e.g. `127.0.0.1:8547`). If set, enables the server for direct DA submission
126+
127+
### Usage
128+
129+
Once enabled, the server exposes a standard Ethereum JSON-RPC endpoint that accepts `eth_sendRawTransaction` calls:
130+
131+
```bash
132+
# Send a raw transaction for force inclusion
133+
curl -X POST http://127.0.0.1:8547 \
134+
-H "Content-Type: application/json" \
135+
-d '{
136+
"jsonrpc": "2.0",
137+
"id": 1,
138+
"method": "eth_sendRawTransaction",
139+
"params": ["0x02f873..."]
140+
}'
141+
```
142+
143+
The transaction will be submitted directly to the DA layer in the force inclusion namespace, bypassing the normal mempool. The response returns a pseudo-transaction hash based on the DA height:
144+
145+
```json
146+
{
147+
"jsonrpc": "2.0",
148+
"id": 1,
149+
"result": "0x0000000000000000000000000000000000000000000000000000000000000064"
150+
}
151+
```
152+
153+
### Using with Spamoor
154+
155+
You can use this endpoint with [spamoor](https://github.com/ethpandaops/spamoor) for force inclusion testing:
156+
157+
```bash
158+
spamoor \
159+
--rpc-url http://127.0.0.1:8547 \
160+
--private-key <your-private-key> \
161+
... other spamoor flags ...
162+
```
163+
164+
### Force Inclusion Timing
165+
166+
Transactions submitted to the Force Inclusion API are included in the chain at specific DA heights based on the `da_epoch_forced_inclusion` configuration in `genesis.json`. The API logs will show when the transaction will be force included:
167+
168+
```
169+
INF transaction successfully submitted to DA layer da_height=100
170+
INF transaction will be force included blocks_until_inclusion=8 inclusion_at_height=110
171+
```

apps/evm/cmd/post_tx_cmd.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package cmd
2+
3+
import (
4+
"encoding/hex"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/spf13/cobra"
12+
13+
evblock "github.com/evstack/ev-node/block"
14+
"github.com/evstack/ev-node/core/da"
15+
"github.com/evstack/ev-node/da/jsonrpc"
16+
rollcmd "github.com/evstack/ev-node/pkg/cmd"
17+
rollconf "github.com/evstack/ev-node/pkg/config"
18+
genesispkg "github.com/evstack/ev-node/pkg/genesis"
19+
seqcommon "github.com/evstack/ev-node/sequencers/common"
20+
"github.com/evstack/ev-node/types"
21+
)
22+
23+
const (
24+
flagNamespace = "namespace"
25+
flagGasPrice = "gas-price"
26+
)
27+
28+
// PostTxCmd returns a command to post a signed Ethereum transaction to the DA layer
29+
func PostTxCmd() *cobra.Command {
30+
cobraCmd := &cobra.Command{
31+
Use: "post-tx",
32+
Short: "Post a signed Ethereum transaction to the DA layer",
33+
Long: `Post a signed Ethereum transaction to the DA layer using the Evolve configuration.
34+
35+
This command submits a signed Ethereum transaction tzo the configured DA layer for forced inclusion.
36+
The transaction is provided as an argument, which accepts either:
37+
1. A hex-encoded signed transaction (with or without 0x prefix)
38+
2. A path to a file containing the hex-encoded transaction
39+
3. A JSON object with a "raw" field containing the hex-encoded transaction
40+
41+
The command automatically detects the input format.
42+
43+
Examples:
44+
# From hex string
45+
evm post-tx 0x02f873...
46+
47+
# From file
48+
evm post-tx tx.txt
49+
50+
# From JSON
51+
evm post-tx '{"raw":"0x02f873..."}'
52+
`,
53+
Args: cobra.ExactArgs(1),
54+
RunE: postTxRunE,
55+
}
56+
57+
// Add evolve config flags
58+
rollconf.AddFlags(cobraCmd)
59+
60+
// Add command-specific flags
61+
cobraCmd.Flags().String(flagNamespace, "", "DA namespace ID (if not provided, uses config namespace)")
62+
cobraCmd.Flags().Float64(flagGasPrice, -1, "Gas price for DA submission (if not provided, uses config gas price)")
63+
64+
return cobraCmd
65+
}
66+
67+
// postTxRunE executes the post-tx command
68+
func postTxRunE(cmd *cobra.Command, args []string) error {
69+
nodeConfig, err := rollcmd.ParseConfig(cmd)
70+
if err != nil {
71+
return err
72+
}
73+
74+
logger := rollcmd.SetupLogger(nodeConfig.Log)
75+
76+
txInput := args[0]
77+
if txInput == "" {
78+
return fmt.Errorf("transaction cannot be empty")
79+
}
80+
81+
var txData []byte
82+
if _, err := os.Stat(txInput); err == nil {
83+
// Input is a file path
84+
txData, err = decodeTxFromFile(txInput)
85+
if err != nil {
86+
return fmt.Errorf("failed to decode transaction from file: %w", err)
87+
}
88+
} else {
89+
// Input is a JSON string
90+
txData, err = decodeTxFromJSON(txInput)
91+
if err != nil {
92+
return fmt.Errorf("failed to decode transaction from JSON: %w", err)
93+
}
94+
}
95+
96+
if len(txData) == 0 {
97+
return fmt.Errorf("transaction data cannot be empty")
98+
}
99+
100+
// Get namespace (use flag if provided, otherwise use config)
101+
namespace, _ := cmd.Flags().GetString(flagNamespace)
102+
if namespace == "" {
103+
namespace = nodeConfig.DA.GetForcedInclusionNamespace()
104+
}
105+
106+
if namespace == "" {
107+
return fmt.Errorf("forced inclusionnamespace cannot be empty")
108+
}
109+
110+
namespaceBz := da.NamespaceFromString(namespace).Bytes()
111+
112+
// Get gas price (use flag if provided, otherwise use config)
113+
gasPrice, err := cmd.Flags().GetFloat64(flagGasPrice)
114+
if err != nil {
115+
return fmt.Errorf("failed to get gas-price flag: %w", err)
116+
}
117+
118+
logger.Info().Str("namespace", namespace).Float64("gas_price", gasPrice).Int("tx_size", len(txData)).Msg("posting transaction to DA layer")
119+
120+
daClient, err := jsonrpc.NewClient(
121+
cmd.Context(),
122+
logger,
123+
nodeConfig.DA.Address,
124+
nodeConfig.DA.AuthToken,
125+
seqcommon.AbsoluteMaxBlobSize,
126+
)
127+
if err != nil {
128+
return fmt.Errorf("failed to create DA client: %w", err)
129+
}
130+
131+
// Submit transaction to DA layer
132+
logger.Info().Msg("submitting transaction to DA layer...")
133+
134+
blobs := [][]byte{txData}
135+
options := []byte(nodeConfig.DA.SubmitOptions)
136+
137+
dac := evblock.NewDAClient(&daClient.DA, nodeConfig, logger)
138+
result := dac.Submit(cmd.Context(), blobs, gasPrice, namespaceBz, options)
139+
140+
// Check result
141+
switch result.Code {
142+
case da.StatusSuccess:
143+
logger.Info().Msg("transaction successfully submitted to DA layer")
144+
cmd.Printf("\n✓ Transaction posted successfully\n\n")
145+
cmd.Printf("Namespace: %s\n", namespace)
146+
cmd.Printf("DA Height: %d\n", result.Height)
147+
cmd.Printf("Data Size: %d bytes\n", len(txData))
148+
149+
genesisPath := filepath.Join(filepath.Dir(nodeConfig.ConfigPath()), "genesis.json")
150+
genesis, err := genesispkg.LoadGenesis(genesisPath)
151+
if err != nil {
152+
return fmt.Errorf("failed to load genesis for calculating inclusion time estimate: %w", err)
153+
}
154+
155+
_, epochEnd, _ := types.CalculateEpochBoundaries(result.Height, genesis.DAStartHeight, genesis.DAEpochForcedInclusion)
156+
cmd.Printf(
157+
"DA Blocks until inclusion: %d (at DA height %d)\n",
158+
epochEnd-(result.Height+1),
159+
epochEnd+1,
160+
)
161+
162+
cmd.Printf("\n")
163+
return nil
164+
165+
case da.StatusTooBig:
166+
return fmt.Errorf("transaction too large for DA layer: %s", result.Message)
167+
168+
case da.StatusNotIncludedInBlock:
169+
return fmt.Errorf("transaction not included in DA block: %s", result.Message)
170+
171+
case da.StatusAlreadyInMempool:
172+
cmd.Printf("⚠ Transaction already in mempool\n")
173+
if result.Height > 0 {
174+
cmd.Printf(" DA Height: %d\n", result.Height)
175+
}
176+
return nil
177+
178+
case da.StatusContextCanceled:
179+
return fmt.Errorf("submission canceled: %s", result.Message)
180+
181+
default:
182+
return fmt.Errorf("DA submission failed (code: %d): %s", result.Code, result.Message)
183+
}
184+
}
185+
186+
// decodeTxFromFile reads an Ethereum transaction from a file and decodes it to bytes
187+
func decodeTxFromFile(filePath string) ([]byte, error) {
188+
data, err := os.ReadFile(filePath)
189+
if err != nil {
190+
return nil, fmt.Errorf("reading file: %w", err)
191+
}
192+
193+
return decodeTxFromJSON(string(data))
194+
}
195+
196+
// decodeTxFromJSON decodes an Ethereum transaction from various formats to bytes
197+
func decodeTxFromJSON(input string) ([]byte, error) {
198+
input = strings.TrimSpace(input)
199+
200+
// Try to decode as JSON with "raw" field
201+
var txJSON map[string]any
202+
if err := json.Unmarshal([]byte(input), &txJSON); err == nil {
203+
if rawTx, ok := txJSON["raw"].(string); ok {
204+
return decodeHexTx(rawTx)
205+
}
206+
return nil, fmt.Errorf("JSON must contain 'raw' field with hex-encoded transaction")
207+
}
208+
209+
// Try to decode as hex string directly
210+
return decodeHexTx(input)
211+
}
212+
213+
// decodeHexTx decodes a hex-encoded Ethereum transaction
214+
func decodeHexTx(hexStr string) ([]byte, error) {
215+
hexStr = strings.TrimSpace(hexStr)
216+
217+
// Remove 0x prefix if present
218+
if strings.HasPrefix(hexStr, "0x") || strings.HasPrefix(hexStr, "0X") {
219+
hexStr = hexStr[2:]
220+
}
221+
222+
// Decode hex string to bytes
223+
txBytes, err := hex.DecodeString(hexStr)
224+
if err != nil {
225+
return nil, fmt.Errorf("decoding hex transaction: %w", err)
226+
}
227+
228+
if len(txBytes) == 0 {
229+
return nil, fmt.Errorf("decoded transaction is empty")
230+
}
231+
232+
return txBytes, nil
233+
}

apps/evm/cmd/run.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"time"
910

1011
"github.com/ethereum/go-ethereum/common"
1112
"github.com/ipfs/go-datastore"
@@ -29,9 +30,14 @@ import (
2930
"github.com/evstack/ev-node/sequencers/based"
3031
seqcommon "github.com/evstack/ev-node/sequencers/common"
3132
"github.com/evstack/ev-node/sequencers/single"
33+
34+
"github.com/evstack/ev-node/apps/evm/server"
3235
)
3336

34-
const evmDbName = "evm-single"
37+
const (
38+
flagForceInclusionServer = "force-inclusion-server"
39+
evmDbName = "evm-single"
40+
)
3541

3642
var RunCmd = &cobra.Command{
3743
Use: "start",
@@ -96,6 +102,44 @@ var RunCmd = &cobra.Command{
96102
return err
97103
}
98104

105+
// Start force inclusion API server if address is provided
106+
forceInclusionAddr, err := cmd.Flags().GetString(flagForceInclusionServer)
107+
if err != nil {
108+
return fmt.Errorf("failed to get '%s' flag: %w", flagForceInclusionServer, err)
109+
}
110+
111+
if forceInclusionAddr != "" {
112+
ethURL, err := cmd.Flags().GetString(evm.FlagEvmEthURL)
113+
if err != nil {
114+
return fmt.Errorf("failed to get '%s' flag: %w", evm.FlagEvmEthURL, err)
115+
}
116+
117+
forceInclusionServer, err := server.NewForceInclusionServer(
118+
forceInclusionAddr,
119+
&daJrpc.DA,
120+
nodeConfig,
121+
genesis,
122+
logger,
123+
ethURL,
124+
)
125+
if err != nil {
126+
return fmt.Errorf("failed to create force inclusion server: %w", err)
127+
}
128+
129+
if err := forceInclusionServer.Start(cmd.Context()); err != nil {
130+
return fmt.Errorf("failed to start force inclusion API server: %w", err)
131+
}
132+
133+
// Ensure server is stopped when node stops
134+
defer func() {
135+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
136+
defer cancel()
137+
if err := forceInclusionServer.Stop(shutdownCtx); err != nil {
138+
logger.Error().Err(err).Msg("failed to stop force inclusion API server")
139+
}
140+
}()
141+
}
142+
99143
return rollcmd.StartNode(logger, cmd, executor, sequencer, &daJrpc.DA, p2pClient, datastore, nodeConfig, genesis, node.NodeOptions{})
100144
},
101145
}
@@ -213,4 +257,5 @@ func addFlags(cmd *cobra.Command) {
213257
cmd.Flags().String(evm.FlagEvmJWTSecretFile, "", "Path to file containing the JWT secret for authentication")
214258
cmd.Flags().String(evm.FlagEvmGenesisHash, "", "Hash of the genesis block")
215259
cmd.Flags().String(evm.FlagEvmFeeRecipient, "", "Address that will receive transaction fees")
260+
cmd.Flags().String(flagForceInclusionServer, "", "Address for force inclusion API server (e.g. 127.0.0.1:8547). If set, enables the server for direct DA submission")
216261
}

0 commit comments

Comments
 (0)