@@ -19,3 +19,8 @@ target
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
|
||||||
|
/target
|
||||||
|
|||||||
Generated
+2510
File diff suppressed because it is too large
Load Diff
+24
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "sol-hunter"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.104"
|
||||||
|
futures-util = "0.3.33"
|
||||||
|
|
||||||
|
serde = { version = "1.0.229", features = ["derive", "serde_derive"] }
|
||||||
|
serde_json = "1.0.151"
|
||||||
|
|
||||||
|
tokio = { version = "1.53.1", features = [
|
||||||
|
"macros",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"sync",
|
||||||
|
"fs",
|
||||||
|
] }
|
||||||
|
tokio-tungstenite = { version = "0.30.0", features = ["native-tls"] }
|
||||||
|
log = "0.4.33"
|
||||||
|
env_logger = "0.11.11"
|
||||||
|
rust_decimal = "1.42.1"
|
||||||
|
reqwest = { version = "0.13.4", features = ["json"] }
|
||||||
|
async-trait = "0.1.91"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
|
use crate::executor::pump_fun::PumpDevAccount;
|
||||||
|
use anyhow::{Context, anyhow};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub fn get_accounts_path() -> anyhow::Result<PathBuf> {
|
||||||
|
Ok(std::env::home_dir()
|
||||||
|
.context("Failed to get home dir")?
|
||||||
|
.join(".config")
|
||||||
|
.join("sol-hunter")
|
||||||
|
.join("accounts.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum Account {
|
||||||
|
PumpDev(PumpDevAccount),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AccountManager {
|
||||||
|
pub active: String,
|
||||||
|
pub accounts: HashMap<String, Account>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
pub async fn new() -> anyhow::Result<Self> {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post("https://pumpdev.io/api/wallet/create")
|
||||||
|
.json(&serde_json::json!({}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to create PumpDev wallet")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||||
|
let message = body
|
||||||
|
.get("error")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Failed to create PumpDev wallet ({status}): {message}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let account: PumpDevAccount = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse PumpDev wallet")?;
|
||||||
|
|
||||||
|
Ok(Self::PumpDev(account))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountManager {
|
||||||
|
pub async fn get() -> anyhow::Result<Self> {
|
||||||
|
let path = get_accounts_path()?;
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
let default = Self::new().await?;
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(path.parent().unwrap()).await?;
|
||||||
|
tokio::fs::write(&path, default.to_string()?).await?;
|
||||||
|
|
||||||
|
return Ok(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = tokio::fs::read_to_string(path).await?;
|
||||||
|
|
||||||
|
Self::from_str(&output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new() -> anyhow::Result<Self> {
|
||||||
|
let mut accounts = HashMap::new();
|
||||||
|
accounts.insert("default".to_string(), Account::new().await?);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
active: "default".to_string(),
|
||||||
|
accounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||||
|
serde_json::from_str(s).context("Failed to parse accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_string(&self) -> anyhow::Result<String> {
|
||||||
|
serde_json::to_string(self).context("Failed to serialize accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||||
|
|
||||||
|
use crate::{account::AccountManager, executor::Executor, types::NewToken};
|
||||||
|
|
||||||
|
pub struct Bot {
|
||||||
|
pub ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||||
|
pub accounts: AccountManager,
|
||||||
|
pub executor: Box<dyn Executor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bot {
|
||||||
|
pub async fn on_new_coin(&mut self, token: NewToken) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bot {
|
||||||
|
pub async fn new() -> anyhow::Result<Self> {
|
||||||
|
let accounts = AccountManager::get().await?;
|
||||||
|
|
||||||
|
let account = accounts
|
||||||
|
.accounts
|
||||||
|
.get(&accounts.active)
|
||||||
|
.context("Failed to get account")?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
ws: connect_async("wss://pumpdev.io/ws").await?.0,
|
||||||
|
executor: Box::new(account.executor()),
|
||||||
|
accounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_account(&mut self) -> anyhow::Result<()> {
|
||||||
|
let account = self
|
||||||
|
.accounts
|
||||||
|
.accounts
|
||||||
|
.get(&self.accounts.active)
|
||||||
|
.context("Failed to get account")?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
self.executor = Box::new(account.executor());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn initialize_websocket_subscribe(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.ws
|
||||||
|
.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||||
|
json!({ "method": "subscribeNewToken" }).to_string().into(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.initialize_websocket_subscribe().await?;
|
||||||
|
|
||||||
|
while let Some(msg) = self.ws.next().await {
|
||||||
|
let msg = msg?;
|
||||||
|
if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
|
||||||
|
match serde_json::from_str::<crate::types::PumpDevEvent>(&text) {
|
||||||
|
Ok(crate::types::PumpDevEvent::Create(token)) => {
|
||||||
|
self.on_new_coin(token).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(event) => {
|
||||||
|
println!("{:?}", event);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
pub mod pump_fun;
|
||||||
|
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
use crate::account::Account;
|
||||||
|
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait Executor {
|
||||||
|
async fn buy(
|
||||||
|
&self,
|
||||||
|
mint: String,
|
||||||
|
amount: Decimal,
|
||||||
|
priority: Decimal,
|
||||||
|
slippage: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sell(
|
||||||
|
&self,
|
||||||
|
mint: String,
|
||||||
|
amount: Decimal,
|
||||||
|
priority: Decimal,
|
||||||
|
slippage: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sell_percent(
|
||||||
|
&self,
|
||||||
|
mint: String,
|
||||||
|
amount: u8,
|
||||||
|
priority: Decimal,
|
||||||
|
slippage: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
pub fn executor(self) -> impl Executor {
|
||||||
|
match self {
|
||||||
|
Self::PumpDev(account) => pump_fun::PumpDev::new(account),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
use anyhow::{Context, anyhow};
|
||||||
|
use reqwest::Client;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::executor::Executor;
|
||||||
|
|
||||||
|
const TRADE_LIGHTNING_URL: &str = "https://pumpdev.io/api/trade-lightning";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PumpDevAccount {
|
||||||
|
#[serde(rename = "apiKey")]
|
||||||
|
pub api_key: String,
|
||||||
|
#[serde(rename = "publicKey")]
|
||||||
|
pub public_key: String,
|
||||||
|
#[serde(rename = "privateKey")]
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TradeResponse {
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PumpDev {
|
||||||
|
pub account: Mutex<PumpDevAccount>,
|
||||||
|
pub client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PumpDev {
|
||||||
|
pub fn new(account: PumpDevAccount) -> Self {
|
||||||
|
Self {
|
||||||
|
account: Mutex::new(account),
|
||||||
|
client: Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trade(
|
||||||
|
&self,
|
||||||
|
action: &str,
|
||||||
|
mint: String,
|
||||||
|
amount: String,
|
||||||
|
priority: Decimal,
|
||||||
|
slippage: u16,
|
||||||
|
denominated_in_sol: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let account = self.account.lock().await;
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(format!("{TRADE_LIGHTNING_URL}?api-key={}", account.api_key))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"action": action,
|
||||||
|
"mint": mint,
|
||||||
|
"amount": amount,
|
||||||
|
"denominatedInSol": if denominated_in_sol { "true" } else { "false" },
|
||||||
|
"slippage": slippage,
|
||||||
|
"priorityFee": priority,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to send trade request")?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error = serde_json::from_str::<serde_json::Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("error").cloned())
|
||||||
|
.and_then(|value| value.as_str().map(str::to_string))
|
||||||
|
.unwrap_or_else(|| body.trim().to_string());
|
||||||
|
|
||||||
|
return Err(anyhow!("Trade failed ({status}): {error}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: TradeResponse =
|
||||||
|
serde_json::from_str(&body).context("Failed to parse trade response")?;
|
||||||
|
|
||||||
|
log::info!("{action} executed: {}", data.signature);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Executor for PumpDev {
|
||||||
|
async fn buy(
|
||||||
|
&self,
|
||||||
|
mint: String,
|
||||||
|
amount: Decimal,
|
||||||
|
priority: Decimal,
|
||||||
|
slippage: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.trade(
|
||||||
|
"buy",
|
||||||
|
mint,
|
||||||
|
amount.round_dp(3).to_string(),
|
||||||
|
priority,
|
||||||
|
slippage,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sell(
|
||||||
|
&self,
|
||||||
|
mint: String,
|
||||||
|
amount: Decimal,
|
||||||
|
priority: Decimal,
|
||||||
|
slippage: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.trade(
|
||||||
|
"sell",
|
||||||
|
mint,
|
||||||
|
amount.round_dp(3).to_string(),
|
||||||
|
priority,
|
||||||
|
slippage,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sell_percent(
|
||||||
|
&self,
|
||||||
|
mint: String,
|
||||||
|
amount: u8,
|
||||||
|
priority: Decimal,
|
||||||
|
slippage: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.trade(
|
||||||
|
"sell",
|
||||||
|
mint,
|
||||||
|
format!("{amount}%"),
|
||||||
|
priority,
|
||||||
|
slippage,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
pub mod account;
|
||||||
|
pub mod bot;
|
||||||
|
pub mod executor;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use crate::bot::Bot;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let mut builder = env_logger::Builder::from_default_env();
|
||||||
|
builder.filter_level(log::LevelFilter::Info);
|
||||||
|
builder.init();
|
||||||
|
|
||||||
|
let mut bot = Bot::new().await?;
|
||||||
|
|
||||||
|
bot.start().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PumpDevEvent {
|
||||||
|
Connected { client_id: u64, message: String },
|
||||||
|
ConnectionStatus { connected: bool, timestamp: u64 },
|
||||||
|
Subscribed { method: String },
|
||||||
|
Create(NewToken),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for PumpDevEvent {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = Value::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
if value.get("txType").is_some() {
|
||||||
|
let token: NewToken =
|
||||||
|
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||||
|
|
||||||
|
return Ok(PumpDevEvent::Create(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
enum Tagged {
|
||||||
|
#[serde(rename = "connected")]
|
||||||
|
Connected {
|
||||||
|
#[serde(rename = "clientId")]
|
||||||
|
client_id: u64,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[serde(rename = "connectionStatus")]
|
||||||
|
ConnectionStatus { connected: bool, timestamp: u64 },
|
||||||
|
|
||||||
|
#[serde(rename = "subscribed")]
|
||||||
|
Subscribed { method: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::from_value(value).map_err(serde::de::Error::custom)? {
|
||||||
|
Tagged::Connected { client_id, message } => {
|
||||||
|
Ok(PumpDevEvent::Connected { client_id, message })
|
||||||
|
}
|
||||||
|
Tagged::ConnectionStatus {
|
||||||
|
connected,
|
||||||
|
timestamp,
|
||||||
|
} => Ok(PumpDevEvent::ConnectionStatus {
|
||||||
|
connected,
|
||||||
|
timestamp,
|
||||||
|
}),
|
||||||
|
Tagged::Subscribed { method } => Ok(PumpDevEvent::Subscribed { method }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewToken {
|
||||||
|
pub mint: String,
|
||||||
|
#[serde(rename = "traderPublicKey")]
|
||||||
|
pub trader_public_key: String,
|
||||||
|
|
||||||
|
pub name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub uri: String,
|
||||||
|
#[serde(rename = "marketCapSol")]
|
||||||
|
pub market_cap_sol: f64,
|
||||||
|
#[serde(rename = "solAmount")]
|
||||||
|
pub sol_amount: f64,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user