A robust, production-ready Go library for managing Ethereum wallets and executing transactions with automatic retry logic, gas management, and resilience features.
- Multi-wallet Management: Manage multiple wallets across multiple networks simultaneously
- Hardware Wallet Support: Sign transactions with Ledger, Trezor, keystore files, or raw private keys via the jarvis account system
- Automatic Nonce Management: Race-safe nonce acquisition with automatic release on failure
- Smart Gas Handling: Automatic gas estimation, price suggestions, and dynamic gas bumping for slow transactions
- Retry Logic: Configurable retry mechanism with exponential backoff for failed transactions
- Circuit Breaker: Protects against cascading failures from unreliable RPC nodes
- Idempotency: Prevents duplicate transaction submissions with idempotency keys
- EIP-1559 Support: Full support for dynamic fee transactions
- Hook System: Extensible hooks for custom logic at various transaction lifecycle stages
- Context Support: Full context.Context integration for cancellation and timeouts
- Builder Pattern API: Fluent, easy-to-use API similar to go-resty
go get github.com/tranvictor/walletarmypackage main
import (
"fmt"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/tranvictor/jarvis/networks"
"github.com/tranvictor/jarvis/util/account"
"github.com/tranvictor/walletarmy"
)
func main() {
// Create a new wallet manager with default configuration
wm := walletarmy.NewWalletManager(
walletarmy.WithDefaultNumRetries(5),
walletarmy.WithDefaultNetwork(networks.EthereumMainnet),
)
// Create account from private key (without 0x prefix)
privateKey := "your_private_key_hex_without_0x_prefix"
acc, err := account.NewPrivateKeyAccount(privateKey)
if err != nil {
panic(err)
}
// Register the account with the wallet manager
wm.SetAccount(acc)
// Get the wallet address
wallet := acc.Address()
fmt.Printf("Wallet address: %s\n", wallet.Hex())
// Execute a transaction using the builder pattern
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(common.HexToAddress("0xRecipientAddress")).
SetValue(big.NewInt(1e18)). // 1 ETH
SetNetwork(networks.EthereumMainnet).
Execute()
// Note: err can be non-nil even when the tx was mined. A mined-but-reverted
// tx returns ErrTxReverted alongside valid tx and receipt. Always check
// errors.Is before assuming the tx wasn't mined.
if err != nil && !errors.Is(err, walletarmy.ErrTxReverted) {
panic(err)
}
fmt.Printf("Transaction mined: %s\n", tx.Hash().Hex())
fmt.Printf("Gas used: %d\n", receipt.GasUsed)
}ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNetwork(networks.EthereumMainnet).
ExecuteContext(ctx)// Prepare contract call data
data := contractABI.Pack("transfer", recipient, amount)
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(contractAddress).
SetData(data).
SetNetwork(networks.EthereumMainnet).
SetAbis(contractABI). // For better error decoding
Execute()WalletArmy supports adding wallets dynamically. Accounts are created via the jarvis account package, which supports multiple signing backends — private keys, keystore files, and hardware wallets (Ledger, Trezor). The signing backend is transparent to WalletArmy; once registered, all accounts sign transactions the same way.
import "github.com/tranvictor/jarvis/util/account"
wm := walletarmy.NewWalletManager()
// Method 1: From private key string (hex without 0x prefix)
acc, err := account.NewPrivateKeyAccount("abc123...")
if err != nil {
panic(err)
}
wm.SetAccount(acc)
// Method 2: From an encrypted keystore file
acc, err := account.NewKeystoreAccount("/path/to/keystore.json", "passphrase")
if err != nil {
panic(err)
}
wm.SetAccount(acc)
// Method 3: Hardware wallet — Ledger
// The derivation path follows BIP-44 (e.g., "m/44'/60'/0'/0/0")
// The address must match what the Ledger derives at that path
acc, err := account.NewLedgerAccount("m/44'/60'/0'/0/0", "0xYourLedgerAddress")
if err != nil {
panic(err)
}
wm.SetAccount(acc)
// Method 4: Hardware wallet — Trezor
acc, err := account.NewTrezorAccount("m/44'/60'/0'/0/0", "0xYourTrezorAddress")
if err != nil {
panic(err)
}
wm.SetAccount(acc)
// Method 5: Using jarvis wallet store (auto-detects signing backend)
// This looks up the account in jarvis's local wallet store and unlocks it.
// Works with any backend that jarvis knows about (keystore, Ledger, Trezor).
walletAddress := common.HexToAddress("0x...")
acc, err := wm.UnlockAccount(walletAddress)
if err != nil {
panic(err)
}
// Account is automatically registered — no need to call SetAccount
// Add multiple wallets in bulk
privateKeys := []string{"key1...", "key2...", "key3..."}
for _, pk := range privateKeys {
acc, err := account.NewPrivateKeyAccount(pk)
if err != nil {
panic(err)
}
wm.SetAccount(acc)
}
// Access a registered account later
acc = wm.Account(walletAddress)
if acc == nil {
fmt.Println("Account not found")
}Note: All account types expose the same
SignTx(tx, chainID)method. WalletArmy does not need to know the signing backend — it just callsSignTxand the jarvis account handles the rest (including USB communication for hardware wallets).
WalletArmy uses the jarvis networks package which supports many EVM networks out of the box:
import "github.com/tranvictor/jarvis/networks"
// Built-in networks
networks.EthereumMainnet
networks.Goerli
networks.Sepolia
networks.BSCMainnet
networks.Polygon
networks.Arbitrum
networks.Optimism
networks.Avalanche
networks.Fantom
// ... and many more
// Get network by name
network, err := networks.GetNetwork("mainnet")
// Get network by chain ID
network, err := networks.GetNetworkByID(1)
// Use in transactions
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNetwork(networks.Polygon). // Use Polygon network
Execute()You can create custom networks for any EVM-compatible chain. Here's an example for creating a custom Optimism L2 network:
import (
"github.com/ethereum/go-ethereum/common"
"github.com/tranvictor/jarvis/networks"
)
// Create a custom Optimism L2 network
customNetwork := networks.NewGenericOptimismNetwork(networks.GenericOptimismNetworkConfig{
// Name: Primary identifier for the network
// Used in logs, UI, and as the main lookup key
Name: "rise-testnet",
// AlternativeNames: Other names that can be used to look up this network
// Useful for aliases like "rise", "risetestnet", etc.
AlternativeNames: []string{"rise", "riselabs-testnet"},
// ChainID: The unique chain identifier (EIP-155)
// Must match the chain ID returned by the RPC node
ChainID: 11155931,
// NativeTokenSymbol: Symbol of the native gas token
// Usually "ETH" for Optimism L2s, but could be custom
NativeTokenSymbol: "ETH",
// NativeTokenDecimal: Decimal places for the native token
// Almost always 18 for ETH-based chains
NativeTokenDecimal: 18,
// BlockTime: Average block time in seconds
// Used for estimating confirmation times
BlockTime: 1,
// NodeVariableName: Environment variable name for custom RPC URL
// If set, the system will check os.Getenv("RISE_TESTNET_NODE") for a custom node URL
NodeVariableName: "RISE_TESTNET_NODE",
// DefaultNodes: Map of node name -> RPC URL
// These are the default RPC endpoints if no custom node is configured
DefaultNodes: map[string]string{
"rise-public": "https://testnet.riselabs.xyz",
// You can add multiple nodes for redundancy:
// "backup-node": "https://backup.riselabs.xyz",
},
// BlockExplorerAPIKeyVariableName: Environment variable for block explorer API key
// Used for fetching ABIs and verifying contracts
BlockExplorerAPIKeyVariableName: "RISE_TESTNET_SCAN_API_KEY",
// BlockExplorerAPIURL: Base URL for the block explorer API (Etherscan-compatible)
// Leave empty if no explorer API is available
BlockExplorerAPIURL: "",
// MultiCallContractAddress: Address of the Multicall3 contract
// Used for batching multiple read calls into one RPC request
// Multicall3 is deployed at the same address on most chains
MultiCallContractAddress: common.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11"),
// SyncTxSupported: Whether the network supports eth_sendRawTransactionSync
// This is an Optimism-specific RPC that returns the receipt immediately
// Set to true for Optimism-based L2s that support this feature
SyncTxSupported: true,
})
// Use the custom network in transactions
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNetwork(customNetwork).
Execute()The jarvis networks package supports different network types:
// For standard EVM networks (Ethereum, BSC, Polygon, etc.)
networks.NewGenericNetwork(networks.GenericNetworkConfig{...})
// For Optimism-based L2s (Optimism, Base, custom OP Stack chains)
networks.NewGenericOptimismNetwork(networks.GenericOptimismNetworkConfig{...})
// For Arbitrum-based L2s
networks.NewGenericArbitrumNetwork(networks.GenericArbitrumNetworkConfig{...})When you create custom networks, they work immediately with methods that accept a network parameter (like EnsureTx, BuildTx, etc.). However, some operations need to look up a network by chain ID:
- Crash recovery: Resuming pending transactions after a restart
- BroadcastTx/BroadcastTxSync: Broadcasting raw transactions that only contain chain ID
By default, WalletArmy uses jarvis's built-in network registry, which supports standard EVM networks (Ethereum, Polygon, Arbitrum, Optimism, etc.). For custom networks, you need to provide a network resolver:
// Create your custom network
customNetwork := networks.NewGenericOptimismNetwork(networks.GenericOptimismNetworkConfig{
Name: "my-private-chain",
ChainID: 12345,
// ... other config ...
})
// Provide a resolver that knows about your custom network
wm := walletarmy.NewWalletManager(
walletarmy.WithNetworkResolver(func(chainID uint64) (networks.Network, error) {
switch chainID {
case 12345:
return customNetwork, nil
default:
// Fallback to jarvis for standard networks
return networks.GetNetworkByID(chainID)
}
}),
// ... other options ...
)If you're only using standard networks (Ethereum, Polygon, etc.), you don't need to configure a network resolver.
For production, it's recommended to use environment variables for RPC URLs:
# Set custom RPC URL
export RISE_TESTNET_NODE="https://your-private-rpc.com"
# Set block explorer API key (if available)
export RISE_TESTNET_SCAN_API_KEY="your-api-key"The network will automatically use the environment variable if set, falling back to DefaultNodes otherwise.
Some networks support eth_sendRawTransactionSync, a special RPC method that broadcasts a transaction and waits for it to be mined in a single call. This is particularly useful for L2 networks with fast block times.
Standard Flow (eth_sendRawTransaction):
1. Build transaction
2. Sign transaction
3. Broadcast transaction → returns tx hash immediately
4. Poll for receipt (multiple RPC calls over time)
5. Transaction mined → return receipt
Sync Flow (eth_sendRawTransactionSync):
1. Build transaction
2. Sign transaction
3. Broadcast transaction → waits for mining → returns receipt immediately
(Single RPC call that blocks until mined)
- Faster execution: No polling loop required
- Fewer RPC calls: Single call instead of broadcast + multiple receipt checks
- Simpler flow: Immediate confirmation in the response
- Ideal for L2s: Fast block times (1-2 seconds) make sync calls practical
Networks that support this feature include:
- Optimism and OP Stack chains (Base, Zora, Mode, Rise, etc.)
- Arbitrum chains
- Many custom L2 networks
When creating a custom network, set SyncTxSupported: true:
customNetwork := networks.NewGenericOptimismNetwork(networks.GenericOptimismNetworkConfig{
Name: "my-l2-network",
ChainID: 12345,
// ... other config ...
// Enable synchronous transaction support
SyncTxSupported: true,
})WalletArmy automatically uses sync transactions when available:
// WalletArmy checks network.IsSyncTxSupported() internally
// If true, it uses BroadcastTxSync which returns the receipt immediately
// If false, it uses BroadcastTx and polls for the receipt
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNetwork(optimismNetwork). // Supports sync tx
Execute()
// receipt is available immediately after broadcast on sync-supported networks
// No additional polling was neededYou can also use sync broadcast directly:
// Build and sign transaction
tx, err := wm.BuildTx(...)
_, signedTx, err := wm.SignTx(wallet, tx, network)
// Broadcast synchronously - blocks until mined
receipt, err := wm.BroadcastTxSync(signedTx)
if err != nil {
// Handle error
}
// Receipt is immediately available
fmt.Printf("Mined in block: %d\n", receipt.BlockNumber)
fmt.Printf("Gas used: %d\n", receipt.GasUsed)
fmt.Printf("Status: %d\n", receipt.Status) // 1 = success, 0 = revert| Network Type | Block Time | Sync Support | Typical Confirmation |
|---|---|---|---|
| Ethereum Mainnet | ~12s | No | 12-24 seconds |
| Polygon | ~2s | No | 4-6 seconds |
| Optimism | ~2s | Yes | 2 seconds (single call) |
| Arbitrum | ~0.25s | Yes | <1 second (single call) |
| Base | ~2s | Yes | 2 seconds (single call) |
Execute transactions from multiple wallets concurrently:
import "sync"
func executeParallelTransfers(wm *walletarmy.WalletManager, wallets []common.Address, recipient common.Address, network networks.Network) error {
var wg sync.WaitGroup
errChan := make(chan error, len(wallets))
for _, wallet := range wallets {
wg.Add(1)
go func(from common.Address) {
defer wg.Done()
_, _, err := wm.R().
SetFrom(from).
SetTo(recipient).
SetValue(big.NewInt(1e17)). // 0.1 ETH
SetNetwork(network).
Execute()
if err != nil {
errChan <- fmt.Errorf("transfer from %s failed: %w", from.Hex(), err)
}
}(wallet)
}
wg.Wait()
close(errChan)
// Collect errors
var errors []error
for err := range errChan {
errors = append(errors, err)
}
if len(errors) > 0 {
return fmt.Errorf("%d transfers failed", len(errors))
}
return nil
}Work with multiple networks simultaneously:
wm := walletarmy.NewWalletManager()
// Register the same wallet for use on multiple networks
acc, _ := account.NewPrivateKeyAccount(privateKey)
wm.SetAccount(acc)
wallet := acc.Address()
// Send on Ethereum mainnet
wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNetwork(networks.EthereumMainnet).
Execute()
// Send on Polygon
wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNetwork(networks.Polygon).
Execute()
// Send on Arbitrum
wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNetwork(networks.Arbitrum).
Execute()
// Each network maintains its own:
// - Nonce tracking
// - Gas price cache
// - RPC connections
// - Circuit breaker stateAccess lower-level network components when needed:
// Get the EthReader for a network (for read operations)
reader, err := wm.Reader(networks.EthereumMainnet)
if err != nil {
panic(err)
}
balance, err := reader.GetBalance(wallet.Hex())
// Get the Broadcaster (for custom broadcast logic)
broadcaster, err := wm.Broadcaster(networks.EthereumMainnet)
// Get the TxAnalyzer (for transaction analysis)
analyzer, err := wm.Analyzer(networks.EthereumMainnet)
// Get current gas settings
gasInfo, err := wm.GasSetting(networks.EthereumMainnet)
fmt.Printf("Gas Price: %.2f gwei\n", gasInfo.GasPrice)
fmt.Printf("Tip Cap: %.2f gwei\n", gasInfo.MaxPriorityPrice)wm := walletarmy.NewWalletManager(
// Retry configuration
walletarmy.WithDefaultNumRetries(9),
walletarmy.WithDefaultSleepDuration(5*time.Second),
walletarmy.WithDefaultTxCheckInterval(5*time.Second),
// Gas configuration
walletarmy.WithDefaultExtraGasLimit(10000),
walletarmy.WithDefaultExtraGasPrice(1.0), // Extra gwei
walletarmy.WithDefaultExtraTipCap(0.5), // Extra gwei
walletarmy.WithDefaultMaxGasPrice(100.0), // Max gwei
walletarmy.WithDefaultMaxTipCap(10.0), // Max gwei
// Network
walletarmy.WithDefaultNetwork(networks.EthereumMainnet),
// Transaction type (0 = legacy, 2 = EIP-1559)
walletarmy.WithDefaultTxType(2),
// Idempotency store (optional)
walletarmy.WithDefaultIdempotencyStore(24*time.Hour),
)The builder pattern allows overriding defaults per request:
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetNumRetries(3). // Override default
SetMaxGasPrice(50.0). // Gas price protection
SetMaxTipCap(5.0). // Tip cap protection
SetGasLimit(21000). // Fixed gas limit
SetIdempotencyKey("unique-tx-id"). // Prevent duplicates
Execute()By default, WalletArmy runs an eth_call simulation after building a transaction but before signing and broadcasting. If the simulation shows the transaction would revert, execution stops (or retries, if a SimulationFailedHook requests it).
To bypass this check and force-broadcast regardless of simulation results, use SetSkipSimulation(true). This is useful when you know the simulation will fail but still want the transaction submitted — for example, transactions that depend on state changes within the same block, or contracts with view-only revert guards that don't apply during actual execution.
When skipping simulation, you should also provide a manual gas limit via SetGasLimit(), since gas estimation may fail for the same reason the simulation would revert.
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(contractAddress).
SetData(calldata).
SetGasLimit(500000). // Required — estimation likely fails too
SetSkipSimulation(true). // Bypass eth_call simulation
Execute()Warning: Skipping simulation removes a safety net. The transaction will be broadcast even if it would revert on-chain, consuming gas. Use this only when you understand why the simulation fails and are confident the transaction should proceed.
Hooks allow you to inject custom logic at various stages of transaction execution:
Called after the transaction is built but before signing:
wm.R().
SetBeforeSignAndBroadcastHook(func(tx *types.Transaction, err error) error {
log.Printf("About to sign tx with nonce: %d", tx.Nonce())
// Return an error to abort the transaction
return nil
}).
// ... other settings ...
Execute()Called after successful broadcast:
wm.R().
SetAfterSignAndBroadcastHook(func(tx *types.Transaction, err error) error {
log.Printf("Broadcasted tx: %s", tx.Hash().Hex())
return nil
}).
// ... other settings ...
Execute()Called when gas estimation fails (usually means the tx would revert):
wm.R().
SetGasEstimationFailedHook(func(tx *types.Transaction, abiError *abi.Error, revertParams any, revertMsgError, gasEstimationError error) (*big.Int, error) {
log.Printf("Gas estimation failed: %v", gasEstimationError)
// Return a gas limit to override and continue, or an error to stop
return nil, nil // Continue with default behavior
}).
// ... other settings ...
Execute()Called when eth_call simulation shows the tx would revert:
wm.R().
SetSimulationFailedHook(func(tx *types.Transaction, revertData []byte, abiError *abi.Error, revertParams any, err error) (shouldRetry bool, retErr error) {
log.Printf("Simulation failed: %v", err)
// Return shouldRetry=true to retry, or an error to stop
return false, err // Stop and return the error
}).
// ... other settings ...
Execute()Called when a transaction is mined (success or revert):
wm.R().
SetTxMinedHook(func(tx *types.Transaction, receipt *types.Receipt) error {
if receipt.Status == 0 {
log.Printf("Transaction reverted: %s", tx.Hash().Hex())
} else {
log.Printf("Transaction succeeded: %s", tx.Hash().Hex())
}
return nil
}).
// ... other settings ...
Execute()Prevent duplicate transactions with idempotency keys:
// Configure idempotency store
wm := walletarmy.NewWalletManager(
walletarmy.WithDefaultIdempotencyStore(24*time.Hour),
)
// Use the same key for retried requests
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetIdempotencyKey("payment-order-12345").
Execute()
// Calling again with the same key returns the cached result
tx2, receipt2, err2 := wm.R().
SetFrom(wallet).
SetTo(recipient).
SetValue(amount).
SetIdempotencyKey("payment-order-12345"). // Same key
Execute()
// tx2 and receipt2 will be the same as tx and receiptWalletArmy supports optional persistence for crash-resilient transaction management. This allows your application to recover gracefully after unexpected restarts.
Without persistence, if your application crashes:
- Nonce tracking is lost: On restart, nonces are re-queried from the network. If the RPC's pending state lags, you might reuse a nonce that was already broadcast.
- In-flight transactions are forgotten: Pending transactions won't be monitored, and you won't know their final status.
- Idempotency is lost: Duplicate transactions may be created if business logic retries.
Implement the NonceStore and TxStore interfaces, or use a provided implementation:
// Option 1: Use provided SQLite store (coming in store/sqlite package)
// import "github.com/tranvictor/walletarmy/store/sqlite"
// nonceStore, _ := sqlite.NewNonceStore("./walletarmy.db")
// txStore, _ := sqlite.NewTxStore("./walletarmy.db")
// Option 2: Implement your own stores
type myNonceStore struct { /* ... */ }
func (s *myNonceStore) Get(ctx context.Context, wallet common.Address, chainID uint64) (*walletarmy.NonceState, error) { /* ... */ }
// ... implement other methods
// Configure WalletManager with stores
wm := walletarmy.NewWalletManager(
walletarmy.WithNonceStore(myNonceStore),
walletarmy.WithTxStore(myTxStore),
)Call Recover() during application startup before processing new transactions:
func main() {
wm := walletarmy.NewWalletManager(
walletarmy.WithNonceStore(nonceStore),
walletarmy.WithTxStore(txStore),
)
// Recover from any previous crash
ctx := context.Background()
result, err := wm.Recover(ctx)
if err != nil {
log.Fatalf("Recovery failed: %v", err)
}
log.Printf("Recovery complete: %d txs recovered, %d already mined, %d dropped, %d nonces reconciled",
result.RecoveredTxs, result.MinedTxs, result.DroppedTxs, result.ReconciledNonces)
// Now safe to process new transactions
// ...
}For more control over the recovery process:
opts := walletarmy.RecoveryOptions{
ResumeMonitoring: true, // Resume monitoring recovered pending txs
TxCheckInterval: 5 * time.Second,
MaxConcurrentMonitors: 10,
// Callbacks for application-specific logic
OnTxRecovered: func(tx *walletarmy.PendingTx) {
log.Printf("Recovered pending tx: %s", tx.Hash.Hex())
},
OnTxMined: func(tx *walletarmy.PendingTx, receipt *types.Receipt) {
log.Printf("Recovered tx was mined: %s, status: %d", tx.Hash.Hex(), receipt.Status)
},
OnTxDropped: func(tx *walletarmy.PendingTx) {
log.Printf("Recovered tx was dropped: %s", tx.Hash.Hex())
// Maybe retry the transaction
},
}
result, err := wm.RecoverWithOptions(ctx, opts)NonceStore - Persists nonce tracking state:
type NonceStore interface {
Get(ctx context.Context, wallet common.Address, chainID uint64) (*NonceState, error)
Save(ctx context.Context, state *NonceState) error
SavePendingNonce(ctx context.Context, wallet common.Address, chainID uint64, nonce uint64) error
AddReservedNonce(ctx context.Context, wallet common.Address, chainID uint64, nonce uint64) error
RemoveReservedNonce(ctx context.Context, wallet common.Address, chainID uint64, nonce uint64) error
ListAll(ctx context.Context) ([]*NonceState, error)
}TxStore - Tracks in-flight transactions:
type TxStore interface {
Save(ctx context.Context, tx *PendingTx) error
Get(ctx context.Context, hash common.Hash) (*PendingTx, error)
GetByNonce(ctx context.Context, wallet common.Address, chainID uint64, nonce uint64) ([]*PendingTx, error)
ListPending(ctx context.Context, wallet common.Address, chainID uint64) ([]*PendingTx, error)
ListAllPending(ctx context.Context) ([]*PendingTx, error)
UpdateStatus(ctx context.Context, hash common.Hash, status PendingTxStatus, receipt *types.Receipt) error
Delete(ctx context.Context, hash common.Hash) error
DeleteOlderThan(ctx context.Context, age time.Duration) (int, error)
}When persistence is enabled, WalletArmy automatically persists:
- Nonce acquisitions: When a nonce is reserved for a transaction
- Nonce releases: When an unused nonce is released
- Transaction broadcasts: When a transaction is broadcast to the network
- Transaction completions: When a transaction is mined or reverted
The circuit breaker protects against cascading failures from unreliable RPC nodes:
// Check circuit breaker status
stats := wm.GetCircuitBreakerStats(networks.EthereumMainnet)
fmt.Printf("State: %s, Failures: %d\n", stats.State, stats.ConsecutiveFailures)
// Manually reset if needed
wm.ResetCircuitBreaker(networks.EthereumMainnet)
// Record success/failure manually (usually automatic)
wm.RecordNetworkSuccess(networks.EthereumMainnet)
wm.RecordNetworkFailure(networks.EthereumMainnet)WalletArmy has intelligent gas management that distinguishes between blocking and non-blocking transactions to avoid wasting gas while ensuring the nonce sequence stays unblocked.
A nonce is blocking when it equals the chain's mined nonce — i.e., it's the next nonce the chain expects to process. This transaction must be confirmed before any higher-nonce transactions can proceed.
For example, if the chain has mined up to nonce 36, then nonce 37 is blocking — it must be included in a block before nonces 38, 39, etc. can be mined.
Since gas bumping is applied one nonce at a time (only the blocking nonce), you should set your gas protection limits (MaxGasPrice, MaxTipCap) aggressively enough to resolve stuck transactions.
Note: "Slow" is a walletarmy-internal concept, not a status from the node or transaction monitor. WalletArmy generates
TxStatusSlowwhen a broadcasted transaction is not mined withinSlowTxTimeout(default: 5s) after the monitor has checked the node at least once. The external TxMonitor only reports terminal statuses like "done", "reverted", and "lost".The slow timer is deferred until the monitor delivers its first non-terminal status (e.g., "pending"). This ensures that a
SlowTxTimeoutshorter thanTxCheckIntervaldoes not produce false "slow" signals before the node is ever queried. The timeout counts from the first check, not from broadcast time.
When a transaction is detected as slow (not confirmed within SlowTxTimeout after the first monitor check):
| Scenario | Behavior |
|---|---|
| Blocking nonce | Gas price and tip cap are bumped (default: +20% gas price, +10% tip cap) and the transaction is re-broadcast with the same nonce |
| Non-blocking nonce | No gas bump — the transaction simply continues waiting. It will get mined once the blocking nonce ahead of it is resolved |
This avoids unnecessarily spending gas on transactions that are only slow because they're waiting for an earlier nonce to be confirmed.
When a transaction is detected as lost (dropped from the node's mempool):
| Scenario | Behavior |
|---|---|
| Normal case | Re-broadcast with the same nonce and bumped gas price. Unlike the old behavior (which acquired a new nonce and created an unfillable gap), the lost transaction is retried in place |
| Gas limit reached | If bumping would exceed MaxGasPrice / MaxTipCap, the transaction gives up with ErrGasPriceLimitReached |
Initial state:
MaxGasPrice: 100 gwei, MaxTipCap: 10 gwei
Mined nonce: 37, Tx nonce: 37 (blocking)
Nonce 37 tx is lost from mempool:
1. Retry with same nonce, gas bumped to 55 gwei (+10%) → broadcast
2. Lost again, gas bumped to 60.5 gwei (+10%) → broadcast
3. Lost again, gas bumped to 66.5 gwei (+10%) → broadcast
...continues until MaxGasPrice (100 gwei) is reached
N. Gas bumped to 102 gwei > MaxGasPrice (100)
→ ErrGasPriceLimitReached, stops retrying
wm := walletarmy.NewWalletManager(
// Gas price protection — max gas price and tip cap per transaction.
// Set these aggressively since gas bumping only applies to the single
// blocking nonce, not all queued transactions.
walletarmy.WithDefaultMaxGasPrice(100.0), // gwei
walletarmy.WithDefaultMaxTipCap(10.0), // gwei
// Gas bumping rate when a blocking tx is slow/lost
walletarmy.WithDefaultGasPriceIncreasePercent(1.2), // 20% increase per bump
walletarmy.WithDefaultTipCapIncreasePercent(1.1), // 10% increase per bump
// How long to wait (after the first monitor check) before walletarmy
// considers a broadcasted tx "slow". The timer starts after the monitor
// confirms the tx is still pending, not from broadcast time.
walletarmy.WithDefaultSlowTxTimeout(5*time.Second),
)Note: When
MaxGasPriceorMaxTipCapis set to0, WalletArmy defaults to5xthe initial gas price / tip cap as the protection limit (MaxCapMultiplier = 5.0).
WalletArmy uses the github.com/KyberNetwork/logger package. Enable debug logging to troubleshoot nonce issues and transaction flow:
import "github.com/KyberNetwork/logger"
func init() {
// Set log level to debug
logger.SetLevel(logger.DebugLevel)
}Debug logs include:
- Nonce acquisition: What nonce was chosen and why
- Nonce release: When unused nonces are released
- Transaction lifecycle: Build, sign, broadcast, and mining status
- Retry decisions: Why retries are happening
Example debug output:
DEBUG acquireNonce: nonce acquired and reserved wallet=0x123... network=mainnet chain_id=1 acquired_nonce=42 mined_nonce=40 remote_pending=41 local_pending=41 decision="pending on nodes, using remote (>= local)"
DEBUG ReleaseNonce: nonce released successfully wallet=0x123... network=mainnet chain_id=1 released_nonce=42 new_stored=41
WalletArmy provides specific error types for different failure scenarios:
import "errors"
tx, receipt, err := wm.R()./* ... */.Execute()
if err != nil {
switch {
case errors.Is(err, walletarmy.ErrTxReverted):
// Transaction was mined but reverted (tx and receipt are still returned)
case errors.Is(err, walletarmy.ErrEstimateGasFailed):
// Gas estimation failed - tx would likely revert
case errors.Is(err, walletarmy.ErrAcquireNonceFailed):
// Couldn't get nonce from network
case errors.Is(err, walletarmy.ErrGetGasSettingFailed):
// Couldn't get gas price from network
case errors.Is(err, walletarmy.ErrEnsureTxOutOfRetries):
// Exhausted all retries
case errors.Is(err, walletarmy.ErrGasPriceLimitReached):
// Gas price protection limit hit
case errors.Is(err, walletarmy.ErrSimulatedTxReverted):
// Simulation showed tx would revert
case errors.Is(err, walletarmy.ErrCircuitBreakerOpen):
// Network circuit breaker is open
case errors.Is(err, context.Canceled):
// Context was cancelled
case errors.Is(err, context.DeadlineExceeded):
// Context deadline exceeded
}
}When you provide ABIs via SetAbis(), WalletArmy automatically decodes Solidity revert
reasons and returns them in typed errors. Use errors.As to extract the structured error —
no hooks or closure variables needed:
tx, receipt, err := wm.R().
SetFrom(wallet).
SetTo(contractAddress).
SetData(calldata).
SetAbis(contractABI). // Enable error decoding
Execute()
// Simulation revert — eth_call showed the tx would revert
var simErr *walletarmy.SimulationRevertError
if errors.As(err, &simErr) {
fmt.Printf("Tx: %s\n", simErr.Tx.Hash().Hex())
fmt.Printf("Revert data: 0x%x\n", simErr.RevertData)
if simErr.AbiError != nil {
fmt.Printf("Contract error: %s(%v)\n", simErr.AbiError.Name, simErr.RevertParams)
}
}
// Gas estimation failure — gas estimation failed (possibly due to a revert)
var gasErr *walletarmy.GasEstimationError
if errors.As(err, &gasErr) {
if gasErr.AbiError != nil {
fmt.Printf("Contract error: %s(%v)\n", gasErr.AbiError.Name, gasErr.RevertParams)
}
}These structured errors are returned consistently on every error exit path:
SimulationRevertError— wheneth_callshows the tx would revert (ErrSimulatedTxReverted)GasEstimationError— when gas estimation fails due to a revert (ErrEstimateGasFailed)
For mined-but-reverted transactions, ErrTxReverted is returned along with the tx and
receipt. The on-chain revert data is not available from the receipt alone.
Hooks (SimulationFailedHook, GasEstimationFailedHook, TxMinedHook) are intended for
control flow decisions — whether to retry, abort, override gas limits, etc. You do NOT need
hooks to inspect error details. All error information is available through the returned err.
- WalletManager: Central component managing wallets, networks, and transaction execution
- TxRequest: Builder pattern for constructing transaction requests
- TxExecutionContext: Holds mutable state during transaction execution
- CircuitBreaker: Protects against cascading RPC failures
- IdempotencyStore: Prevents duplicate transaction submissions
The codebase is organized for maintainability and reusability:
walletarmy/
├── manager.go # Core WalletManager struct, constructors, account management
├── manager_nonce.go # Nonce acquisition/release (delegates to internal/nonce)
├── manager_network.go # Network infrastructure (reader, broadcaster, analyzer)
├── manager_tx.go # Transaction building, signing, broadcasting, EnsureTx
├── request.go # TxRequest builder pattern
├── execution_context.go # TxExecutionContext for transaction state
├── interface.go # Manager interface for mockability
├── hook.go # Hook interfaces (Hook, TxMinedHook, SimulationFailedHook)
├── errors.go # Error definitions
├── types.go # Core types (TxStatus, TxExecutionResult, ManagerDefaults)
├── options.go # WalletManagerOption functional options
├── broadcast_error.go # Broadcast error handling and detection
├── error_decoder.go # ABI error decoding for contract reverts
├── gas_info.go # Gas information types
├── idempotency/ # Idempotency store subpackage (public)
│ └── idempotency.go # Store interface and InMemoryStore implementation
├── internal/
│ ├── circuitbreaker/
│ │ └── circuitbreaker.go # Circuit breaker implementation
│ └── nonce/
│ └── tracker.go # Thread-safe nonce tracking
├── examples/
│ └── fund_distribution/ # Example application
└── README.md
| File | Responsibility |
|---|---|
manager.go |
WalletManager struct definition, NewWalletManager, account management, defaults access |
manager_nonce.go |
Public nonce API, delegates to internal/nonce tracker |
manager_network.go |
Reader, Broadcaster, Analyzer, initNetwork, GasSetting, circuit breaker access |
manager_tx.go |
BuildTx, SignTx, BroadcastTx, EnsureTx*, MonitorTx, transaction handlers |
request.go |
TxRequest builder with fluent API, Execute/ExecuteContext |
execution_context.go |
TxExecutionContext for managing retry state and gas adjustments |
interface.go |
Manager interface for dependency injection and mocking |
options.go |
Functional options for WalletManager configuration |
errors.go |
All error sentinel values (ErrEstimateGasFailed, etc.) |
types.go |
Core types and constants (TxStatus, ManagerDefaults, etc.) |
idempotency/ |
Public subpackage: Store interface and InMemoryStore |
internal/nonce/ |
Thread-safe nonce tracking with Tracker struct |
internal/circuitbreaker/ |
Circuit breaker pattern for RPC resilience |
WalletArmy is designed for concurrent use:
- Per-wallet locks: Nonce operations are locked per wallet, not globally
- Per-network locks: Network infrastructure is locked per network
- Atomic nonce acquisition: Nonces are reserved atomically to prevent races
- Thread-safe defaults: Configuration access is protected by RWMutex
- sync.Map usage: Internal maps use sync.Map for lock-free concurrent access
Contributions are welcome! Please follow these guidelines:
# Clone the repository
git clone https://github.com/tranvictor/walletarmy.git
cd walletarmy
# Install dependencies
go mod download
# Set up git hooks (runs tests and linter before each push)
make setup
# Run tests
go test ./...
# Build
go build ./...make setup # Configure git hooks for pre-push checks
make test # Run all tests with race detection
make lint # Run golangci-lint
make vet # Run go vet
make check # Run all checks (vet, test, lint)
make build # Build all packages
make clean # Remove generated filesThe pre-push hook will automatically run go vet, go test -race, and golangci-lint before allowing pushes. This ensures code quality is maintained.
- Follow standard Go conventions
- Use
gofmtfor formatting - Add comments for exported functions
- Include debug logging for critical operations
# Run all tests
go test ./...
# Run tests with race detection
go test -race ./...
# Run tests with coverage
go test -cover ./...
# Run specific package tests
go test ./internal/circuitbreaker/...The internal/ directory contains packages that are not part of the public API:
- circuitbreaker: A thread-safe circuit breaker implementation for RPC failover protection. Not exported to allow internal refactoring without breaking changes.
- nonce: Thread-safe nonce tracking for multiple wallets across multiple networks. Encapsulates all nonce acquisition, release, and tracking logic.
- idempotency: Provides
Storeinterface andInMemoryStorefor preventing duplicate transactions. Import asgithub.com/tranvictor/walletarmy/idempotency.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Add/update tests as needed
- Run
go test ./...to ensure tests pass - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
When reporting issues, please include:
- Go version
- WalletArmy version
- Network (mainnet, testnet, etc.)
- Debug logs if available
- Minimal reproduction case
This project is open source. See the LICENSE file for details.