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