#![warn(missing_docs)]
mod batch;
mod env;
pub use batch::BatchSigner;
use std::str::FromStr;
use std::time::Duration;
use arch_program::pubkey::Pubkey;
use arch_program::sanitized::ArchMessage;
use arch_sdk::{RuntimeTransaction, Signature};
use async_trait::async_trait;
use base64::Engine;
use bitcoin::key::UntweakedKeypair;
use bitcoin::secp256k1::{Secp256k1, SecretKey, XOnlyPublicKey};
#[derive(Clone, Debug, thiserror::Error)]
pub enum SignError {
#[error("signer configuration: {0}")]
Config(String),
#[error("signing failed: {0}")]
Signing(String),
#[error("cosigner proxy error{}: {detail}", fmt_status(.status))]
Proxy {
status: Option<u16>,
detail: String,
},
#[error("response verification failed: {0}")]
Verification(String),
}
fn fmt_status(status: &Option<u16>) -> String {
match status {
Some(code) => format!(" (http {code})"),
None => String::new(),
}
}
#[derive(Debug, Clone)]
pub struct SignResponse {
pub signature: [u8; 64],
pub arch_account_pubkey: [u8; 32],
pub digest_hex: Option<String>,
pub turnkey_activity_id: Option<String>,
}
impl SignResponse {
pub fn signature(&self) -> &[u8; 64] {
&self.signature
}
}
#[async_trait]
pub trait ArchSignerT: Send + Sync {
fn pubkey(&self) -> Pubkey;
async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError>;
async fn sign_messages(
&self,
messages: &[ArchMessage],
) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
let mut results = Vec::with_capacity(messages.len());
for message in messages {
results.push(self.sign_message(message).await);
}
Ok(results)
}
async fn sign_transaction(
&self,
message: ArchMessage,
) -> Result<RuntimeTransaction, SignError> {
self.sign_transaction_mixed(message, &[]).await
}
async fn sign_transaction_mixed(
&self,
message: ArchMessage,
local_cosigners: &[UntweakedKeypair],
) -> Result<RuntimeTransaction, SignError> {
let digest = message.hash();
let required = message.header.num_required_signatures as usize;
let mut signatures = Vec::with_capacity(required);
for key in message.account_keys.iter().take(required) {
if *key == self.pubkey() {
signatures.push(Signature(self.sign_message(&message).await?.signature));
} else if let Some(kp) = local_cosigners
.iter()
.find(|kp| XOnlyPublicKey::from_keypair(kp).0.serialize() == key.serialize())
{
let sig = arch_sdk::sign_message_bip322(kp, &digest, bitcoin::Network::Bitcoin)
.map_err(|e| SignError::Signing(e.to_string()))?;
signatures.push(Signature(sig));
} else {
return Err(SignError::Signing(format!(
"no signer for required key {}",
hex::encode(key.serialize())
)));
}
}
Ok(RuntimeTransaction {
version: 0,
signatures,
message,
})
}
}
#[derive(Clone)]
pub struct LocalSigner {
keypair: UntweakedKeypair,
pubkey: Pubkey,
network: bitcoin::Network,
}
impl LocalSigner {
pub fn new(keypair: UntweakedKeypair) -> Self {
let pubkey = Pubkey::from_slice(&XOnlyPublicKey::from_keypair(&keypair).0.serialize());
Self {
keypair,
pubkey,
network: bitcoin::Network::Bitcoin,
}
}
pub fn from_key_file(path: &str) -> Result<Self, SignError> {
let content = std::fs::read_to_string(path)
.map_err(|e| SignError::Config(format!("reading key file {path}: {e}")))?;
let secret = parse_secret_key(&content)
.map_err(|e| SignError::Config(format!("key file {path}: {e}")))?;
Ok(Self::new(UntweakedKeypair::from_secret_key(
&Secp256k1::new(),
&secret,
)))
}
pub fn with_network(mut self, network: bitcoin::Network) -> Self {
self.network = network;
self
}
pub fn pubkey(&self) -> Pubkey {
self.pubkey
}
pub fn network(&self) -> bitcoin::Network {
self.network
}
}
fn parse_secret_key(content: &str) -> Result<SecretKey, String> {
if let Ok(secret) = SecretKey::from_str(content) {
return Ok(secret);
}
let bytes: Vec<u8> = serde_json::from_str(content)
.map_err(|_| "neither a hex secret key nor a JSON byte array".to_string())?;
let head = bytes
.get(..32)
.ok_or_else(|| format!("byte array holds {} bytes, need 32", bytes.len()))?;
SecretKey::from_slice(head).map_err(|e| e.to_string())
}
#[async_trait]
impl ArchSignerT for LocalSigner {
fn pubkey(&self) -> Pubkey {
self.pubkey
}
async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
let signature = arch_sdk::sign_message_bip322(&self.keypair, &message.hash(), self.network)
.map_err(|e| SignError::Signing(e.to_string()))?;
Ok(SignResponse {
signature,
arch_account_pubkey: self.pubkey.serialize(),
digest_hex: None,
turnkey_activity_id: None,
})
}
}
#[derive(Clone)]
pub struct RemoteSigner {
http: reqwest::Client,
url: String,
token: String,
role: String,
intent: String,
pubkey: Pubkey,
network: bitcoin::Network,
retries: u32,
backoff: Duration,
}
impl RemoteSigner {
pub fn new(url: &str, token: &str, role: &str, pubkey: Pubkey) -> Self {
Self {
http: http_client(Duration::from_secs(35)),
url: url.trim_end_matches('/').to_string(),
token: token.to_string(),
role: role.to_string(),
intent: "unlabeled".to_string(),
pubkey,
network: bitcoin::Network::Bitcoin,
retries: 2,
backoff: Duration::from_millis(250),
}
}
pub fn with_network(mut self, network: bitcoin::Network) -> Self {
self.network = network;
self
}
pub fn with_intent(mut self, intent: &str) -> Self {
self.intent = intent.to_string();
self
}
pub fn with_retries(mut self, retries: u32) -> Self {
self.retries = retries;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.http = http_client(timeout);
self
}
pub fn pubkey(&self) -> Pubkey {
self.pubkey
}
pub fn network(&self) -> bitcoin::Network {
self.network
}
pub fn base_url(&self) -> &str {
&self.url
}
async fn sign_once(
&self,
message_b64: &str,
message: &ArchMessage,
) -> Result<SignResponse, SignError> {
let resp = self
.http
.post(format!("{}/v1/sign", self.url))
.bearer_auth(&self.token)
.json(&serde_json::json!({
"role": self.role,
"intent_type": self.intent,
"unsigned_message_b64": message_b64,
}))
.send()
.await
.map_err(|e| SignError::Proxy {
status: None,
detail: e.to_string(),
})?;
let status = resp.status().as_u16();
let body: serde_json::Value = match resp.json().await {
Ok(body) => body,
Err(e) if status == 200 => {
return Err(SignError::Proxy {
status: None,
detail: format!("response body: {e}"),
})
}
Err(_) => serde_json::Value::Null,
};
if status != 200 {
let detail = body["error"]
.as_str()
.or_else(|| body["halted"].as_str())
.unwrap_or("<no detail>")
.to_string();
return Err(SignError::Proxy {
status: Some(status),
detail,
});
}
self.verified_response(&body, message)
}
fn verified_response(
&self,
body: &serde_json::Value,
message: &ArchMessage,
) -> Result<SignResponse, SignError> {
let signature: [u8; 64] = hex::decode(body["signature_hex"].as_str().unwrap_or_default())
.ok()
.and_then(|v| v.try_into().ok())
.ok_or_else(|| {
SignError::Verification("signature_hex missing or not 64 bytes".into())
})?;
let expected_pubkey = hex::encode(self.pubkey.serialize());
if body["arch_account_pubkey"].as_str() != Some(expected_pubkey.as_str()) {
return Err(SignError::Verification(format!(
"proxy signed with {} but this signer is configured for {expected_pubkey}",
body["arch_account_pubkey"]
)));
}
let digest = message.hash();
arch_sdk::verify_message_bip322(
&digest,
self.pubkey.serialize(),
signature,
false,
self.network,
)
.or_else(|_| {
arch_sdk::verify_message_bip322(
&digest,
self.pubkey.serialize(),
signature,
true,
self.network,
)
})
.map_err(|e| SignError::Verification(e.to_string()))?;
Ok(SignResponse {
signature,
arch_account_pubkey: self.pubkey.serialize(),
digest_hex: body["digest_hex"].as_str().map(str::to_string),
turnkey_activity_id: body["turnkey_activity_id"].as_str().map(str::to_string),
})
}
async fn sign_batch_once(
&self,
messages: &[ArchMessage],
) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
let items: Vec<serde_json::Value> = messages
.iter()
.map(|message| {
serde_json::json!({
"intent_type": self.intent,
"unsigned_message_b64": base64::engine::general_purpose::STANDARD
.encode(message.serialize()),
})
})
.collect();
let resp = self
.http
.post(format!("{}/v1/sign_batch", self.url))
.bearer_auth(&self.token)
.json(&serde_json::json!({ "role": self.role, "items": items }))
.send()
.await
.map_err(|e| SignError::Proxy {
status: None,
detail: e.to_string(),
})?;
let status = resp.status().as_u16();
let body: serde_json::Value = match resp.json().await {
Ok(body) => body,
Err(e) if status == 200 => {
return Err(SignError::Proxy {
status: None,
detail: format!("response body: {e}"),
})
}
Err(_) => serde_json::Value::Null,
};
if status != 200 {
return Err(SignError::Proxy {
status: Some(status),
detail: body["error"].as_str().unwrap_or("<no detail>").to_string(),
});
}
let results = body["results"]
.as_array()
.ok_or_else(|| SignError::Verification("batch response has no results array".into()))?;
if results.len() != messages.len() {
return Err(SignError::Verification(format!(
"batch response has {} results for {} messages",
results.len(),
messages.len()
)));
}
Ok(results
.iter()
.zip(messages)
.map(|(item, message)| {
if item["status"].as_str() == Some("signed") {
self.verified_response(item, message)
} else {
Err(SignError::Signing(format!(
"proxy rejected this message: {}",
item["error"].as_str().unwrap_or("<no detail>")
)))
}
})
.collect())
}
async fn send_with_retries<T, F, Fut>(&self, attempt: F) -> Result<T, SignError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T, SignError>>,
{
let mut n = 0;
loop {
match attempt().await {
Err(err) if n < self.retries && is_retryable(&err) => {
tokio::time::sleep(self.backoff.saturating_mul(2u32.saturating_pow(n))).await;
n += 1;
}
result => return result,
}
}
}
}
impl std::fmt::Debug for RemoteSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RemoteSigner")
.field("url", &self.url)
.field("token", &"<redacted>")
.field("role", &self.role)
.field("intent", &self.intent)
.field("pubkey", &self.pubkey)
.field("network", &self.network)
.field("retries", &self.retries)
.finish_non_exhaustive()
}
}
fn http_client(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("client construction with static config cannot fail")
}
pub(crate) fn is_retryable(err: &SignError) -> bool {
matches!(
err,
SignError::Proxy {
status: Some(502) | None,
..
}
)
}
#[async_trait]
impl ArchSignerT for RemoteSigner {
fn pubkey(&self) -> Pubkey {
self.pubkey
}
async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
let message_b64 = base64::engine::general_purpose::STANDARD.encode(message.serialize());
self.send_with_retries(|| self.sign_once(&message_b64, message))
.await
}
async fn sign_messages(
&self,
messages: &[ArchMessage],
) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
if messages.is_empty() {
return Ok(Vec::new());
}
self.send_with_retries(|| self.sign_batch_once(messages))
.await
}
}
#[derive(Clone)]
pub enum ArchSigner {
Local(LocalSigner),
Remote(RemoteSigner),
}
impl ArchSigner {
pub fn local(keypair: UntweakedKeypair) -> Self {
Self::Local(LocalSigner::new(keypair))
}
pub fn local_from_key_file(path: &str) -> Result<Self, SignError> {
Ok(Self::Local(LocalSigner::from_key_file(path)?))
}
pub fn remote(url: &str, token: &str, role: &str, pubkey: Pubkey) -> Self {
Self::Remote(RemoteSigner::new(url, token, role, pubkey))
}
pub fn from_env() -> Result<Self, SignError> {
env::resolve("")
}
pub fn from_prefixed_env(prefix: &str) -> Result<Self, SignError> {
env::resolve(prefix)
}
pub fn with_network(self, network: bitcoin::Network) -> Self {
match self {
Self::Local(s) => Self::Local(s.with_network(network)),
Self::Remote(s) => Self::Remote(s.with_network(network)),
}
}
pub fn with_intent(self, intent: &str) -> Self {
match self {
Self::Remote(s) => Self::Remote(s.with_intent(intent)),
local => local,
}
}
pub fn with_retries(self, retries: u32) -> Self {
match self {
Self::Remote(s) => Self::Remote(s.with_retries(retries)),
local => local,
}
}
pub fn with_timeout(self, timeout: Duration) -> Self {
match self {
Self::Remote(s) => Self::Remote(s.with_timeout(timeout)),
local => local,
}
}
pub fn network(&self) -> bitcoin::Network {
match self {
Self::Local(s) => s.network(),
Self::Remote(s) => s.network(),
}
}
pub fn is_remote(&self) -> bool {
matches!(self, Self::Remote(_))
}
pub fn as_local(&self) -> Option<&LocalSigner> {
match self {
Self::Local(s) => Some(s),
Self::Remote(_) => None,
}
}
pub fn as_remote(&self) -> Option<&RemoteSigner> {
match self {
Self::Local(_) => None,
Self::Remote(s) => Some(s),
}
}
}
#[async_trait]
impl ArchSignerT for ArchSigner {
fn pubkey(&self) -> Pubkey {
match self {
Self::Local(s) => s.pubkey(),
Self::Remote(s) => s.pubkey(),
}
}
async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
match self {
Self::Local(s) => s.sign_message(message).await,
Self::Remote(s) => s.sign_message(message).await,
}
}
async fn sign_messages(
&self,
messages: &[ArchMessage],
) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
match self {
Self::Local(s) => s.sign_messages(messages).await,
Self::Remote(s) => s.sign_messages(messages).await,
}
}
}