polynode 0.13.5

Rust SDK for the PolyNode API — real-time Polymarket data
Documentation
//! Privy server-side wallet integration.
//!
//! Creates signers from Privy's HTTP API for headless order signing.
//! No native Privy SDK needed — pure HTTP calls.
//!
//! Requires the `privy` feature flag.

use alloy_primitives::Address;
use async_trait::async_trait;

use crate::error::{Error, Result};
use super::signer::TradingSigner;
use super::types::Eip712Payload;

/// Configuration for Privy server-auth.
#[derive(Debug, Clone)]
pub struct PrivyConfig {
    pub app_id: String,
    pub app_secret: String,
    pub authorization_key: String,
}

impl PrivyConfig {
    /// Load from environment variables: PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_AUTHORIZATION_KEY.
    pub fn from_env() -> Result<Self> {
        Ok(Self {
            app_id: std::env::var("PRIVY_APP_ID")
                .map_err(|_| Error::Trading("Missing PRIVY_APP_ID".into()))?,
            app_secret: std::env::var("PRIVY_APP_SECRET")
                .map_err(|_| Error::Trading("Missing PRIVY_APP_SECRET".into()))?,
            authorization_key: std::env::var("PRIVY_AUTHORIZATION_KEY")
                .map_err(|_| Error::Trading("Missing PRIVY_AUTHORIZATION_KEY".into()))?,
        })
    }
}

/// Privy-backed signer that calls Privy's wallet API over HTTP.
pub struct PrivySigner {
    client: reqwest::Client,
    config: PrivyConfig,
    wallet_id: String,
    wallet_address: Address,
}

impl PrivySigner {
    pub fn new(config: PrivyConfig, wallet_id: String, wallet_address: Address) -> Self {
        Self {
            client: reqwest::Client::new(),
            config,
            wallet_id,
            wallet_address,
        }
    }

    /// Create from environment variables.
    pub fn from_env(wallet_id: String, wallet_address: Address) -> Result<Self> {
        Ok(Self::new(PrivyConfig::from_env()?, wallet_id, wallet_address))
    }

    fn auth_headers(&self) -> reqwest::header::HeaderMap {
        let mut headers = reqwest::header::HeaderMap::new();
        let basic = base64::Engine::encode(
            &base64::engine::general_purpose::STANDARD,
            format!("{}:{}", self.config.app_id, self.config.app_secret),
        );
        headers.insert("Authorization", format!("Basic {}", basic).parse().unwrap());
        headers.insert("privy-app-id", self.config.app_id.parse().unwrap());
        headers.insert("Content-Type", "application/json".parse().unwrap());
        headers
    }
}

#[async_trait]
impl TradingSigner for PrivySigner {
    fn address(&self) -> Address {
        self.wallet_address
    }

    async fn sign_typed_data(&self, payload: &Eip712Payload) -> Result<Vec<u8>> {
        let types = payload.types.clone();

        let body = serde_json::json!({
            "address": format!("{}", self.wallet_address),
            "method": "eth_signTypedData_v4",
            "params": {
                "typed_data": {
                    "domain": payload.domain,
                    "types": types,
                    "primary_type": payload.primary_type,
                    "message": payload.message,
                }
            }
        });

        let resp = self.client
            .post(format!(
                "https://api.privy.io/v1/wallets/{}/rpc",
                self.wallet_id
            ))
            .headers(self.auth_headers())
            .json(&body)
            .send()
            .await?;

        if !resp.status().is_success() {
            let err = resp.text().await.unwrap_or_default();
            return Err(Error::Signing(format!("Privy signTypedData failed: {}", err)));
        }

        let data: serde_json::Value = resp.json().await?;
        extract_privy_signature(&data)
    }

    async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>> {
        let body = serde_json::json!({
            "address": format!("{}", self.wallet_address),
            "method": "personal_sign",
            "params": {
                "message": format!("0x{}", hex::encode(message)),
                "encoding": "hex",
            }
        });

        let resp = self.client
            .post(format!(
                "https://api.privy.io/v1/wallets/{}/rpc",
                self.wallet_id
            ))
            .headers(self.auth_headers())
            .json(&body)
            .send()
            .await?;

        if !resp.status().is_success() {
            let err = resp.text().await.unwrap_or_default();
            return Err(Error::Signing(format!("Privy signMessage failed: {}", err)));
        }

        let data: serde_json::Value = resp.json().await?;
        extract_privy_signature(&data)
    }
}

/// Extract signature from Privy RPC response.
/// Privy can return it as `data`, `signature`, or nested under `data.signature`.
fn extract_privy_signature(data: &serde_json::Value) -> Result<Vec<u8>> {
    let sig_hex = data.get("data")
        .and_then(|d| {
            // If data is a string, it's the signature directly
            d.as_str().map(|s| s.to_string())
                // If data is an object, look for signature inside
                .or_else(|| d.get("signature").and_then(|v| v.as_str()).map(|s| s.to_string()))
        })
        .or_else(|| data.get("signature").and_then(|v| v.as_str()).map(|s| s.to_string()))
        .ok_or_else(|| Error::Signing(format!("No signature in Privy response: {}", data)))?;

    let mut sig_bytes = hex::decode(sig_hex.strip_prefix("0x").unwrap_or(&sig_hex))
        .map_err(|e| Error::Signing(format!("Invalid signature hex: {}", e)))?;
    // Normalize v to raw recovery id (0/1): CLOB expects v=0/1, not Ethereum's 27/28
    if sig_bytes.len() == 65 && sig_bytes[64] >= 27 {
        sig_bytes[64] -= 27;
    }
    Ok(sig_bytes)
}