Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 internal/adapters/anvil/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ func buildAnvilArgs(instance *domain.AnvilInstance) []string {
if instance.ForkURL != "" {
args = append(args, "--fork-url", instance.ForkURL)
}
if instance.ForkBlockNumber > 0 {
args = append(args, "--fork-block-number", strconv.FormatUint(instance.ForkBlockNumber, 10))
}
return args
}

Expand Down
72 changes: 64 additions & 8 deletions internal/cli/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"encoding/json"
"fmt"
"os"
"sort"

"github.com/fatih/color"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"github.com/trebuchet-org/treb-cli/internal/cli/render"
cfg "github.com/trebuchet-org/treb-cli/internal/config"
Expand Down Expand Up @@ -48,6 +51,7 @@ Use --url to connect to an already-running Anvil fork instead of starting one lo
}

cmd.Flags().String("url", "", "Connect to an external Anvil endpoint instead of starting a local fork")
cmd.Flags().Uint64("fork-block-number", 0, "Fork at a specific block number (default: latest)")

return cmd
}
Expand Down Expand Up @@ -118,13 +122,16 @@ func runForkEnter(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to resolve network '%s': %w", network, err)
}

forkBlockNumber, _ := cmd.Flags().GetUint64("fork-block-number")

// Execute the use case
params := usecase.EnterForkParams{
Network: network,
RPCURL: resolvedNetwork.RPCURL,
ChainID: resolvedNetwork.ChainID,
EnvVarName: envVarName,
ExternalURL: externalURL,
Network: network,
RPCURL: resolvedNetwork.RPCURL,
ChainID: resolvedNetwork.ChainID,
EnvVarName: envVarName,
ExternalURL: externalURL,
ForkBlockNumber: forkBlockNumber,
}

result, err := app.EnterFork.Execute(ctx, params)
Expand Down Expand Up @@ -170,9 +177,31 @@ func runForkExit(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
network = args[0]
} else if !allFlag {
// Use current configured network
if app.Config.Network != nil {
network = app.Config.Network.Name
// No network specified and not --all: check if multiple forks are active
state, err := app.ForkStateStore.Load(ctx)
if err != nil {
return fmt.Errorf("failed to load fork state: %w", err)
}

switch len(state.Forks) {
case 0:
return fmt.Errorf("no active forks")
case 1:
// Only one fork — use it directly
for name := range state.Forks {
network = name
}
default:
// Multiple forks — prompt user to select
if app.Config.NonInteractive {
return fmt.Errorf("multiple active forks. Specify a network or use --all")
}

selected, err := selectForkNetwork(state.ActiveNetworks())
if err != nil {
return err
}
network = selected
}
Comment on lines +180 to 205
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the no-arg fork exit help text in sync with this behavior.

Lines 155-156 still say the no-arg form uses the currently configured network, but this branch now ignores that and chooses from the active-fork set instead. Either preserve the old fallback before this switch or update the command help in the same change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/fork.go` around lines 180 - 205, The help text and code diverge:
the no-arg fork exit used to fall back to the currently configured network but
this branch always selects from state.Forks; fix by restoring the old fallback
or updating help. Concretely, before loading/inspecting state.Forks
(ForkStateStore.Load) check the configured network value (the CLI/app config
field that holds the current network) and if it is non-empty and corresponds to
an active fork in state.Forks, set network = configuredNetwork and skip the
interactive selection; otherwise keep the existing switch that uses
selectForkNetwork and app.Config.NonInteractive. Alternatively, if you prefer
the new behavior, update the command help to say the no-arg form chooses from
active forks instead of using the configured network.

}

Expand All @@ -190,6 +219,33 @@ func runForkExit(cmd *cobra.Command, args []string) error {
return renderer.RenderExit(result)
}

// selectForkNetwork prompts the user to select a fork network from a list
func selectForkNetwork(networks []string) (string, error) {
sort.Strings(networks)

templates := &promptui.SelectTemplates{
Label: "{{ . }}",
Active: "▸ {{ . | cyan }}",
Inactive: " {{ . | faint }}",
Selected: "✓ {{ . | green }}",
Help: color.New(color.FgYellow).Sprint("Use arrow keys to navigate, Enter to select"),
}

prompt := promptui.Select{
Label: "Multiple active forks. Which one do you want to exit?",
Items: networks,
Templates: templates,
Size: 10,
}

_, selected, err := prompt.Run()
if err != nil {
return "", fmt.Errorf("selection cancelled: %w", err)
}

return selected, nil
}

// newForkRevertCmd creates the fork revert subcommand
func newForkRevertCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down
15 changes: 8 additions & 7 deletions internal/domain/anvil.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ package domain

// AnvilInstance represents a local anvil node instance
type AnvilInstance struct {
Name string `json:"name"`
Port string `json:"port"`
ChainID string `json:"chainId,omitempty"`
ForkURL string `json:"forkUrl,omitempty"`
RPCURL string `json:"rpcUrl,omitempty"` // Full RPC endpoint URL (used for external forks)
PidFile string `json:"pidFile"`
LogFile string `json:"logFile"`
Name string `json:"name"`
Port string `json:"port"`
ChainID string `json:"chainId,omitempty"`
ForkURL string `json:"forkUrl,omitempty"`
ForkBlockNumber uint64 `json:"forkBlockNumber,omitempty"` // Fork at a specific block number (0 = latest)
RPCURL string `json:"rpcUrl,omitempty"` // Full RPC endpoint URL (used for external forks)
PidFile string `json:"pidFile"`
LogFile string `json:"logFile"`
}

// AnvilStatus represents the status of an anvil instance
Expand Down
12 changes: 12 additions & 0 deletions internal/domain/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ func (s *ForkState) GetActiveFork(network string) *ForkEntry {
return s.Forks[network]
}

// ActiveNetworks returns a list of all active fork network names
func (s *ForkState) ActiveNetworks() []string {
if s == nil || s.Forks == nil {
return nil
}
networks := make([]string, 0, len(s.Forks))
for name := range s.Forks {
networks = append(networks, name)
}
return networks
}

// AnvilInstance returns an AnvilInstance for this fork entry.
// For external forks, RPCURL is set to the full fork URL so RPC calls
// go to the correct endpoint instead of constructing http://localhost:<port>.
Expand Down
24 changes: 13 additions & 11 deletions internal/usecase/enter_fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ func NewEnterFork(

// EnterForkParams contains parameters for entering fork mode
type EnterForkParams struct {
Network string // network name from foundry.toml
RPCURL string // resolved RPC URL (after env var expansion)
ChainID uint64 // chain ID
EnvVarName string // env var name that foundry.toml uses for the RPC endpoint
ExternalURL string // optional external Anvil endpoint URL (skips local Anvil startup)
Network string // network name from foundry.toml
RPCURL string // resolved RPC URL (after env var expansion)
ChainID uint64 // chain ID
EnvVarName string // env var name that foundry.toml uses for the RPC endpoint
ExternalURL string // optional external Anvil endpoint URL (skips local Anvil startup)
ForkBlockNumber uint64 // optional block number to fork at (0 = latest)
}
Comment on lines +49 to 55
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject ForkBlockNumber when attaching to an external Anvil.

executeExternal ignores this field, so callers can pass ExternalURL and ForkBlockNumber together and silently attach to whatever head the external endpoint is already on. Validate that combination in the use case and return an explicit error instead of dropping the block pin.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/usecase/enter_fork.go` around lines 49 - 55, The EnterFork use case
must reject the combination of an ExternalURL with a non-zero ForkBlockNumber
because executeExternal ignores ForkBlockNumber; add a validation early in the
use case (e.g., in the method that orchestrates entering a fork such as
Execute/Enter or the public Run method that consumes the struct with fields
Network, ExternalURL, ForkBlockNumber) that returns an explicit error when
ExternalURL is non-empty and ForkBlockNumber != 0. Reference the ForkBlockNumber
field and the executeExternal path in the error message (e.g., "cannot pin fork
block when attaching to external Anvil") so callers get a clear failure instead
of silently dropping the block pin. Ensure unit tests or callers that expect
this validation are updated accordingly.


// EnterForkResult contains the result of entering fork mode
Expand Down Expand Up @@ -144,12 +145,13 @@ func (uc *EnterFork) executeLocal(ctx context.Context, state *domain.ForkState,
}

instance := &domain.AnvilInstance{
Name: fmt.Sprintf("fork-%s", params.Network),
Port: fmt.Sprintf("%d", port),
ChainID: fmt.Sprintf("%d", params.ChainID),
ForkURL: params.RPCURL,
PidFile: filepath.Join(privDir, fmt.Sprintf("fork-%s.pid", params.Network)),
LogFile: filepath.Join(privDir, fmt.Sprintf("fork-%s.log", params.Network)),
Name: fmt.Sprintf("fork-%s", params.Network),
Port: fmt.Sprintf("%d", port),
ChainID: fmt.Sprintf("%d", params.ChainID),
ForkURL: params.RPCURL,
ForkBlockNumber: params.ForkBlockNumber,
PidFile: filepath.Join(privDir, fmt.Sprintf("fork-%s.pid", params.Network)),
LogFile: filepath.Join(privDir, fmt.Sprintf("fork-%s.log", params.Network)),
Comment on lines +148 to +154
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist the pinned block number in fork state.

This only sets ForkBlockNumber on the transient AnvilInstance. fork restart later reconstructs a fresh instance from persisted domain.ForkEntry data without this field, so a fork entered at a specific block comes back at latest. Save the value on ForkEntry and restore it when rebuilding the instance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/usecase/enter_fork.go` around lines 148 - 154, The code only sets
ForkBlockNumber on the transient AnvilInstance but doesn't persist it to
domain.ForkEntry, so a restarted fork loses the pinned block; update the logic
in the enter/fork creation path (the function that builds the domain.ForkEntry
and calls NewAnvilInstance) to copy params.ForkBlockNumber into the persisted
domain.ForkEntry (e.g., set ForkEntry.ForkBlockNumber = params.ForkBlockNumber)
and ensure when rebuilding the instance from a saved entry (the code that
constructs AnvilInstance from domain.ForkEntry on restart) you restore that
value onto the reconstructed AnvilInstance.ForkBlockNumber so pinned-block
restarts correctly.

}

// Start anvil (includes CreateX deployment)
Expand Down
175 changes: 175 additions & 0 deletions test/integration/fork_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,181 @@ func TestForkExternalExit(t *testing.T) {
RunIntegrationTests(t, tests)
}

func TestForkEnterBlockNumber(t *testing.T) {
tests := []IntegrationTest{
{
Name: "fork_enter_with_block_number",
PreSetup: func(t *testing.T, ctx *helpers.TestContext) {
setupForkEnvVars(t, ctx)
},
SetupCmds: [][]string{
s("config set network anvil-31337"),
},
TestCmds: [][]string{},
SkipGolden: true,
PostTest: func(t *testing.T, ctx *helpers.TestContext, _ string) {
// First, mine a few blocks on the test anvil so we have a known block to fork at
node := ctx.AnvilNodes["anvil-31337"]
require.NotNil(t, node)
mineBlocks(t, node.URL, 10)

// Record block number before fork
baseBlock := ethBlockNumber(t, node.URL)
require.GreaterOrEqual(t, baseBlock, uint64(10))

// Fork at a specific block
forkBlock := baseBlock - 5
output, err := ctx.TrebContext.Treb("fork", "enter", "anvil-31337",
"--fork-block-number", strconv.FormatUint(forkBlock, 10))
require.NoError(t, err, "fork enter with --fork-block-number should succeed")
defer cleanupForkAnvil(t, ctx, "anvil-31337")

assert.Contains(t, output, "Fork mode entered")

// Verify fork state exists and anvil is running
state := readForkState(t, ctx)
fork := state.Forks["anvil-31337"]
require.NotNil(t, fork, "fork entry should exist")
assert.True(t, isProcessAlive(fork.AnvilPID), "fork anvil should be running")

// Verify the fork anvil's block number matches the fork block
blockNum := ethBlockNumber(t, fork.ForkURL)
assert.Equal(t, forkBlock, blockNum,
"fork anvil should be at the specified fork-block-number")
},
},
}

RunIntegrationTests(t, tests)
}

// mineBlocks mines n empty blocks on an anvil instance via the evm_mine RPC
func mineBlocks(t *testing.T, rpcURL string, n int) {
t.Helper()
for range n {
reqBody, err := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"method": "evm_mine",
"params": []interface{}{},
"id": 1,
})
require.NoError(t, err)

resp, err := http.Post(rpcURL, "application/json", bytes.NewBuffer(reqBody)) //nolint:gosec
require.NoError(t, err)
resp.Body.Close()
}
}

func TestForkExitMultipleForks(t *testing.T) {
tests := []IntegrationTest{
{
Name: "fork_exit_no_network_single_fork_exits_it",
PreSetup: func(t *testing.T, ctx *helpers.TestContext) {
setupForkEnvVars(t, ctx)
},
SetupCmds: [][]string{
s("config set network anvil-31337"),
{"fork", "enter", "anvil-31337"},
},
TestCmds: [][]string{
// No network argument — should auto-select the only active fork
{"fork", "exit"},
},
SkipGolden: true,
PostTest: func(t *testing.T, ctx *helpers.TestContext, output string) {
assert.Contains(t, output, "Fork mode exited")
assert.Contains(t, output, "anvil-31337")

// Verify fork state file is gone
workDir := ctx.TrebContext.GetWorkDir()
statePath := filepath.Join(workDir, ".treb", "priv", "fork-state.json")
_, err := os.Stat(statePath)
assert.True(t, os.IsNotExist(err), "fork-state.json should be deleted")
},
},
{
Name: "fork_exit_no_network_multiple_forks_errors_non_interactive",
PreSetup: func(t *testing.T, ctx *helpers.TestContext) {
setupForkEnvVars(t, ctx)
},
SetupCmds: [][]string{
s("config set network anvil-31337"),
{"fork", "enter", "anvil-31337"},
{"fork", "enter", "anvil-31338"},
},
TestCmds: [][]string{
// No network argument, multiple forks, non-interactive — should error
{"fork", "exit"},
},
ExpectErr: true,
SkipGolden: true,
PostTest: func(t *testing.T, ctx *helpers.TestContext, output string) {
defer cleanupForkAnvil(t, ctx, "anvil-31337")
defer cleanupForkAnvil(t, ctx, "anvil-31338")

assert.Contains(t, output, "multiple active forks")
},
},
{
Name: "fork_exit_all_exits_multiple_forks",
PreSetup: func(t *testing.T, ctx *helpers.TestContext) {
setupForkEnvVars(t, ctx)
},
SetupCmds: [][]string{
s("config set network anvil-31337"),
{"fork", "enter", "anvil-31337"},
{"fork", "enter", "anvil-31338"},
},
TestCmds: [][]string{
{"fork", "exit", "--all"},
},
SkipGolden: true,
PostTest: func(t *testing.T, ctx *helpers.TestContext, output string) {
assert.Contains(t, output, "2 fork(s) exited")

// Verify fork state file is gone
workDir := ctx.TrebContext.GetWorkDir()
statePath := filepath.Join(workDir, ".treb", "priv", "fork-state.json")
_, err := os.Stat(statePath)
assert.True(t, os.IsNotExist(err), "fork-state.json should be deleted")
},
},
}

RunIntegrationTests(t, tests)
}

// ethBlockNumber makes an eth_blockNumber RPC call and returns the block number
func ethBlockNumber(t *testing.T, rpcURL string) uint64 {
t.Helper()

reqBody, err := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"method": "eth_blockNumber",
"params": []interface{}{},
"id": 1,
})
require.NoError(t, err)

resp, err := http.Post(rpcURL, "application/json", bytes.NewBuffer(reqBody)) //nolint:gosec
require.NoError(t, err)
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

var rpcResp struct {
Result string `json:"result"`
}
require.NoError(t, json.Unmarshal(body, &rpcResp))

blockNum, err := strconv.ParseUint(strings.TrimPrefix(rpcResp.Result, "0x"), 16, 64)
require.NoError(t, err)

return blockNum
}

func TestForkExternalStatus(t *testing.T) {
tests := []IntegrationTest{
{
Expand Down
Loading