Skip to main content

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}