cosigner-client 0.4.0

Local and proxy-backed Arch Network signers for the arch-cosigner custody proxy
Documentation
//! Environment-based signer resolution shared by [`ArchSigner::from_env`] and
//! [`ArchSigner::from_prefixed_env`].

use arch_program::pubkey::Pubkey;

use crate::{ArchSigner, LocalSigner, RemoteSigner, SignError};

const URL: &str = "COSIGNER_URL";
const TOKEN: &str = "COSIGNER_TOKEN";
const ROLE: &str = "COSIGNER_ROLE";
const PUBKEY: &str = "COSIGNER_PUBKEY";
const KEY_PATH: &str = "ARCH_KEY_PATH";

enum Backend {
    Remote,
    Local,
}

/// Variable lookups for one prefix, treating empty values as unset.
struct Env<'a> {
    prefix: &'a str,
}

impl Env<'_> {
    fn prefixed_name(&self, base: &str) -> Option<String> {
        (!self.prefix.is_empty()).then(|| format!("{}_{base}", self.prefix))
    }

    fn prefixed(&self, base: &str) -> Option<String> {
        self.prefixed_name(base).and_then(|name| get(&name))
    }

    fn bare(&self, base: &str) -> Option<String> {
        get(base)
    }

    /// Per-variable fill: the prefixed value first, then the bare value.
    fn value(&self, base: &str) -> Option<String> {
        self.prefixed(base).or_else(|| self.bare(base))
    }

    /// Names the variable for error messages, covering both levels.
    fn describe(&self, base: &str) -> String {
        match self.prefixed_name(base) {
            Some(prefixed) => format!("{prefixed} (or {base})"),
            None => base.to_string(),
        }
    }
}

fn get(name: &str) -> Option<String> {
    std::env::var(name).ok().filter(|v| !v.is_empty())
}

/// Resolves a signer for `prefix` per the rules on
/// [`ArchSigner::from_prefixed_env`].
pub(crate) fn resolve(prefix: &str) -> Result<ArchSigner, SignError> {
    // "ORACLE_" means "ORACLE": the separator is implied by the scheme.
    let env = Env {
        prefix: prefix.trim_end_matches('_'),
    };
    match choose_backend(&env)? {
        Backend::Remote => build_remote(&env),
        Backend::Local => {
            let path = env
                .value(KEY_PATH)
                .expect("a local backend choice implies a key path");
            Ok(ArchSigner::Local(LocalSigner::from_key_file(&path)?))
        }
    }
}

/// Chooses remote vs local at the most specific level that sets a
/// backend-selecting variable.
fn choose_backend(env: &Env) -> Result<Backend, SignError> {
    if !env.prefix.is_empty() {
        match (
            env.prefixed(URL).is_some(),
            env.prefixed(KEY_PATH).is_some(),
        ) {
            (true, true) => {
                return Err(SignError::Config(format!(
                    "both {0}_{URL} and {0}_{KEY_PATH} are set; configure exactly one backend",
                    env.prefix
                )))
            }
            (true, false) => return Ok(Backend::Remote),
            (false, true) => return Ok(Backend::Local),
            (false, false) => {}
        }
    }
    match (env.bare(URL).is_some(), env.bare(KEY_PATH).is_some()) {
        (true, true) => Err(SignError::Config(format!(
            "both {URL} and {KEY_PATH} are set; configure exactly one backend"
        ))),
        (true, false) => Ok(Backend::Remote),
        (false, true) => Ok(Backend::Local),
        (false, false) => Err(SignError::Config(format!(
            "no signer configured: set {} (remote) or {} (local)",
            env.describe(URL),
            env.describe(KEY_PATH)
        ))),
    }
}

fn build_remote(env: &Env) -> Result<ArchSigner, SignError> {
    let url = env
        .value(URL)
        .expect("a remote backend choice implies a URL");

    let mut missing = Vec::new();
    let mut want = |base: &str| {
        let value = env.value(base);
        if value.is_none() {
            missing.push(env.describe(base));
        }
        value
    };
    let token = want(TOKEN);
    let role = want(ROLE);
    let pubkey_hex = want(PUBKEY);

    let (Some(token), Some(role), Some(pubkey_hex)) = (token, role, pubkey_hex) else {
        return Err(SignError::Config(format!(
            "remote signer needs {}",
            missing.join(", ")
        )));
    };

    let bytes: [u8; 32] = hex::decode(&pubkey_hex)
        .ok()
        .and_then(|v| v.try_into().ok())
        .ok_or_else(|| {
            SignError::Config(format!(
                "{} must be 64 hex characters",
                env.describe(PUBKEY)
            ))
        })?;

    Ok(ArchSigner::Remote(RemoteSigner::new(
        &url,
        &token,
        &role,
        Pubkey::from_slice(&bytes),
    )))
}