use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::{Client, StatusCode};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solana_sdk::signer::Signer;
use thiserror::Error;
use tracing::{debug, warn};
use crate::retry::{retry_async, RetryPolicy};
use crate::stream::proto::MarketContextMsg;
#[derive(Clone, Debug)]
pub struct WalletProof {
pub wallet_pubkey: String,
pub signature: String,
pub message: String,
}
pub fn prove_ownership(keypair: &solana_sdk::signature::Keypair) -> WalletProof {
let wallet_pubkey = keypair.pubkey().to_string();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let message = format!("lasersell-register:{wallet_pubkey}:{timestamp}");
let signature = keypair.sign_message(message.as_bytes());
WalletProof {
wallet_pubkey,
signature: signature.to_string(),
message,
}
}
const ERROR_BODY_SNIPPET_LEN: usize = 220;
pub const EXIT_API_BASE_URL: &str = "https://api.lasersell.io";
pub const LOCAL_EXIT_API_BASE_URL: &str = "http://localhost:8080";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ExitApiDefaults;
impl ExitApiDefaults {
pub const CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
pub const ATTEMPT_TIMEOUT: Duration = Duration::from_millis(900);
pub const MAX_ATTEMPTS: usize = 2;
pub const BACKOFF: Duration = Duration::from_millis(25);
pub const JITTER: Duration = Duration::from_millis(25);
}
#[derive(Clone, Debug)]
pub struct ExitApiClientOptions {
pub connect_timeout: Duration,
pub attempt_timeout: Duration,
pub retry_policy: RetryPolicy,
}
impl Default for ExitApiClientOptions {
fn default() -> Self {
Self {
connect_timeout: ExitApiDefaults::CONNECT_TIMEOUT,
attempt_timeout: ExitApiDefaults::ATTEMPT_TIMEOUT,
retry_policy: RetryPolicy {
max_attempts: ExitApiDefaults::MAX_ATTEMPTS,
initial_backoff: ExitApiDefaults::BACKOFF,
max_backoff: ExitApiDefaults::BACKOFF,
jitter: ExitApiDefaults::JITTER,
},
}
}
}
#[derive(Clone)]
pub struct ExitApiClient {
http: Client,
api_key: Option<SecretString>,
attempt_timeout: Duration,
retry_policy: RetryPolicy,
local: bool,
base_url_override: Option<String>,
}
impl ExitApiClient {
pub fn new() -> Result<Self, ExitApiError> {
Self::with_options(None, ExitApiClientOptions::default())
}
pub fn with_api_key(api_key: SecretString) -> Result<Self, ExitApiError> {
Self::with_options(Some(api_key), ExitApiClientOptions::default())
}
pub fn with_options(
api_key: Option<SecretString>,
options: ExitApiClientOptions,
) -> Result<Self, ExitApiError> {
let http = Client::builder()
.no_proxy()
.connect_timeout(options.connect_timeout)
.build()
.map_err(ExitApiError::Transport)?;
Ok(Self {
http,
api_key,
attempt_timeout: options.attempt_timeout,
retry_policy: options.retry_policy,
local: false,
base_url_override: None,
})
}
pub async fn connect(
api_key: SecretString,
proofs: &[WalletProof],
) -> Result<Self, ExitApiError> {
let client = Self::with_api_key(api_key)?;
for proof in proofs {
client.register_wallet(proof, None).await?;
}
Ok(client)
}
pub fn with_local_mode(mut self, local: bool) -> Self {
self.local = local;
self
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
let base_url = base_url.into();
self.base_url_override = Some(base_url.trim_end_matches('/').to_string());
self
}
pub async fn build_sell_tx(
&self,
request: &BuildSellTxRequest,
) -> Result<BuildTxResponse, ExitApiError> {
self.build_tx("/v1/sell", request).await
}
pub async fn build_partial_sell_tx(
&self,
handle: &crate::stream::session::PositionHandle,
amount_tokens: u64,
slippage_bps: u16,
output: Option<SellOutput>,
) -> Result<BuildTxResponse, ExitApiError> {
self.build_sell_tx(&BuildSellTxRequest {
mint: handle.mint.clone(),
user_pubkey: handle.wallet_pubkey.clone(),
amount_tokens,
output: output.unwrap_or(SellOutput::Sol),
slippage_bps,
mode: None,
market_context: None,
send_mode: None,
tip_lamports: None,
partner_fee_recipient: None,
partner_fee_bps: None,
partner_fee_lamports: None,
}).await
}
pub async fn build_sell_tx_b64(
&self,
request: &BuildSellTxRequest,
) -> Result<String, ExitApiError> {
Ok(self.build_sell_tx(request).await?.tx)
}
pub async fn build_buy_tx(
&self,
request: &BuildBuyTxRequest,
) -> Result<BuildTxResponse, ExitApiError> {
self.build_tx("/v1/buy", request).await
}
pub async fn register_wallet(
&self,
proof: &WalletProof,
label: Option<&str>,
) -> Result<(), ExitApiError> {
let mut body = serde_json::json!({
"wallet_pubkey": proof.wallet_pubkey,
"signature": proof.signature,
"message": proof.message,
});
if let Some(label) = label {
body["label"] = serde_json::Value::String(label.to_string());
}
let endpoint = self.endpoint("/v1/wallets/register");
let mut builder = self
.http
.post(&endpoint)
.timeout(self.attempt_timeout)
.json(&body);
if let Some(api_key) = self.api_key.as_ref() {
builder = builder.header("x-api-key", api_key.expose_secret());
}
let response = builder.send().await.map_err(ExitApiError::Transport)?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.map_err(ExitApiError::Transport)?;
return Err(ExitApiError::HttpStatus {
status,
body: summarize_error_body(&body),
});
}
debug!(event = "wallet_registered", wallet = %proof.wallet_pubkey);
Ok(())
}
async fn build_tx<T>(&self, path: &str, request: &T) -> Result<BuildTxResponse, ExitApiError>
where
T: Serialize + Clone,
{
let endpoint = self.endpoint(path);
let policy = self.retry_policy.clone();
debug!(event = "exit_api_build_tx", endpoint = %endpoint);
retry_async(
&policy,
|_| {
let endpoint = endpoint.clone();
let body = request.clone();
async move { self.send_attempt(&endpoint, &body).await }
},
ExitApiError::is_retryable,
)
.await
}
fn endpoint(&self, path: &str) -> String {
format!("{}{}", self.base_url(), path)
}
fn base_url(&self) -> &str {
if let Some(base_url) = self.base_url_override.as_deref() {
return base_url;
}
if self.local {
LOCAL_EXIT_API_BASE_URL
} else {
EXIT_API_BASE_URL
}
}
async fn send_attempt<T: Serialize + ?Sized>(
&self,
endpoint: &str,
request: &T,
) -> Result<BuildTxResponse, ExitApiError> {
let mut builder = self
.http
.post(endpoint)
.timeout(self.attempt_timeout)
.json(request);
if let Some(api_key) = self.api_key.as_ref() {
builder = builder.header("x-api-key", api_key.expose_secret());
}
let response = builder.send().await.map_err(ExitApiError::Transport)?;
let status = response.status();
let body = response.text().await.map_err(ExitApiError::Transport)?;
debug!(event = "exit_api_response", status = %status);
if !status.is_success() {
warn!(event = "exit_api_http_error", status = %status, body = %summarize_error_body(&body));
return Err(ExitApiError::HttpStatus {
status,
body: summarize_error_body(&body),
});
}
let result = parse_build_tx_response(&body);
match &result {
Ok(resp) => {
debug!(event = "exit_api_build_tx_ok", tx_len = resp.tx.len());
}
Err(ExitApiError::EnvelopeStatus { status, detail }) => {
warn!(event = "exit_api_envelope_error", status = %status, detail = %detail);
}
_ => {}
}
result
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct BuildSellTxRequest {
pub mint: String,
pub user_pubkey: String,
pub amount_tokens: u64,
pub output: SellOutput,
pub slippage_bps: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub market_context: Option<MarketContextMsg>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tip_lamports: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partner_fee_recipient: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partner_fee_bps: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partner_fee_lamports: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct BuildBuyTxRequest {
pub mint: String,
pub user_pubkey: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount_in_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
pub slippage_bps: u16,
#[serde(serialize_with = "serialize_buy_input")]
pub input: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tip_lamports: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partner_fee_recipient: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partner_fee_bps: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partner_fee_lamports: Option<u64>,
}
fn serialize_buy_input<S: serde::Serializer>(
input: &Option<String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(input.as_deref().unwrap_or("SOL"))
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Eq, PartialEq)]
pub enum SellOutput {
#[default]
#[serde(rename = "SOL")]
Sol,
#[serde(rename = "USD1")]
Usd1,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct BuildTxResponse {
pub tx: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub route: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debug: Option<Value>,
}
#[derive(Debug, Error)]
pub enum ExitApiError {
#[error("request failed: {0}")]
Transport(reqwest::Error),
#[error("http status {status}: {body}")]
HttpStatus { status: StatusCode, body: String },
#[error("exit-api status {status}: {detail}")]
EnvelopeStatus { status: String, detail: String },
#[error("failed to parse response: {0}")]
Parse(String),
}
impl ExitApiError {
pub fn is_retryable(&self) -> bool {
match self {
Self::Transport(err) => err.is_timeout() || err.is_connect(),
Self::HttpStatus { status, .. } => {
status.is_server_error() || *status == StatusCode::TOO_MANY_REQUESTS
}
Self::EnvelopeStatus { .. } | Self::Parse(_) => false,
}
}
}
#[derive(Debug, Deserialize)]
struct TaggedBuildResponse {
status: String,
#[serde(default)]
tx: Option<String>,
#[serde(default)]
unsigned_tx_b64: Option<String>,
#[serde(default)]
route: Option<Value>,
#[serde(default)]
debug: Option<Value>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LegacyBuildResponse {
unsigned_tx_b64: String,
#[serde(default)]
route: Option<Value>,
#[serde(default)]
debug: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct BareBuildResponse {
tx: String,
#[serde(default)]
route: Option<Value>,
#[serde(default)]
debug: Option<Value>,
}
fn parse_build_tx_response(body: &str) -> Result<BuildTxResponse, ExitApiError> {
if let Ok(tagged) = serde_json::from_str::<TaggedBuildResponse>(body) {
if tagged.status.eq_ignore_ascii_case("ok") {
let tx = tagged
.tx
.or(tagged.unsigned_tx_b64)
.ok_or_else(|| ExitApiError::Parse("status=ok payload missing tx".to_string()))?;
return Ok(BuildTxResponse {
tx,
route: tagged.route,
debug: tagged.debug,
});
}
let detail = tagged
.reason
.or(tagged.message)
.or(tagged.error)
.unwrap_or_else(|| "unknown failure".to_string());
return Err(ExitApiError::EnvelopeStatus {
status: tagged.status,
detail,
});
}
if let Ok(legacy) = serde_json::from_str::<LegacyBuildResponse>(body) {
return Ok(BuildTxResponse {
tx: legacy.unsigned_tx_b64,
route: legacy.route,
debug: legacy.debug,
});
}
if let Ok(bare) = serde_json::from_str::<BareBuildResponse>(body) {
return Ok(BuildTxResponse {
tx: bare.tx,
route: bare.route,
debug: bare.debug,
});
}
Err(ExitApiError::Parse(
"response did not match any supported schema".to_string(),
))
}
fn summarize_error_body(body: &str) -> String {
#[derive(Debug, Deserialize)]
struct ErrorBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
reason: Option<String>,
}
if let Ok(parsed) = serde_json::from_str::<ErrorBody>(body) {
if let Some(message) = parsed.error.or(parsed.message).or(parsed.reason) {
return message;
}
}
body.chars().take(ERROR_BODY_SNIPPET_LEN).collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
parse_build_tx_response, BuildSellTxRequest, BuildTxResponse, ExitApiClient,
ExitApiClientOptions, ExitApiError, SellOutput, EXIT_API_BASE_URL, LOCAL_EXIT_API_BASE_URL,
};
#[test]
fn parse_envelope_ok_response() {
let payload = r#"{"status":"ok","tx":"abc","route":{"market_type":"pumpfun"}}"#;
let parsed = parse_build_tx_response(payload).expect("parse ok envelope");
assert_eq!(
parsed,
BuildTxResponse {
tx: "abc".to_string(),
route: Some(json!({"market_type":"pumpfun"})),
debug: None,
}
);
}
#[test]
fn parse_legacy_unsigned_tx_response() {
let payload = r#"{"unsigned_tx_b64":"legacy_tx"}"#;
let parsed = parse_build_tx_response(payload).expect("parse legacy");
assert_eq!(parsed.tx, "legacy_tx");
}
#[test]
fn parse_bare_tx_response() {
let payload = r#"{"tx":"bare_tx"}"#;
let parsed = parse_build_tx_response(payload).expect("parse bare");
assert_eq!(parsed.tx, "bare_tx");
}
#[test]
fn parse_non_ok_envelope_as_error() {
let payload = r#"{"status":"not_ready","reason":"indexing"}"#;
let error = parse_build_tx_response(payload).expect_err("non-ok should error");
match error {
ExitApiError::EnvelopeStatus { status, detail } => {
assert_eq!(status, "not_ready");
assert_eq!(detail, "indexing");
}
other => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn sell_request_serializes_amount_tokens_contract() {
let request = BuildSellTxRequest {
mint: "mint".to_string(),
user_pubkey: "user".to_string(),
amount_tokens: 42,
output: SellOutput::Sol,
slippage_bps: 1200,
mode: Some("fast".to_string()),
..Default::default()
};
let value = serde_json::to_value(request).expect("serialize request");
assert_eq!(
value.get("amount_tokens").and_then(|v| v.as_u64()),
Some(42)
);
assert!(value.get("amount").is_none());
assert_eq!(value.get("output").and_then(|v| v.as_str()), Some("SOL"));
}
#[test]
fn exit_api_client_uses_production_base_url() {
assert_eq!(EXIT_API_BASE_URL, "https://api.lasersell.io");
}
#[test]
fn exit_api_client_uses_local_base_url_when_enabled() {
let client = ExitApiClient::with_options(None, ExitApiClientOptions::default())
.expect("build client")
.with_local_mode(true);
assert_eq!(LOCAL_EXIT_API_BASE_URL, "http://localhost:8080");
assert_eq!(client.base_url(), LOCAL_EXIT_API_BASE_URL);
}
#[test]
fn exit_api_client_override_base_url_takes_precedence() {
let client = ExitApiClient::with_options(None, ExitApiClientOptions::default())
.expect("build client")
.with_local_mode(true)
.with_base_url("https://api-dev.example///");
assert_eq!(client.base_url(), "https://api-dev.example");
assert_eq!(
client.endpoint("/v1/sell"),
"https://api-dev.example/v1/sell"
);
}
}