Skip to main content

bark/
config.rs

1
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6use bitcoin::{FeeRate, Network};
7
8use bitcoin_ext::{BlockDelta, BlockHeight};
9
10use crate::chain::ChainSourceSpec;
11use crate::secret::Secret;
12
13
14/// Networks bark can be used on
15#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum BarkNetwork {
17	/// Bitcoin's mainnet
18	Mainnet,
19	/// The official Bitcoin Core signet
20	Signet,
21	/// Mutinynet
22	Mutinynet,
23	/// Any regtest network
24	Regtest,
25}
26
27impl BarkNetwork {
28	/// Map to the [Network] types
29	pub fn as_bitcoin(&self) -> Network {
30		match self {
31			Self::Mainnet => Network::Bitcoin,
32			Self::Signet => Network::Signet,
33			Self::Mutinynet => Network::Signet,
34			Self::Regtest => Network::Regtest,
35		}
36	}
37}
38
39impl fmt::Display for BarkNetwork {
40	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41	    match self {
42			Self::Mainnet => f.write_str("mainnet"),
43			Self::Signet => f.write_str("signet"),
44			Self::Mutinynet => f.write_str("mutinynet"),
45			Self::Regtest => f.write_str("regtest"),
46		}
47	}
48}
49
50impl fmt::Debug for BarkNetwork {
51	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52		fmt::Display::fmt(self, f)
53	}
54}
55
56/// Consecutive unused seed-derived VTXO key indices we tolerate before
57/// concluding a VTXO isn't ours.
58///
59/// The default for [Config::vtxo_key_gap_limit].
60pub const DEFAULT_VTXO_KEY_GAP_LIMIT: u32 = 250;
61
62/// The largest gap limit a VTXO key scan will accept.
63///
64/// A scan for a key that isn't ours runs the limit to its end, deriving a
65/// keypair per index and holding the unmatched ones, so an unbounded limit is
66/// unbounded work. The worst real case seen so far needed 10,000.
67pub const MAX_VTXO_KEY_GAP_LIMIT: u32 = 100_000;
68
69/// Configuration of the Bark wallet.
70///
71/// - [Config::esplora_address] or [Config::bitcoind_address] must be provided.
72/// - If you use [Config::bitcoind_address], you must also provide:
73///   - [Config::bitcoind_cookiefile] or
74///   - [Config::bitcoind_user] and [Config::bitcoind_pass]
75/// - Other optional fields can be omitted.
76///
77/// # Example
78/// Configure the wallet using defaults, then override endpoints for public signet:
79///
80/// ```rust
81/// use bark::Config;
82///
83/// let cfg = Config {
84///   server_address: "https://ark.signet.2nd.dev".into(),
85///   esplora_address: Some("https://esplora.signet.2nd.dev".into()),
86///   ..Config::network_default(bitcoin::Network::Bitcoin)
87/// };
88/// // cfg now has all other fields from the default configuration
89/// ```
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Config {
92	/// The address of your ark server.
93	pub server_address: String,
94
95	/// An access token used to access a private server.
96	#[deprecated(
97		since = "0.2.4",
98		note = "access tokens are not enforced by the server; this field will be removed",
99	)]
100	pub server_access_token: Option<String>,
101
102	/// Client identifier sent on every RPC to the Ark server (as the
103	/// `x-user-agent` header) so server-side telemetry can attribute traffic
104	/// per implementation.
105	///
106	/// Defaults to `bark/<version>` when unset. Integrators embedding bark
107	/// (FFI bindings, WASM wallets, custom apps) should set their own value,
108	/// e.g. `"aqua/1.4.2"`.
109	///
110	/// Format: `<name>/<version>`. The name must be 1-32 chars of lowercase
111	/// ASCII alphanumeric, `-`, or `_`. Anything else (uppercase, missing
112	/// slash, invalid chars, too long) gets the RPC rejected by the server
113	/// with `invalid_argument`.
114	pub user_agent: Option<String>,
115
116	/// The address of the Esplora HTTP REST server to use.
117	///
118	/// Either this or the `bitcoind_address` field has to be provided.
119	pub esplora_address: Option<String>,
120
121	/// The address of the bitcoind RPC server to use.
122	///
123	/// Either this or the `esplora_address` field has to be provided.
124	/// Either `bitcoind_cookiefile` or `bitcoind_user` and `bitcoind_pass` has to be provided.
125	/// The node must run with `txindex=1`; the wallet refuses to start otherwise.
126	pub bitcoind_address: Option<String>,
127
128	/// The path to the bitcoind rpc cookie file.
129	///
130	/// Only used with `bitcoind_address`.
131	pub bitcoind_cookiefile: Option<PathBuf>,
132
133	/// The bitcoind RPC username.
134	///
135	/// Only used with `bitcoind_address`.
136	pub bitcoind_user: Option<String>,
137
138	/// The bitcoind RPC password.
139	///
140	/// Only used with `bitcoind_address`.
141	///
142	/// The [Secret] wrapper keeps the password out of debug logs.
143	pub bitcoind_pass: Option<Secret<String>>,
144
145	/// The ZMQ endpoint of the bitcoind node (e.g. `tcp://127.0.0.1:28332`),
146	/// used to get notified of new blocks.
147	///
148	/// Only used with `bitcoind_address`. When unset, the chain tip is
149	/// polled instead.
150	pub bitcoind_zmq_address: Option<String>,
151
152	/// The number of blocks before expiration to refresh vtxos.
153	///
154	/// Default value: 144 (24h) for mainnet, 12 for testnets
155	pub vtxo_refresh_expiry_threshold: BlockDelta,
156
157	/// An upper limit of the number of blocks we expect to need to
158	/// safely exit the vtxos.
159	///
160	/// Default value: 12
161	pub vtxo_exit_margin: BlockDelta,
162
163	/// The number of blocks to claim a HTLC-recv VTXO.
164	///
165	/// Default value: 18
166	pub htlc_recv_claim_delta: BlockDelta,
167
168	/// Maximum number of retry attempts when claiming a Lightning receive
169	/// against the server fails. After this budget is exhausted, the HTLC-recv
170	/// VTXOs will be exited on-chain.
171	///
172	/// Default value: 5
173	pub lightning_receive_claim_retries: u8,
174
175	/// Optional SOCKS5 proxy URL for network traffic.
176	///
177	/// The proxy is automatically bypassed for localhost addresses
178	/// (127.0.0.1, localhost, ::1), so a local bitcoind works without
179	/// extra configuration.
180	///
181	/// Use `socks5h://` to resolve DNS through the proxy which is required for .onion addresses
182	/// and to prevent DNS leaks. We don't allow `socks5://` to be used to preserve privacy.
183	///
184	/// Example: `socks5h://127.0.0.1:9050` for a local Tor daemon.
185	#[cfg(feature = "socks5-proxy")]
186	pub socks5_proxy: Option<String>,
187
188	/// A fallback fee rate to use in sat/kWu when we fail to retrieve a fee rate from the
189	/// configured bitcoind/esplora connection.
190	///
191	/// Example for 1 sat/vB: --fallback-fee-rate 250
192	pub fallback_fee_rate: Option<FeeRate>,
193
194	/// The number of confirmations required before considering a round tx
195	/// fully confirmed
196	///
197	/// Default value: 6 for mainnet, 2 for testnets
198	pub round_tx_required_confirmations: BlockHeight,
199
200	/// The number of confirmations required before considering an offboard tx
201	/// confirmed. If set to 0, offboard movements are marked as successful
202	/// immediately without waiting for confirmation.
203	///
204	/// Default value: 2 for mainnet
205	pub offboard_required_confirmations: BlockHeight,
206
207	/// How long, in seconds, a broadcast offboard tx may be missing from
208	/// both chain and mempool before the wallet reports the offboard as
209	/// lost. Within the grace period the wallet re-broadcasts the tx
210	/// instead: the chain backend might just be slow or out of sync.
211	///
212	/// Default value: 3600 (one hour)
213	pub offboard_lost_tx_grace_period_secs: u64,
214
215	/// Daemon sync interval in seconds for periodic tasks (onchain, exits,
216	/// boards, offboards, maintenance, rounds, mailbox).
217	///
218	/// Default value: 60
219	pub daemon_sync_interval_secs: u64,
220
221	/// The number of pieces to split arkoor and lightning-send change into,
222	/// between 1 (no splitting) and 3 (the server's default arkoor fanout
223	/// limit of 4, minus the payment output).
224	///
225	/// Default value: 2
226	pub change_vtxo_split_factor: u8,
227
228	/// When set, the daemon skips all automatic wallet syncing — startup
229	/// sync, the fast/slow sync intervals, round event subscription, and
230	/// the mailbox subscription. Only the server connection heartbeat
231	/// keeps running. The operator is responsible for triggering syncs
232	/// via the REST API (e.g. `POST /sync`).
233	///
234	/// Default value: false
235	pub daemon_manual_sync: bool,
236
237	/// How many consecutive unused seed-derived VTXO key indices a scan crosses
238	/// before concluding a VTXO isn't ours.
239	///
240	/// Used by [Wallet::recover_vtxos](crate::Wallet::recover_vtxos) and
241	/// [Wallet::import_vtxos](crate::Wallet::import_vtxos). Every match extends
242	/// the window, so this bounds the run of unused indices, not the total keys
243	/// derived. Raise it for a wallet that handed out many addresses without
244	/// receiving into them. Capped at [MAX_VTXO_KEY_GAP_LIMIT].
245	///
246	/// Default value: 250
247	pub vtxo_key_gap_limit: u32,
248}
249
250impl Config {
251	/// A network-dependent default config that sets some useful defaults
252	///
253	/// The [Default::default] provides a sane default for mainnet
254	pub fn network_default(network: Network) -> Self {
255		#[allow(deprecated)]
256		let mut ret = Self {
257			server_address: "http://127.0.0.1:3535".to_owned(),
258			server_access_token: None,
259			user_agent: None,
260			esplora_address: None,
261			bitcoind_address: None,
262			bitcoind_cookiefile: None,
263			bitcoind_user: None,
264			bitcoind_pass: None,
265			bitcoind_zmq_address: None,
266			#[cfg(feature = "socks5-proxy")]
267			socks5_proxy: None,
268			vtxo_refresh_expiry_threshold: 144,
269			vtxo_exit_margin: 12,
270			htlc_recv_claim_delta: 18,
271			lightning_receive_claim_retries: 5,
272			fallback_fee_rate: Some(FeeRate::from_sat_per_vb_u32(2)),
273			round_tx_required_confirmations: 1,
274			offboard_required_confirmations: 2,
275			offboard_lost_tx_grace_period_secs: 3600,
276			daemon_sync_interval_secs: 60,
277			daemon_manual_sync: false,
278			change_vtxo_split_factor: 2,
279			vtxo_key_gap_limit: DEFAULT_VTXO_KEY_GAP_LIMIT,
280		};
281
282		if network != Network::Bitcoin {
283			ret.vtxo_refresh_expiry_threshold = 12;
284			ret.fallback_fee_rate = Some(FeeRate::from_sat_per_vb_u32(1));
285			ret.round_tx_required_confirmations = 1;
286			ret.offboard_required_confirmations = 0;
287		}
288
289		ret
290	}
291
292	/// Load config from the config file path, filling missing fields
293	/// from the network default.
294	///
295	/// Config values are loaded in the following priority order (highest to lowest):
296	/// 1. Environment variables with `BARK_` prefix (e.g., `BARK_ESPLORA_ADDRESS`)
297	/// 2. Config file values
298	/// 3. Network defaults
299	pub fn load(network: Network, path: impl AsRef<Path>) -> anyhow::Result<Config> {
300		let default = config::Config::try_from(&Self::network_default(network))
301			.expect("default config failed to deconstruct");
302
303		let config = config::Config::builder()
304			.add_source(default)
305			.add_source(config::File::from(path.as_ref()).required(false))
306			.add_source(config::Environment::with_prefix("BARK"))
307			.build().context("error building config")?
308			.try_deserialize::<Config>().context("error parsing config")?;
309
310		// Caught here so an out-of-range limit is refused up front, rather than
311		// failing the first scan that reads it.
312		if config.vtxo_key_gap_limit > MAX_VTXO_KEY_GAP_LIMIT {
313			bail!("vtxo_key_gap_limit {} is above the maximum of {}",
314				config.vtxo_key_gap_limit, MAX_VTXO_KEY_GAP_LIMIT);
315		}
316
317		Ok(config)
318	}
319
320	/// Creates a [crate::chain::ChainSource] instance to communicate with a chain
321	/// backend from this [Config].
322	pub fn chain_source(&self) -> anyhow::Result<ChainSourceSpec> {
323		if let Some(ref url) = self.esplora_address {
324			Ok(ChainSourceSpec::Esplora {
325				url: url.clone(),
326			})
327		} else if let Some(ref url) = self.bitcoind_address {
328			let auth = if let Some(ref c) = self.bitcoind_cookiefile {
329				bitcoin_ext::rpc::Auth::CookieFile(c.clone())
330			} else {
331				bitcoin_ext::rpc::Auth::UserPass(
332					self.bitcoind_user.clone().context("need bitcoind auth config")?,
333					self.bitcoind_pass.as_ref().context("need bitcoind auth config")?
334						.leak_ref().clone(),
335				)
336			};
337			Ok(ChainSourceSpec::Bitcoind {
338				url: url.clone(),
339				auth,
340				zmq: self.bitcoind_zmq_address.clone(),
341			})
342		} else {
343			bail!("Need to either provide esplora or bitcoind info");
344		}
345	}
346}
347
348#[cfg(test)]
349mod test {
350	use super::*;
351
352	/// A wallet created before a field existed has a config file without it, so
353	/// loading must fall back to the network default instead of failing.
354	#[test]
355	fn config_file_without_gap_limit_gets_the_default() {
356		let dir = std::env::temp_dir().join(format!("bark-cfg-{}", std::process::id()));
357		std::fs::create_dir_all(&dir).unwrap();
358		let path = dir.join("config.toml");
359		std::fs::write(&path, "\
360			server_address = \"http://127.0.0.1:3535\"\n\
361			esplora_address = \"http://127.0.0.1:3002\"\n\
362		").unwrap();
363
364		let config = Config::load(Network::Regtest, &path).expect("an old config should load");
365		assert_eq!(config.vtxo_key_gap_limit, DEFAULT_VTXO_KEY_GAP_LIMIT,
366			"a missing gap limit should fall back to the default");
367
368		std::fs::remove_dir_all(&dir).ok();
369	}
370
371	/// An out-of-range gap limit is refused when the config loads, rather than
372	/// surfacing later from whichever scan first reads it.
373	#[test]
374	fn config_file_with_an_out_of_range_gap_limit_is_refused() {
375		let dir = std::env::temp_dir().join(format!("bark-cfg-max-{}", std::process::id()));
376		std::fs::create_dir_all(&dir).unwrap();
377		let path = dir.join("config.toml");
378		let write = |limit: u32| std::fs::write(&path, format!("\
379			server_address = \"http://127.0.0.1:3535\"\n\
380			esplora_address = \"http://127.0.0.1:3002\"\n\
381			vtxo_key_gap_limit = {limit}\n\
382		")).unwrap();
383
384		write(MAX_VTXO_KEY_GAP_LIMIT + 1);
385		let err = Config::load(Network::Regtest, &path)
386			.expect_err("a gap limit above the maximum should be refused");
387		assert!(err.to_string().contains("above the maximum"), "err: {err:#}");
388
389		// The maximum itself loads, so the guard is a ceiling.
390		write(MAX_VTXO_KEY_GAP_LIMIT);
391		let config = Config::load(Network::Regtest, &path).expect("the maximum should load");
392		assert_eq!(config.vtxo_key_gap_limit, MAX_VTXO_KEY_GAP_LIMIT);
393
394		std::fs::remove_dir_all(&dir).ok();
395	}
396}