cosigner-client 0.4.0

Local and proxy-backed Arch Network signers for the arch-cosigner custody proxy
Documentation
//! Shared fixture: the in-process arch-cosigner proxy backed by mock Turnkey,
//! plus the signature check the validator runs.
//!
//! Zero live Turnkey signatures are spent.

// Compiled into each test binary separately, so a helper only one of them needs
// reads as dead code in the other.
#![allow(dead_code)]

use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;

use arch_program::sanitized::ArchMessage;
use arch_program::{pubkey::Pubkey, system_instruction};
use bitcoin::key::UntweakedKeypair;
use bitcoin::secp256k1::{Secp256k1, SecretKey, XOnlyPublicKey};

pub fn test_keypair(seed: u8) -> (UntweakedKeypair, Pubkey) {
    let secp = Secp256k1::new();
    let kp = UntweakedKeypair::from_secret_key(&secp, &SecretKey::from_slice(&[seed; 32]).unwrap());
    let pk = Pubkey::from_slice(&XOnlyPublicKey::from_keypair(&kp).0.serialize());
    (kp, pk)
}

/// The validator's signature check (tpu/processing.rs): DEFAULT then ALL.
pub fn verifies_under(digest: &[u8], key: &Pubkey, sig: [u8; 64]) -> bool {
    arch_sdk::verify_message_bip322(
        digest,
        key.serialize(),
        sig,
        false,
        bitcoin::Network::Bitcoin,
    )
    .is_ok()
        || arch_sdk::verify_message_bip322(
            digest,
            key.serialize(),
            sig,
            true,
            bitcoin::Network::Bitcoin,
        )
        .is_ok()
}

pub struct ProxyFixture {
    pub url: String,
    pub token: String,
    pub role_pubkey: Pubkey,
    pub mock: Arc<mock_turnkey::MockTurnkey>,
    /// Requests received by the proxy's router, for retry-count assertions.
    pub proxy_requests: Arc<AtomicU64>,
    /// While set, the fixture answers 503 in front of the proxy — an
    /// unavailable-proxy stand-in (an LB or reverse proxy answering for a
    /// down service).
    pub force_503: Arc<AtomicBool>,
    pub server: tokio::task::JoinHandle<()>,
    pub tmpdir: std::path::PathBuf,
}

impl ProxyFixture {
    pub fn proxy_requests(&self) -> u64 {
        self.proxy_requests.load(Ordering::Relaxed)
    }
}

impl Drop for ProxyFixture {
    fn drop(&mut self) {
        self.server.abort();
        let _ = std::fs::remove_dir_all(&self.tmpdir);
    }
}

/// Minimal arb-only proxy over an in-process mock Turnkey.
/// `policy_allows_role`: false simulates a Turnkey-side policy denial.
pub async fn proxy_fixture(policy_allows_role: bool) -> ProxyFixture {
    use arch_cosigner::authentication::token_hash;
    use axum::response::IntoResponse;

    let tmpdir = std::env::temp_dir().join(format!(
        "sigeq-{}-{}",
        std::process::id(),
        rand::random::<u32>()
    ));
    std::fs::create_dir_all(&tmpdir).unwrap();

    let api_key = turnkey_api_key_stamper::TurnkeyP256ApiKey::generate();
    let stamper_pub = hex::encode(api_key.compressed_public_key());
    let cred = tmpdir.join("arb.stamper.json");
    std::fs::write(
        &cred,
        format!(
            r#"{{"publicKey":"{stamper_pub}","privateKey":"{}"}}"#,
            hex::encode(api_key.private_key())
        ),
    )
    .unwrap();

    // policy_allows_role == false: the role's key allows a different stamper,
    // while a dummy second key keeps this stamper in the org's known-user set —
    // so the sign attempt yields a 403 policy denial, not a 401
    // unknown-credential.
    let other_stamper =
        hex::encode(turnkey_api_key_stamper::TurnkeyP256ApiKey::generate().compressed_public_key());
    let allowed = if policy_allows_role {
        vec![stamper_pub.clone()]
    } else {
        vec![other_stamper]
    };
    let (mock_key, xonly) = mock_turnkey::fresh_role_key(None, bitcoin::Network::Bitcoin, allowed);
    let address = mock_key.address.clone();
    let mut keys = vec![mock_key];
    if !policy_allows_role {
        let (dummy, _) = mock_turnkey::fresh_role_key(
            None,
            bitcoin::Network::Bitcoin,
            vec![stamper_pub.clone()],
        );
        keys.push(dummy);
    }
    let mock = mock_turnkey::MockTurnkey::new("test-org", keys);
    let (mock_url, _h) = mock.spawn().await;

    let token = format!("arb-{}", rand::random::<u64>());
    let toml = format!(
        r#"
listen_addr = "127.0.0.1:0"
network = "bitcoin"
audit_log_path = "{audit}"

[turnkey]
base_url = "{mock_url}"
timeout_ms = 3000
retry_attempts = 0
retry_backoff_ms = 10

[roles.arb]
organization_id = "test-org"
sign_with = "{address}"
arch_pubkey_hex = "{pk}"
token_hashes = ["{th}"]
stamper = "file:{cred}"
"#,
        audit = tmpdir.join("audit.jsonl").display(),
        pk = hex::encode(xonly.serialize()),
        th = hex::encode(token_hash(&token)),
        cred = cred.display(),
    );
    let state =
        arch_cosigner::AppState::build(arch_cosigner::Config::from_toml(&toml).unwrap()).unwrap();

    let proxy_requests = Arc::new(AtomicU64::new(0));
    let count_layer = {
        let proxy_requests = Arc::clone(&proxy_requests);
        axum::middleware::from_fn(
            move |req: axum::extract::Request, next: axum::middleware::Next| {
                let proxy_requests = Arc::clone(&proxy_requests);
                async move {
                    proxy_requests.fetch_add(1, Ordering::Relaxed);
                    next.run(req).await
                }
            },
        )
    };
    let force_503 = Arc::new(AtomicBool::new(false));
    let unavailable_layer = {
        let force_503 = Arc::clone(&force_503);
        axum::middleware::from_fn(
            move |req: axum::extract::Request, next: axum::middleware::Next| {
                let force_503 = Arc::clone(&force_503);
                async move {
                    if force_503.load(Ordering::Relaxed) {
                        return (
                            axum::http::StatusCode::SERVICE_UNAVAILABLE,
                            axum::Json(serde_json::json!({ "error": "service_unavailable" })),
                        )
                            .into_response();
                    }
                    next.run(req).await
                }
            },
        )
    };
    // Layer order: the request counter wraps the 503 gate, so even rejected
    // requests are counted for the retry assertions.
    let app = arch_cosigner::router(Arc::clone(&state))
        .layer(unavailable_layer)
        .layer(count_layer);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

    ProxyFixture {
        url: format!("http://{addr}"),
        token,
        role_pubkey: Pubkey::from_slice(&xonly.serialize()),
        mock,
        proxy_requests,
        force_503,
        server,
        tmpdir,
    }
}

pub fn transfer_message(from: Pubkey, seed: u8) -> ArchMessage {
    let (_k, dest) = test_keypair(seed);
    let ix = system_instruction::transfer(&from, &dest, 9000);
    ArchMessage::new(&[ix], Some(from), arch_program::hash::Hash::from([7; 32]))
}