Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a18e75f
feat: forced inclusion
julienrbrt Nov 21, 2025
5bbfe08
Merge branch 'main' into julien/fi
julienrbrt Nov 24, 2025
46087a4
Apply suggestions from code review
julienrbrt Nov 24, 2025
1412fc2
Merge branch 'main' into julien/fi
julienrbrt Nov 25, 2025
0fb6264
feat(evm): add force inclusion command
julienrbrt Nov 26, 2025
a928c93
docs: add description about da epoch
julienrbrt Nov 26, 2025
fcf4b08
add smoothing checks
julienrbrt Nov 26, 2025
d787575
add docs
julienrbrt Nov 26, 2025
379e793
Merge branch 'main' into julien/fi
julienrbrt Nov 27, 2025
f6c7b01
ai docs
julienrbrt Nov 27, 2025
3a211d3
Merge branch 'julien/fi' into julien/fi-evm-cmd
julienrbrt Nov 27, 2025
bc0bc97
wip
julienrbrt Nov 27, 2025
2786d7b
skip gibberish txs
julienrbrt Nov 27, 2025
d96c74c
use local go.mod
julienrbrt Nov 27, 2025
fe2f80c
cleanups
julienrbrt Nov 27, 2025
230263d
Merge branch 'main' into julien/fi
julienrbrt Nov 28, 2025
28b675d
feat: proxy all other msgs to ev-reth
julienrbrt Nov 28, 2025
fe51201
Merge branch 'julien/fi' into julien/fi-evm-cmd
julienrbrt Nov 28, 2025
e5ba965
implement feedback
julienrbrt Dec 1, 2025
d46db8c
feedback 2/2
julienrbrt Dec 1, 2025
550aceb
Merge branch 'main' into julien/fi
julienrbrt Dec 1, 2025
a587a45
chore: docs nits
julienrbrt Dec 1, 2025
55124ef
Merge branch 'main' into julien/fi
julienrbrt Dec 2, 2025
5755287
Merge branch 'julien/fi' into julien/fi-evm-cmd
julienrbrt Dec 2, 2025
aab910d
api break
julienrbrt Dec 2, 2025
689fdfa
Merge branch 'main' into julien/fi-evm-cmd
julienrbrt Dec 3, 2025
0d98330
merge conflict
julienrbrt Dec 3, 2025
2832ee4
optimize tx sanity checks
julienrbrt Dec 3, 2025
cc61206
modernize
julienrbrt Dec 3, 2025
bb4f511
chore: fix linting
julienrbrt Dec 4, 2025
91e83e5
add replaces
julienrbrt Dec 4, 2025
caa7fba
chore: disable feature again. Code cleanup
julienrbrt Dec 4, 2025
59bc5bb
Merge branch 'main' into julien/fi-evm-cmd
julienrbrt Dec 4, 2025
3a9144b
add changelog
julienrbrt Dec 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Implement forced inclusion and based sequencing ([#2797](https://github.com/evstack/ev-node/pull/2797))
This changes requires to add a `da_epoch_forced_inclusion` field in `genesis.json` file.
To enable this feature, set the force inclusion namespace in the `evnode.yaml`.
- 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))
Additionally, modified the core package to support marking transactions as forced included transactions.
The execution client ought to perform basic validation on those transactions as they have skipped the execution client's mempool.

### Changed

Expand Down
66 changes: 66 additions & 0 deletions apps/evm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,69 @@ If you'd ever like to restart a fresh node, make sure to remove the originally c
```bash
rm -rf ~/.evm-full-node
```

## Force Inclusion API

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.

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.

### Enabling the Force Inclusion API

To enable the Force Inclusion API server, add the following flag when starting the node:

```bash
./evm start \
--force-inclusion-server="127.0.0.1:8547" \
... other flags ...
```

### Configuration Flag

- `--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

### Usage

Once enabled, the server exposes a standard Ethereum JSON-RPC endpoint that accepts `eth_sendRawTransaction` calls:

```bash
# Send a raw transaction for force inclusion
curl -X POST http://127.0.0.1:8547 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_sendRawTransaction",
"params": ["0x02f873..."]
}'
```

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:

```json
{
"jsonrpc": "2.0",
"id": 1,
"result": "0x0000000000000000000000000000000000000000000000000000000000000064"
}
```

### Using with Spamoor

You can use this endpoint with [spamoor](https://github.com/ethpandaops/spamoor) for force inclusion testing:

```bash
spamoor \
--rpc-url http://127.0.0.1:8547 \
--private-key <your-private-key> \
... other spamoor flags ...
```

### Force Inclusion Timing

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:

```
INF transaction successfully submitted to DA layer da_height=100
INF transaction will be force included blocks_until_inclusion=8 inclusion_at_height=110
```
233 changes: 233 additions & 0 deletions apps/evm/cmd/post_tx_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package cmd

import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"

evblock "github.com/evstack/ev-node/block"
"github.com/evstack/ev-node/core/da"
"github.com/evstack/ev-node/da/jsonrpc"
rollcmd "github.com/evstack/ev-node/pkg/cmd"
rollconf "github.com/evstack/ev-node/pkg/config"
genesispkg "github.com/evstack/ev-node/pkg/genesis"
seqcommon "github.com/evstack/ev-node/sequencers/common"
"github.com/evstack/ev-node/types"
)

const (
flagNamespace = "namespace"
flagGasPrice = "gas-price"
)

// PostTxCmd returns a command to post a signed Ethereum transaction to the DA layer
func PostTxCmd() *cobra.Command {
cobraCmd := &cobra.Command{
Use: "post-tx",
Short: "Post a signed Ethereum transaction to the DA layer",
Long: `Post a signed Ethereum transaction to the DA layer using the Evolve configuration.

This command submits a signed Ethereum transaction tzo the configured DA layer for forced inclusion.
The transaction is provided as an argument, which accepts either:
1. A hex-encoded signed transaction (with or without 0x prefix)
2. A path to a file containing the hex-encoded transaction
3. A JSON object with a "raw" field containing the hex-encoded transaction

The command automatically detects the input format.

Examples:
# From hex string
evm post-tx 0x02f873...

# From file
evm post-tx tx.txt

# From JSON
evm post-tx '{"raw":"0x02f873..."}'
`,
Args: cobra.ExactArgs(1),
RunE: postTxRunE,
}

// Add evolve config flags
rollconf.AddFlags(cobraCmd)

// Add command-specific flags
cobraCmd.Flags().String(flagNamespace, "", "DA namespace ID (if not provided, uses config namespace)")
cobraCmd.Flags().Float64(flagGasPrice, -1, "Gas price for DA submission (if not provided, uses config gas price)")

return cobraCmd
}

// postTxRunE executes the post-tx command
func postTxRunE(cmd *cobra.Command, args []string) error {
nodeConfig, err := rollcmd.ParseConfig(cmd)
if err != nil {
return err
}

logger := rollcmd.SetupLogger(nodeConfig.Log)

txInput := args[0]
if txInput == "" {
return fmt.Errorf("transaction cannot be empty")
}

var txData []byte
if _, err := os.Stat(txInput); err == nil {
// Input is a file path
txData, err = decodeTxFromFile(txInput)
if err != nil {
return fmt.Errorf("failed to decode transaction from file: %w", err)
}
} else {
// Input is a JSON string
txData, err = decodeTxFromJSON(txInput)
if err != nil {
return fmt.Errorf("failed to decode transaction from JSON: %w", err)
}
}

if len(txData) == 0 {
return fmt.Errorf("transaction data cannot be empty")
}

// Get namespace (use flag if provided, otherwise use config)
namespace, _ := cmd.Flags().GetString(flagNamespace)
if namespace == "" {
namespace = nodeConfig.DA.GetForcedInclusionNamespace()
}

if namespace == "" {
return fmt.Errorf("forced inclusionnamespace cannot be empty")
}

namespaceBz := da.NamespaceFromString(namespace).Bytes()

// Get gas price (use flag if provided, otherwise use config)
gasPrice, err := cmd.Flags().GetFloat64(flagGasPrice)
if err != nil {
return fmt.Errorf("failed to get gas-price flag: %w", err)
}

logger.Info().Str("namespace", namespace).Float64("gas_price", gasPrice).Int("tx_size", len(txData)).Msg("posting transaction to DA layer")

daClient, err := jsonrpc.NewClient(
cmd.Context(),
logger,
nodeConfig.DA.Address,
nodeConfig.DA.AuthToken,
seqcommon.AbsoluteMaxBlobSize,
)
if err != nil {
return fmt.Errorf("failed to create DA client: %w", err)
}

// Submit transaction to DA layer
logger.Info().Msg("submitting transaction to DA layer...")

blobs := [][]byte{txData}
options := []byte(nodeConfig.DA.SubmitOptions)

dac := evblock.NewDAClient(&daClient.DA, nodeConfig, logger)
result := dac.Submit(cmd.Context(), blobs, gasPrice, namespaceBz, options)

// Check result
switch result.Code {
case da.StatusSuccess:
logger.Info().Msg("transaction successfully submitted to DA layer")
cmd.Printf("\n✓ Transaction posted successfully\n\n")
cmd.Printf("Namespace: %s\n", namespace)
cmd.Printf("DA Height: %d\n", result.Height)
cmd.Printf("Data Size: %d bytes\n", len(txData))

genesisPath := filepath.Join(filepath.Dir(nodeConfig.ConfigPath()), "genesis.json")
genesis, err := genesispkg.LoadGenesis(genesisPath)
if err != nil {
return fmt.Errorf("failed to load genesis for calculating inclusion time estimate: %w", err)
}

_, epochEnd, _ := types.CalculateEpochBoundaries(result.Height, genesis.DAStartHeight, genesis.DAEpochForcedInclusion)
cmd.Printf(
"DA Blocks until inclusion: %d (at DA height %d)\n",
epochEnd-(result.Height+1),
epochEnd+1,
)

cmd.Printf("\n")
return nil

case da.StatusTooBig:
return fmt.Errorf("transaction too large for DA layer: %s", result.Message)

case da.StatusNotIncludedInBlock:
return fmt.Errorf("transaction not included in DA block: %s", result.Message)

case da.StatusAlreadyInMempool:
cmd.Printf("⚠ Transaction already in mempool\n")
if result.Height > 0 {
cmd.Printf(" DA Height: %d\n", result.Height)
}
return nil

case da.StatusContextCanceled:
return fmt.Errorf("submission canceled: %s", result.Message)

default:
return fmt.Errorf("DA submission failed (code: %d): %s", result.Code, result.Message)
}
}

// decodeTxFromFile reads an Ethereum transaction from a file and decodes it to bytes
func decodeTxFromFile(filePath string) ([]byte, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("reading file: %w", err)
}

return decodeTxFromJSON(string(data))
}

// decodeTxFromJSON decodes an Ethereum transaction from various formats to bytes
func decodeTxFromJSON(input string) ([]byte, error) {
input = strings.TrimSpace(input)

// Try to decode as JSON with "raw" field
var txJSON map[string]any
if err := json.Unmarshal([]byte(input), &txJSON); err == nil {
if rawTx, ok := txJSON["raw"].(string); ok {
return decodeHexTx(rawTx)
}
return nil, fmt.Errorf("JSON must contain 'raw' field with hex-encoded transaction")
}

// Try to decode as hex string directly
return decodeHexTx(input)
}

// decodeHexTx decodes a hex-encoded Ethereum transaction
func decodeHexTx(hexStr string) ([]byte, error) {
hexStr = strings.TrimSpace(hexStr)

// Remove 0x prefix if present
if strings.HasPrefix(hexStr, "0x") || strings.HasPrefix(hexStr, "0X") {
hexStr = hexStr[2:]
}

// Decode hex string to bytes
txBytes, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("decoding hex transaction: %w", err)
}

if len(txBytes) == 0 {
return nil, fmt.Errorf("decoded transaction is empty")
}

return txBytes, nil
}
47 changes: 46 additions & 1 deletion apps/evm/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/ipfs/go-datastore"
Expand All @@ -29,9 +30,14 @@ import (
"github.com/evstack/ev-node/sequencers/based"
seqcommon "github.com/evstack/ev-node/sequencers/common"
"github.com/evstack/ev-node/sequencers/single"

"github.com/evstack/ev-node/apps/evm/server"
)

const evmDbName = "evm-single"
const (
flagForceInclusionServer = "force-inclusion-server"
evmDbName = "evm-single"
)

var RunCmd = &cobra.Command{
Use: "start",
Expand Down Expand Up @@ -96,6 +102,44 @@ var RunCmd = &cobra.Command{
return err
}

// Start force inclusion API server if address is provided
forceInclusionAddr, err := cmd.Flags().GetString(flagForceInclusionServer)
if err != nil {
return fmt.Errorf("failed to get '%s' flag: %w", flagForceInclusionServer, err)
}

if forceInclusionAddr != "" {
ethURL, err := cmd.Flags().GetString(evm.FlagEvmEthURL)
if err != nil {
return fmt.Errorf("failed to get '%s' flag: %w", evm.FlagEvmEthURL, err)
}

forceInclusionServer, err := server.NewForceInclusionServer(
forceInclusionAddr,
&daJrpc.DA,
nodeConfig,
genesis,
logger,
ethURL,
)
if err != nil {
return fmt.Errorf("failed to create force inclusion server: %w", err)
}

if err := forceInclusionServer.Start(cmd.Context()); err != nil {
return fmt.Errorf("failed to start force inclusion API server: %w", err)
}

// Ensure server is stopped when node stops
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := forceInclusionServer.Stop(shutdownCtx); err != nil {
logger.Error().Err(err).Msg("failed to stop force inclusion API server")
}
}()
}

return rollcmd.StartNode(logger, cmd, executor, sequencer, &daJrpc.DA, p2pClient, datastore, nodeConfig, genesis, node.NodeOptions{})
},
}
Expand Down Expand Up @@ -213,4 +257,5 @@ func addFlags(cmd *cobra.Command) {
cmd.Flags().String(evm.FlagEvmJWTSecretFile, "", "Path to file containing the JWT secret for authentication")
cmd.Flags().String(evm.FlagEvmGenesisHash, "", "Hash of the genesis block")
cmd.Flags().String(evm.FlagEvmFeeRecipient, "", "Address that will receive transaction fees")
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")
}
Loading
Loading