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}