Skip to content

Commit f0d71f0

Browse files
authored
feat(staking-cli): add private key signing and calldata export (#3894)
* feat(staking-cli): add private key signing and calldata export Add --private-key flag for raw key signing alongside mnemonic/ledger. Add --export-calldata mode for Safe multisig users to output transaction data instead of executing. Validates via eth_call simulation using --sender-address (skip with --skip-simulation). Supports JSON, TOML, and human-readable output formats. Consolidate all state-changing operations into Transaction enum with unified calldata generation for both execute and export modes. Other changes: - Move main.rs logic into cli.rs module for better encapsulation - Simplify ValidSignerConfig::wallet() to return EthereumWallet directly - Add AddressExt trait for cleaner address resolution from wallet - Use trimmed format for ESP token display (e.g. "100 ESP" not "100.0") - Reduce public API surface: make internal modules pub(crate) - Remove funding.rs module, use Transaction::Transfer for ESP transfers * refactor(adapter): simplify DecodeRevert trait with generic impls Consolidate 3 concrete implementations into 2 generic ones, reducing code duplication while maintaining the same functionality. * docs(staking-cli): add security recommendations for wallet selection Recommend hardware wallet (Ledger) for mainnet funds, warn about key leakage risks, and advise using environment variables for secrets. * test(staking-cli): add tests for claim-rewards and unclaimed-rewards with no balance Add setup_reward_claim_not_found_mock helper and parameterize reward tests with rstest to cover both with-balance and no-balance cases.
1 parent 92e8026 commit f0d71f0

File tree

17 files changed

+2318
-1071
lines changed

17 files changed

+2318
-1071
lines changed

contracts/rust/adapter/src/evm.rs

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
1-
use alloy::{network::Ethereum, providers::PendingTransactionBuilder, sol_types::SolInterface};
1+
use alloy::{
2+
sol_types::SolInterface,
3+
transports::{RpcError, TransportErrorKind},
4+
};
25

3-
pub trait DecodeRevert {
4-
fn maybe_decode_revert<E: SolInterface + std::fmt::Debug>(
5-
self,
6-
) -> anyhow::Result<PendingTransactionBuilder<Ethereum>>;
6+
pub trait DecodeRevert<T> {
7+
fn maybe_decode_revert<E: SolInterface + std::fmt::Debug>(self) -> anyhow::Result<T>;
78
}
89

9-
impl DecodeRevert
10-
for alloy::contract::Result<PendingTransactionBuilder<Ethereum>, alloy::contract::Error>
11-
{
12-
fn maybe_decode_revert<E: SolInterface + std::fmt::Debug>(
13-
self,
14-
) -> anyhow::Result<PendingTransactionBuilder<Ethereum>> {
10+
impl<T> DecodeRevert<T> for Result<T, alloy::contract::Error> {
11+
fn maybe_decode_revert<E: SolInterface + std::fmt::Debug>(self) -> anyhow::Result<T> {
1512
match self {
1613
Ok(ret) => Ok(ret),
1714
Err(err) => {
18-
let decoded = err.as_decoded_interface_error::<E>();
19-
let msg = match decoded {
15+
let msg = match err.as_decoded_interface_error::<E>() {
2016
Some(e) => format!("{e:?}"),
2117
None => format!("{err:?}"),
2218
};
@@ -26,18 +22,33 @@ impl DecodeRevert
2622
}
2723
}
2824

25+
impl<T> DecodeRevert<T> for Result<T, RpcError<TransportErrorKind>> {
26+
fn maybe_decode_revert<E: SolInterface + std::fmt::Debug>(self) -> anyhow::Result<T> {
27+
match self {
28+
Ok(ret) => Ok(ret),
29+
Err(RpcError::ErrorResp(payload)) => match payload.as_decoded_interface_error::<E>() {
30+
Some(e) => Err(anyhow::anyhow!("{e:?}")),
31+
None => Err(anyhow::anyhow!("{payload}")),
32+
},
33+
Err(err) => Err(anyhow::anyhow!("{err:?}")),
34+
}
35+
}
36+
}
37+
2938
#[cfg(test)]
3039
mod test {
3140
use alloy::{
3241
primitives::{Address, U256},
33-
providers::ProviderBuilder,
42+
providers::{Provider, ProviderBuilder},
43+
rpc::types::{TransactionInput, TransactionRequest},
44+
sol_types::SolCall,
3445
};
3546

3647
use super::*;
37-
use crate::sol_types::EspToken::{self, EspTokenErrors};
48+
use crate::sol_types::EspToken::{self, transferCall, EspTokenErrors};
3849

3950
#[tokio::test]
40-
async fn test_decode_revert_error() -> anyhow::Result<()> {
51+
async fn test_decode_revert_contract_error() -> anyhow::Result<()> {
4152
let provider = ProviderBuilder::new().connect_anvil_with_wallet();
4253

4354
let token = EspToken::deploy(&provider).await?;
@@ -51,4 +62,27 @@ mod test {
5162

5263
Ok(())
5364
}
65+
66+
#[tokio::test]
67+
async fn test_decode_revert_rpc_error() -> anyhow::Result<()> {
68+
let provider = ProviderBuilder::new().connect_anvil_with_wallet();
69+
70+
let token = EspToken::deploy(&provider).await?;
71+
let call = transferCall {
72+
to: Address::random(),
73+
value: U256::MAX,
74+
};
75+
let tx = TransactionRequest::default()
76+
.to(*token.address())
77+
.input(TransactionInput::new(call.abi_encode().into()));
78+
79+
let err = provider
80+
.send_transaction(tx)
81+
.await
82+
.maybe_decode_revert::<EspTokenErrors>()
83+
.unwrap_err();
84+
assert!(err.to_string().contains("ERC20InsufficientBalance"));
85+
86+
Ok(())
87+
}
5488
}

staking-cli/README.md

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ This CLI helps users interact with the Espresso staking contract, either as a de
1111
- [Espresso staking CLI](#espresso-staking-cli)
1212
- [Getting Started](#getting-started)
1313
- [Getting Help](#getting-help)
14-
- [Choose your type of wallet (mnemonic based or Ledger)](#choose-your-type-of-wallet-mnemonic-based-or-ledger)
14+
- [Choose your type of wallet (mnemonic, private key, or Ledger)](#choose-your-type-of-wallet-mnemonic-private-key-or-ledger)
1515
- [Initialize the configuration file](#initialize-the-configuration-file)
1616
- [Inspect the configuration](#inspect-the-configuration)
1717
- [View the stake table](#view-the-stake-table)
18+
- [Calldata Export (for Multisig Wallets)](#calldata-export-for-multisig-wallets)
1819
- [Delegators (or stakers)](#delegators-or-stakers)
1920
- [Delegating](#delegating)
2021
- [Undelegating](#undelegating)
@@ -99,6 +100,11 @@ Options:
99100
100101
[env: STAKE_TABLE_ADDRESS=]
101102
103+
--espresso-url [<ESPRESSO_URL>]
104+
Espresso sequencer API URL for reward claims
105+
106+
[env: ESPRESSO_URL=]
107+
102108
--mnemonic <MNEMONIC>
103109
The mnemonic to use when deriving the key
104110
@@ -116,6 +122,34 @@ Options:
116122
117123
[env: USE_LEDGER=]
118124
125+
--private-key <PRIVATE_KEY>
126+
Raw private key (hex-encoded with or without 0x prefix)
127+
128+
[env: PRIVATE_KEY=]
129+
130+
--export-calldata
131+
Export calldata for multisig wallets instead of sending transaction
132+
133+
[env: EXPORT_CALLDATA=]
134+
135+
--sender-address <SENDER_ADDRESS>
136+
Sender address for calldata export (required for simulation)
137+
138+
[env: SENDER_ADDRESS=]
139+
140+
--skip-simulation
141+
Skip eth_call validation when exporting calldata
142+
143+
[env: SKIP_SIMULATION=]
144+
145+
--output <OUTPUT>
146+
Output file path. If not specified, outputs to stdout
147+
148+
--format <FORMAT>
149+
Output format for calldata export
150+
151+
[possible values: json, toml]
152+
119153
```
120154

121155
or by passing `--help` to a command, for example `delegate`:
@@ -135,56 +169,135 @@ Options:
135169
-h, --help Print help
136170
```
137171

138-
### Choose your type of wallet (mnemonic based or Ledger)
172+
### Choose your type of wallet (mnemonic, private key, or Ledger)
139173

140-
First, determine if you would like to use a Mnemonic phrase or ledger hardware wallet.
174+
**Security** Utmost care must be taken to avoid leaking the Ethereum private key used for staking or registering
175+
validators. There is currently no built-in key rotation feature for Ethereum keys.
141176

142-
If you don't know which account index to use, you can find it by running:
177+
First, determine which signing method you would like to use:
178+
179+
1. **Ledger hardware wallet** - (recommended) sign transactions with a Ledger device
180+
1. **Mnemonic phrase** - derive keys from a BIP-39 mnemonic with account index
181+
1. **Private key** - use a raw hex-encoded private key directly
182+
183+
**Security recommendations:** For managing significant funds on mainnet, we recommend using a hardware wallet (Ledger)
184+
for extra security. Hardware wallets keep your private keys isolated from your computer, offering some protection
185+
against malware and phishing attacks. If you need support for other hardware signers, please open an issue at
186+
https://github.com/EspressoSystems/espresso-network.
187+
188+
For mnemonics and private keys, to avoid passing secrets on the command line, use environment variables:
189+
190+
- `MNEMONIC` for mnemonic phrase
191+
- `PRIVATE_KEY` for raw private key
192+
193+
If using a ledger or mnemonic and you don't know which account index to use, you can find it by running:
143194

144195
```bash
145196
staking-cli --mnemonic MNEMONIC --account-index 0 account
146197
staking-cli --mnemonic MNEMONIC --account-index 1 account
147198
# etc, or
148-
staking-cli --ledger-index 0 account
149-
staking-cli --ledger-index 1 account
199+
staking-cli --ledger --account-index 0 account
200+
staking-cli --ledger --account-index 1 account
150201
# etc
151202
```
152203

153204
Repeat with different indices until you find the address you want to use.
154205

206+
If using a private key, ensure PRIVATE_KEY env var is set
207+
208+
```bash
209+
staking-cli account
210+
```
211+
155212
Note that for ledger signing to work
156213

157214
1. the ledger needs to be unlocked,
158215
1. the Ethereum app needs to be open,
159216
1. blind signing needs to be enabled in the Ethereum app settings on the ledger.
160217

161-
To avoid passing the mnemonic on the command line, the MNEMONIC env var can be set instead.
162-
163-
### Initialize the configuration file
218+
### Initialize the configuration file (optional)
164219

165220
Once you've identified your desired account index (here 2), initialize a configuration file:
166221

167222
staking-cli init --mnemonic MNEMONIC --account-index 2
168223
# or
169-
staking-cli init --ledger-index 2
224+
staking-cli init --ledger --account-index 2
225+
# or
226+
staking-cli init --private-key 0x1234...abcd
170227

171228
This creates a TOML config file with the contracts of our decaf Testnet, deployed on Sepolia. With the config file you
172229
don't need to provide the configuration values every time you run the CLI.
173230

174-
NOTE: only for this `init` command the `--mnemonic` and `--ledger-index` flags are specified _after_ the command.
231+
NOTE: only for this `init` command the wallet flags are specified _after_ the command.
175232

176233
### Inspect the configuration
177234

178235
You can inspect the configuration file by running:
179236

180-
staking-cli config
237+
staking-cli config
181238

182239
### View the stake table
183240

184241
You can use the following command to display the current L1 stake table:
185242

186243
staking-cli stake-table
187244

245+
## Calldata Export (for Multisig Wallets)
246+
247+
If you're using a multisig wallet (e.g., Safe, Gnosis Safe) or other smart contract wallet, you can export the
248+
transaction calldata instead of signing and sending the transaction directly. This allows you to propose the transaction
249+
through your multisig's interface.
250+
251+
To export calldata for any command, add the `--export-calldata` flag:
252+
253+
```bash
254+
# Export delegate calldata as JSON (default)
255+
staking-cli --export-calldata delegate --validator-address 0x12...34 --amount 100
256+
257+
# Export as TOML
258+
staking-cli --export-calldata --format toml delegate --validator-address 0x12...34 --amount 100
259+
260+
# Save to file
261+
staking-cli --export-calldata --format json --output delegate.json delegate --validator-address 0x12...34 --amount 100
262+
```
263+
264+
The output includes the target contract address and the encoded calldata:
265+
266+
```json
267+
{
268+
"to": "0x...",
269+
"data": "0x..."
270+
}
271+
```
272+
273+
This works with all state-changing commands: `approve`, `delegate`, `undelegate`, `claim-withdrawal`,
274+
`claim-validator-exit`, `claim-rewards`, `register-validator`, `update-commission`, `update-metadata-uri`,
275+
`update-consensus-keys`, `deregister-validator`, and `transfer`.
276+
277+
Note: When using `--export-calldata`, no wallet/signer is required since the transaction is not sent.
278+
279+
### Calldata Simulation
280+
281+
By default, the CLI simulates exported calldata via `eth_call` to catch errors before you submit the transaction through
282+
your multisig. Provide `--sender-address` (your multisig address) for accurate simulation:
283+
284+
```bash
285+
staking-cli --export-calldata --sender-address 0xYourSafe... delegate --validator-address 0x12...34 --amount 100
286+
```
287+
288+
To skip simulation (e.g., for batch exports):
289+
290+
```bash
291+
staking-cli --export-calldata --skip-simulation delegate --validator-address 0x12...34 --amount 100
292+
```
293+
294+
Note: The `claim-rewards` command always requires `--sender-address` (even with `--skip-simulation`) because the address
295+
is needed to fetch the reward proof from the Espresso node:
296+
297+
```bash
298+
staking-cli --export-calldata --sender-address 0xYourSafe... --espresso-url https://... claim-rewards
299+
```
300+
188301
## Delegators (or stakers)
189302

190303
This section covers commands for stakers/delegators.
@@ -245,7 +358,8 @@ This section covers commands for node operators.
245358

246359
### Registering a validator
247360

248-
1. Obtain your validator's BLS and state private keys, choose your commission in percent (with 2 decimals), and prepare a metadata URL.
361+
1. Obtain your validator's BLS and state private keys, choose your commission in percent (with 2 decimals), and prepare
362+
a metadata URL.
249363
1. Use the `register-validator` command to register your validator.
250364

251365
staking-cli register-validator --consensus-private-key <BLS_KEY> --state-private-key <STATE_KEY> --commission 4.99 --metadata-uri https://example.com/validator-metadata.json
@@ -282,6 +396,7 @@ This section covers commands for node operators.
282396
### Updating your commission
283397
284398
Validators can update their commission rate, subject to the following rate limits:
399+
285400
- Commission updates are limited to once per week (7 days by default)
286401
- Commission increases are capped at 5% per update (e.g., from 10% to 15%)
287402
- Commission decreases have no limit
@@ -296,8 +411,8 @@ Note: The minimum time interval and maximum increase are contract parameters tha
296411
297412
### Updating your metadata URL
298413
299-
Validators can update their metadata URL at any time. The metadata URL is used to provide additional
300-
information about your validator but the official schema is yet to be decided.
414+
Validators can update their metadata URL at any time. The metadata URL is used to provide additional information about
415+
your validator but the official schema is yet to be decided.
301416
302417
To update your metadata URL:
303418
@@ -308,12 +423,13 @@ To clear your metadata URL (set it to empty):
308423
staking-cli update-metadata-uri --no-metadata-uri
309424
310425
The metadata URL:
426+
311427
- Must be a valid URL (e.g., starting with `https://`) unless using --no-metadata-uri flag
312428
- Can be empty when using --no-metadata-uri flag
313429
- Cannot exceed 2048 bytes
314430
315-
Note: The metadata URL is emitted in events only. Off-chain indexers track the current URL by
316-
listening to registration and update events.
431+
Note: The metadata URL is emitted in events only. Off-chain indexers track the current URL by listening to registration
432+
and update events.
317433
318434
### De-registering your validator
319435
@@ -353,12 +469,13 @@ key updates. The exported payload can later be used to build the Ethereum transa
353469
354470
Output formats:
355471
356-
- JSON to stdout (default): `staking-cli export-node-signatures --address 0x12...34 --consensus-private-key <BLS_KEY> --state-private-key <STATE_KEY>`
472+
- JSON to stdout (default):
473+
`staking-cli export-node-signatures --address 0x12...34 --consensus-private-key <BLS_KEY> --state-private-key <STATE_KEY>`
357474
- JSON to file: `--output signatures.json`
358475
- TOML to file: `--output signatures.toml`
359476
- Explicit format override: `--output signatures.json --format toml` (saves TOML content to .json file)
360477
361-
The command will generate a signature payload file that doesn't contain any secrets:
478+
The command will generate a signature payload file that doesn't contain any secrets:
362479
363480
```toml
364481
address = "0x..."
@@ -377,7 +494,8 @@ Format handling:
377494

378495
- File extension auto-detection: `.json` and `.toml` files are automatically parsed in the correct format
379496
- Stdin defaults to JSON: `cat signatures.json | staking-cli register-validator --node-signatures - --commission 4.99`
380-
- Explicit format for stdin: `cat signatures.toml | staking-cli register-validator --node-signatures - --format toml --commission 4.99`
497+
- Explicit format for stdin:
498+
`cat signatures.toml | staking-cli register-validator --node-signatures - --format toml --commission 4.99`
381499

382500
### Native Demo Staking
383501

0 commit comments

Comments
 (0)