polynode 0.13.0

Rust SDK for the PolyNode API — real-time Polymarket data
Documentation
//! Polymarket builder-relayer client (Rust port).
//!
//! Ports the minimum needed from @polymarket/builder-relayer-client +
//! @polymarket/builder-signing-sdk to submit gasless Safe transactions.

use std::time::Duration;

use alloy_primitives::{keccak256, Address, B256, U256};
use base64::prelude::*;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Sha256;

use super::constants::{RELAYER_HOST, SAFE_MULTISEND, CHAIN_ID};
use super::onboarding::derive_safe_address;
use super::signer::TradingSigner;
use super::types::BuilderCredentials;
use crate::error::{Error, Result};

type HmacSha256 = Hmac<Sha256>;

const ZERO_ADDR: &str = "0x0000000000000000000000000000000000000000";

/// A single Safe sub-transaction.
#[derive(Debug, Clone)]
pub struct SafeSubTx {
    pub to: Address,
    pub value: U256,
    pub data: Vec<u8>,
    pub operation: u8, // 0=Call, 1=DelegateCall
}

// ── EIP-712 ──

fn safe_tx_type_hash() -> B256 {
    keccak256(
        b"SafeTx(address to,uint256 value,bytes data,uint8 operation,\
        uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,\
        address refundReceiver,uint256 nonce)",
    )
}

fn domain_type_hash() -> B256 {
    keccak256(b"EIP712Domain(uint256 chainId,address verifyingContract)")
}

fn encode_u256(n: U256) -> [u8; 32] {
    n.to_be_bytes::<32>()
}

fn encode_address(a: Address) -> [u8; 32] {
    let mut buf = [0u8; 32];
    buf[12..].copy_from_slice(a.as_slice());
    buf
}

fn domain_separator(chain_id: u64, verifying: Address) -> B256 {
    let mut enc = Vec::with_capacity(96);
    enc.extend_from_slice(domain_type_hash().as_slice());
    enc.extend_from_slice(&encode_u256(U256::from(chain_id)));
    enc.extend_from_slice(&encode_address(verifying));
    keccak256(&enc)
}

fn safe_tx_struct_hash(
    to: Address, value: U256, data: &[u8], operation: u8,
    safe_tx_gas: U256, base_gas: U256, gas_price: U256,
    gas_token: Address, refund_receiver: Address, nonce: U256,
) -> B256 {
    let data_hash = keccak256(data);
    let mut enc = Vec::with_capacity(384);
    enc.extend_from_slice(safe_tx_type_hash().as_slice());
    enc.extend_from_slice(&encode_address(to));
    enc.extend_from_slice(&encode_u256(value));
    enc.extend_from_slice(data_hash.as_slice());
    enc.extend_from_slice(&encode_u256(U256::from(operation)));
    enc.extend_from_slice(&encode_u256(safe_tx_gas));
    enc.extend_from_slice(&encode_u256(base_gas));
    enc.extend_from_slice(&encode_u256(gas_price));
    enc.extend_from_slice(&encode_address(gas_token));
    enc.extend_from_slice(&encode_address(refund_receiver));
    enc.extend_from_slice(&encode_u256(nonce));
    keccak256(&enc)
}

fn eip712_digest(chain_id: u64, safe: Address, struct_hash: B256) -> B256 {
    let mut enc = Vec::with_capacity(66);
    enc.extend_from_slice(&[0x19, 0x01]);
    enc.extend_from_slice(domain_separator(chain_id, safe).as_slice());
    enc.extend_from_slice(struct_hash.as_slice());
    keccak256(&enc)
}

// ── Safe multisend encoding ──

fn encode_multisend_calldata(txns: &[SafeSubTx]) -> Vec<u8> {
    // multiSend(bytes) selector
    let selector = [0x8d, 0x80, 0xff, 0x0a];
    // Inner bytes: concat(uint8 op || address to || uint256 value || uint256 dataLen || bytes data)
    let mut inner = Vec::new();
    for tx in txns {
        inner.push(tx.operation);
        inner.extend_from_slice(tx.to.as_slice());
        inner.extend_from_slice(&encode_u256(tx.value));
        inner.extend_from_slice(&encode_u256(U256::from(tx.data.len() as u64)));
        inner.extend_from_slice(&tx.data);
    }
    // ABI-encode single `bytes` arg: offset(32) + length(32) + padded data
    let pad = (32 - inner.len() % 32) % 32;
    let mut out = Vec::with_capacity(4 + 64 + inner.len() + pad);
    out.extend_from_slice(&selector);
    out.extend_from_slice(&encode_u256(U256::from(32)));
    out.extend_from_slice(&encode_u256(U256::from(inner.len() as u64)));
    out.extend_from_slice(&inner);
    out.extend_from_slice(&vec![0u8; pad]);
    out
}

// ── Builder HMAC ──

fn build_hmac_headers(
    creds: &BuilderCredentials, method: &str, path: &str, body: &str,
) -> Result<Vec<(String, String)>> {
    use std::time::{SystemTime, UNIX_EPOCH};
    let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
    let msg = format!("{}{}{}{}", ts, method, path, body);
    let secret_norm = creds.secret.replace('-', "+").replace('_', "/");
    let key = BASE64_STANDARD.decode(&secret_norm)
        .map_err(|e| Error::Trading(format!("bad builder secret: {}", e)))?;
    let mut mac = HmacSha256::new_from_slice(&key)
        .map_err(|e| Error::Trading(format!("hmac init: {}", e)))?;
    mac.update(msg.as_bytes());
    let sig_bytes = mac.finalize().into_bytes();
    let sig = BASE64_STANDARD.encode(&sig_bytes).replace('+', "-").replace('/', "_");
    Ok(vec![
        ("POLY_BUILDER_API_KEY".into(), creds.key.clone()),
        ("POLY_BUILDER_PASSPHRASE".into(), creds.passphrase.clone()),
        ("POLY_BUILDER_SIGNATURE".into(), sig),
        ("POLY_BUILDER_TIMESTAMP".into(), ts.to_string()),
    ])
}

// ── Signature packing ──

fn pack_sig(sig_bytes: &[u8]) -> Result<String> {
    if sig_bytes.len() != 65 {
        return Err(Error::Trading(format!("expected 65-byte sig, got {}", sig_bytes.len())));
    }
    let mut v = sig_bytes[64];
    v = match v {
        0 | 1 => v + 31,
        27 | 28 => v + 4,
        _ => return Err(Error::Trading(format!("invalid sig v: {}", v))),
    };
    let mut out = Vec::with_capacity(65);
    out.extend_from_slice(&sig_bytes[..64]);
    out.push(v);
    Ok(format!("0x{}", hex::encode(&out)))
}

// ── RelayClient ──

#[derive(Deserialize)]
struct NonceResp {
    nonce: String,
}

#[derive(Deserialize)]
struct SubmitResp {
    #[serde(rename = "transactionID")]
    transaction_id: String,
}

#[derive(Deserialize)]
struct TxStatus {
    state: Option<String>,
    #[serde(rename = "transactionHash")]
    transaction_hash: Option<String>,
}

pub struct RelayClient {
    relayer_url: String,
    chain_id: u64,
    creds: BuilderCredentials,
    http: reqwest::Client,
}

impl RelayClient {
    pub fn new(creds: BuilderCredentials) -> Self {
        Self {
            relayer_url: RELAYER_HOST.trim_end_matches('/').to_string(),
            chain_id: CHAIN_ID as u64,
            creds,
            http: reqwest::Client::new(),
        }
    }

    /// Build, sign, submit, and wait for a batch of Safe transactions.
    /// Returns the on-chain transaction hash.
    pub async fn execute_safe(
        &self,
        signer: &dyn TradingSigner,
        txns: Vec<SafeSubTx>,
    ) -> Result<String> {
        let eoa_address = signer.address();

        if txns.is_empty() {
            return Err(Error::Trading("no transactions to execute".into()));
        }

        let safe_addr = derive_safe_address(eoa_address);

        // 1. GET /nonce
        let resp = self
            .http
            .get(format!("{}/nonce", self.relayer_url))
            .query(&[("address", format!("{:?}", eoa_address)), ("type", "SAFE".into())])
            .send()
            .await
            .map_err(|e| Error::Trading(format!("relayer /nonce: {}", e)))?;
        if !resp.status().is_success() {
            return Err(Error::Trading(format!("relayer /nonce status {}", resp.status())));
        }
        let nonce_resp: NonceResp = resp.json().await
            .map_err(|e| Error::Trading(format!("relayer /nonce parse: {}", e)))?;
        let nonce: u64 = nonce_resp.nonce.parse().unwrap_or(0);

        // 2. Aggregate into single tx or multisend
        let (to_addr, data_bytes, operation) = if txns.len() == 1 {
            let t = &txns[0];
            (t.to, t.data.clone(), t.operation)
        } else {
            let multisend_addr: Address = SAFE_MULTISEND.parse().unwrap();
            (multisend_addr, encode_multisend_calldata(&txns), 1u8)
        };

        // 3. Compute EIP-712 digest
        let zero: Address = ZERO_ADDR.parse().unwrap();
        let struct_hash = safe_tx_struct_hash(
            to_addr, U256::ZERO, &data_bytes, operation,
            U256::ZERO, U256::ZERO, U256::ZERO,
            zero, zero, U256::from(nonce),
        );
        let digest = eip712_digest(self.chain_id, safe_addr, struct_hash);

        // 4. EIP-191 personal_sign on the digest
        let signed = signer.sign_message(digest.as_slice()).await?;
        let packed_sig = pack_sig(&signed)?;

        // 5. Build submit payload
        let req = json!({
            "from": format!("{:?}", eoa_address),
            "to": format!("{:?}", to_addr),
            "proxyWallet": format!("{:?}", safe_addr),
            "data": format!("0x{}", hex::encode(&data_bytes)),
            "nonce": nonce.to_string(),
            "signature": packed_sig,
            "signatureParams": {
                "gasPrice": "0",
                "operation": operation.to_string(),
                "safeTxnGas": "0",
                "baseGas": "0",
                "gasToken": ZERO_ADDR,
                "refundReceiver": ZERO_ADDR,
            },
            "type": "SAFE",
            "metadata": "",
        });
        let body = serde_json::to_string(&req).unwrap();

        // 6. POST /submit with builder HMAC
        let headers = build_hmac_headers(&self.creds, "POST", "/submit", &body)?;
        let mut req_builder = self.http
            .post(format!("{}/submit", self.relayer_url))
            .header("Content-Type", "application/json");
        for (k, v) in headers {
            req_builder = req_builder.header(k, v);
        }
        let resp = req_builder.body(body).send().await
            .map_err(|e| Error::Trading(format!("relayer /submit: {}", e)))?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(Error::Trading(format!("relayer /submit status {}: {}", status, text)));
        }
        let submit: SubmitResp = resp.json().await
            .map_err(|e| Error::Trading(format!("relayer /submit parse: {}", e)))?;

        // 7. Poll until mined
        for _ in 0..30 {
            tokio::time::sleep(Duration::from_secs(2)).await;
            let resp = self
                .http
                .get(format!("{}/transaction", self.relayer_url))
                .query(&[("id", &submit.transaction_id)])
                .send()
                .await;
            let Ok(resp) = resp else { continue };
            if !resp.status().is_success() { continue }
            let rows: serde_json::Value = match resp.json().await {
                Ok(v) => v,
                Err(_) => continue,
            };
            let row = if rows.is_array() {
                rows.get(0).cloned()
            } else {
                Some(rows)
            };
            let Some(row) = row else { continue };
            let row: TxStatus = match serde_json::from_value(row) {
                Ok(v) => v,
                Err(_) => continue,
            };
            let state = row.state.as_deref().unwrap_or("");
            if state == "STATE_MINED" || state == "STATE_CONFIRMED" {
                return row.transaction_hash.ok_or_else(|| Error::Trading("no tx hash".into()));
            }
            if state == "STATE_FAILED" || state == "STATE_REVERTED" {
                return Err(Error::Trading(format!(
                    "tx {} failed: state={} hash={:?}",
                    submit.transaction_id, state, row.transaction_hash
                )));
            }
        }
        Err(Error::Trading(format!("tx {} timed out", submit.transaction_id)))
    }
}