cosigner_client/lib.rs
1//! Signers for Arch Network transactions, local or through the
2//! `arch-cosigner` custody proxy.
3//!
4//! [`ArchSignerT`] is the signing interface: implementors provide
5//! [`pubkey`](ArchSignerT::pubkey) and
6//! [`sign_message`](ArchSignerT::sign_message), and inherit transaction
7//! assembly ([`sign_transaction`](ArchSignerT::sign_transaction),
8//! [`sign_transaction_mixed`](ArchSignerT::sign_transaction_mixed)).
9//! [`LocalSigner`] signs with an in-memory keypair; [`RemoteSigner`] delegates
10//! to the proxy's `POST /v1/sign` and verifies every response, or coalesces
11//! several messages into one `POST /v1/sign_batch`
12//! ([`sign_messages`](ArchSignerT::sign_messages)). [`ArchSigner`]
13//! wraps both behind one type so the deployment environment can choose the
14//! backend ([`ArchSigner::from_env`]).
15//!
16//! # Examples
17//!
18//! ```
19//! use cosigner_client::{ArchSigner, ArchSignerT, SignError};
20//!
21//! async fn submit(
22//! message: arch_program::sanitized::ArchMessage,
23//! ) -> Result<arch_sdk::RuntimeTransaction, SignError> {
24//! let signer = ArchSigner::from_env()?;
25//! signer.sign_transaction(message).await
26//! }
27//! ```
28
29#![warn(missing_docs)]
30
31mod batch;
32mod env;
33
34pub use batch::BatchSigner;
35
36use std::str::FromStr;
37use std::time::Duration;
38
39use arch_program::pubkey::Pubkey;
40use arch_program::sanitized::ArchMessage;
41use arch_sdk::{RuntimeTransaction, Signature};
42use async_trait::async_trait;
43use base64::Engine;
44use bitcoin::key::UntweakedKeypair;
45use bitcoin::secp256k1::{Secp256k1, SecretKey, XOnlyPublicKey};
46
47/// Error surface for signer construction and signing.
48///
49/// `Clone` so one whole-request failure can be reported to every caller whose
50/// message shared the batch that failed.
51#[derive(Clone, Debug, thiserror::Error)]
52pub enum SignError {
53 /// Signer configuration is missing, ambiguous, or invalid.
54 #[error("signer configuration: {0}")]
55 Config(String),
56 /// Producing a signature failed, or a required signer key has no matching
57 /// signer.
58 #[error("signing failed: {0}")]
59 Signing(String),
60 /// The cosigner proxy answered with a non-success status, or the request
61 /// did not complete.
62 #[error("cosigner proxy error{}: {detail}", fmt_status(.status))]
63 Proxy {
64 /// HTTP status answered by the proxy; `None` for transport failures
65 /// (connect errors and timeouts).
66 status: Option<u16>,
67 /// Detail from the proxy's error body, or the transport error text.
68 detail: String,
69 },
70 /// A proxy response failed the checks described on [`RemoteSigner`].
71 #[error("response verification failed: {0}")]
72 Verification(String),
73}
74
75fn fmt_status(status: &Option<u16>) -> String {
76 match status {
77 Some(code) => format!(" (http {code})"),
78 None => String::new(),
79 }
80}
81
82/// A signature over a message's BIP322 digest, with backend metadata.
83#[derive(Debug, Clone)]
84pub struct SignResponse {
85 /// 64-byte BIP340 signature over the message's BIP322 digest.
86 pub signature: [u8; 64],
87 /// The Arch account key the signature verifies under.
88 pub arch_account_pubkey: [u8; 32],
89 /// Digest the proxy reports having signed, hex; `None` for local signing.
90 pub digest_hex: Option<String>,
91 /// Turnkey activity id for audit reconciliation; `None` for local signing.
92 pub turnkey_activity_id: Option<String>,
93}
94
95impl SignResponse {
96 /// Returns the 64-byte BIP340 signature.
97 pub fn signature(&self) -> &[u8; 64] {
98 &self.signature
99 }
100}
101
102/// Signing interface shared by local and remote backends.
103///
104/// Implementors supply [`pubkey`](Self::pubkey) and
105/// [`sign_message`](Self::sign_message); the transaction-assembly methods are
106/// provided. Object-safe: `&dyn ArchSignerT` works.
107#[async_trait]
108pub trait ArchSignerT: Send + Sync {
109 /// Returns the Arch account key this signer signs for.
110 fn pubkey(&self) -> Pubkey;
111
112 /// Signs the message's BIP322 digest.
113 ///
114 /// # Errors
115 /// [`SignError::Signing`] when producing the signature fails.
116 /// [`RemoteSigner`] also returns [`SignError::Proxy`] and
117 /// [`SignError::Verification`] as described on its type documentation.
118 async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError>;
119
120 /// Signs every message in `messages`, returning one result per input in
121 /// input order.
122 ///
123 /// A per-item failure does not fail its neighbours. The default
124 /// implementation signs sequentially; [`RemoteSigner`] overrides it with a
125 /// single `POST /v1/sign_batch` round trip, which costs one request against
126 /// the role's Turnkey rate limit instead of one per message.
127 ///
128 /// Each signature is verified against the message it was requested for, in
129 /// both implementations.
130 ///
131 /// # Errors
132 /// [`SignError`] as the whole-request outcome: authentication, transport, a
133 /// batch larger than the proxy accepts, or a response that cannot be mapped
134 /// back to the requested messages. Per-item failures are in the returned
135 /// vector.
136 async fn sign_messages(
137 &self,
138 messages: &[ArchMessage],
139 ) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
140 let mut results = Vec::with_capacity(messages.len());
141 for message in messages {
142 results.push(self.sign_message(message).await);
143 }
144 Ok(results)
145 }
146
147 /// Signs a transaction whose only required signer is this signer.
148 ///
149 /// # Errors
150 /// As for [`sign_transaction_mixed`](Self::sign_transaction_mixed) with no
151 /// cosigners.
152 async fn sign_transaction(
153 &self,
154 message: ArchMessage,
155 ) -> Result<RuntimeTransaction, SignError> {
156 self.sign_transaction_mixed(message, &[]).await
157 }
158
159 /// Signs a transaction with this signer plus local cosigner keypairs.
160 ///
161 /// Each of the first `num_required_signatures` entries of
162 /// `message.account_keys` receives a signature at its own position: this
163 /// signer's key is signed via [`sign_message`](Self::sign_message), a
164 /// cosigner key is signed with its keypair, and the assembled
165 /// [`RuntimeTransaction`] carries the signatures in `account_keys` order.
166 ///
167 /// # Errors
168 /// Propagates [`sign_message`](Self::sign_message) errors;
169 /// [`SignError::Signing`] when a required key matches neither this signer
170 /// nor a cosigner, or when a cosigner signature fails.
171 async fn sign_transaction_mixed(
172 &self,
173 message: ArchMessage,
174 local_cosigners: &[UntweakedKeypair],
175 ) -> Result<RuntimeTransaction, SignError> {
176 let digest = message.hash();
177 let required = message.header.num_required_signatures as usize;
178 let mut signatures = Vec::with_capacity(required);
179
180 for key in message.account_keys.iter().take(required) {
181 if *key == self.pubkey() {
182 signatures.push(Signature(self.sign_message(&message).await?.signature));
183 } else if let Some(kp) = local_cosigners
184 .iter()
185 .find(|kp| XOnlyPublicKey::from_keypair(kp).0.serialize() == key.serialize())
186 {
187 // BIP-0322 P2TR signatures are network-independent (the
188 // script_pubkey carries no network), so Bitcoin is safe for
189 // ephemeral cosigners regardless of deployment network.
190 let sig = arch_sdk::sign_message_bip322(kp, &digest, bitcoin::Network::Bitcoin)
191 .map_err(|e| SignError::Signing(e.to_string()))?;
192 signatures.push(Signature(sig));
193 } else {
194 return Err(SignError::Signing(format!(
195 "no signer for required key {}",
196 hex::encode(key.serialize())
197 )));
198 }
199 }
200
201 Ok(RuntimeTransaction {
202 version: 0,
203 signatures,
204 message,
205 })
206 }
207}
208
209/// Signs with an in-memory keypair via [`arch_sdk::sign_message_bip322`].
210#[derive(Clone)]
211pub struct LocalSigner {
212 keypair: UntweakedKeypair,
213 pubkey: Pubkey,
214 network: bitcoin::Network,
215}
216
217impl LocalSigner {
218 /// Wraps an in-memory keypair, with the network defaulting to Bitcoin.
219 pub fn new(keypair: UntweakedKeypair) -> Self {
220 let pubkey = Pubkey::from_slice(&XOnlyPublicKey::from_keypair(&keypair).0.serialize());
221 Self {
222 keypair,
223 pubkey,
224 network: bitcoin::Network::Bitcoin,
225 }
226 }
227
228 /// Loads a keypair from a file in [`arch_sdk::with_secret_key_file`]
229 /// format: a hex-encoded secret key or a JSON byte array.
230 ///
231 /// Never generates or writes a key.
232 ///
233 /// # Errors
234 /// [`SignError::Config`] when the file is unreadable or does not parse
235 /// as a secret key.
236 pub fn from_key_file(path: &str) -> Result<Self, SignError> {
237 let content = std::fs::read_to_string(path)
238 .map_err(|e| SignError::Config(format!("reading key file {path}: {e}")))?;
239 let secret = parse_secret_key(&content)
240 .map_err(|e| SignError::Config(format!("key file {path}: {e}")))?;
241 Ok(Self::new(UntweakedKeypair::from_secret_key(
242 &Secp256k1::new(),
243 &secret,
244 )))
245 }
246
247 /// Returns this signer with `network` used for BIP322 digests.
248 pub fn with_network(mut self, network: bitcoin::Network) -> Self {
249 self.network = network;
250 self
251 }
252
253 /// Returns the Arch account key derived from the wrapped keypair.
254 pub fn pubkey(&self) -> Pubkey {
255 self.pubkey
256 }
257
258 /// Returns the network used for BIP322 digests.
259 pub fn network(&self) -> bitcoin::Network {
260 self.network
261 }
262}
263
264/// Parses the two encodings accepted by [`arch_sdk::with_secret_key_file`]:
265/// a hex secret key, or a JSON byte array whose first 32 bytes are the key.
266fn parse_secret_key(content: &str) -> Result<SecretKey, String> {
267 if let Ok(secret) = SecretKey::from_str(content) {
268 return Ok(secret);
269 }
270 let bytes: Vec<u8> = serde_json::from_str(content)
271 .map_err(|_| "neither a hex secret key nor a JSON byte array".to_string())?;
272 let head = bytes
273 .get(..32)
274 .ok_or_else(|| format!("byte array holds {} bytes, need 32", bytes.len()))?;
275 SecretKey::from_slice(head).map_err(|e| e.to_string())
276}
277
278#[async_trait]
279impl ArchSignerT for LocalSigner {
280 fn pubkey(&self) -> Pubkey {
281 self.pubkey
282 }
283
284 async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
285 let signature = arch_sdk::sign_message_bip322(&self.keypair, &message.hash(), self.network)
286 .map_err(|e| SignError::Signing(e.to_string()))?;
287 Ok(SignResponse {
288 signature,
289 arch_account_pubkey: self.pubkey.serialize(),
290 digest_hex: None,
291 turnkey_activity_id: None,
292 })
293 }
294}
295
296/// Signs by delegating to an `arch-cosigner` proxy over `POST /v1/sign`.
297///
298/// Every response is verified before it is returned: the response's
299/// `arch_account_pubkey` must equal the configured [`pubkey`](Self::pubkey),
300/// and the signature must BIP322-verify for the exact submitted message under
301/// that key (DEFAULT-then-ALL sighash, the validator's order). A failed check
302/// is [`SignError::Verification`].
303///
304/// [`sign_message`](ArchSignerT::sign_message) retries `Proxy` errors with
305/// status 502 and transport failures (`Proxy` with status `None`) up to the
306/// configured retry count, with exponential backoff. Every other error,
307/// including a 503 from an unavailable proxy or infrastructure in front of
308/// it, returns on first occurrence.
309#[derive(Clone)]
310pub struct RemoteSigner {
311 http: reqwest::Client,
312 url: String,
313 token: String,
314 role: String,
315 intent: String,
316 pubkey: Pubkey,
317 network: bitcoin::Network,
318 retries: u32,
319 backoff: Duration,
320}
321
322impl RemoteSigner {
323 /// Creates a signer for `role` at the proxy base `url`, verifying every
324 /// response against `pubkey`.
325 ///
326 /// Defaults: network Bitcoin, 35 s request timeout, 2 retries, intent
327 /// "unlabeled", 250 ms retry backoff.
328 pub fn new(url: &str, token: &str, role: &str, pubkey: Pubkey) -> Self {
329 Self {
330 // The default timeout covers the proxy's worst case of
331 // (1 + retries) × turnkey_timeout + backoff ≈ 31 s.
332 http: http_client(Duration::from_secs(35)),
333 url: url.trim_end_matches('/').to_string(),
334 token: token.to_string(),
335 role: role.to_string(),
336 intent: "unlabeled".to_string(),
337 pubkey,
338 network: bitcoin::Network::Bitcoin,
339 retries: 2,
340 backoff: Duration::from_millis(250),
341 }
342 }
343
344 /// Returns this signer with `network` used for response verification.
345 pub fn with_network(mut self, network: bitcoin::Network) -> Self {
346 self.network = network;
347 self
348 }
349
350 /// Returns this signer with the intent label recorded in the proxy's
351 /// audit log.
352 pub fn with_intent(mut self, intent: &str) -> Self {
353 self.intent = intent.to_string();
354 self
355 }
356
357 /// Returns this signer with the retry budget for retryable errors.
358 pub fn with_retries(mut self, retries: u32) -> Self {
359 self.retries = retries;
360 self
361 }
362
363 /// Returns this signer with the per-request HTTP timeout.
364 pub fn with_timeout(mut self, timeout: Duration) -> Self {
365 self.http = http_client(timeout);
366 self
367 }
368
369 /// Returns the Arch account key responses are verified against.
370 pub fn pubkey(&self) -> Pubkey {
371 self.pubkey
372 }
373
374 /// Returns the network used for response verification.
375 pub fn network(&self) -> bitcoin::Network {
376 self.network
377 }
378
379 /// Returns the proxy base URL with any trailing `/` trimmed.
380 pub fn base_url(&self) -> &str {
381 &self.url
382 }
383
384 /// One `POST /v1/sign` round trip, verified; no retries at this level.
385 async fn sign_once(
386 &self,
387 message_b64: &str,
388 message: &ArchMessage,
389 ) -> Result<SignResponse, SignError> {
390 let resp = self
391 .http
392 .post(format!("{}/v1/sign", self.url))
393 .bearer_auth(&self.token)
394 .json(&serde_json::json!({
395 "role": self.role,
396 "intent_type": self.intent,
397 "unsigned_message_b64": message_b64,
398 }))
399 .send()
400 .await
401 .map_err(|e| SignError::Proxy {
402 status: None,
403 detail: e.to_string(),
404 })?;
405
406 let status = resp.status().as_u16();
407 let body: serde_json::Value = match resp.json().await {
408 Ok(body) => body,
409 // A 200 whose body fails to arrive is a transport failure;
410 // error-status bodies only feed the detail string below.
411 Err(e) if status == 200 => {
412 return Err(SignError::Proxy {
413 status: None,
414 detail: format!("response body: {e}"),
415 })
416 }
417 Err(_) => serde_json::Value::Null,
418 };
419 if status != 200 {
420 // "halted" is the 0.1 proxy's 503 body shape — read for wire
421 // compatibility with deployed proxies.
422 let detail = body["error"]
423 .as_str()
424 .or_else(|| body["halted"].as_str())
425 .unwrap_or("<no detail>")
426 .to_string();
427 return Err(SignError::Proxy {
428 status: Some(status),
429 detail,
430 });
431 }
432
433 self.verified_response(&body, message)
434 }
435
436 /// Verifies one signed body against the message it was requested for.
437 ///
438 /// Applied per item in the batch path as well as to the single-sign
439 /// response. Collapsing it into one check per batch would let a misaligned
440 /// response hand the caller a valid signature over a different message, so
441 /// it stays per item.
442 ///
443 /// # Errors
444 /// [`SignError::Verification`] when `signature_hex` is absent or not 64
445 /// bytes, when `arch_account_pubkey` is not this signer's key, or when the
446 /// signature does not BIP322-verify for `message` under that key.
447 fn verified_response(
448 &self,
449 body: &serde_json::Value,
450 message: &ArchMessage,
451 ) -> Result<SignResponse, SignError> {
452 let signature: [u8; 64] = hex::decode(body["signature_hex"].as_str().unwrap_or_default())
453 .ok()
454 .and_then(|v| v.try_into().ok())
455 .ok_or_else(|| {
456 SignError::Verification("signature_hex missing or not 64 bytes".into())
457 })?;
458
459 let expected_pubkey = hex::encode(self.pubkey.serialize());
460 if body["arch_account_pubkey"].as_str() != Some(expected_pubkey.as_str()) {
461 return Err(SignError::Verification(format!(
462 "proxy signed with {} but this signer is configured for {expected_pubkey}",
463 body["arch_account_pubkey"]
464 )));
465 }
466
467 let digest = message.hash();
468 arch_sdk::verify_message_bip322(
469 &digest,
470 self.pubkey.serialize(),
471 signature,
472 false,
473 self.network,
474 )
475 .or_else(|_| {
476 arch_sdk::verify_message_bip322(
477 &digest,
478 self.pubkey.serialize(),
479 signature,
480 true,
481 self.network,
482 )
483 })
484 .map_err(|e| SignError::Verification(e.to_string()))?;
485
486 Ok(SignResponse {
487 signature,
488 arch_account_pubkey: self.pubkey.serialize(),
489 digest_hex: body["digest_hex"].as_str().map(str::to_string),
490 turnkey_activity_id: body["turnkey_activity_id"].as_str().map(str::to_string),
491 })
492 }
493
494 /// One `POST /v1/sign_batch` round trip, every item verified; no retries at
495 /// this level.
496 async fn sign_batch_once(
497 &self,
498 messages: &[ArchMessage],
499 ) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
500 let items: Vec<serde_json::Value> = messages
501 .iter()
502 .map(|message| {
503 serde_json::json!({
504 "intent_type": self.intent,
505 "unsigned_message_b64": base64::engine::general_purpose::STANDARD
506 .encode(message.serialize()),
507 })
508 })
509 .collect();
510
511 let resp = self
512 .http
513 .post(format!("{}/v1/sign_batch", self.url))
514 .bearer_auth(&self.token)
515 .json(&serde_json::json!({ "role": self.role, "items": items }))
516 .send()
517 .await
518 .map_err(|e| SignError::Proxy {
519 status: None,
520 detail: e.to_string(),
521 })?;
522
523 let status = resp.status().as_u16();
524 let body: serde_json::Value = match resp.json().await {
525 Ok(body) => body,
526 Err(e) if status == 200 => {
527 return Err(SignError::Proxy {
528 status: None,
529 detail: format!("response body: {e}"),
530 })
531 }
532 Err(_) => serde_json::Value::Null,
533 };
534 if status != 200 {
535 return Err(SignError::Proxy {
536 status: Some(status),
537 detail: body["error"].as_str().unwrap_or("<no detail>").to_string(),
538 });
539 }
540
541 let results = body["results"]
542 .as_array()
543 .ok_or_else(|| SignError::Verification("batch response has no results array".into()))?;
544 // Results are positional, so a count mismatch makes every mapping a
545 // guess: fail the whole request rather than pair signatures with
546 // messages they may not belong to.
547 if results.len() != messages.len() {
548 return Err(SignError::Verification(format!(
549 "batch response has {} results for {} messages",
550 results.len(),
551 messages.len()
552 )));
553 }
554
555 Ok(results
556 .iter()
557 .zip(messages)
558 .map(|(item, message)| {
559 if item["status"].as_str() == Some("signed") {
560 self.verified_response(item, message)
561 } else {
562 Err(SignError::Signing(format!(
563 "proxy rejected this message: {}",
564 item["error"].as_str().unwrap_or("<no detail>")
565 )))
566 }
567 })
568 .collect())
569 }
570
571 /// Runs `attempt` until it succeeds or fails unretryably, spending the
572 /// configured retry budget with exponential backoff.
573 ///
574 /// # Errors
575 /// The last [`SignError`] seen; see [`RemoteSigner`] for which errors are
576 /// retried.
577 async fn send_with_retries<T, F, Fut>(&self, attempt: F) -> Result<T, SignError>
578 where
579 F: Fn() -> Fut,
580 Fut: std::future::Future<Output = Result<T, SignError>>,
581 {
582 let mut n = 0;
583 loop {
584 match attempt().await {
585 Err(err) if n < self.retries && is_retryable(&err) => {
586 tokio::time::sleep(self.backoff.saturating_mul(2u32.saturating_pow(n))).await;
587 n += 1;
588 }
589 result => return result,
590 }
591 }
592 }
593}
594
595impl std::fmt::Debug for RemoteSigner {
596 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
597 f.debug_struct("RemoteSigner")
598 .field("url", &self.url)
599 .field("token", &"<redacted>")
600 .field("role", &self.role)
601 .field("intent", &self.intent)
602 .field("pubkey", &self.pubkey)
603 .field("network", &self.network)
604 .field("retries", &self.retries)
605 .finish_non_exhaustive()
606 }
607}
608
609fn http_client(timeout: Duration) -> reqwest::Client {
610 reqwest::Client::builder()
611 .timeout(timeout)
612 .build()
613 .expect("client construction with static config cannot fail")
614}
615
616pub(crate) fn is_retryable(err: &SignError) -> bool {
617 matches!(
618 err,
619 SignError::Proxy {
620 status: Some(502) | None,
621 ..
622 }
623 )
624}
625
626#[async_trait]
627impl ArchSignerT for RemoteSigner {
628 fn pubkey(&self) -> Pubkey {
629 self.pubkey
630 }
631
632 async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
633 let message_b64 = base64::engine::general_purpose::STANDARD.encode(message.serialize());
634 self.send_with_retries(|| self.sign_once(&message_b64, message))
635 .await
636 }
637
638 async fn sign_messages(
639 &self,
640 messages: &[ArchMessage],
641 ) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
642 // The proxy rejects an empty batch, and there is nothing to ask for.
643 if messages.is_empty() {
644 return Ok(Vec::new());
645 }
646 self.send_with_retries(|| self.sign_batch_once(messages))
647 .await
648 }
649}
650
651/// A signer whose backend, local or remote, is chosen at runtime.
652#[derive(Clone)]
653pub enum ArchSigner {
654 /// In-process signing with a [`LocalSigner`].
655 Local(LocalSigner),
656 /// Proxy-delegated signing with a [`RemoteSigner`].
657 Remote(RemoteSigner),
658}
659
660impl ArchSigner {
661 /// Wraps `keypair` as a [`LocalSigner`].
662 pub fn local(keypair: UntweakedKeypair) -> Self {
663 Self::Local(LocalSigner::new(keypair))
664 }
665
666 /// Loads a [`LocalSigner`] from a key file.
667 ///
668 /// # Errors
669 /// [`SignError::Config`] as described on [`LocalSigner::from_key_file`].
670 pub fn local_from_key_file(path: &str) -> Result<Self, SignError> {
671 Ok(Self::Local(LocalSigner::from_key_file(path)?))
672 }
673
674 /// Creates a [`RemoteSigner`] for `role` at the proxy base `url`.
675 pub fn remote(url: &str, token: &str, role: &str, pubkey: Pubkey) -> Self {
676 Self::Remote(RemoteSigner::new(url, token, role, pubkey))
677 }
678
679 /// Resolves a signer from bare environment variables.
680 ///
681 /// Remote configuration reads `COSIGNER_URL`, `COSIGNER_TOKEN`,
682 /// `COSIGNER_ROLE`, and `COSIGNER_PUBKEY` (64 hex characters); local
683 /// configuration reads `ARCH_KEY_PATH`. Exactly one of `COSIGNER_URL` and
684 /// `ARCH_KEY_PATH` must be set, and empty values count as unset. The
685 /// network is not read from the environment: it defaults to Bitcoin and
686 /// is set with [`with_network`](Self::with_network).
687 ///
688 /// # Errors
689 /// [`SignError::Config`] when neither or both backends are configured,
690 /// when a required remote variable is missing (the message names every
691 /// missing variable), or when `COSIGNER_PUBKEY` is not 64 hex characters.
692 ///
693 /// # Examples
694 ///
695 /// ```no_run
696 /// use cosigner_client::ArchSigner;
697 ///
698 /// # fn main() -> Result<(), cosigner_client::SignError> {
699 /// let signer = ArchSigner::from_env()?.with_intent("sweep");
700 /// # let _ = signer;
701 /// # Ok(())
702 /// # }
703 /// ```
704 pub fn from_env() -> Result<Self, SignError> {
705 env::resolve("")
706 }
707
708 /// Resolves a signer from `{prefix}_`-prefixed environment variables,
709 /// falling back to the bare names.
710 ///
711 /// Each variable from [`from_env`](Self::from_env) is first looked up as
712 /// `{prefix}_{NAME}`. The backend is chosen at the most specific level
713 /// that sets a backend-selecting variable (`{prefix}_COSIGNER_URL` or
714 /// `{prefix}_ARCH_KEY_PATH`); when the prefixed level sets neither, the
715 /// bare level decides. After the backend is chosen, every variable fills
716 /// per-variable with the prefixed value first, so one bare `COSIGNER_URL`
717 /// can serve several prefixed tokens. Trailing underscores in `prefix`
718 /// are ignored, and an empty `prefix` behaves exactly like
719 /// [`from_env`](Self::from_env).
720 ///
721 /// # Errors
722 /// [`SignError::Config`] under the conditions listed on
723 /// [`from_env`](Self::from_env), with the ambiguity check applied at the
724 /// deciding level.
725 pub fn from_prefixed_env(prefix: &str) -> Result<Self, SignError> {
726 env::resolve(prefix)
727 }
728
729 /// Returns this signer with `network` applied to either variant.
730 pub fn with_network(self, network: bitcoin::Network) -> Self {
731 match self {
732 Self::Local(s) => Self::Local(s.with_network(network)),
733 Self::Remote(s) => Self::Remote(s.with_network(network)),
734 }
735 }
736
737 /// Returns this signer with the intent label set on the remote variant;
738 /// no-op for a local signer.
739 pub fn with_intent(self, intent: &str) -> Self {
740 match self {
741 Self::Remote(s) => Self::Remote(s.with_intent(intent)),
742 local => local,
743 }
744 }
745
746 /// Returns this signer with the retry budget set on the remote variant;
747 /// no-op for a local signer.
748 pub fn with_retries(self, retries: u32) -> Self {
749 match self {
750 Self::Remote(s) => Self::Remote(s.with_retries(retries)),
751 local => local,
752 }
753 }
754
755 /// Returns this signer with the HTTP timeout set on the remote variant;
756 /// no-op for a local signer.
757 pub fn with_timeout(self, timeout: Duration) -> Self {
758 match self {
759 Self::Remote(s) => Self::Remote(s.with_timeout(timeout)),
760 local => local,
761 }
762 }
763
764 /// Returns the configured network.
765 pub fn network(&self) -> bitcoin::Network {
766 match self {
767 Self::Local(s) => s.network(),
768 Self::Remote(s) => s.network(),
769 }
770 }
771
772 /// Returns whether this signer delegates to a proxy.
773 pub fn is_remote(&self) -> bool {
774 matches!(self, Self::Remote(_))
775 }
776
777 /// Returns the local variant, if any.
778 pub fn as_local(&self) -> Option<&LocalSigner> {
779 match self {
780 Self::Local(s) => Some(s),
781 Self::Remote(_) => None,
782 }
783 }
784
785 /// Returns the remote variant, if any.
786 pub fn as_remote(&self) -> Option<&RemoteSigner> {
787 match self {
788 Self::Local(_) => None,
789 Self::Remote(s) => Some(s),
790 }
791 }
792}
793
794#[async_trait]
795impl ArchSignerT for ArchSigner {
796 fn pubkey(&self) -> Pubkey {
797 match self {
798 Self::Local(s) => s.pubkey(),
799 Self::Remote(s) => s.pubkey(),
800 }
801 }
802
803 async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
804 match self {
805 Self::Local(s) => s.sign_message(message).await,
806 Self::Remote(s) => s.sign_message(message).await,
807 }
808 }
809
810 // Delegated rather than inherited: the provided default signs one message
811 // per request, which would silently drop batching for the remote variant.
812 async fn sign_messages(
813 &self,
814 messages: &[ArchMessage],
815 ) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
816 match self {
817 Self::Local(s) => s.sign_messages(messages).await,
818 Self::Remote(s) => s.sign_messages(messages).await,
819 }
820 }
821}