From 25eabbfd419a7202fa15eda0bd68d3ae11f31c80 Mon Sep 17 00:00:00 2001 From: nicolas <48695862+merklefruit@users.noreply.github.com> Date: Wed, 23 Oct 2024 20:22:18 +0200 Subject: [PATCH] feat: working dirk delegation --- bolt-cli/.env.keystore.example | 7 - bolt-cli/.env.local.example | 6 - bolt-cli/.gitignore | 5 +- bolt-cli/README.md | 231 ++++-- bolt-cli/build.rs | 7 +- bolt-cli/src/cli.rs | 15 +- bolt-cli/src/delegation.rs | 32 +- bolt-cli/src/main.rs | 7 +- bolt-cli/src/pb/mod.rs | 10 +- bolt-cli/src/pb/v1.rs | 876 +++++++++++++++++++++ bolt-cli/src/utils/dirk.rs | 76 +- bolt-cli/test_data/dirk/dirk.template.json | 3 + bolt-cli/test_data/dirk/wallet1-pf.txt | 1 + 13 files changed, 1176 insertions(+), 100 deletions(-) delete mode 100644 bolt-cli/.env.keystore.example delete mode 100644 bolt-cli/.env.local.example create mode 100644 bolt-cli/test_data/dirk/wallet1-pf.txt diff --git a/bolt-cli/.env.keystore.example b/bolt-cli/.env.keystore.example deleted file mode 100644 index 1e1938fd8..000000000 --- a/bolt-cli/.env.keystore.example +++ /dev/null @@ -1,7 +0,0 @@ -# generate keystore - -PATH=keys -PASSWORD=password -DELEGATEE_PUBKEY=0x83eeddfac5e60f8fe607ee8713efb8877c295ad9f8ca075f4d8f6f2ae241a30dd57f78f6f3863a9fe0d5b5db9d550b93 -OUTPUT_FILE_PATH=delegations.json -CHAIN=kurtosis \ No newline at end of file diff --git a/bolt-cli/.env.local.example b/bolt-cli/.env.local.example deleted file mode 100644 index 1d5225855..000000000 --- a/bolt-cli/.env.local.example +++ /dev/null @@ -1,6 +0,0 @@ -# generate local - -SECRET_KEYS=0f40d627fa199720b79db91ce3f57034680f3ee6eef161abfb8275e676a7fd15,0f40d627fa199720b79db91ce3f57034680f3ee6eef161abfb8275e676a7fd15 -DELEGATEE_PUBKEY=0x83eeddfac5e60f8fe607ee8713efb8877c295ad9f8ca075f4d8f6f2ae241a30dd57f78f6f3863a9fe0d5b5db9d550b93 -OUTPUT_FILE_PATH=delegations.json -CHAIN=kurtosis \ No newline at end of file diff --git a/bolt-cli/.gitignore b/bolt-cli/.gitignore index 31190eaba..12571ac34 100644 --- a/bolt-cli/.gitignore +++ b/bolt-cli/.gitignore @@ -1,6 +1,7 @@ /target + .env .env.* + delegations.json -!.env.local.example -!.env.keystore.example \ No newline at end of file +pubkeys.json diff --git a/bolt-cli/README.md b/bolt-cli/README.md index 64098fba1..c52d3e9c2 100644 --- a/bolt-cli/README.md +++ b/bolt-cli/README.md @@ -1,82 +1,209 @@ # Bolt CLI -Components: +The Bolt CLI is a collection of command-line tools for interacting with the Bolt protocol. -- `bolt-delegations-cli`: A command-line tool for generating delegation messages signed with a BLS12-381 key. +## Installation -## Bolt-delegations-cli +The Bolt CLI can be built with Cargo. If you don't have the Rust toolchain installed +on your machine, you can follow the steps [here](https://www.rust-lang.org/tools/install). -`bolt-delegations-cli` is an offline command-line tool for safely generating delegation messages -signed with a BLS12-381 key for the [Constraints API](https://docs.boltprotocol.xyz/api/builder) -in [Bolt](https://docs.boltprotocol.xyz/). +Once you have Rust installed, you can build the CLI binary in the following way: -The tool supports two key sources: +```shell +# clone the Bolt repository if you haven't already +git clone git@github.com:chainbound/bolt.git -- Local: A BLS private key provided directly from a file. -- Keystore: A keystore file that contains an encrypted BLS private key. +# navigate to the Bolt CLI package directory +cd bolt-cli -Features: +# build and install the binary on your machine +cargo install --path . --force -- Offline usage: Safely generate delegation messages in an offline environment. -- Flexible key source: Support for both direct local BLS private keys and Ethereum keystore files (ERC-2335 format). -- BLS delegation signing: Sign delegation messages using a BLS secret key and output the signed delegation in JSON format. +# test the installation +bolt-cli --version +``` + +## Usage + +Available commands: + +- [`delegate`](#delegate) - Generate BLS delegation messages for the Constraints API. +- [`pubkeys`](#pubkeys) - List available BLS public keys from various key sources. + +### `Delegate` + +The `delegate` command generates signed delegation messages for the Constraints API. +To learn more about the Constraints API, please refer to the [Bolt documentation][bolt-docs]. + +The command supports three key sources for generating the signed messages: + +- Local BLS secret keys (as hex-encoded strings) via `secret-keys` +- Local EIP-2335 filesystem keystore directories via `local-keystore` +- Remote Dirk keystore via `dirk` (requires TLS credentials) + +
+Usage + +```text +❯ bolt-cli delegate --help + +Generate BLS delegation or revocation messages + +Usage: bolt-cli delegate [OPTIONS] --delegatee-pubkey + +Commands: +secret-keys Use local secret keys to generate the signed messages +local-keystore Use an EIP-2335 filesystem keystore directory to generate the signed messages +dirk Use a remote DIRK keystore to generate the signed messages +help Print this message or the help of the given subcommand(s) + +Options: + --delegatee-pubkey + The BLS public key to which the delegation message should be signed + + [env: DELEGATEE_PUBKEY=] + + --out + The output file for the delegations + + [env: OUTPUT_FILE_PATH=] + [default: delegations.json] + + --chain + The chain for which the delegation message is intended + + [env: CHAIN=] + [default: mainnet] + [possible values: mainnet, holesky, helder, kurtosis] + + --action + The action to perform. The tool can be used to generate delegation or revocation messages (default: delegate) + + [env: ACTION=] + [default: delegate] + + Possible values: + - delegate: Create a delegation message + - revoke: Create a revocation message + +-h, --help + Print help (see a summary with '-h') +``` + +
-### Usage +
+Examples + +1. Generating a delegation using a local BLS secret key + +```text +bolt-cli delegate \ + --delegatee-pubkey 0x8d0edf4fe9c80cd640220ca7a68a48efcbc56a13536d6b274bf3719befaffa13688ebee9f37414b3dddc8c7e77233ce8 \ + --chain holesky \ + secret-keys --secret-keys 642e0d33fde8968a48b5f560c1b20143eb82036c1aa6c7f4adc4beed919a22e3 +``` + +2. Generating a delegation using an ERC-2335 keystore directory + +```text +bolt-cli delegate \ + --delegatee-pubkey 0x8d0edf4fe9c80cd640220ca7a68a48efcbc56a13536d6b274bf3719befaffa13688ebee9f37414b3dddc8c7e77233ce8 \ + --chain holesky \ + local-keystore --path test_data/lighthouse/validators --password-path test_data/lighthouse/secrets +``` + +3. Generating a revocation using a remote DIRK keystore + +```text +bolt-cli delegate \ + --delegatee-pubkey 0x83eeddfac5e60f8fe607ee8713efb8877c295ad9f8ca075f4d8f6f2ae241a30dd57f78f6f3863a9fe0d5b5db9d550b93 \ + dirk --url https://localhost:9091 \ + --client-cert-path ./test_data/dirk/client1.crt \ + --client-key-path ./test_data/dirk/client1.key \ + --ca-cert-path ./test_data/dirk/security/ca.crt \ + --wallet-path wallet1 --passphrases secret +``` + +
+ +### `Pubkeys` + +The `pubkeys` command lists available BLS public keys from different key sources: + +- Local BLS secret keys (as hex-encoded strings) via `secret-keys` +- Local EIP-2335 filesystem keystore directories via `local-keystore` +- Remote Dirk keystore via `dirk` (requires TLS credentials) + +
+Usage ```text -A CLI tool to generate signed delegation messages for BLS keys +❯ bolt-cli pubkeys --help + +Output a list of pubkeys in JSON format -Usage: bolt-delegations-cli +Usage: bolt-cli pubkeys [OPTIONS] Commands: - generate Generate delegation messages - help Print this message or the help of the given subcommand(s) + secret-keys Use local secret keys to generate the signed messages + local-keystore Use an EIP-2335 filesystem keystore directory to generate the signed messages + dirk Use a remote DIRK keystore to generate the signed messages + help Print this message or the help of the given subcommand(s) Options: - -h, --help Print help - -V, --version Print version + --out The output file for the pubkeys [env: OUTPUT_FILE_PATH=] [default: pubkeys.json] + -h, --help Print help +``` + +
+ +
+Examples + +1. Listing BLS public keys from a local secret key + +```text +bolt-cli pubkeys secret-keys --secret-keys 642e0d33fde8968a48b5f560c1b20143eb82036c1aa6c7f4adc4beed919a22e3 ``` -#### Example +2. Listing BLS public keys from an ERC-2335 keystore directory -1. Using a local BLS private key: +```text +bolt-cli pubkeys local-keystore \ + --path test_data/lighthouse/validators \ + --password-path test_data/lighthouse/secrets +``` - ```text - bolt-delegations-cli generate \ - --delegatee-pubkey 0x7890ab... \ - --out my_delegations.json \ - --chain kurtosis \ - local \ - --secret-keys 0xabc123...,0xdef456.. - ``` +3. Listing BLS public keys from a remote DIRK keystore -2. Using an Ethereum keystore file: +```text +bolt-cli pubkeys dirk --url https://localhost:9091 \ + --client-cert-path ./test_data/dirk/client1.crt \ + --client-key-path ./test_data/dirk/client1.key \ + --ca-cert-path ./test_data/dirk/security/ca.crt \ + --wallet-path wallet1 --passphrases secret +``` - ```text - bolt-delegations-cli generate \ - --delegatee-pubkey 0x7890ab... \ - --out my_delegations.json \ - --chain kurtosis \ - keystore \ - --path /keys \ - --password myS3cr3tP@ssw0rd - ``` +
-When using the `keystore` key source, the `--path` flag should point to the directory -containing the encrypted keypair directories. +--- -In case of validator-specific passwords (e.g. Lighthouse format) the `--password-path` -flag must be used instead of `--password`, pointing to the directory containing the password files. +## Security -You can find a reference Lighthouse keystore [here](./test_data/lighthouse/). +The Bolt CLI is designed to be used offline. It does not require any network connections +unless you are using the remote `dirk` key source. In that case, the tool will connect to +the Dirk server with the provided TLS credentials. -#### Supported Chains +The tool does not store any sensitive information beyond the duration of the execution. +It is recommended to use the tool in a secure environment and to avoid storing any sensitive +information in the shell history. -The tool supports the following chains: +If you have any security concerns or have found a security issue/bug, please contact Chainbound +on our official [Discord][discord] or [Twitter][twitter] channels. -- `mainnet` -- `holesky` -- `helder` -- `kurtosis` + -Each chain has its specific fork version used in computing the signing root. +[bolt-docs]: https://docs.boltprotocol.xyz/ +[discord]: https://discord.gg/G5BJjCD9ss +[twitter]: https://twitter.com/chainbound_ diff --git a/bolt-cli/build.rs b/bolt-cli/build.rs index 07c7d4466..9342cfe4c 100644 --- a/bolt-cli/build.rs +++ b/bolt-cli/build.rs @@ -10,7 +10,12 @@ fn main() -> io::Result<()> { } tonic_build::configure().build_client(true).out_dir(PB_OUT_DIR).compile_protos( - &["proto/eth2-signer-api/v1/lister.proto", "proto/eth2-signer-api/v1/signer.proto"], + &[ + "proto/eth2-signer-api/v1/lister.proto", + "proto/eth2-signer-api/v1/signer.proto", + "proto/eth2-signer-api/v1/accountmanager.proto", + "proto/eth2-signer-api/v1/walletmanager.proto", + ], &["proto/eth2-signer-api/v1/", "proto/eth2-signer-api/"], ) } diff --git a/bolt-cli/src/cli.rs b/bolt-cli/src/cli.rs index 1b152126f..1f387988c 100644 --- a/bolt-cli/src/cli.rs +++ b/bolt-cli/src/cli.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use crate::utils::keystore::DEFAULT_KEYSTORE_PASSWORD; -/// A CLI tool to generate signed delegation messages for BLS keys. +/// A CLI tool to interact with Bolt Protocol ✨ #[derive(Parser, Debug, Clone, Deserialize)] #[command(author, version, about, long_about = None)] pub struct Opts { @@ -119,13 +119,18 @@ pub struct DirkOpts { #[clap(long, env = "DIRK_URL")] pub url: String, + /// The path of the wallets in the DIRK keystore. + #[clap(long, env = "DIRK_WALLET_PATH")] + pub wallet_path: String, + + /// The passphrases to unlock the wallet in the DIRK keystore. + /// If multiple are provided, they are tried in order until one works. + #[clap(long, env = "DIRK_PASSPHRASES", value_delimiter = ',', hide_env_values = true)] + pub passphrases: Option>, + /// The TLS credentials for connecting to the DIRK keystore. #[clap(flatten)] pub tls_credentials: TlsCredentials, - - /// The paths to the accounts in the DIRK keystore. - #[clap(long, env = "DIRK_ACCOUNTS", value_delimiter = ',', hide_env_values = true)] - pub accounts: Vec, } /// TLS credentials for connecting to a remote server. diff --git a/bolt-cli/src/delegation.rs b/bolt-cli/src/delegation.rs index 46b3cdede..1d3f7fde5 100644 --- a/bolt-cli/src/delegation.rs +++ b/bolt-cli/src/delegation.rs @@ -1,8 +1,9 @@ +use alloy_primitives::B256; use alloy_signer::k256::sha2::{Digest, Sha256}; use ethereum_consensus::crypto::{ PublicKey as BlsPublicKey, SecretKey as BlsSecretKey, Signature as BlsSignature, }; -use eyre::Result; +use eyre::{bail, Result}; use lighthouse_eth2_keystore::Keystore; use serde::Serialize; use tracing::debug; @@ -70,6 +71,7 @@ pub fn generate_from_keystore( ) -> Result> { let keystores_paths = keystore_paths(keys_path)?; let mut signed_messages = Vec::with_capacity(keystores_paths.len()); + debug!("Found {} keys in the keystore", keystores_paths.len()); for path in keystores_paths { let ks = Keystore::from_json_file(path).map_err(KeystoreError::Eth2Keystore)?; @@ -105,35 +107,47 @@ pub fn generate_from_keystore( pub async fn generate_from_dirk( dirk: &mut Dirk, delegatee_pubkey: BlsPublicKey, - account_paths: Vec, + account_path: String, + passphrases: Option>, chain: Chain, action: Action, ) -> Result> { - let mut signed_messages = Vec::new(); - // first read the accounts from the remote keystore - let accounts = dirk.list_accounts(account_paths).await?; - debug!("Found {} remote accounts", accounts.len()); + let accounts = dirk.list_accounts(account_path).await?; + debug!("Found {} remote accounts to sign with", accounts.len()); + + let mut signed_messages = Vec::with_capacity(accounts.len()); // specify the signing domain (needs to be included in the signing request) - let domain = compute_domain_from_mask(chain.fork_version()); + let domain = B256::from(compute_domain_from_mask(chain.fork_version())); for account in accounts { // for each available pubkey we control, sign a delegation message let pubkey = BlsPublicKey::try_from(account.public_key.as_slice())?; + // Note: before signing, we must unlock the account + if let Some(ref passphrases) = passphrases { + for passphrase in passphrases { + if dirk.unlock_account(account.name.clone(), passphrase.clone()).await? { + break; + } + } + } else { + bail!("A passphrase is required in order to sign messages remotely with Dirk"); + } + match action { Action::Delegate => { let message = DelegationMessage::new(pubkey.clone(), delegatee_pubkey.clone()); let signing_root = compute_commit_boost_signing_root(message.digest(), &chain)?; - let signature = dirk.request_signature(pubkey, signing_root, domain.into()).await?; + let signature = dirk.request_signature(&account, signing_root, domain).await?; let signed = SignedDelegation { message, signature }; signed_messages.push(SignedMessage::Delegation(signed)); } Action::Revoke => { let message = RevocationMessage::new(pubkey.clone(), delegatee_pubkey.clone()); let signing_root = compute_commit_boost_signing_root(message.digest(), &chain)?; - let signature = dirk.request_signature(pubkey, signing_root, domain.into()).await?; + let signature = dirk.request_signature(&account, signing_root, domain).await?; let signed = SignedRevocation { message, signature }; signed_messages.push(SignedMessage::Revocation(signed)); } diff --git a/bolt-cli/src/main.rs b/bolt-cli/src/main.rs index 367eaf0a3..5dc574376 100644 --- a/bolt-cli/src/main.rs +++ b/bolt-cli/src/main.rs @@ -60,10 +60,12 @@ async fn main() -> Result<()> { KeySource::Dirk { opts } => { let mut dirk = Dirk::connect(opts.url, opts.tls_credentials).await?; let delegatee_pubkey = parse_bls_public_key(&delegatee_pubkey)?; + let signed_messages = delegation::generate_from_dirk( &mut dirk, delegatee_pubkey, - opts.accounts, + opts.wallet_path, + opts.passphrases, chain, action, ) @@ -90,8 +92,9 @@ async fn main() -> Result<()> { println!("Pubkeys generated and saved to {}", out); } KeySource::Dirk { opts } => { + // Note: we don't need to unlock wallets to list pubkeys let mut dirk = Dirk::connect(opts.url, opts.tls_credentials).await?; - let accounts = dirk.list_accounts(opts.accounts).await?; + let accounts = dirk.list_accounts(opts.wallet_path).await?; let pubkeys = pubkeys::list_from_dirk_accounts(&accounts)?; write_to_file(&out, &pubkeys)?; diff --git a/bolt-cli/src/pb/mod.rs b/bolt-cli/src/pb/mod.rs index 435dccf78..5a22114fc 100644 --- a/bolt-cli/src/pb/mod.rs +++ b/bolt-cli/src/pb/mod.rs @@ -5,8 +5,12 @@ pub mod eth2_signer_api { #[allow(unused_imports)] pub use super::v1::{ - lister_client::ListerClient, sign_request::Id as SignRequestId, - signer_client::SignerClient, Account, DistributedAccount, ListAccountsRequest, - ListAccountsResponse, ResponseState, SignRequest, SignResponse, + account_manager_client::AccountManagerClient, lister_client::ListerClient, + sign_request::Id as SignRequestId, signer_client::SignerClient, + wallet_manager_client::WalletManagerClient, Account, DistributedAccount, + ListAccountsRequest, ListAccountsResponse, LockAccountRequest, LockAccountResponse, + LockWalletRequest, LockWalletResponse, MultisignRequest, MultisignResponse, ResponseState, + SignRequest, SignResponse, UnlockAccountRequest, UnlockAccountResponse, + UnlockWalletRequest, UnlockWalletResponse, }; } diff --git a/bolt-cli/src/pb/v1.rs b/bolt-cli/src/pb/v1.rs index a4e1e7a81..dbef7a820 100644 --- a/bolt-cli/src/pb/v1.rs +++ b/bolt-cli/src/pb/v1.rs @@ -1066,3 +1066,879 @@ pub mod signer_server { const NAME: &'static str = SERVICE_NAME; } } +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UnlockAccountRequest { + #[prost(string, tag = "1")] + pub account: ::prost::alloc::string::String, + #[prost(bytes = "vec", tag = "2")] + pub passphrase: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LockAccountRequest { + #[prost(string, tag = "1")] + pub account: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct UnlockAccountResponse { + #[prost(enumeration = "ResponseState", tag = "1")] + pub state: i32, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct LockAccountResponse { + #[prost(enumeration = "ResponseState", tag = "1")] + pub state: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GenerateRequest { + #[prost(string, tag = "1")] + pub account: ::prost::alloc::string::String, + #[prost(bytes = "vec", tag = "2")] + pub passphrase: ::prost::alloc::vec::Vec, + #[prost(uint32, tag = "3")] + pub participants: u32, + #[prost(uint32, tag = "4")] + pub signing_threshold: u32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GenerateResponse { + #[prost(enumeration = "ResponseState", tag = "1")] + pub state: i32, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, + #[prost(bytes = "vec", tag = "3")] + pub public_key: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "4")] + pub participants: ::prost::alloc::vec::Vec, +} +/// Generated client implementations. +pub mod account_manager_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct AccountManagerClient { + inner: tonic::client::Grpc, + } + impl AccountManagerClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl AccountManagerClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> AccountManagerClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + AccountManagerClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn unlock( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/v1.AccountManager/Unlock"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("v1.AccountManager", "Unlock")); + self.inner.unary(req, path, codec).await + } + pub async fn lock( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/v1.AccountManager/Lock"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("v1.AccountManager", "Lock")); + self.inner.unary(req, path, codec).await + } + pub async fn generate( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/v1.AccountManager/Generate", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("v1.AccountManager", "Generate")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod account_manager_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with AccountManagerServer. + #[async_trait] + pub trait AccountManager: std::marker::Send + std::marker::Sync + 'static { + async fn unlock( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn lock( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn generate( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + #[derive(Debug)] + pub struct AccountManagerServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl AccountManagerServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for AccountManagerServer + where + T: AccountManager, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/v1.AccountManager/Unlock" => { + #[allow(non_camel_case_types)] + struct UnlockSvc(pub Arc); + impl< + T: AccountManager, + > tonic::server::UnaryService + for UnlockSvc { + type Response = super::UnlockAccountResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::unlock(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UnlockSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/v1.AccountManager/Lock" => { + #[allow(non_camel_case_types)] + struct LockSvc(pub Arc); + impl< + T: AccountManager, + > tonic::server::UnaryService + for LockSvc { + type Response = super::LockAccountResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::lock(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LockSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/v1.AccountManager/Generate" => { + #[allow(non_camel_case_types)] + struct GenerateSvc(pub Arc); + impl< + T: AccountManager, + > tonic::server::UnaryService + for GenerateSvc { + type Response = super::GenerateResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::generate(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GenerateSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new(empty_body()); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for AccountManagerServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "v1.AccountManager"; + impl tonic::server::NamedService for AccountManagerServer { + const NAME: &'static str = SERVICE_NAME; + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UnlockWalletRequest { + #[prost(string, tag = "1")] + pub wallet: ::prost::alloc::string::String, + #[prost(bytes = "vec", tag = "2")] + pub passphrase: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LockWalletRequest { + #[prost(string, tag = "1")] + pub wallet: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct UnlockWalletResponse { + #[prost(enumeration = "ResponseState", tag = "1")] + pub state: i32, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct LockWalletResponse { + #[prost(enumeration = "ResponseState", tag = "1")] + pub state: i32, +} +/// Generated client implementations. +pub mod wallet_manager_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct WalletManagerClient { + inner: tonic::client::Grpc, + } + impl WalletManagerClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl WalletManagerClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> WalletManagerClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + WalletManagerClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn unlock( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/v1.WalletManager/Unlock"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("v1.WalletManager", "Unlock")); + self.inner.unary(req, path, codec).await + } + pub async fn lock( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/v1.WalletManager/Lock"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("v1.WalletManager", "Lock")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod wallet_manager_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with WalletManagerServer. + #[async_trait] + pub trait WalletManager: std::marker::Send + std::marker::Sync + 'static { + async fn unlock( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn lock( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + #[derive(Debug)] + pub struct WalletManagerServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl WalletManagerServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for WalletManagerServer + where + T: WalletManager, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/v1.WalletManager/Unlock" => { + #[allow(non_camel_case_types)] + struct UnlockSvc(pub Arc); + impl< + T: WalletManager, + > tonic::server::UnaryService + for UnlockSvc { + type Response = super::UnlockWalletResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::unlock(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UnlockSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/v1.WalletManager/Lock" => { + #[allow(non_camel_case_types)] + struct LockSvc(pub Arc); + impl< + T: WalletManager, + > tonic::server::UnaryService + for LockSvc { + type Response = super::LockWalletResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::lock(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LockSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new(empty_body()); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for WalletManagerServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "v1.WalletManager"; + impl tonic::server::NamedService for WalletManagerServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/bolt-cli/src/utils/dirk.rs b/bolt-cli/src/utils/dirk.rs index 2d161edf1..f4b311758 100644 --- a/bolt-cli/src/utils/dirk.rs +++ b/bolt-cli/src/utils/dirk.rs @@ -1,24 +1,32 @@ use std::fs; use alloy_primitives::B256; -use ethereum_consensus::crypto::bls::{PublicKey as BlsPublicKey, Signature as BlsSignature}; -use eyre::{Context, Result}; +use ethereum_consensus::crypto::bls::Signature as BlsSignature; +use eyre::{bail, Context, Result}; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity}; +use tracing::debug; use crate::{ cli::TlsCredentials, pb::eth2_signer_api::{ - Account, ListAccountsRequest, ListerClient, SignRequest, SignRequestId, SignerClient, + Account, AccountManagerClient, ListAccountsRequest, ListerClient, ResponseState, + SignRequest, SignRequestId, SignerClient, UnlockAccountRequest, }, }; /// A Dirk remote signer. /// +/// Available services: +/// - `Lister`: List accounts in the keystore. +/// - `Signer`: Request a signature from the remote signer. +/// - `AccountManager`: Manage accounts in the keystore (lock and unlock accounts). +/// /// Reference: https://github.com/attestantio/dirk #[derive(Clone)] pub struct Dirk { lister: ListerClient, signer: SignerClient, + account_mng: AccountManagerClient, } impl Dirk { @@ -30,34 +38,76 @@ impl Dirk { let lister = ListerClient::new(conn.clone()); let signer = SignerClient::new(conn.clone()); + let account_mng = AccountManagerClient::new(conn); - Ok(Self { lister, signer }) + Ok(Self { lister, signer, account_mng }) } /// List all accounts in the keystore. - pub async fn list_accounts(&mut self, paths: Vec) -> Result> { - let accs = self.lister.list_accounts(ListAccountsRequest { paths }).await?; + pub async fn list_accounts(&mut self, wallet_path: String) -> Result> { + // Request all accounts in the given path. Only one path at a time + // as done in https://github.com/wealdtech/go-eth2-wallet-dirk/blob/182f99b22b64d01e0d4ae67bf47bb055763465d7/grpc.go#L121 + let req = ListAccountsRequest { paths: vec![wallet_path] }; + let res = self.lister.list_accounts(req).await?.into_inner(); + + if !matches!(res.state(), ResponseState::Succeeded) { + bail!("Failed to list accounts: {:?}", res); + } - Ok(accs.into_inner().accounts) + debug!("{} Accounts listed successfully", res.accounts.len()); + Ok(res.accounts) + } + + /// Unlock an account in the keystore with the given passphrase. + pub async fn unlock_account( + &mut self, + account_name: String, + passphrase: String, + ) -> Result { + let pf_bytes = passphrase.as_bytes().to_vec(); + let req = UnlockAccountRequest { account: account_name.clone(), passphrase: pf_bytes }; + let res = self.account_mng.unlock(req).await?.into_inner(); + + match res.state() { + ResponseState::Succeeded => { + debug!("Unlock request succeeded for account {}", account_name); + Ok(true) + } + ResponseState::Denied => { + debug!("Unlock request denied for account {}", account_name); + Ok(false) + } + ResponseState::Unknown => bail!("Unknown response from unlock account: {:?}", res), + ResponseState::Failed => bail!("Failed to unlock account: {:?}", res), + } } /// Request a signature from the remote signer. pub async fn request_signature( &mut self, - pubkey: BlsPublicKey, + account: &Account, hash: B256, domain: B256, ) -> Result { let req = SignRequest { data: hash.to_vec(), domain: domain.to_vec(), - id: Some(SignRequestId::PublicKey(pubkey.to_vec())), + id: Some(SignRequestId::Account(account.name.clone())), }; - let res = self.signer.sign(req).await?; - let sig = res.into_inner().signature; - let sig = BlsSignature::try_from(sig.as_slice()).wrap_err("Failed to parse signature")?; + let res = self.signer.sign(req).await?.into_inner(); + + if !matches!(res.state(), ResponseState::Succeeded) { + bail!("Failed to sign data: {:?}", res); + } + if res.signature.is_empty() { + bail!("Empty signature returned"); + } + + let sig = BlsSignature::try_from(res.signature.as_slice()) + .wrap_err("Failed to parse signature")?; + debug!("Signature request succeeded for account {}", account.name); Ok(sig) } } @@ -133,7 +183,7 @@ mod tests { let mut dirk = Dirk::connect(url, cred).await?; - let accounts = dirk.list_accounts(vec!["wallet1".to_string()]).await?; + let accounts = dirk.list_accounts("wallet1".to_string()).await?; println!("Dirk Accounts: {:?}", accounts); // make sure to stop the dirk server diff --git a/bolt-cli/test_data/dirk/dirk.template.json b/bolt-cli/test_data/dirk/dirk.template.json index 54c954320..e51b81701 100644 --- a/bolt-cli/test_data/dirk/dirk.template.json +++ b/bolt-cli/test_data/dirk/dirk.template.json @@ -19,5 +19,8 @@ "localhost": { "wallet1": "All" } + }, + "unlocker": { + "wallet-passphrases": ["file://$PWD/wallet1-pf.txt"] } } diff --git a/bolt-cli/test_data/dirk/wallet1-pf.txt b/bolt-cli/test_data/dirk/wallet1-pf.txt new file mode 100644 index 000000000..536aca34d --- /dev/null +++ b/bolt-cli/test_data/dirk/wallet1-pf.txt @@ -0,0 +1 @@ +secret \ No newline at end of file