use std::time::Duration;
use alloy_primitives::{keccak256, Address, B256, U256};
use base64::prelude::*;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::json;
use sha2::Sha256;
use super::constants::{CHAIN_ID, CTF, DEPOSIT_WALLET_FACTORY, RELAYER_HOST, SAFE_MULTISEND};
use super::constants_v2;
use super::onboarding::derive_safe_address;
use super::signer::TradingSigner;
use super::types::BuilderCredentials;
use super::types::Eip712Payload;
use crate::error::{Error, Result};
type HmacSha256 = Hmac<Sha256>;
const ZERO_ADDR: &str = "0x0000000000000000000000000000000000000000";
#[derive(Debug, Clone)]
pub struct SafeSubTx {
pub to: Address,
pub value: U256,
pub data: Vec<u8>,
pub operation: u8, }
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)
}
fn encode_multisend_calldata(txns: &[SafeSubTx]) -> Vec<u8> {
let selector = [0x8d, 0x80, 0xff, 0x0a];
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);
}
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
}
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()),
])
}
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)))
}
fn eth_sig_hex(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 out = sig_bytes.to_vec();
out[64] = match out[64] {
0 | 1 => out[64] + 27,
27 | 28 => out[64],
v => return Err(Error::Trading(format!("invalid sig v: {}", v))),
};
Ok(format!("0x{}", hex::encode(out)))
}
#[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(),
}
}
pub async fn execute_safe(
&self,
signer: &dyn TradingSigner,
txns: Vec<SafeSubTx>,
) -> Result<String> {
let eoa_address = signer.address();
let safe_addr = derive_safe_address(eoa_address);
self.execute_safe_for(signer, safe_addr, txns).await
}
pub async fn execute_safe_for(
&self,
signer: &dyn TradingSigner,
safe_addr: Address,
txns: Vec<SafeSubTx>,
) -> Result<String> {
let eoa_address = signer.address();
if txns.is_empty() {
return Err(Error::Trading("no transactions to execute".into()));
}
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);
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)
};
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);
let signed = signer.sign_message(digest.as_slice()).await?;
let packed_sig = pack_sig(&signed)?;
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();
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)))?;
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
)))
}
async fn poll_tx(&self, tx_id: &str) -> Result<String> {
for _ in 0..45 {
tokio::time::sleep(Duration::from_secs(2)).await;
let resp = self
.http
.get(format!("{}/transaction", self.relayer_url))
.query(&[("id", tx_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: {}", tx_id, state)));
}
}
Err(Error::Trading(format!("tx {} timed out", tx_id)))
}
async fn submit_with_auth_no_wait(&self, body: &serde_json::Value) -> Result<String> {
let body_str = serde_json::to_string(body).unwrap();
let headers = build_hmac_headers(&self.creds, "POST", "/submit", &body_str)?;
let mut req = self
.http
.post(format!("{}/submit", self.relayer_url))
.header("Content-Type", "application/json");
for (k, v) in headers {
req = req.header(k, v);
}
let resp = req
.body(body_str)
.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, text
)));
}
let submit: SubmitResp = resp
.json()
.await
.map_err(|e| Error::Trading(format!("relayer /submit parse: {}", e)))?;
Ok(submit.transaction_id)
}
pub async fn submit_deposit_wallet_create(&self, signer: &dyn TradingSigner) -> Result<String> {
let eoa = signer.address();
let factory: Address = DEPOSIT_WALLET_FACTORY.parse().unwrap();
let body = json!({
"type": "WALLET-CREATE",
"from": format!("{:?}", eoa),
"to": format!("{:?}", factory),
});
self.submit_with_auth_no_wait(&body).await
}
pub async fn deploy_deposit_wallet(&self, signer: &dyn TradingSigner) -> Result<String> {
let tx_id = self.submit_deposit_wallet_create(signer).await?;
self.poll_tx(&tx_id).await
}
async fn submit_deposit_wallet_call_values(
&self,
signer: &dyn TradingSigner,
wallet_address: Address,
calls: Vec<serde_json::Value>,
) -> Result<String> {
if calls.is_empty() {
return Err(Error::Trading("no deposit wallet calls to execute".into()));
}
let eoa = signer.address();
let factory: Address = DEPOSIT_WALLET_FACTORY.parse().unwrap();
let resp = self
.http
.get(format!("{}/nonce", self.relayer_url))
.query(&[("address", format!("{:?}", eoa)), ("type", "WALLET".into())])
.send()
.await
.map_err(|e| Error::Trading(format!("relayer /nonce: {}", e)))?;
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);
let deadline = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
let domain = json!({
"name": "DepositWallet",
"version": "1",
"chainId": self.chain_id,
"verifyingContract": format!("{:?}", wallet_address),
});
let types = json!({
"Call": [
{"name": "target", "type": "address"},
{"name": "value", "type": "uint256"},
{"name": "data", "type": "bytes"}
],
"Batch": [
{"name": "wallet", "type": "address"},
{"name": "nonce", "type": "uint256"},
{"name": "deadline", "type": "uint256"},
{"name": "calls", "type": "Call[]"}
]
});
let message = json!({
"wallet": format!("{:?}", wallet_address),
"nonce": nonce.to_string(),
"deadline": deadline.to_string(),
"calls": calls,
});
let payload = Eip712Payload {
domain,
types,
primary_type: "Batch".into(),
message,
};
let sig_bytes = signer.sign_typed_data(&payload).await?;
let sig_hex = eth_sig_hex(&sig_bytes)?;
let body = json!({
"type": "WALLET",
"from": format!("{:?}", eoa),
"to": format!("{:?}", factory),
"nonce": nonce.to_string(),
"signature": sig_hex,
"depositWalletParams": {
"depositWallet": format!("{:?}", wallet_address),
"deadline": deadline.to_string(),
"calls": payload.message["calls"].clone(),
},
});
self.submit_with_auth_no_wait(&body).await
}
pub async fn submit_deposit_wallet_calls(
&self,
signer: &dyn TradingSigner,
wallet_address: Address,
txns: Vec<SafeSubTx>,
) -> Result<String> {
let calls: Vec<serde_json::Value> = txns
.into_iter()
.map(|tx| {
json!({
"target": format!("{:?}", tx.to),
"value": tx.value.to_string(),
"data": format!("0x{}", hex::encode(tx.data)),
})
})
.collect();
self.submit_deposit_wallet_call_values(signer, wallet_address, calls)
.await
}
pub async fn execute_deposit_wallet_calls(
&self,
signer: &dyn TradingSigner,
wallet_address: Address,
txns: Vec<SafeSubTx>,
) -> Result<String> {
let tx_id = self
.submit_deposit_wallet_calls(signer, wallet_address, txns)
.await?;
self.poll_tx(&tx_id).await
}
pub async fn submit_deposit_wallet_approvals(
&self,
signer: &dyn TradingSigner,
wallet_address: Address,
) -> Result<String> {
let usdc: Address = super::constants::USDC.parse().unwrap();
let poly_usd: Address = constants_v2::POLY_USD.parse().unwrap();
let onramp: Address = constants_v2::COLLATERAL_ONRAMP.parse().unwrap();
let ctf: Address = CTF.parse().unwrap();
let max_u256 = U256::MAX;
let approve_selector: [u8; 4] = [0x09, 0x5e, 0xa7, 0xb3]; let set_approval_selector: [u8; 4] = [0xa2, 0x2c, 0xb4, 0x65];
let mut calls = Vec::new();
let mut onramp_approve = Vec::with_capacity(68);
onramp_approve.extend_from_slice(&approve_selector);
onramp_approve.extend_from_slice(&encode_address(onramp));
onramp_approve.extend_from_slice(&encode_u256(max_u256));
calls.push(json!({ "target": format!("{:?}", usdc), "value": "0", "data": format!("0x{}", hex::encode(&onramp_approve)) }));
for spender_str in &constants_v2::V2_SPENDERS[..3] {
let spender: Address = spender_str.parse().unwrap();
let mut data = Vec::with_capacity(68);
data.extend_from_slice(&approve_selector);
data.extend_from_slice(&encode_address(spender));
data.extend_from_slice(&encode_u256(max_u256));
calls.push(json!({ "target": format!("{:?}", poly_usd), "value": "0", "data": format!("0x{}", hex::encode(&data)) }));
let mut data2 = Vec::with_capacity(68);
data2.extend_from_slice(&set_approval_selector);
data2.extend_from_slice(&encode_address(spender));
data2.extend_from_slice(&encode_u256(U256::from(1)));
calls.push(json!({ "target": format!("{:?}", ctf), "value": "0", "data": format!("0x{}", hex::encode(&data2)) }));
}
self.submit_deposit_wallet_call_values(signer, wallet_address, calls)
.await
}
pub async fn set_deposit_wallet_approvals(
&self,
signer: &dyn TradingSigner,
wallet_address: Address,
) -> Result<String> {
let tx_id = self
.submit_deposit_wallet_approvals(signer, wallet_address)
.await?;
self.poll_tx(&tx_id).await
}
}