use std::borrow::Cow;
use alloy_primitives::{Address, B256};
use serde::{Deserialize, Serialize};
use url::Url;
pub struct Config {
base_url: Url,
chain_id: u64,
ctf_exchange: Address,
neg_risk_ctf_exchange: Address,
neg_risk_adapter: Address,
conditional_tokens: Address,
usdc_e: Address,
proxy_wallet_factory: Address,
relay_hub: Address,
safe_factory: Address,
safe_multisend: Address,
safe_init_code_hash: B256,
proxy_init_code_hash: B256,
}
#[bon::bon]
impl Config {
#[builder]
pub fn new(
base_url: Option<Cow<'static, str>>,
chain_id: Option<u64>,
ctf_exchange: Option<Address>,
neg_risk_ctf_exchange: Option<Address>,
neg_risk_adapter: Option<Address>,
conditional_tokens: Option<Address>,
usdc_e: Option<Address>,
proxy_wallet_factory: Option<Address>,
relay_hub: Option<Address>,
safe_factory: Option<Address>,
safe_multisend: Option<Address>,
safe_init_code_hash: Option<B256>,
proxy_init_code_hash: Option<B256>,
) -> Result<Self, crate::PolyrelError> {
let url_str = base_url.as_deref().unwrap_or(crate::RELAYER_BASE_URL);
let mut parsed = Url::parse(url_str)
.map_err(|e| crate::PolyrelError::Http(Cow::Owned(format!("invalid base URL: {e}"))))?;
match parsed.scheme() {
"http" | "https" => {},
_ => {
return Err(crate::PolyrelError::Http(Cow::Borrowed(
"base URL must use http or https scheme",
)));
},
}
if parsed.query().is_some() {
return Err(crate::PolyrelError::Http(Cow::Borrowed(
"base URL must not contain a query string",
)));
}
if parsed.fragment().is_some() {
return Err(crate::PolyrelError::Http(Cow::Borrowed(
"base URL must not contain a fragment",
)));
}
let trimmed = parsed.path().trim_end_matches('/').to_owned();
parsed.set_path(&trimmed);
Ok(Self {
base_url: parsed,
chain_id: chain_id.unwrap_or(crate::CHAIN_ID),
ctf_exchange: ctf_exchange.unwrap_or(crate::CTF_EXCHANGE),
neg_risk_ctf_exchange: neg_risk_ctf_exchange.unwrap_or(crate::NEG_RISK_CTF_EXCHANGE),
neg_risk_adapter: neg_risk_adapter.unwrap_or(crate::NEG_RISK_ADAPTER),
conditional_tokens: conditional_tokens.unwrap_or(crate::CONDITIONAL_TOKENS),
usdc_e: usdc_e.unwrap_or(crate::USDC_E),
proxy_wallet_factory: proxy_wallet_factory.unwrap_or(crate::PROXY_WALLET_FACTORY),
relay_hub: relay_hub.unwrap_or(crate::RELAY_HUB),
safe_factory: safe_factory.unwrap_or(crate::SAFE_FACTORY),
safe_multisend: safe_multisend.unwrap_or(crate::SAFE_MULTISEND),
safe_init_code_hash: safe_init_code_hash
.unwrap_or_else(|| crate::SAFE_INIT_CODE_HASH.into()),
proxy_init_code_hash: proxy_init_code_hash
.unwrap_or_else(|| crate::PROXY_INIT_CODE_HASH.into()),
})
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub fn chain_id(&self) -> u64 {
self.chain_id
}
pub fn ctf_exchange(&self) -> Address {
self.ctf_exchange
}
pub fn neg_risk_ctf_exchange(&self) -> Address {
self.neg_risk_ctf_exchange
}
pub fn neg_risk_adapter(&self) -> Address {
self.neg_risk_adapter
}
pub fn conditional_tokens(&self) -> Address {
self.conditional_tokens
}
pub fn usdc_e(&self) -> Address {
self.usdc_e
}
pub fn proxy_wallet_factory(&self) -> Address {
self.proxy_wallet_factory
}
pub fn relay_hub(&self) -> Address {
self.relay_hub
}
pub fn safe_factory(&self) -> Address {
self.safe_factory
}
pub fn safe_multisend(&self) -> Address {
self.safe_multisend
}
pub fn safe_init_code_hash(&self) -> B256 {
self.safe_init_code_hash
}
pub fn proxy_init_code_hash(&self) -> B256 {
self.proxy_init_code_hash
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransactionState {
#[serde(rename = "STATE_NEW")]
New,
#[serde(rename = "STATE_EXECUTED")]
Executed,
#[serde(rename = "STATE_MINED")]
Mined,
#[serde(rename = "STATE_CONFIRMED")]
Confirmed,
#[serde(rename = "STATE_INVALID")]
Invalid,
#[serde(rename = "STATE_FAILED")]
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WalletType {
#[serde(rename = "SAFE")]
Safe,
#[serde(rename = "PROXY")]
Proxy,
#[serde(rename = "SAFE-CREATE")]
SafeCreate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum OperationType {
Call = 0,
DelegateCall = 1,
}
impl OperationType {
pub fn as_u8(self) -> u8 {
self as u8
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignatureParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub gas_price: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub safe_txn_gas: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_gas: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gas_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refund_receiver: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payment_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payment_receiver: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relayer_fee: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gas_limit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_hub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relay: Option<String>,
}
impl SignatureParams {
pub fn safe(operation: u8) -> Self {
Self {
gas_price: Some("0".to_owned()),
operation: Some(operation.to_string()),
safe_txn_gas: Some("0".to_owned()),
base_gas: Some("0".to_owned()),
gas_token: Some(Address::ZERO.to_string()),
refund_receiver: Some(Address::ZERO.to_string()),
..Default::default()
}
}
pub fn safe_create() -> Self {
Self {
payment_token: Some(Address::ZERO.to_string()),
payment: Some("0".to_owned()),
payment_receiver: Some(Address::ZERO.to_string()),
..Default::default()
}
}
pub fn proxy(
gas_price: Cow<'static, str>,
gas_limit: Cow<'static, str>,
relay_hub: Address,
relay: Address,
) -> Self {
Self {
gas_price: Some(gas_price.into_owned()),
gas_limit: Some(gas_limit.into_owned()),
relayer_fee: Some("0".to_owned()),
relay_hub: Some(relay_hub.to_string()),
relay: Some(relay.to_string()),
..Default::default()
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmitRequest {
#[serde(rename = "type")]
pub wallet_type: WalletType,
pub from: String,
pub to: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_wallet: Option<String>,
pub data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
pub signature: String,
pub signature_params: SignatureParams,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>,
}
#[bon::bon]
impl SubmitRequest {
#[builder]
pub fn new(
wallet_type: WalletType,
from: Cow<'static, str>,
to: Cow<'static, str>,
proxy_wallet: Option<Cow<'static, str>>,
data: Cow<'static, str>,
nonce: Option<Cow<'static, str>>,
signature: Cow<'static, str>,
signature_params: SignatureParams,
metadata: Option<Cow<'static, str>>,
) -> Self {
Self {
wallet_type,
from: from.into_owned(),
to: to.into_owned(),
proxy_wallet: proxy_wallet.map(Cow::into_owned),
data: data.into_owned(),
nonce: nonce.map(Cow::into_owned),
signature: signature.into_owned(),
signature_params,
metadata: metadata.map(Cow::into_owned),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubmitResponse {
#[serde(rename = "transactionID")]
pub transaction_id: String,
pub state: String,
#[serde(default)]
pub hash: Option<String>,
#[serde(rename = "transactionHash", default)]
pub transaction_hash: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RelayerTransaction {
#[serde(rename = "transactionID")]
pub transaction_id: String,
#[serde(rename = "transactionHash", default)]
pub transaction_hash: Option<String>,
#[serde(default)]
pub from: Option<String>,
#[serde(default)]
pub to: Option<String>,
#[serde(rename = "proxyAddress", default)]
pub proxy_address: Option<String>,
#[serde(default)]
pub data: Option<String>,
#[serde(default)]
pub nonce: Option<String>,
#[serde(default)]
pub value: Option<String>,
pub state: String,
#[serde(rename = "type", default)]
pub transaction_type: Option<String>,
#[serde(default)]
pub metadata: Option<String>,
#[serde(default)]
pub signature: Option<String>,
#[serde(default)]
pub owner: Option<String>,
#[serde(rename = "createdAt", default)]
pub created_at: Option<String>,
#[serde(rename = "updatedAt", default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RelayerInfo {
pub address: String,
pub nonce: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeployedResponse {
pub deployed: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_default_succeeds() {
let config = Config::builder().build();
assert!(config.is_ok());
}
#[test]
fn config_rejects_ftp_scheme() {
let result = Config::builder().base_url("ftp://example.com".into()).build();
assert!(result.is_err());
}
#[test]
fn config_rejects_mailto_scheme() {
let result = Config::builder().base_url("mailto:test@example.com".into()).build();
assert!(result.is_err());
}
#[test]
fn config_rejects_query_string() {
let result = Config::builder().base_url("https://example.com?key=val".into()).build();
assert!(result.is_err());
}
#[test]
fn config_rejects_fragment() {
let result = Config::builder().base_url("https://example.com#frag".into()).build();
assert!(result.is_err());
}
#[test]
fn config_normalizes_single_trailing_slash() {
let config = Config::builder().base_url("https://example.com/api/".into()).build().unwrap();
assert!(!config.base_url().as_str().ends_with('/'));
}
#[test]
fn config_normalizes_multiple_trailing_slashes() {
let config =
Config::builder().base_url("https://example.com/api///".into()).build().unwrap();
assert_eq!(config.base_url().path(), "/api");
}
#[test]
fn config_accepts_valid_https_url() {
let config =
Config::builder().base_url("https://relayer.example.com".into()).build().unwrap();
assert_eq!(config.base_url().scheme(), "https");
}
#[test]
fn signature_params_safe_sets_gas_defaults() {
let params = SignatureParams::safe(0);
assert_eq!(params.gas_price.as_deref(), Some("0"));
assert_eq!(params.operation.as_deref(), Some("0"));
assert_eq!(params.safe_txn_gas.as_deref(), Some("0"));
assert_eq!(params.base_gas.as_deref(), Some("0"));
assert!(params.payment_token.is_none());
}
#[test]
fn signature_params_safe_create_sets_payment_defaults() {
let params = SignatureParams::safe_create();
assert!(params.payment_token.is_some());
assert_eq!(params.payment.as_deref(), Some("0"));
assert!(params.payment_receiver.is_some());
assert!(params.gas_price.is_none());
}
#[test]
fn submit_response_deserializes_transaction_id_field() {
let json = r#"{"transactionID":"abc-123","state":"STATE_NEW"}"#;
let resp: SubmitResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.transaction_id, "abc-123");
assert_eq!(resp.state, "STATE_NEW");
}
#[test]
fn relayer_transaction_deserializes_full_payload() {
let json = r#"{
"transactionID": "tx-1",
"transactionHash": "0xabc",
"from": "0x1234",
"to": "0x5678",
"proxyAddress": "0xproxy",
"state": "STATE_MINED",
"signature": "0xsig",
"owner": "owner-uuid"
}"#;
let txn: RelayerTransaction = serde_json::from_str(json).unwrap();
assert_eq!(txn.transaction_id, "tx-1");
assert_eq!(txn.signature.as_deref(), Some("0xsig"));
assert_eq!(txn.owner.as_deref(), Some("owner-uuid"));
}
#[test]
fn wallet_type_serializes_safe_create_with_hyphen() {
let json = serde_json::to_string(&WalletType::SafeCreate).unwrap();
assert_eq!(json, "\"SAFE-CREATE\"");
}
#[test]
fn transaction_state_round_trips() {
let json = "\"STATE_CONFIRMED\"";
let state: TransactionState = serde_json::from_str(json).unwrap();
assert_eq!(state, TransactionState::Confirmed);
assert_eq!(serde_json::to_string(&state).unwrap(), json);
}
}