acta-maker-sdk 0.8.2

Rust SDK for the Acta options RFQ venue on Solana: maker and vault-operator WebSocket clients, order signing, chain builders
Documentation
use crate::SigningError;

const AUTH_PREFIX: &str =
    "Acta RFQ Authentication\n\nSign this message to authenticate your wallet.\n\n";

pub fn validate_maker_auth_challenge(challenge: &str) -> Result<(), SigningError> {
    if challenge
        .strip_prefix(AUTH_PREFIX)
        .is_some_and(valid_nonce_and_time)
    {
        Ok(())
    } else {
        Err(SigningError::new("invalid maker authentication challenge"))
    }
}

/// The challenge a `StartAuth { pubkey: wallet }` session (e.g. `/taker`) receives.
pub fn validate_wallet_auth_challenge(challenge: &str, wallet: &str) -> Result<(), SigningError> {
    let body = challenge
        .strip_prefix(AUTH_PREFIX)
        .and_then(|s| s.strip_prefix("Wallet: "))
        .and_then(|s| s.strip_prefix(wallet))
        .and_then(|s| s.strip_prefix('\n'));
    if crate::wire::decode_base58_32(wallet).is_ok() && body.is_some_and(valid_nonce_and_time) {
        Ok(())
    } else {
        Err(SigningError::new("invalid wallet authentication challenge"))
    }
}

/// Validate before handing the challenge to any signer.
pub fn validate_vault_auth_challenge(
    challenge: &str,
    operator: &crate::Address32,
    vault: &crate::Address32,
) -> Result<(), SigningError> {
    let invalid = || SigningError::new("invalid vault authentication challenge");
    let operator = operator.to_base58();
    let vault = vault.to_base58();
    let body = challenge
        .strip_prefix(AUTH_PREFIX)
        .and_then(|s| s.strip_prefix("Wallet: "))
        .and_then(|s| s.strip_prefix(operator.as_str()))
        .and_then(|s| s.strip_prefix('\n'))
        .and_then(|s| s.strip_suffix(vault.as_str()))
        .and_then(|s| s.strip_suffix("\nVault: "))
        .ok_or_else(invalid)?;
    if !valid_nonce_and_time(body) {
        return Err(invalid());
    }
    Ok(())
}

fn valid_nonce_and_time(body: &str) -> bool {
    let Some((nonce, timestamp)) = body
        .strip_prefix("Nonce: ")
        .and_then(|s| s.split_once("\nIssued At: "))
    else {
        return false;
    };
    nonce.len() == 64
        && nonce
            .bytes()
            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
        && timestamp.strip_suffix('\n').is_some_and(is_utc_seconds)
}

fn is_utc_seconds(timestamp: &str) -> bool {
    let b = timestamp.as_bytes();
    if b.len() != 20
        || !b.iter().enumerate().all(|(i, byte)| match i {
            4 | 7 => *byte == b'-',
            10 => *byte == b'T',
            13 | 16 => *byte == b':',
            19 => *byte == b'Z',
            _ => byte.is_ascii_digit(),
        })
    {
        return false;
    }
    let pair = |i: usize| (b[i] - b'0') * 10 + b[i + 1] - b'0';
    let year = u16::from(pair(0)) * 100 + u16::from(pair(2));
    let days = match pair(5) {
        4 | 6 | 9 | 11 => 30,
        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
        2 => 28,
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        _ => return false,
    };
    (1..=days).contains(&pair(8)) && pair(11) < 24 && pair(14) < 60 && pair(17) < 60
}

#[cfg(test)]
pub(crate) const TEST_MAKER_AUTH_CHALLENGE: &str = concat!(
    "Acta RFQ Authentication\n\nSign this message to authenticate your wallet.\n\n",
    "Nonce: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n",
    "Issued At: 2026-09-15T00:00:00Z\n"
);

#[cfg(all(test, feature = "vault"))]
pub(crate) fn test_vault_auth_challenge(operator: &str, vault: &str) -> String {
    let challenge =
        TEST_MAKER_AUTH_CHALLENGE.replace("Nonce: ", &format!("Wallet: {operator}\nNonce: "));
    format!("{challenge}\nVault: {vault}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canonical_maker_challenge_is_accepted() {
        validate_maker_auth_challenge(TEST_MAKER_AUTH_CHALLENGE).expect("canonical");
    }

    #[test]
    fn rejects_noncanonical_or_cross_domain_messages() {
        let canonical = TEST_MAKER_AUTH_CHALLENGE;
        for message in [
            "x".repeat(32),
            canonical.replace("Acta RFQ Authentication", "Acta Admin Authentication"),
            canonical.replace("Nonce: ", "Wallet: owner\nNonce: "),
            canonical.replace("Nonce: ", "Nonce: a"),
            canonical.replace("abcdef", "ABCDEF"),
            canonical.replace('\n', "\r\n"),
            canonical.replace("2026-09-15", "2026-02-30"),
            canonical.replace("00:00:00Z", "00:00:00.000Z"),
            canonical.replace("00:00:00Z", "24:00:00Z"),
            canonical.replace("00:00:00Z", "00:00:00+00:00"),
            canonical.trim_end().to_owned(),
            format!("{canonical}extra\n"),
        ] {
            assert!(
                validate_maker_auth_challenge(&message).is_err(),
                "{message:?}"
            );
        }
    }

    #[test]
    fn wallet_challenge_binds_the_wallet() {
        let wallet = crate::wire::encode_base58(&[7; 32]);
        let challenge =
            TEST_MAKER_AUTH_CHALLENGE.replace("Nonce: ", &format!("Wallet: {wallet}\nNonce: "));
        validate_wallet_auth_challenge(&challenge, &wallet).expect("wallet challenge");
        let other = crate::wire::encode_base58(&[8; 32]);
        for (message, wallet) in [
            (challenge.as_str(), other.as_str()),
            (TEST_MAKER_AUTH_CHALLENGE, wallet.as_str()),
            (challenge.as_str(), "not-a-key"),
        ] {
            assert!(validate_wallet_auth_challenge(message, wallet).is_err());
        }
    }

    proptest::proptest! {
        #[test]
        fn ascii_order_sized_messages_are_rejected(bytes in proptest::array::uniform32(0u8..=127)) {
            let message = std::str::from_utf8(&bytes).expect("ASCII is UTF-8");
            proptest::prop_assert!(validate_maker_auth_challenge(message).is_err());
        }
    }
}