#![warn(missing_docs)]
use std::sync::Arc;
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::XOnlyPublicKey;
#[derive(Debug, thiserror::Error)]
pub enum SignerError {
#[error("cosigner halted: {0}")]
Halted(String),
#[error("cosigner unreachable: {0}")]
Unreachable(String),
#[error("denied by turnkey policy: {0}")]
Denied(String),
#[error("unauthorized at proxy: {0}")]
Unauthorized(String),
#[error("proxy rejected message as malformed: {0}")]
MalformedMessage(String),
#[error("transient cosigner failure: {0}")]
Transient(String),
#[error("response verification failed: {0}")]
Verification(String),
#[error("no signer available for required key {0}")]
MissingSigner(String),
#[error("local signing failed: {0}")]
Signing(String),
#[error("signer configuration: {0}")]
Config(String),
}
#[async_trait]
pub trait ArchSigner: Send + Sync {
fn pubkey(&self) -> Pubkey;
async fn sign_message(&self, message: &ArchMessage) -> Result<[u8; 64], SignerError>;
}
pub struct LocalSigner {
keypair: UntweakedKeypair,
pubkey: Pubkey,
network: bitcoin::Network,
}
impl LocalSigner {
pub fn new(keypair: UntweakedKeypair, network: bitcoin::Network) -> Self {
let pubkey = Pubkey::from_slice(&XOnlyPublicKey::from_keypair(&keypair).0.serialize());
Self {
keypair,
pubkey,
network,
}
}
pub fn from_key_file(path: &str, network: bitcoin::Network) -> Result<Self, SignerError> {
if !std::path::Path::new(path).exists() {
return Err(SignerError::Config(format!("key file {path} not found")));
}
let (keypair, _pubkey) = arch_sdk::with_secret_key_file(path)
.map_err(|e| SignerError::Config(format!("loading {path}: {e}")))?;
Ok(Self::new(keypair, network))
}
}
#[async_trait]
impl ArchSigner for LocalSigner {
fn pubkey(&self) -> Pubkey {
self.pubkey
}
async fn sign_message(&self, message: &ArchMessage) -> Result<[u8; 64], SignerError> {
arch_sdk::sign_message_bip322(&self.keypair, &message.hash(), self.network)
.map_err(|e| SignerError::Signing(e.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct SignResponse {
pub signature: [u8; 64],
pub arch_account_pubkey: [u8; 32],
pub digest_hex: String,
pub turnkey_activity_id: String,
}
#[derive(Clone)]
pub struct RemoteSigner {
http: reqwest::Client,
url: Arc<str>,
token: Arc<str>,
role: Arc<str>,
intent: Arc<str>,
pubkey: Pubkey,
network: bitcoin::Network,
retries: u32,
retry_backoff: Duration,
}
impl RemoteSigner {
pub fn new(
url: &str,
token: &str,
role: &str,
pubkey: Pubkey,
network: bitcoin::Network,
) -> Self {
Self {
http: reqwest::Client::builder()
.timeout(Duration::from_secs(35))
.build()
.expect("client construction with static config cannot fail"),
url: url.trim_end_matches('/').into(),
token: token.into(),
role: role.into(),
intent: "unlabeled".into(),
pubkey,
network,
retries: 2,
retry_backoff: Duration::from_millis(250),
}
}
pub fn with_intent(&self, intent: &str) -> Self {
let mut s = self.clone();
s.intent = intent.into();
s
}
pub fn with_retries(mut self, retries: u32) -> Self {
self.retries = retries;
self
}
pub async fn sign_detailed(&self, message: &ArchMessage) -> Result<SignResponse, SignerError> {
let b64 = base64::engine::general_purpose::STANDARD.encode(message.serialize());
self.sign_once(&b64, message).await
}
async fn sign_once(
&self,
message_b64: &str,
message: &ArchMessage,
) -> Result<SignResponse, SignerError> {
let resp = self
.http
.post(format!("{}/v1/sign", self.url))
.bearer_auth(self.token.as_ref())
.json(&serde_json::json!({
"role": self.role.as_ref(),
"intent_type": self.intent.as_ref(),
"unsigned_message_b64": message_b64,
}))
.send()
.await
.map_err(|e| {
if e.is_timeout() || e.is_connect() {
SignerError::Unreachable(e.to_string())
} else {
SignerError::Transient(e.to_string())
}
})?;
let status = resp.status().as_u16();
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let err_str = || {
body["error"]
.as_str()
.or(body["halted"].as_str())
.unwrap_or("<no detail>")
.to_string()
};
match status {
200 => {}
503 => return Err(SignerError::Halted(err_str())),
400 => return Err(SignerError::MalformedMessage(err_str())),
401 => return Err(SignerError::Unauthorized(err_str())),
403 if body["error"] == "role_mismatch" => {
return Err(SignerError::Unauthorized(err_str()))
}
403 => return Err(SignerError::Denied(err_str())),
502 => return Err(SignerError::Transient(err_str())),
other => return Err(SignerError::Transient(format!("http {other}: {body}"))),
}
let sig: [u8; 64] = hex::decode(body["signature_hex"].as_str().unwrap_or_default())
.ok()
.and_then(|v| v.try_into().ok())
.ok_or_else(|| SignerError::Verification("signature_hex not 64 bytes".into()))?;
if body["arch_account_pubkey"].as_str()
!= Some(hex::encode(self.pubkey.serialize()).as_str())
{
return Err(SignerError::Verification(format!(
"proxy signed with {} but this signer is configured for {}",
body["arch_account_pubkey"],
hex::encode(self.pubkey.serialize())
)));
}
arch_digest::verify_message_signature(
message,
&self.pubkey.serialize(),
&sig,
self.network,
)
.map_err(|e| SignerError::Verification(e.to_string()))?;
Ok(SignResponse {
signature: sig,
arch_account_pubkey: self.pubkey.serialize(),
digest_hex: body["digest_hex"].as_str().unwrap_or_default().to_string(),
turnkey_activity_id: body["turnkey_activity_id"]
.as_str()
.unwrap_or_default()
.to_string(),
})
}
}
#[async_trait]
impl ArchSigner for RemoteSigner {
fn pubkey(&self) -> Pubkey {
self.pubkey
}
async fn sign_message(&self, message: &ArchMessage) -> Result<[u8; 64], SignerError> {
let b64 = base64::engine::general_purpose::STANDARD.encode(message.serialize());
let mut last = None;
for attempt in 0..=self.retries {
if attempt > 0 {
tokio::time::sleep(self.retry_backoff * 2u32.pow(attempt - 1)).await;
}
match self.sign_once(&b64, message).await {
Ok(resp) => return Ok(resp.signature),
Err(SignerError::Transient(e)) => last = Some(SignerError::Transient(e)),
Err(other) => return Err(other),
}
}
Err(last.expect("the loop always runs at least one attempt"))
}
}
pub fn from_env() -> Result<Arc<dyn ArchSigner>, SignerError> {
let get = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
if let (Some(url), Some(token), Some(role)) = (
get("COSIGNER_URL"),
get("COSIGNER_TOKEN"),
get("COSIGNER_ROLE"),
) {
let pubkey_hex = get("COSIGNER_PUBKEY").ok_or_else(|| {
SignerError::Config(
"COSIGNER_PUBKEY (role's 32-byte Arch pubkey, hex) is required with COSIGNER_URL"
.into(),
)
})?;
let bytes: [u8; 32] = hex::decode(&pubkey_hex)
.map_err(|e| SignerError::Config(format!("COSIGNER_PUBKEY: {e}")))?
.try_into()
.map_err(|_| SignerError::Config("COSIGNER_PUBKEY must be 32 bytes".into()))?;
let network = parse_network(&get("COSIGNER_NETWORK").unwrap_or_else(|| "bitcoin".into()))?;
return Ok(Arc::new(RemoteSigner::new(
&url,
&token,
&role,
Pubkey::from_slice(&bytes),
network,
)));
}
if let Some(path) = get("ARCH_KEY_PATH") {
let network = parse_network(&get("ARCH_NETWORK").unwrap_or_else(|| "testnet".into()))?;
return Ok(Arc::new(LocalSigner::from_key_file(&path, network)?));
}
Err(SignerError::Config(
"set COSIGNER_URL/COSIGNER_TOKEN/COSIGNER_ROLE/COSIGNER_PUBKEY (remote) \
or ARCH_KEY_PATH (local)"
.into(),
))
}
pub fn parse_network(s: &str) -> Result<bitcoin::Network, SignerError> {
match s {
"bitcoin" | "mainnet" => Ok(bitcoin::Network::Bitcoin),
"testnet" => Ok(bitcoin::Network::Testnet),
"signet" => Ok(bitcoin::Network::Signet),
"regtest" => Ok(bitcoin::Network::Regtest),
other => Err(SignerError::Config(format!("unknown network {other:?}"))),
}
}
pub async fn sign_transaction(
signer: &dyn ArchSigner,
message: ArchMessage,
) -> Result<RuntimeTransaction, SignerError> {
sign_transaction_mixed(signer, message, &[]).await
}
pub async fn sign_transaction_mixed(
signer: &dyn ArchSigner,
message: ArchMessage,
local_cosigners: &[UntweakedKeypair],
) -> Result<RuntimeTransaction, SignerError> {
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 == signer.pubkey() {
signatures.push(Signature(signer.sign_message(&message).await?));
} 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| SignerError::Signing(e.to_string()))?;
signatures.push(Signature(sig));
} else {
return Err(SignerError::MissingSigner(hex::encode(key.serialize())));
}
}
Ok(RuntimeTransaction {
version: 0,
signatures,
message,
})
}