add yellowstone grpc

This commit is contained in:
William
2025-01-23 17:48:10 +08:00
parent 429fc78ecf
commit 42b7267c2c
36 changed files with 897 additions and 3356 deletions
+333
View File
@@ -0,0 +1,333 @@
//! Bonding curve account for the Pump.fun Solana Program
//!
//! This module contains the definition for the bonding curve account.
//!
//! # Bonding Curve Account
//!
//! The bonding curve account is used to manage token pricing and liquidity.
//!
//! # Fields
//!
//! - `discriminator`: Unique identifier for the bonding curve
//! - `virtual_token_reserves`: Virtual token reserves used for price calculations
//! - `virtual_sol_reserves`: Virtual SOL reserves used for price calculations
//! - `real_token_reserves`: Actual token reserves available for trading
//! - `real_sol_reserves`: Actual SOL reserves available for trading
//! - `token_total_supply`: Total supply of tokens
//! - `complete`: Whether the bonding curve is complete/finalized
//!
//! # Methods
//!
//! - `new`: Creates a new bonding curve instance
//! - `get_buy_price`: Calculates the amount of tokens received for a given SOL amount
//! - `get_sell_price`: Calculates the amount of SOL received for selling tokens
//! - `get_market_cap_sol`: Calculates the current market cap in SOL
//! - `get_final_market_cap_sol`: Calculates the final market cap in SOL after all tokens are sold
//! - `get_buy_out_price`: Calculates the price to buy out all remaining tokens
use borsh::{BorshDeserialize, BorshSerialize};
/// Represents a bonding curve for token pricing and liquidity management
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct BondingCurveAccount {
/// Unique identifier for the bonding curve
pub discriminator: u64,
/// Virtual token reserves used for price calculations
pub virtual_token_reserves: u64,
/// Virtual SOL reserves used for price calculations
pub virtual_sol_reserves: u64,
/// Actual token reserves available for trading
pub real_token_reserves: u64,
/// Actual SOL reserves available for trading
pub real_sol_reserves: u64,
/// Total supply of tokens
pub token_total_supply: u64,
/// Whether the bonding curve is complete/finalized
pub complete: bool,
}
impl BondingCurveAccount {
/// Creates a new bonding curve instance
///
/// # Arguments
/// * `discriminator` - Unique identifier for the curve
/// * `virtual_token_reserves` - Virtual token reserves for price calculations
/// * `virtual_sol_reserves` - Virtual SOL reserves for price calculations
/// * `real_token_reserves` - Actual token reserves available
/// * `real_sol_reserves` - Actual SOL reserves available
/// * `token_total_supply` - Total supply of tokens
/// * `complete` - Whether the curve is complete
pub fn new(
discriminator: u64,
virtual_token_reserves: u64,
virtual_sol_reserves: u64,
real_token_reserves: u64,
real_sol_reserves: u64,
token_total_supply: u64,
complete: bool,
) -> Self {
Self {
discriminator,
virtual_token_reserves,
virtual_sol_reserves,
real_token_reserves,
real_sol_reserves,
token_total_supply,
complete,
}
}
/// Calculates the amount of tokens received for a given SOL amount
///
/// # Arguments
/// * `amount` - Amount of SOL to spend
///
/// # Returns
/// * `Ok(u64)` - Amount of tokens that would be received
/// * `Err(&str)` - Error message if curve is complete
pub fn get_buy_price(&self, amount: u64) -> Result<u64, &'static str> {
if self.complete {
return Err("Curve is complete");
}
if amount == 0 {
return Ok(0);
}
// Calculate the product of virtual reserves using u128 to avoid overflow
let n: u128 = (self.virtual_sol_reserves as u128) * (self.virtual_token_reserves as u128);
// Calculate the new virtual sol reserves after the purchase
let i: u128 = (self.virtual_sol_reserves as u128) + (amount as u128);
// Calculate the new virtual token reserves after the purchase
let r: u128 = n / i + 1;
// Calculate the amount of tokens to be purchased
let s: u128 = (self.virtual_token_reserves as u128) - r;
// Convert back to u64 and return the minimum of calculated tokens and real reserves
let s_u64 = s as u64;
Ok(if s_u64 < self.real_token_reserves {
s_u64
} else {
self.real_token_reserves
})
}
/// Calculates the amount of SOL received for selling tokens
///
/// # Arguments
/// * `amount` - Amount of tokens to sell
/// * `fee_basis_points` - Fee in basis points (1/100th of a percent)
///
/// # Returns
/// * `Ok(u64)` - Amount of SOL that would be received after fees
/// * `Err(&str)` - Error message if curve is complete
pub fn get_sell_price(&self, amount: u64, fee_basis_points: u64) -> Result<u64, &'static str> {
if self.complete {
return Err("Curve is complete");
}
if amount == 0 {
return Ok(0);
}
// Calculate the proportional amount of virtual sol reserves to be received using u128
let n: u128 = ((amount as u128) * (self.virtual_sol_reserves as u128))
/ ((self.virtual_token_reserves as u128) + (amount as u128));
// Calculate the fee amount in the same units
let a: u128 = (n * (fee_basis_points as u128)) / 10000;
// Return the net amount after deducting the fee, converting back to u64
Ok((n - a) as u64)
}
/// Calculates the current market cap in SOL
pub fn get_market_cap_sol(&self) -> u64 {
if self.virtual_token_reserves == 0 {
return 0;
}
((self.token_total_supply as u128) * (self.virtual_sol_reserves as u128)
/ (self.virtual_token_reserves as u128)) as u64
}
/// Calculates the final market cap in SOL after all tokens are sold
///
/// # Arguments
/// * `fee_basis_points` - Fee in basis points (1/100th of a percent)
pub fn get_final_market_cap_sol(&self, fee_basis_points: u64) -> u64 {
let total_sell_value: u128 =
self.get_buy_out_price(self.real_token_reserves, fee_basis_points) as u128;
let total_virtual_value: u128 = (self.virtual_sol_reserves as u128) + total_sell_value;
let total_virtual_tokens: u128 =
(self.virtual_token_reserves as u128) - (self.real_token_reserves as u128);
if total_virtual_tokens == 0 {
return 0;
}
((self.token_total_supply as u128) * total_virtual_value / total_virtual_tokens) as u64
}
/// Calculates the price to buy out all remaining tokens
///
/// # Arguments
/// * `amount` - Amount of tokens to buy
/// * `fee_basis_points` - Fee in basis points (1/100th of a percent)
pub fn get_buy_out_price(&self, amount: u64, fee_basis_points: u64) -> u64 {
// Get the effective amount of sol tokens
let sol_tokens: u128 = if amount < self.real_sol_reserves {
self.real_sol_reserves as u128
} else {
amount as u128
};
// Calculate total sell value
let total_sell_value: u128 = (sol_tokens * (self.virtual_sol_reserves as u128))
/ ((self.virtual_token_reserves as u128) - sol_tokens)
+ 1;
// Calculate fee
let fee: u128 = (total_sell_value * (fee_basis_points as u128)) / 10000;
// Return total including fee, converting back to u64
(total_sell_value + fee) as u64
}
pub fn get_token_price(&self) -> f64 {
let v_sol = self.virtual_sol_reserves as f64 / 100_000_000.0;
let v_tokens = self.virtual_token_reserves as f64 / 100_000.0;
let token_price = v_sol / v_tokens;
token_price
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_bonding_curve() -> BondingCurveAccount {
BondingCurveAccount::new(
1, // discriminator
1000, // virtual_token_reserves
1000, // virtual_sol_reserves
500, // real_token_reserves
500, // real_sol_reserves
1000, // token_total_supply
false, // complete
)
}
fn get_large_bonding_curve() -> BondingCurveAccount {
BondingCurveAccount::new(
1, // discriminator
u64::MAX / 2, // virtual_token_reserves
u64::MAX / 2, // virtual_sol_reserves
u64::MAX / 4, // real_token_reserves
u64::MAX / 4, // real_sol_reserves
u64::MAX / 2, // token_total_supply
false, // complete
)
}
#[test]
fn test_bonding_curve_account() {
let bonding_curve: BondingCurveAccount = get_bonding_curve();
// Test buy price calculation
assert_eq!(bonding_curve.get_buy_price(0).unwrap(), 0);
let buy_price = bonding_curve.get_buy_price(100).unwrap();
assert!(buy_price > 0);
assert!(buy_price <= bonding_curve.real_token_reserves);
// Test sell price calculation
assert_eq!(bonding_curve.get_sell_price(0, 250).unwrap(), 0);
let sell_price = bonding_curve.get_sell_price(100, 250).unwrap();
assert!(sell_price > 0);
}
#[test]
fn test_bonding_curve_complete() {
let mut bonding_curve: BondingCurveAccount = get_bonding_curve();
// Test operations work when not complete
assert!(bonding_curve.get_buy_price(100).is_ok());
assert!(bonding_curve.get_sell_price(100, 250).is_ok());
// Set curve to complete
bonding_curve.complete = true;
// Test operations fail when complete
assert!(bonding_curve.get_buy_price(100).is_err());
assert!(bonding_curve.get_sell_price(100, 250).is_err());
}
#[test]
fn test_market_cap_calculations() {
let bonding_curve: BondingCurveAccount = get_bonding_curve();
// Test market cap calculations
let market_cap = bonding_curve.get_market_cap_sol();
assert!(market_cap > 0);
let final_market_cap = bonding_curve.get_final_market_cap_sol(250);
assert!(final_market_cap > 0);
}
#[test]
fn test_buy_out_price() {
let bonding_curve: BondingCurveAccount = get_bonding_curve();
let buy_out_price = bonding_curve.get_buy_out_price(100, 250);
assert!(buy_out_price > 0);
// Test with amount less than real_sol_reserves
let small_buy_out = bonding_curve.get_buy_out_price(400, 250);
assert!(small_buy_out > 0);
}
#[test]
fn test_overflow_buy_price() {
let bonding_curve = get_large_bonding_curve();
// Test buying with large SOL amount
let buy_price = bonding_curve.get_buy_price(u64::MAX).unwrap();
assert!(buy_price > 0);
assert!(buy_price <= bonding_curve.real_token_reserves);
}
#[test]
fn test_overflow_sell_price() {
let bonding_curve = get_large_bonding_curve();
// Test selling large token amount
let sell_price = bonding_curve.get_sell_price(u64::MAX / 4, 250).unwrap();
assert!(sell_price > 0);
}
#[test]
fn test_overflow_market_cap() {
let bonding_curve = get_large_bonding_curve();
// Test market cap with large values
let market_cap = bonding_curve.get_market_cap_sol();
assert!(market_cap > 0);
let final_market_cap = bonding_curve.get_final_market_cap_sol(250);
assert!(final_market_cap > 0);
}
#[test]
fn test_overflow_buy_out_price() {
let bonding_curve = get_large_bonding_curve();
// Test buy out with large token amount
let buy_out_price = bonding_curve.get_buy_out_price(u64::MAX / 4, 250);
assert!(buy_out_price > 0);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Global account for the Pump.fun Solana Program
//!
//! This module contains the definition for the global configuration account.
//!
//! # Global Account
//!
//! The global account is used to store the global configuration for the Pump.fun program.
//!
//! # Fields
//!
//! - `discriminator`: Unique identifier for the global account
//! - `initialized`: Whether the global account has been initialized
//! - `authority`: Authority pubkey that can modify settings
//! - `fee_recipient`: Account that receives fees
//! - `initial_virtual_token_reserves`: Initial virtual token reserves for price calculations
//! - `initial_virtual_sol_reserves`: Initial virtual SOL reserves for price calculations
//! - `initial_real_token_reserves`: Initial actual token reserves available for trading
//! - `token_total_supply`: Total supply of tokens
//! - `fee_basis_points`: Fee in basis points (1/100th of a percent)
//!
//! # Methods
//!
//! - `new`: Creates a new global account instance
//! - `get_initial_buy_price`: Calculates the initial amount of tokens received for a given SOL amount
use solana_sdk::pubkey::Pubkey;
use borsh::{BorshDeserialize, BorshSerialize};
/// Represents the global configuration account for token pricing and fees
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct GlobalAccount {
/// Unique identifier for the global account
pub discriminator: u64,
/// Whether the global account has been initialized
pub initialized: bool,
/// Authority that can modify global settings
pub authority: Pubkey,
/// Account that receives fees
pub fee_recipient: Pubkey,
/// Initial virtual token reserves for price calculations
pub initial_virtual_token_reserves: u64,
/// Initial virtual SOL reserves for price calculations
pub initial_virtual_sol_reserves: u64,
/// Initial actual token reserves available for trading
pub initial_real_token_reserves: u64,
/// Total supply of tokens
pub token_total_supply: u64,
/// Fee in basis points (1/100th of a percent)
pub fee_basis_points: u64,
}
impl GlobalAccount {
/// Creates a new global account instance
///
/// # Arguments
/// * `discriminator` - Unique identifier for the account
/// * `initialized` - Whether the account is initialized
/// * `authority` - Authority pubkey that can modify settings
/// * `fee_recipient` - Account that receives fees
/// * `initial_virtual_token_reserves` - Initial virtual token reserves
/// * `initial_virtual_sol_reserves` - Initial virtual SOL reserves
/// * `initial_real_token_reserves` - Initial actual token reserves
/// * `token_total_supply` - Total supply of tokens
/// * `fee_basis_points` - Fee in basis points
#[allow(clippy::too_many_arguments)]
pub fn new(
discriminator: u64,
initialized: bool,
authority: Pubkey,
fee_recipient: Pubkey,
initial_virtual_token_reserves: u64,
initial_virtual_sol_reserves: u64,
initial_real_token_reserves: u64,
token_total_supply: u64,
fee_basis_points: u64,
) -> Self {
Self {
discriminator,
initialized,
authority,
fee_recipient,
initial_virtual_token_reserves,
initial_virtual_sol_reserves,
initial_real_token_reserves,
token_total_supply,
fee_basis_points,
}
}
/// Calculates the initial amount of tokens received for a given SOL amount
///
/// # Arguments
/// * `amount` - Amount of SOL to spend
///
/// # Returns
/// Amount of tokens that would be received
pub fn get_initial_buy_price(&self, amount: u64) -> u64 {
if amount == 0 {
return 0;
}
let n: u128 = (self.initial_virtual_sol_reserves as u128)
* (self.initial_virtual_token_reserves as u128);
let i: u128 = (self.initial_virtual_sol_reserves as u128) + (amount as u128);
let r: u128 = n / i + 1;
let s: u128 = (self.initial_virtual_token_reserves as u128) - r;
if s < (self.initial_real_token_reserves as u128) {
s as u64
} else {
self.initial_real_token_reserves
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_global() -> GlobalAccount {
GlobalAccount::new(
1,
true,
Pubkey::new_unique(),
Pubkey::new_unique(),
1000,
1000,
500,
1000,
250,
)
}
fn get_large_global() -> GlobalAccount {
GlobalAccount::new(
1,
true,
Pubkey::new_unique(),
Pubkey::new_unique(),
u64::MAX,
u64::MAX,
u64::MAX / 2,
u64::MAX,
250,
)
}
#[test]
fn test_global_account() {
let global: GlobalAccount = get_global();
// Test initial buy price calculation
assert_eq!(global.get_initial_buy_price(0), 0);
let price: u64 = global.get_initial_buy_price(100);
assert!(price > 0);
assert!(price <= global.initial_real_token_reserves);
}
#[test]
fn test_global_account_max_reserves() {
let mut global: GlobalAccount = get_global();
global.initial_real_token_reserves = 100;
// Test that returned amount is capped by real_token_reserves
let price: u64 = global.get_initial_buy_price(1000);
assert_eq!(price, global.initial_real_token_reserves);
}
#[test]
fn test_global_account_overflow() {
let global: GlobalAccount = get_large_global();
// Test with maximum possible SOL amount
let price: u64 = global.get_initial_buy_price(u64::MAX);
assert!(price > 0);
assert!(price <= global.initial_real_token_reserves);
// Test with large but not maximum SOL amount
let price: u64 = global.get_initial_buy_price(u64::MAX / 2);
assert!(price > 0);
assert!(price <= global.initial_real_token_reserves);
}
#[test]
fn test_global_account_overflow_edge_cases() {
let mut global: GlobalAccount = get_large_global();
global.initial_virtual_sol_reserves = u64::MAX - 1000;
global.initial_virtual_token_reserves = u64::MAX - 1000;
global.initial_real_token_reserves = u64::MAX / 4;
// Test with amounts near u64::MAX
let price: u64 = global.get_initial_buy_price(u64::MAX - 1);
assert!(price > 0);
assert!(price <= global.initial_real_token_reserves);
let price: u64 = global.get_initial_buy_price(u64::MAX - 1000);
assert!(price > 0);
assert!(price <= global.initial_real_token_reserves);
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Accounts for the Pump.fun Solana Program
//!
//! This module contains the definitions for the accounts used by the Pump.fun program.
//!
//! # Accounts
//!
//! - `BondingCurve`: Represents a bonding curve account.
//! - `Global`: Represents the global configuration account.
mod bonding_curve;
mod global;
pub use bonding_curve::*;
pub use global::*;
+53
View File
@@ -0,0 +1,53 @@
//! Constants used by the crate.
//!
//! This module contains various constants used throughout the crate, including:
//!
//! - Seeds for deriving Program Derived Addresses (PDAs)
//! - Program account addresses and public keys
//!
//! The constants are organized into submodules for better organization:
//!
//! - `seeds`: Contains seed values used for PDA derivation
//! - `accounts`: Contains important program account addresses
/// Constants used as seeds for deriving PDAs (Program Derived Addresses)
pub mod seeds {
/// Seed for the global state PDA
pub const GLOBAL_SEED: &[u8] = b"global";
/// Seed for the mint authority PDA
pub const MINT_AUTHORITY_SEED: &[u8] = b"mint-authority";
/// Seed for bonding curve PDAs
pub const BONDING_CURVE_SEED: &[u8] = b"bonding-curve";
/// Seed for metadata PDAs
pub const METADATA_SEED: &[u8] = b"metadata";
}
/// Constants related to program accounts and authorities
pub mod accounts {
use solana_sdk::{pubkey, pubkey::Pubkey};
/// Public key for the Pump.fun program
pub const PUMPFUN: Pubkey = pubkey!("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P");
/// Public key for the MPL Token Metadata program
pub const MPL_TOKEN_METADATA: Pubkey = pubkey!("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s");
/// Authority for program events
pub const EVENT_AUTHORITY: Pubkey = pubkey!("Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1");
/// System Program ID
pub const SYSTEM_PROGRAM: Pubkey = pubkey!("11111111111111111111111111111111");
/// Token Program ID
pub const TOKEN_PROGRAM: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
/// Associated Token Program ID
pub const ASSOCIATED_TOKEN_PROGRAM: Pubkey =
pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
/// Rent Sysvar ID
pub const RENT: Pubkey = pubkey!("SysvarRent111111111111111111111111111111111");
}
+181
View File
@@ -0,0 +1,181 @@
//! Error types for the Pump.fun SDK.
//!
//! This module defines the `ClientError` enum, which encompasses various error types that can occur when interacting with the Pump.fun program.
//! It includes specific error cases for bonding curve operations, metadata uploads, Solana client errors, and more.
//!
//! The `ClientError` enum provides a comprehensive set of error types to help developers handle and debug issues that may arise during interactions with the Pump.fun program.
//!
//! # Error Types
//!
//! - `BondingCurveNotFound`: The bonding curve account was not found.
//! - `BondingCurveError`: An error occurred while interacting with the bonding curve.
//! - `BorshError`: An error occurred while serializing or deserializing data using Borsh.
//! - `SolanaClientError`: An error occurred while interacting with the Solana RPC client.
//! - `UploadMetadataError`: An error occurred while uploading metadata to IPFS.
//! - `InvalidInput`: Invalid input parameters were provided.
//! - `InsufficientFunds`: Insufficient funds for a transaction.
//! - `SimulationError`: Transaction simulation failed.
//! - `RateLimitExceeded`: Rate limit exceeded.
use serde_json::Error;
use solana_client::{
client_error::ClientError as SolanaClientError,
pubsub_client::PubsubClientError
};
use solana_sdk::pubkey::ParsePubkeyError;
#[derive(Debug)]
pub enum ClientError {
/// Bonding curve account was not found
BondingCurveNotFound,
/// Error related to bonding curve operations
BondingCurveError(&'static str),
/// Error deserializing data using Borsh
BorshError(std::io::Error),
/// Error from Solana RPC client
SolanaClientError(solana_client::client_error::ClientError),
/// Error uploading metadata
UploadMetadataError(Box<dyn std::error::Error>),
/// Invalid input parameters
InvalidInput(&'static str),
/// Insufficient funds for transaction
InsufficientFunds,
/// Transaction simulation failed
SimulationError(String),
/// Rate limit exceeded
RateLimitExceeded,
OrderLimitExceeded,
ExternalService(String),
Redis(String, String),
Solana(String, String),
Parse(String, String),
Pubkey(String, String),
Jito(String, String),
Join(String),
Subscribe(String, String),
Send(String, String),
Other(String),
InvalidData(String),
PumpFunBuy(String),
PumpFunSell(String),
Timeout(String, String),
Duplicate(String),
InvalidEventType,
ChannelClosed,
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BondingCurveNotFound => write!(f, "Bonding curve not found"),
Self::BondingCurveError(msg) => write!(f, "Bonding curve error: {}", msg),
Self::BorshError(err) => write!(f, "Borsh serialization error: {}", err),
Self::SolanaClientError(err) => write!(f, "Solana client error: {}", err),
Self::UploadMetadataError(err) => write!(f, "Metadata upload error: {}", err),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::InsufficientFunds => write!(f, "Insufficient funds for transaction"),
Self::SimulationError(msg) => write!(f, "Transaction simulation failed: {}", msg),
Self::ExternalService(msg) => write!(f, "External service error: {}", msg),
Self::RateLimitExceeded => write!(f, "Rate limit exceeded"),
Self::OrderLimitExceeded => write!(f, "Order limit exceeded"),
Self::Solana(msg, details) => write!(f, "Solana error: {}, details: {}", msg, details),
Self::Parse(msg, details) => write!(f, "Parse error: {}, details: {}", msg, details),
Self::Jito(msg, details) => write!(f, "Jito error: {}, details: {}", msg, details),
Self::Redis(msg, details) => write!(f, "Redis error: {}, details: {}", msg, details),
Self::Join(msg) => write!(f, "Task join error: {}", msg),
Self::Pubkey(msg, details) => write!(f, "Pubkey error: {}, details: {}", msg, details),
Self::Subscribe(msg, details) => write!(f, "Subscribe error: {}, details: {}", msg, details),
Self::Send(msg, details) => write!(f, "Send error: {}, details: {}", msg, details),
Self::Other(msg) => write!(f, "Other error: {}", msg),
Self::PumpFunBuy(msg) => write!(f, "PumpFun buy error: {}", msg),
Self::PumpFunSell(msg) => write!(f, "PumpFun sell error: {}", msg),
Self::InvalidData(msg) => write!(f, "Invalid data: {}", msg),
Self::Timeout(msg, details) => write!(f, "Operation timed out: {}, details: {}", msg, details),
Self::Duplicate(msg) => write!(f, "Duplicate event: {}", msg),
Self::InvalidEventType => write!(f, "Invalid event type"),
Self::ChannelClosed => write!(f, "Channel closed"),
}
}
}
impl std::error::Error for ClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::BorshError(err) => Some(err),
Self::SolanaClientError(err) => Some(err),
Self::UploadMetadataError(err) => Some(err.as_ref()),
Self::ExternalService(_) => None,
Self::Redis(_, _) => None,
Self::Solana(_, _) => None,
Self::Parse(_, _) => None,
Self::Jito(_, _) => None,
Self::Join(_) => None,
Self::Pubkey(_, _) => None,
Self::Subscribe(_, _) => None,
Self::Send(_, _) => None,
Self::Other(_) => None,
Self::PumpFunBuy(_) => None,
Self::PumpFunSell(_) => None,
Self::Timeout(_, _) => None,
Self::Duplicate(_) => None,
Self::InvalidEventType => None,
Self::ChannelClosed => None,
_ => None,
}
}
}
impl From<SolanaClientError> for ClientError {
fn from(error: SolanaClientError) -> Self {
ClientError::Solana(
"Solana client error".to_string(),
error.to_string(),
)
}
}
impl From<PubsubClientError> for ClientError {
fn from(error: PubsubClientError) -> Self {
ClientError::Solana(
"PubSub client error".to_string(),
error.to_string(),
)
}
}
impl From<ParsePubkeyError> for ClientError {
fn from(error: ParsePubkeyError) -> Self {
ClientError::Pubkey(
"Pubkey error".to_string(),
error.to_string(),
)
}
}
impl From<Error> for ClientError {
fn from(err: Error) -> Self {
ClientError::Parse(
"JSON serialization error".to_string(),
err.to_string()
)
}
}
pub type ClientResult<T> = Result<T, ClientError>;
+123
View File
@@ -0,0 +1,123 @@
use anyhow::anyhow;
use base64::engine::general_purpose;
use base64::Engine;
use borsh::{BorshDeserialize, BorshSerialize};
use regex::Regex;
use solana_sdk::pubkey::Pubkey;
use super::myerror::AppError;
pub const PROGRAM_DATA: &str = "Program data: ";
#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize)]
pub struct TradeEvent {
pub mint: Pubkey,
pub sol_amount: u64,
pub token_amount: u64,
pub is_buy: bool,
pub user: Pubkey,
pub timestamp: u64,
pub virtual_sol_reserves: u64,
pub virtual_token_reserves: u64,
pub real_sol_reserves: u64,
pub real_token_reserves: u64,
}
#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize)]
pub struct CompleteEvent {
pub user: Pubkey,
pub mint: Pubkey,
pub bonding_curve: Pubkey,
pub timestamp: u64,
}
#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize)]
pub struct SwapBaseInLog {
pub log_type: u8,
// input
pub amount_in: u64,
pub minimum_out: u64,
pub direction: u64,
// user info
pub user_source: u64,
// pool info
pub pool_coin: u64,
pub pool_pc: u64,
// calc result
pub out_amount: u64,
}
pub trait EventTrait: Sized + std::fmt::Debug {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError>;
}
impl EventTrait for TradeEvent {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError> {
TradeEvent::try_from_slice(bytes).map_err(|e| AppError::from(anyhow!(e.to_string())))
}
}
impl EventTrait for CompleteEvent {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError> {
CompleteEvent::try_from_slice(bytes).map_err(|e| AppError::from(anyhow!(e.to_string())))
}
}
impl EventTrait for SwapBaseInLog {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError> {
SwapBaseInLog::try_from_slice(bytes).map_err(|e| AppError::from(anyhow!(e.to_string())))
}
}
#[derive(Debug, Clone, Copy)]
pub struct PumpEvent {}
impl PumpEvent {
pub fn parse_logs<T: EventTrait + Clone>(logs: &Vec<String>) -> Option<T> {
let mut event: Option<T> = None;
if !logs.is_empty() {
let logs_iter = logs.iter().peekable();
for l in logs_iter.rev() {
if let Some(log) = l.strip_prefix(PROGRAM_DATA) {
let borsh_bytes = general_purpose::STANDARD.decode(log).unwrap();
let slice: &[u8] = &borsh_bytes[8..];
if let Ok(e) = T::from_bytes(slice) {
event = Some(e);
}
}
}
}
event
}
}
#[derive(Debug, Clone, Copy)]
pub struct RaydiumEvent {}
impl RaydiumEvent {
pub fn parse_logs<T: EventTrait + Clone>(logs: &Vec<String>) -> Option<T> {
let mut event: Option<T> = None;
if !logs.is_empty() {
let logs_iter = logs.iter().peekable();
for l in logs_iter.rev() {
let re = Regex::new(r"ray_log: (?P<base64>[A-Za-z0-9+/=]+)").unwrap();
if let Some(caps) = re.captures(l) {
if let Some(base64) = caps.name("base64") {
let bytes = general_purpose::STANDARD.decode(base64.as_str()).unwrap();
if let Ok(e) = T::from_bytes(&bytes) {
event = Some(e);
}
}
}
}
}
event
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod event;
pub mod myerror;
// pub mod subscribe_logs;
// pub mod subscribe_tx;
pub mod yellowstone_grpc;
+13
View File
@@ -0,0 +1,13 @@
use anyhow::Error;
#[derive(Debug)]
#[allow(dead_code)]
pub struct AppError(Error);
impl<E> From<E> for AppError
where
E: Into<Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
+136
View File
@@ -0,0 +1,136 @@
#[cfg(test)]
mod subscribe_tx_tests {
use crate::common::{
event::{PumpEvent, RaydiumEvent, SwapBaseInLog, TradeEvent},
myerror::AppError,
yellowstone_grpc::{TransactionPretty, YellowstoneGrpc},
};
use anyhow::anyhow;
use chrono::Local;
use dotenvy::dotenv;
use futures::{channel::mpsc, sink::SinkExt, stream::StreamExt};
use log::{error, info};
use solana_sdk::pubkey;
use solana_sdk::pubkey::Pubkey;
use solana_transaction_status::{
option_serializer::OptionSerializer, EncodedTransactionWithStatusMeta,
};
use std::env;
use tokio::test;
use yellowstone_grpc_proto::geyser::{
subscribe_update::UpdateOneof, SubscribeRequest, SubscribeRequestPing,
};
const AMM_V4: Pubkey = pubkey!("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8");
const PUMP_PROGRAM_ID: Pubkey = pubkey!("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P");
pub enum SwapType {
Pump,
Raydium,
}
#[test]
async fn test_subscribe_tx() -> Result<(), AppError> {
dotenv().ok();
pretty_env_logger::init_custom_env("RUST_LOG");
let yellowstone_url = env::var("YELLOWSTONE_URL")?;
info!("::: {:?}", yellowstone_url);
let yellowstone_grpc = YellowstoneGrpc::new(yellowstone_url);
let addrs = vec![
"Aa4QWNkS3RLUv7DA9BM1a2Hzm4HDQo5PyRefqDJnpump".to_string(),
"BnDssYyGDF9aj5j2N5BwsJFk9YMneQ8P7LQkoYkrpump".to_string(),
];
let transactions = yellowstone_grpc.subscribe_transaction(addrs, vec![], vec![]);
let (mut subscribe_tx, mut stream) = yellowstone_grpc.connect(transactions).await??;
let (mut tx, mut rx) = mpsc::channel::<TransactionPretty>(1000);
tokio::spawn(async move {
while let Some(message) = stream.next().await {
match message {
Ok(msg) => {
match msg.update_oneof {
Some(UpdateOneof::Transaction(sut)) => {
let transaction_pretty: TransactionPretty = sut.into();
let _ = tx.try_send(transaction_pretty);
}
Some(UpdateOneof::Ping(_)) => {
// This is necessary to keep load balancers that expect client pings alive. If your load balancer doesn't
// require periodic client pings then this is unnecessary
let _ = subscribe_tx
.send(SubscribeRequest {
ping: Some(SubscribeRequestPing { id: 1 }),
..Default::default()
})
.await;
info!("service is ping: {}", Local::now());
}
Some(UpdateOneof::Pong(_)) => {
info!("service is pong: {}", Local::now());
}
_ => {}
}
continue;
}
Err(error) => {
error!("error: {error:?}");
break;
}
}
}
});
while let Some(transaction_pretty) = rx.next().await {
let trade_raw = transaction_pretty.tx.clone();
let meta = &trade_raw.meta.clone().unwrap();
if meta.err.is_some() {
continue;
}
let logs = if let OptionSerializer::Some(logs) = &meta.log_messages {
logs
} else {
&vec![]
};
if let Ok(swap_type) = get_swap_type(&trade_raw) {
match swap_type {
SwapType::Raydium => {
let event = RaydiumEvent::parse_logs::<SwapBaseInLog>(logs);
info!("RaydiumEvent {:#?}", event);
}
SwapType::Pump => {
let event = PumpEvent::parse_logs::<TradeEvent>(logs);
info!("PumpEvent {:#?}", event);
}
}
};
}
Ok(())
}
#[allow(dead_code)]
pub fn get_swap_type(
trade_raw: &EncodedTransactionWithStatusMeta,
) -> Result<SwapType, AppError> {
let transaction = &trade_raw.transaction.decode();
if let Some(transaction) = transaction {
let account_keys = transaction.message.static_account_keys();
let program_index = account_keys
.iter()
.position(|item| item == &AMM_V4 || item == &PUMP_PROGRAM_ID)
.ok_or(anyhow!("swap type program_id not found"))?;
let program_id = account_keys[program_index];
let _type = match program_id {
AMM_V4 => Ok(SwapType::Raydium),
PUMP_PROGRAM_ID => Ok(SwapType::Pump),
_ => Err(AppError::from(anyhow!("program_id ix not found"))),
};
return _type;
}
Err(AppError::from(anyhow!("program_id ix not found")))
}
}
+73
View File
@@ -0,0 +1,73 @@
#[cfg(test)]
mod subscribe_tx_tests {
use crate::common::{
myerror::AppError,
yellowstone_grpc::{TransactionPretty, YellowstoneGrpc},
};
use chrono::Local;
use dotenvy::dotenv;
use futures::{channel::mpsc, sink::SinkExt, stream::StreamExt};
use log::{error, info};
use std::env;
use tokio::test;
use yellowstone_grpc_proto::geyser::{
subscribe_update::UpdateOneof, SubscribeRequest, SubscribeRequestPing,
};
#[test]
async fn test_subscribe_tx() -> Result<(), AppError> {
dotenv().ok();
pretty_env_logger::init_custom_env("RUST_LOG");
let yellowstone_url = env::var("YELLOWSTONE_URL")?;
info!("::: {:?}", yellowstone_url);
let yellowstone_grpc = YellowstoneGrpc::new(yellowstone_url);
let addrs = vec!["6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P".to_string()];
let transactions = yellowstone_grpc.subscribe_transaction(addrs, vec![], vec![]);
let (mut subscribe_tx, mut stream) = yellowstone_grpc.connect(transactions).await??;
let (mut tx, mut rx) = mpsc::channel::<TransactionPretty>(1000);
tokio::spawn(async move {
while let Some(message) = stream.next().await {
match message {
Ok(msg) => {
match msg.update_oneof {
Some(UpdateOneof::Transaction(sut)) => {
let transaction_pretty: TransactionPretty = sut.into();
let _ = tx.try_send(transaction_pretty);
}
Some(UpdateOneof::Ping(_)) => {
// This is necessary to keep load balancers that expect client pings alive. If your load balancer doesn't
// require periodic client pings then this is unnecessary
let _ = subscribe_tx
.send(SubscribeRequest {
ping: Some(SubscribeRequestPing { id: 1 }),
..Default::default()
})
.await;
info!("service is ping: {}", Local::now());
}
Some(UpdateOneof::Pong(_)) => {
info!("service is pong: {}", Local::now());
}
_ => {}
}
continue;
}
Err(error) => {
error!("error: {error:?}");
break;
}
}
}
});
while let Some(event) = rx.next().await {
info!("TransactionPretty {:#?}", event);
}
Ok(())
}
}
+284
View File
@@ -0,0 +1,284 @@
use std::{collections::HashMap, fmt, time::Duration};
use futures::{channel::mpsc, sink::Sink, Stream, StreamExt, SinkExt};
use rustls::crypto::{ring::default_provider, CryptoProvider};
use tonic::{transport::channel::ClientTlsConfig, Status};
use yellowstone_grpc_client::{GeyserGrpcClient, GeyserGrpcClientResult};
use yellowstone_grpc_proto::geyser::{
CommitmentLevel, SubscribeRequest, SubscribeRequestFilterTransactions, SubscribeUpdate,
SubscribeUpdateTransaction, subscribe_update::UpdateOneof, SubscribeRequestPing,
};
use log::{error, info};
use chrono::Local;
use solana_sdk::{pubkey, pubkey::Pubkey, signature::Signature};
use solana_transaction_status::{
option_serializer::OptionSerializer, EncodedTransactionWithStatusMeta, UiTransactionEncoding,
};
use anyhow::anyhow;
use crate::grpc::event::{PumpEvent, RaydiumEvent, SwapBaseInLog, TradeEvent};
use crate::grpc::myerror::AppError;
// 类型别名定义
type TransactionsFilterMap = HashMap<String, SubscribeRequestFilterTransactions>;
type GrpcStreamResult = GeyserGrpcClientResult<(
Box<dyn Sink<SubscribeRequest, Error = mpsc::SendError> + Unpin + Send>,
Box<dyn Stream<Item = Result<SubscribeUpdate, Status>> + Unpin + Send>,
)>;
// 常量定义
const AMM_V4: Pubkey = pubkey!("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8");
const PUMP_PROGRAM_ID: Pubkey = pubkey!("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P");
const CONNECT_TIMEOUT: u64 = 10;
const REQUEST_TIMEOUT: u64 = 60;
const CHANNEL_SIZE: usize = 1000;
// 枚举定义
#[derive(Debug)]
pub enum SwapType {
Pump,
Raydium,
}
// 结构体定义
#[allow(dead_code)]
pub struct TransactionPretty {
pub slot: u64,
pub signature: Signature,
pub is_vote: bool,
pub tx: EncodedTransactionWithStatusMeta,
}
impl fmt::Debug for TransactionPretty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
struct TxWrap<'a>(&'a EncodedTransactionWithStatusMeta);
impl<'a> fmt::Debug for TxWrap<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let serialized = serde_json::to_string(self.0).expect("failed to serialize");
fmt::Display::fmt(&serialized, f)
}
}
f.debug_struct("TransactionPretty")
.field("slot", &self.slot)
.field("signature", &self.signature)
.field("is_vote", &self.is_vote)
.field("tx", &TxWrap(&self.tx))
.finish()
}
}
impl From<SubscribeUpdateTransaction> for TransactionPretty {
fn from(SubscribeUpdateTransaction { transaction, slot }: SubscribeUpdateTransaction) -> Self {
let tx = transaction.expect("should be defined");
Self {
slot,
signature: Signature::try_from(tx.signature.as_slice()).expect("valid signature"),
is_vote: tx.is_vote,
tx: yellowstone_grpc_proto::convert_from::create_tx_with_meta(tx)
.expect("valid tx with meta")
.encode(UiTransactionEncoding::Base64, Some(u8::MAX), true)
.expect("failed to encode"),
}
}
}
pub struct YellowstoneGrpc {
endpoint: String,
}
impl YellowstoneGrpc {
pub fn new(endpoint: String) -> Self {
Self { endpoint }
}
pub async fn connect(
&self,
transactions: TransactionsFilterMap,
) -> Result<
GeyserGrpcClientResult<(
impl Sink<SubscribeRequest, Error = mpsc::SendError>,
impl Stream<Item = Result<SubscribeUpdate, Status>>,
)>,
AppError,
> {
if CryptoProvider::get_default().is_none() {
default_provider()
.install_default()
.map_err(|e| anyhow::anyhow!("Failed to install crypto provider: {:?}", e))?;
}
let mut client = GeyserGrpcClient::build_from_shared(self.endpoint.clone())?
.tls_config(ClientTlsConfig::new().with_native_roots())?
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.connect()
.await?;
let subscribe_request = SubscribeRequest {
transactions,
commitment: Some(CommitmentLevel::Processed.into()),
..Default::default()
};
Ok(client.subscribe_with_request(Some(subscribe_request)).await)
}
pub async fn subscribe_accounts(&self, accounts: Vec<String>) -> Result<(), AppError> {
let transactions = self.get_subscribe_request_filter(accounts, vec![], vec![]);
let (mut subscribe_tx, mut stream) = self.connect(transactions).await??;
let (mut tx, mut rx) = mpsc::channel::<TransactionPretty>(CHANNEL_SIZE);
tokio::spawn(async move {
while let Some(message) = stream.next().await {
match message {
Ok(msg) => {
if let Err(e) = Self::handle_stream_message(msg, &mut tx, &mut subscribe_tx).await {
error!("Error handling message: {:?}", e);
break;
}
}
Err(error) => {
error!("Stream error: {error:?}");
break;
}
}
}
});
while let Some(transaction_pretty) = rx.next().await {
if let Err(e) = Self::process_transaction(transaction_pretty).await {
error!("Error processing transaction: {:?}", e);
}
}
Ok(())
}
pub async fn subscribe_pumpfun(&self) -> Result<(), AppError> {
let addrs = vec![PUMP_PROGRAM_ID.to_string()];
let transactions = self.get_subscribe_request_filter(addrs, vec![], vec![]);
let (mut subscribe_tx, mut stream) = self.connect(transactions).await??;
let (mut tx, mut rx) = mpsc::channel::<TransactionPretty>(CHANNEL_SIZE);
tokio::spawn(async move {
while let Some(message) = stream.next().await {
match message {
Ok(msg) => {
if let Err(e) = Self::handle_stream_message(msg, &mut tx, &mut subscribe_tx).await {
error!("Error handling message: {:?}", e);
break;
}
}
Err(error) => {
error!("Stream error: {error:?}");
break;
}
}
}
});
while let Some(transaction_pretty) = rx.next().await {
if let Err(e) = Self::process_transaction(transaction_pretty).await {
error!("Error processing transaction: {:?}", e);
}
}
Ok(())
}
pub fn get_subscribe_request_filter(
&self,
account_include: Vec<String>,
account_exclude: Vec<String>,
account_required: Vec<String>,
) -> TransactionsFilterMap {
let mut transactions = HashMap::new();
transactions.insert(
"client".to_string(),
SubscribeRequestFilterTransactions {
vote: Some(false),
failed: Some(false),
signature: None,
account_include,
account_exclude,
account_required,
},
);
transactions
}
async fn handle_stream_message(
msg: SubscribeUpdate,
tx: &mut mpsc::Sender<TransactionPretty>,
subscribe_tx: &mut (impl Sink<SubscribeRequest, Error = mpsc::SendError> + Unpin),
) -> Result<(), AppError> {
match msg.update_oneof {
Some(UpdateOneof::Transaction(sut)) => {
let transaction_pretty = TransactionPretty::from(sut);
tx.try_send(transaction_pretty).map_err(|e| AppError::from(anyhow!("Send error: {:?}", e)))?;
}
Some(UpdateOneof::Ping(_)) => {
subscribe_tx
.send(SubscribeRequest {
ping: Some(SubscribeRequestPing { id: 1 }),
..Default::default()
})
.await
.map_err(|e| AppError::from(anyhow!("Ping error: {:?}", e)))?;
info!("service is ping: {}", Local::now());
}
Some(UpdateOneof::Pong(_)) => {
info!("service is pong: {}", Local::now());
}
_ => {}
}
Ok(())
}
async fn process_transaction(transaction_pretty: TransactionPretty) -> Result<(), AppError> {
let trade_raw = transaction_pretty.tx;
let meta = trade_raw.meta.as_ref()
.ok_or_else(|| AppError::from(anyhow!("Missing transaction metadata")))?;
if meta.err.is_some() {
return Ok(());
}
let logs = if let OptionSerializer::Some(logs) = &meta.log_messages {
logs
} else {
&vec![]
};
if let Ok(swap_type) = Self::get_swap_type(&trade_raw) {
match swap_type {
SwapType::Raydium => {
let event = RaydiumEvent::parse_logs::<SwapBaseInLog>(logs);
info!("RaydiumEvent {:#?}", event);
}
SwapType::Pump => {
let event = PumpEvent::parse_logs::<TradeEvent>(logs);
info!("PumpEvent {:#?}", event);
}
}
}
Ok(())
}
pub fn get_swap_type(trade_raw: &EncodedTransactionWithStatusMeta) -> Result<SwapType, AppError> {
let transaction = trade_raw.transaction.decode()
.ok_or_else(|| AppError::from(anyhow!("Failed to decode transaction")))?;
let account_keys = transaction.message.static_account_keys();
let program_index = account_keys
.iter()
.position(|item| item == &AMM_V4 || item == &PUMP_PROGRAM_ID)
.ok_or_else(|| AppError::from(anyhow!("swap type program_id not found")))?;
match account_keys[program_index] {
AMM_V4 => Ok(SwapType::Raydium),
PUMP_PROGRAM_ID => Ok(SwapType::Pump),
_ => Err(AppError::from(anyhow!("Invalid program_id")))
}
}
}
+123
View File
@@ -0,0 +1,123 @@
use anyhow::anyhow;
use base64::engine::general_purpose;
use base64::Engine;
use borsh::{BorshDeserialize, BorshSerialize};
use regex::Regex;
use solana_sdk::pubkey::Pubkey;
use super::myerror::AppError;
pub const PROGRAM_DATA: &str = "Program data: ";
#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize)]
pub struct TradeEvent {
pub mint: Pubkey,
pub sol_amount: u64,
pub token_amount: u64,
pub is_buy: bool,
pub user: Pubkey,
pub timestamp: u64,
pub virtual_sol_reserves: u64,
pub virtual_token_reserves: u64,
pub real_sol_reserves: u64,
pub real_token_reserves: u64,
}
#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize)]
pub struct CompleteEvent {
pub user: Pubkey,
pub mint: Pubkey,
pub bonding_curve: Pubkey,
pub timestamp: u64,
}
#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize)]
pub struct SwapBaseInLog {
pub log_type: u8,
// input
pub amount_in: u64,
pub minimum_out: u64,
pub direction: u64,
// user info
pub user_source: u64,
// pool info
pub pool_coin: u64,
pub pool_pc: u64,
// calc result
pub out_amount: u64,
}
pub trait EventTrait: Sized + std::fmt::Debug {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError>;
}
impl EventTrait for TradeEvent {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError> {
TradeEvent::try_from_slice(bytes).map_err(|e| AppError::from(anyhow!(e.to_string())))
}
}
impl EventTrait for CompleteEvent {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError> {
CompleteEvent::try_from_slice(bytes).map_err(|e| AppError::from(anyhow!(e.to_string())))
}
}
impl EventTrait for SwapBaseInLog {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError> {
SwapBaseInLog::try_from_slice(bytes).map_err(|e| AppError::from(anyhow!(e.to_string())))
}
}
#[derive(Debug, Clone, Copy)]
pub struct PumpEvent {}
impl PumpEvent {
pub fn parse_logs<T: EventTrait + Clone>(logs: &Vec<String>) -> Option<T> {
let mut event: Option<T> = None;
if !logs.is_empty() {
let logs_iter = logs.iter().peekable();
for l in logs_iter.rev() {
if let Some(log) = l.strip_prefix(PROGRAM_DATA) {
let borsh_bytes = general_purpose::STANDARD.decode(log).unwrap();
let slice: &[u8] = &borsh_bytes[8..];
if let Ok(e) = T::from_bytes(slice) {
event = Some(e);
}
}
}
}
event
}
}
#[derive(Debug, Clone, Copy)]
pub struct RaydiumEvent {}
impl RaydiumEvent {
pub fn parse_logs<T: EventTrait + Clone>(logs: &Vec<String>) -> Option<T> {
let mut event: Option<T> = None;
if !logs.is_empty() {
let logs_iter = logs.iter().peekable();
for l in logs_iter.rev() {
let re = Regex::new(r"ray_log: (?P<base64>[A-Za-z0-9+/=]+)").unwrap();
if let Some(caps) = re.captures(l) {
if let Some(base64) = caps.name("base64") {
let bytes = general_purpose::STANDARD.decode(base64.as_str()).unwrap();
if let Ok(e) = T::from_bytes(&bytes) {
event = Some(e);
}
}
}
}
}
event
}
}
+37
View File
@@ -0,0 +1,37 @@
use serde::{Serialize, Deserialize};
#[derive(Debug)]
pub enum DexInstruction {
CreateToken(CreateTokenInfo),
UserTrade(TradeInfo),
BotTrade(TradeInfo),
Other,
}
// 添加新的数据结构
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateTokenInfo {
pub signature: String,
pub name: String,
pub symbol: String,
pub uri: String,
pub mint: String,
pub bonding_curve: String,
pub user: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TradeInfo {
pub signature: String,
pub mint: String,
pub bonding_curve: String,
pub sol_amount: u64,
pub token_amount: u64,
pub is_buy: bool,
pub user: String,
pub timestamp: i64,
pub virtual_sol_reserves: u64,
pub virtual_token_reserves: u64,
pub real_sol_reserves: u64,
pub real_token_reserves: u64,
}
+10
View File
@@ -0,0 +1,10 @@
use serde::{Serialize, Deserialize};
use crate::instruction::logs_data::{CreateTokenInfo, TradeInfo};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DexEvent {
NewToken(CreateTokenInfo),
NewUserTrade(TradeInfo),
NewBotTrade(TradeInfo),
Error(String),
}
+88
View File
@@ -0,0 +1,88 @@
use crate::instruction::logs_data::DexInstruction;
use crate::instruction::logs_parser::{parse_create_token_data, parse_trade_data};
use crate::error::ClientResult;
use solana_sdk::pubkey::Pubkey;
pub struct LogFilter;
impl LogFilter {
const PROGRAM_ID: &'static str = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
/// Parse transaction logs and return instruction type and data
pub fn parse_instruction(logs: &[String], bot_wallet: Option<Pubkey>) -> ClientResult<Vec<DexInstruction>> {
let mut current_instruction = None;
let mut program_data = String::new();
let mut invoke_depth = 0;
let mut last_data_len = 0;
let mut instructions = Vec::new();
for log in logs {
// Check program invocation
if log.contains(&format!("Program {} invoke", Self::PROGRAM_ID)) {
invoke_depth += 1;
if invoke_depth == 1 { // Only reset state at top level call
current_instruction = None;
program_data.clear();
last_data_len = 0;
}
continue;
}
// Skip if not in our program
if invoke_depth == 0 {
continue;
}
// Identify instruction type (only at top level)
if invoke_depth == 1 && log.contains("Program log: Instruction:") {
if log.contains("Create") {
current_instruction = Some("create");
} else if log.contains("Buy") || log.contains("Sell") {
current_instruction = Some("trade");
}
continue;
}
// Collect Program data
if log.starts_with("Program data: ") {
let data = log.trim_start_matches("Program data: ");
if data.len() > last_data_len {
program_data = data.to_string();
last_data_len = data.len();
}
}
// Check if program ends
if log.contains(&format!("Program {} success", Self::PROGRAM_ID)) {
invoke_depth -= 1;
if invoke_depth == 0 { // Only process data when top level program ends
if let Some(instruction_type) = current_instruction {
if !program_data.is_empty() {
match instruction_type {
"create" => {
if let Ok(token_info) = parse_create_token_data(&program_data) {
instructions.push(DexInstruction::CreateToken(token_info));
}
},
"trade" => {
if let Ok(trade_info) = parse_trade_data(&program_data) {
if let Some(bot_wallet_pubkey) = bot_wallet {
if trade_info.user == bot_wallet_pubkey.to_string() {
instructions.push(DexInstruction::BotTrade(trade_info));
} else {
instructions.push(DexInstruction::UserTrade(trade_info));
}
} else {
instructions.push(DexInstruction::UserTrade(trade_info));
}
}
},
_ => {}
}
}
}
}
}
}
Ok(instructions)
}
}
+173
View File
@@ -0,0 +1,173 @@
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::error::{ClientError, ClientResult};
use crate::instruction::{
logs_data::{DexInstruction, CreateTokenInfo, TradeInfo},
logs_filters::LogFilter
};
use solana_sdk::pubkey::Pubkey;
pub async fn process_logs<F>(
signature: &str,
logs: Vec<String>,
callback: F,
payer: Option<Pubkey>,
) -> ClientResult<()>
where
F: Fn(&str, DexInstruction) + Send + Sync,
{
let instructions = LogFilter::parse_instruction(&logs, payer)?;
for instruction in instructions {
callback(signature, instruction);
}
Ok(())
}
// Add parsing function
pub fn parse_create_token_data(data: &str) -> ClientResult<CreateTokenInfo> {
// First do base64 decoding
let decoded = BASE64.decode(data)
.map_err(|e| ClientError::Other(format!("Failed to decode base64: {}", e)))?;
// Skip prefix bytes (if any)
let mut cursor = if decoded.len() > 8 { 8 } else { 0 };
// Read name length and name
if cursor + 4 > decoded.len() {
return Err(ClientError::Other("Data too short for name length".to_string()));
}
let name_len = read_u32(&decoded[cursor..]) as usize;
cursor += 4;
if cursor + name_len > decoded.len() {
return Err(ClientError::Other(format!("Data too short for name: need {} bytes", name_len)));
}
let name = String::from_utf8(decoded[cursor..cursor + name_len].to_vec())
.map_err(|e| ClientError::Other(format!("Invalid UTF-8 in name: {}", e)))?;
cursor += name_len;
// Read symbol length and symbol
if cursor + 4 > decoded.len() {
return Err(ClientError::Other("Data too short for symbol length".to_string()));
}
let symbol_len = read_u32(&decoded[cursor..]) as usize;
cursor += 4;
if cursor + symbol_len > decoded.len() {
return Err(ClientError::Other(format!("Data too short for symbol: need {} bytes", symbol_len)));
}
let symbol = String::from_utf8(decoded[cursor..cursor + symbol_len].to_vec())
.map_err(|e| ClientError::Other(format!("Invalid UTF-8 in symbol: {}", e)))?;
cursor += symbol_len;
// Read URI length and URI
if cursor + 4 > decoded.len() {
return Err(ClientError::Other("Data too short for URI length".to_string()));
}
let uri_len = read_u32(&decoded[cursor..]) as usize;
cursor += 4;
if cursor + uri_len > decoded.len() {
return Err(ClientError::Other(format!("Data too short for URI: need {} bytes", uri_len)));
}
let uri = String::from_utf8(decoded[cursor..cursor + uri_len].to_vec())
.map_err(|e| ClientError::Other(format!("Invalid UTF-8 in uri: {}", e)))?;
cursor += uri_len;
// Make sure there is enough data to read public keys
if cursor + 32 * 3 > decoded.len() {
return Err(ClientError::Other("Data too short for public keys".to_string()));
}
// Parse Mint Public Key
let mint = bs58::encode(&decoded[cursor..cursor+32]).into_string();
cursor += 32;
// Parse Bonding Curve Public Key
let bonding_curve = bs58::encode(&decoded[cursor..cursor+32]).into_string();
cursor += 32;
// Parse User Public Key
let user = bs58::encode(&decoded[cursor..cursor+32]).into_string();
Ok(CreateTokenInfo {
signature: String::new(),
name,
symbol,
uri,
mint,
bonding_curve,
user,
})
}
fn read_u32(data: &[u8]) -> u32 {
let mut bytes = [0u8; 4];
bytes.copy_from_slice(&data[..4]);
u32::from_le_bytes(bytes)
}
pub fn parse_trade_data(data: &str) -> ClientResult<TradeInfo> {
let engine = base64::engine::general_purpose::STANDARD;
let decoded = engine.decode(data).map_err(|e|
ClientError::Parse(
"Failed to decode base64".to_string(),
e.to_string()
)
)?;
let mut cursor = 8; // Skip prefix
// 1. Mint (32 bytes)
let mint = bs58::encode(&decoded[cursor..cursor + 32]).into_string();
cursor += 32;
// 2. Sol Amount (8 bytes)
let sol_amount = u64::from_le_bytes(decoded[cursor..cursor + 8].try_into().unwrap());
cursor += 8;
// 3. Token Amount (8 bytes)
let token_amount = u64::from_le_bytes(decoded[cursor..cursor + 8].try_into().unwrap());
cursor += 8;
// 4. Is Buy (1 byte)
let is_buy = decoded[cursor] != 0;
cursor += 1;
// 5. User (32 bytes)
let user = bs58::encode(&decoded[cursor..cursor + 32]).into_string();
cursor += 32;
// 6. Timestamp (8 bytes)
let timestamp = i64::from_le_bytes(decoded[cursor..cursor + 8].try_into().unwrap());
cursor += 8;
// 7. Virtual Sol Reserves (8 bytes)
let virtual_sol_reserves = u64::from_le_bytes(decoded[cursor..cursor + 8].try_into().unwrap());
cursor += 8;
// 8. Virtual Token Reserves (8 bytes)
let virtual_token_reserves = u64::from_le_bytes(decoded[cursor..cursor + 8].try_into().unwrap());
cursor += 8;
let real_sol_reserves = u64::from_le_bytes(decoded[cursor..cursor + 8].try_into().unwrap());
cursor += 8;
let real_token_reserves = u64::from_le_bytes(decoded[cursor..cursor + 8].try_into().unwrap());
Ok(TradeInfo {
signature: String::new(),
mint,
bonding_curve: String::new(),
sol_amount,
token_amount,
is_buy,
user,
timestamp,
virtual_sol_reserves,
virtual_token_reserves,
real_sol_reserves,
real_token_reserves,
})
}
+103
View File
@@ -0,0 +1,103 @@
use solana_client::{
nonblocking::pubsub_client::PubsubClient,
rpc_config::{RpcTransactionLogsConfig, RpcTransactionLogsFilter}
};
use solana_sdk::{commitment_config::CommitmentConfig, pubkey::Pubkey};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use futures::StreamExt;
use crate::{constants, instruction::{
logs_data::DexInstruction, logs_events::DexEvent, logs_filters::LogFilter
}};
/// Subscription handle containing task and unsubscribe logic
pub struct SubscriptionHandle {
pub task: JoinHandle<()>,
pub unsub_fn: Box<dyn Fn() + Send>,
}
impl SubscriptionHandle {
pub async fn shutdown(self) {
(self.unsub_fn)();
self.task.abort();
}
}
pub async fn create_pubsub_client(ws_url: &str) -> PubsubClient {
PubsubClient::new(ws_url).await.unwrap()
}
/// 启动订阅
pub async fn tokens_subscription<F>(
ws_url: &str,
commitment: CommitmentConfig,
callback: F,
bot_wallet: Option<Pubkey>,
) -> Result<SubscriptionHandle, Box<dyn std::error::Error>>
where
F: Fn(DexEvent) + Send + Sync + 'static,
{
let program_address = constants::accounts::PUMPFUN.to_string();
let logs_filter = RpcTransactionLogsFilter::Mentions(vec![program_address]);
let logs_config = RpcTransactionLogsConfig {
commitment: Some(commitment),
};
// Create PubsubClient
let sub_client = Arc::new(PubsubClient::new(ws_url).await.unwrap());
let sub_client_clone = Arc::clone(&sub_client);
// Create channel for unsubscribe
let (unsub_tx, _) = mpsc::channel(1);
// Start subscription task
let task = tokio::spawn(async move {
let (mut stream, _) = sub_client_clone.logs_subscribe(logs_filter, logs_config).await.unwrap();
loop {
let msg = stream.next().await;
match msg {
Some(msg) => {
if let Some(_err) = msg.value.err {
continue;
}
let instructions = LogFilter::parse_instruction(&msg.value.logs, bot_wallet).unwrap();
for instruction in instructions {
match instruction {
DexInstruction::CreateToken(token_info) => {
callback(DexEvent::NewToken(token_info));
}
DexInstruction::UserTrade(trade_info) => {
callback(DexEvent::NewUserTrade(trade_info));
}
DexInstruction::BotTrade(trade_info) => {
callback(DexEvent::NewBotTrade(trade_info));
}
_ => {}
}
}
}
None => {
println!("Token subscription stream ended");
}
}
}
});
// Return subscription handle and unsubscribe logic
Ok(SubscriptionHandle {
task,
unsub_fn: Box::new(move || {
let _ = unsub_tx.try_send(());
}),
})
}
pub async fn stop_subscription(handle: SubscriptionHandle) {
handle.shutdown().await;
}
+207
View File
@@ -0,0 +1,207 @@
//! Instructions for interacting with the Pump.fun program.
//!
//! This module contains instruction builders for creating Solana instructions to interact with the
//! Pump.fun program. Each function takes the required accounts and instruction data and returns a
//! properly formatted Solana instruction.
//!
//! # Instructions
//!
//! - `create`: Instruction to create a new token with an associated bonding curve.
//! - `buy`: Instruction to buy tokens from a bonding curve by providing SOL.
//! - `sell`: Instruction to sell tokens back to the bonding curve in exchange for SOL.
pub mod logs_data;
pub mod logs_parser;
pub mod logs_filters;
pub mod logs_events;
pub mod logs_subscribe;
pub use logs_data::*;
pub use logs_parser::*;
pub use logs_filters::*;
pub use logs_events::*;
pub use logs_subscribe::*;
use crate::{constants, PumpFun};
use spl_associated_token_account::get_associated_token_address;
use solana_sdk::{
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
signature::Keypair,
signer::Signer,
};
pub struct Create {
pub _name: String,
pub _symbol: String,
pub _uri: String,
}
impl Create {
pub fn data(&self) -> Vec<u8> {
let mut data = Vec::with_capacity(8 + 8 + 8);
data.extend_from_slice(&[24, 30, 200, 40, 5, 28, 7, 119]); // discriminator
data.extend_from_slice(&self._name.as_bytes());
data.extend_from_slice(&self._symbol.as_bytes());
data.extend_from_slice(&self._uri.as_bytes());
data
}
}
pub struct Buy {
pub _amount: u64,
pub _max_sol_cost: u64,
}
impl Buy {
pub fn data(&self) -> Vec<u8> {
let mut data = Vec::with_capacity(8 + 8 + 8);
data.extend_from_slice(&[102, 6, 61, 18, 1, 218, 235, 234]); // discriminator
data.extend_from_slice(&self._amount.to_le_bytes());
data.extend_from_slice(&self._max_sol_cost.to_le_bytes());
data
}
}
pub struct Sell {
pub _amount: u64,
pub _min_sol_output: u64,
}
impl Sell {
pub fn data(&self) -> Vec<u8> {
let mut data = Vec::with_capacity(8 + 8 + 8);
data.extend_from_slice(&[51, 230, 133, 164, 1, 127, 131, 173]); // discriminator
data.extend_from_slice(&self._amount.to_le_bytes());
data.extend_from_slice(&self._min_sol_output.to_le_bytes());
data
}
}
/// Creates an instruction to create a new token with bonding curve
///
/// Creates a new SPL token with an associated bonding curve that determines its price.
///
/// # Arguments
///
/// * `payer` - Keypair that will pay for account creation and transaction fees
/// * `mint` - Keypair for the new token mint account that will be created
/// * `args` - Create instruction data containing token name, symbol and metadata URI
///
/// # Returns
///
/// Returns a Solana instruction that when executed will create the token and its accounts
pub fn create(payer: &Keypair, mint: &Keypair, args: Create) -> Instruction {
let bonding_curve: Pubkey = PumpFun::get_bonding_curve_pda(&mint.pubkey()).unwrap();
Instruction::new_with_bytes(
constants::accounts::PUMPFUN,
&args.data(),
vec![
AccountMeta::new(mint.pubkey(), true),
AccountMeta::new(PumpFun::get_mint_authority_pda(), false),
AccountMeta::new(bonding_curve, false),
AccountMeta::new(
get_associated_token_address(&bonding_curve, &mint.pubkey()),
false,
),
AccountMeta::new_readonly(PumpFun::get_global_pda(), false),
AccountMeta::new_readonly(constants::accounts::MPL_TOKEN_METADATA, false),
AccountMeta::new(PumpFun::get_metadata_pda(&mint.pubkey()), false),
AccountMeta::new(payer.pubkey(), true),
AccountMeta::new_readonly(constants::accounts::SYSTEM_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::TOKEN_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::ASSOCIATED_TOKEN_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::RENT, false),
AccountMeta::new_readonly(constants::accounts::EVENT_AUTHORITY, false),
AccountMeta::new_readonly(constants::accounts::PUMPFUN, false),
],
)
}
/// Creates an instruction to buy tokens from a bonding curve
///
/// Buys tokens by providing SOL. The amount of tokens received is calculated based on
/// the bonding curve formula. A portion of the SOL is taken as a fee and sent to the
/// fee recipient account.
///
/// # Arguments
///
/// * `payer` - Keypair that will provide the SOL to buy tokens
/// * `mint` - Public key of the token mint to buy
/// * `fee_recipient` - Public key of the account that will receive the transaction fee
/// * `args` - Buy instruction data containing the SOL amount and maximum acceptable token price
///
/// # Returns
///
/// Returns a Solana instruction that when executed will buy tokens from the bonding curve
pub fn buy(
payer: &Keypair,
mint: &Pubkey,
fee_recipient: &Pubkey,
args: Buy,
) -> Instruction {
let bonding_curve: Pubkey = PumpFun::get_bonding_curve_pda(mint).unwrap();
Instruction::new_with_bytes(
constants::accounts::PUMPFUN,
&args.data(),
vec![
AccountMeta::new_readonly(PumpFun::get_global_pda(), false),
AccountMeta::new(*fee_recipient, false),
AccountMeta::new_readonly(*mint, false),
AccountMeta::new(bonding_curve, false),
AccountMeta::new(get_associated_token_address(&bonding_curve, mint), false),
AccountMeta::new(get_associated_token_address(&payer.pubkey(), mint), false),
AccountMeta::new(payer.pubkey(), true),
AccountMeta::new_readonly(constants::accounts::SYSTEM_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::TOKEN_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::RENT, false),
AccountMeta::new_readonly(constants::accounts::EVENT_AUTHORITY, false),
AccountMeta::new_readonly(constants::accounts::PUMPFUN, false),
],
)
}
/// Creates an instruction to sell tokens back to a bonding curve
///
/// Sells tokens back to the bonding curve in exchange for SOL. The amount of SOL received
/// is calculated based on the bonding curve formula. A portion of the SOL is taken as
/// a fee and sent to the fee recipient account.
///
/// # Arguments
///
/// * `payer` - Keypair that owns the tokens to sell
/// * `mint` - Public key of the token mint to sell
/// * `fee_recipient` - Public key of the account that will receive the transaction fee
/// * `args` - Sell instruction data containing token amount and minimum acceptable SOL output
///
/// # Returns
///
/// Returns a Solana instruction that when executed will sell tokens to the bonding curve
pub fn sell(
payer: &Keypair,
mint: &Pubkey,
fee_recipient: &Pubkey,
args: Sell,
) -> Instruction {
let bonding_curve: Pubkey = PumpFun::get_bonding_curve_pda(mint).unwrap();
Instruction::new_with_bytes(
constants::accounts::PUMPFUN,
&args.data(),
vec![
AccountMeta::new_readonly(PumpFun::get_global_pda(), false),
AccountMeta::new(*fee_recipient, false),
AccountMeta::new_readonly(*mint, false),
AccountMeta::new(bonding_curve, false),
AccountMeta::new(get_associated_token_address(&bonding_curve, mint), false),
AccountMeta::new(get_associated_token_address(&payer.pubkey(), mint), false),
AccountMeta::new(payer.pubkey(), true),
AccountMeta::new_readonly(constants::accounts::SYSTEM_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::ASSOCIATED_TOKEN_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::TOKEN_PROGRAM, false),
AccountMeta::new_readonly(constants::accounts::EVENT_AUTHORITY, false),
AccountMeta::new_readonly(constants::accounts::PUMPFUN, false),
],
)
}
+235
View File
@@ -0,0 +1,235 @@
use std::fmt;
use std::str::FromStr;
use rand::seq::SliceRandom;
use reqwest::Client;
use serde_json::{json, Value};
use solana_sdk::{
pubkey::Pubkey,
transaction::Transaction,
};
use crate::error::{ClientError, ClientResult};
#[derive(Clone, Debug)]
pub struct JitoClient {
base_url: String,
uuid: Option<String>,
client: Client,
}
#[derive(Debug)]
pub struct PrettyJsonValue(pub Value);
impl fmt::Display for PrettyJsonValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", serde_json::to_string_pretty(&self.0).unwrap())
}
}
impl From<Value> for PrettyJsonValue {
fn from(value: Value) -> Self {
PrettyJsonValue(value)
}
}
impl JitoClient {
pub fn new(base_url: &str, uuid: Option<String>) -> Self {
Self {
base_url: base_url.to_string(),
uuid,
client: Client::new(),
}
}
async fn send_request(&self, endpoint: &str, method: &str, params: Option<Value>) -> ClientResult<Value> {
let url = format!("{}{}", self.base_url, endpoint);
let data = json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params.unwrap_or(json!([]))
});
// println!("Sending request to: {}", url);
// println!("Request body: {}", serde_json::to_string_pretty(&data).unwrap());
let response = self.client
.post(&url)
.header("Content-Type", "application/json")
.json(&data)
.send()
.await
.map_err(|e| ClientError::Other(format!("Request failed: {}", e)))?;
let status = response.status();
// println!("Response status: {}", status);
let body = response.json::<Value>().await
.map_err(|e| ClientError::Other(format!("Failed to parse response: {}", e)))?;
// println!("Response body: {}", serde_json::to_string_pretty(&body).unwrap());
Ok(body)
}
pub async fn get_tip_accounts(&self) -> ClientResult<Value> {
let endpoint = if let Some(uuid) = &self.uuid {
format!("/bundles?uuid={}", uuid)
} else {
"/bundles".to_string()
};
self.send_request(&endpoint, "getTipAccounts", None).await
}
// Get a random tip account
pub async fn get_tip_account(&self) -> ClientResult<Pubkey> {
let tip_accounts_response = self.get_tip_accounts().await?;
let tip_accounts = tip_accounts_response["result"]
.as_array()
.ok_or_else(|| ClientError::Other("Failed to parse tip accounts as array".to_string()))?;
if tip_accounts.is_empty() {
return Err(ClientError::Other("No tip accounts available".to_string()));
}
let random_account = tip_accounts
.choose(&mut rand::thread_rng())
.ok_or_else(|| ClientError::Other("Failed to choose random tip account".to_string()))?;
let address = random_account
.as_str()
.ok_or_else(|| ClientError::Other("Failed to parse tip account as string".to_string()))?;
Pubkey::from_str(address)
.map_err(|e| ClientError::Other(format!("Failed to parse pubkey: {}", e)))
}
pub async fn get_bundle_statuses(&self, bundle_uuids: Vec<String>) -> ClientResult<Value> {
let endpoint = if let Some(uuid) = &self.uuid {
format!("/bundles?uuid={}", uuid)
} else {
"/bundles".to_string()
};
// Construct the params as a list within a list
let params = json!([bundle_uuids]);
self.send_request(&endpoint, "getBundleStatuses", Some(params))
.await
}
pub async fn send_transaction(
&self,
transaction: &Transaction,
) -> ClientResult<String> {
let wire_transaction = bincode::serialize(transaction).map_err(|e| {
ClientError::Parse(
"Transaction serialization failed".to_string(),
e.to_string(),
)
})?;
let serialized_tx = bs58::encode(&wire_transaction).into_string();
// Prepare bundle for submission (array of transactions)
let bundle = json!([serialized_tx]);
// UUID for the bundle
let uuid = None;
// Send bundle using Jito SDK
// println!("Sending bundle with 1 transaction...");
let response = self.send_bundle(Some(bundle), uuid).await?;
response["result"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| ClientError::Parse(
"Invalid response format".to_string(),
"Missing result field".to_string(),
))
}
pub async fn send_bundle(&self, params: Option<Value>, uuid: Option<&str>) -> ClientResult<Value> {
let mut endpoint = "/bundles".to_string();
if let Some(uuid) = uuid {
endpoint = format!("{}?uuid={}", endpoint, uuid);
}
// Ensure params is an array of transactions
let transactions = match params {
Some(Value::Array(transactions)) => {
if transactions.is_empty() {
return Err(ClientError::Other("Bundle must contain at least one transaction".to_string()));
}
if transactions.len() > 5 {
return Err(ClientError::Other("Bundle can contain at most 5 transactions".to_string()));
}
transactions
},
_ => return Err(ClientError::Other("Invalid bundle format: expected an array of transactions".to_string())),
};
// Wrap the transactions array in another array
let params = json!([transactions]);
// Send the wrapped transactions array
self.send_request(&endpoint, "sendBundle", Some(params))
.await
}
pub async fn send_txn(&self, params: Option<Value>, bundle_only: bool) -> ClientResult<Value> {
let mut query_params = Vec::new();
if bundle_only {
query_params.push("bundleOnly=true".to_string());
}
let endpoint = if query_params.is_empty() {
"/transactions".to_string()
} else {
format!("/transactions?{}", query_params.join("&"))
};
// Construct params as an array instead of an object
let params = match params {
Some(Value::Object(map)) => {
let tx = map.get("tx").and_then(Value::as_str).unwrap_or_default();
let skip_preflight = map.get("skipPreflight").and_then(Value::as_bool).unwrap_or(false);
json!([
tx,
{
"encoding": "base64",
"skipPreflight": skip_preflight
}
])
},
_ => json!([]),
};
self.send_request(&endpoint, "sendTransaction", Some(params)).await
}
pub async fn get_in_flight_bundle_statuses(&self, bundle_uuids: Vec<String>) -> ClientResult<Value> {
let endpoint = if let Some(uuid) = &self.uuid {
format!("/bundles?uuid={}", uuid)
} else {
"/bundles".to_string()
};
// Construct the params as a list within a list
let params = json!([bundle_uuids]);
self.send_request(&endpoint, "getInflightBundleStatuses", Some(params))
.await
}
// Helper method to convert Value to PrettyJsonValue
pub fn prettify(value: Value) -> PrettyJsonValue {
PrettyJsonValue(value)
}
}
Executable
+642
View File
@@ -0,0 +1,642 @@
pub mod accounts;
pub mod constants;
pub mod error;
pub mod instruction;
pub mod utils;
pub mod jito;
pub mod grpc;
use solana_client::rpc_client::RpcClient;
use solana_sdk::{
commitment_config::CommitmentConfig,
pubkey::Pubkey,
signature::{Keypair, Signature},
signer::Signer,
instruction::Instruction,
system_instruction,
compute_budget::ComputeBudgetInstruction,
transaction::Transaction,
};
use spl_associated_token_account::{
get_associated_token_address,
create_associated_token_account,
};
use instruction::logs_subscribe;
use instruction::logs_subscribe::SubscriptionHandle;
use instruction::logs_events::DexEvent;
use std::sync::Arc;
use std::time::Instant;
use crate::jito::JitoClient;
use crate::error::ClientError;
use borsh::BorshDeserialize;
// Constants
const DEFAULT_SLIPPAGE: u64 = 1000; // 10%
const DEFAULT_COMPUTE_UNIT_LIMIT: u32 = 10_000_000;
const DEFAULT_COMPUTE_UNIT_PRICE: u64 = 500_000;
const JITO_TIP_AMOUNT: u64 = 1_000; // 0.000001 SOL
/// Priority fee configuration
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PriorityFee {
pub limit: Option<u32>,
pub price: Option<u64>,
}
impl Default for PriorityFee {
fn default() -> Self {
Self { limit: Some(DEFAULT_COMPUTE_UNIT_LIMIT), price: Some(DEFAULT_COMPUTE_UNIT_PRICE) }
}
}
pub struct PumpFun {
pub rpc: RpcClient,
pub payer: Arc<Keypair>,
pub jito_client: Option<JitoClient>,
}
impl Clone for PumpFun {
fn clone(&self) -> Self {
Self {
rpc: RpcClient::new_with_commitment(
self.rpc.url().to_string(),
self.rpc.commitment()
),
payer: self.payer.clone(),
jito_client: self.jito_client.clone(),
}
}
}
impl PumpFun {
/// Create a new PumpFun client instance
pub fn new(
rpc_url: String,
commitment: Option<CommitmentConfig>,
payer: Arc<Keypair>,
jito_url: Option<String>,
) -> Self {
let rpc = RpcClient::new_with_commitment(
rpc_url,
commitment.unwrap_or(CommitmentConfig::confirmed())
);
let jito_client = jito_url.map(|url| JitoClient::new(&url, None));
Self {
rpc,
payer,
jito_client,
}
}
/// Create a new token
pub async fn create(
&self,
mint: &Keypair,
metadata: utils::CreateTokenMetadata,
priority_fee: Option<PriorityFee>,
) -> Result<Signature, ClientError> {
let ipfs = utils::create_token_metadata(metadata)
.await
.map_err(ClientError::UploadMetadataError)?;
let mut instructions = self.create_priority_fee_instructions(priority_fee);
instructions.push(instruction::create(
&self.payer.clone(),
mint,
instruction::Create {
_name: ipfs.metadata.name,
_symbol: ipfs.metadata.symbol,
_uri: ipfs.metadata.image,
},
));
let recent_blockhash = self.rpc.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&instructions,
Some(&self.payer.pubkey()),
&[&self.payer.clone(), mint],
recent_blockhash,
);
let signature = self.rpc.send_and_confirm_transaction(&transaction)?;
Ok(signature)
}
/// Create and buy tokens in one transaction
pub async fn create_and_buy(
&self,
mint: &Keypair,
metadata: utils::CreateTokenMetadata,
amount_sol: u64,
slippage_basis_points: Option<u64>,
priority_fee: Option<PriorityFee>,
) -> Result<Signature, ClientError> {
let ipfs = utils::create_token_metadata(metadata)
.await
.map_err(ClientError::UploadMetadataError)?;
let global_account = self.get_global_account()?;
let buy_amount = global_account.get_initial_buy_price(amount_sol);
let buy_amount_with_slippage =
utils::calculate_with_slippage_buy(amount_sol, slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE));
let mut instructions = self.create_priority_fee_instructions(priority_fee);
instructions.push(instruction::create(
&self.payer.clone(),
mint,
instruction::Create {
_name: ipfs.metadata.name,
_symbol: ipfs.metadata.symbol,
_uri: ipfs.metadata.image,
},
));
let ata = get_associated_token_address(&self.payer.pubkey(), &mint.pubkey());
if self.rpc.get_account(&ata).is_err() {
instructions.push(create_associated_token_account(
&self.payer.pubkey(),
&self.payer.pubkey(),
&mint.pubkey(),
));
}
instructions.push(instruction::buy(
&self.payer.clone(),
&mint.pubkey(),
&global_account.fee_recipient,
instruction::Buy {
_amount: buy_amount,
_max_sol_cost: buy_amount_with_slippage,
},
));
let recent_blockhash = self.rpc.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&instructions,
Some(&self.payer.pubkey()),
&[&self.payer.clone(), mint],
recent_blockhash,
);
let signature = self.rpc.send_and_confirm_transaction(&transaction)?;
Ok(signature)
}
/// Buy tokens
pub async fn buy(
&self,
mint: &Pubkey,
amount_sol: u64,
slippage_basis_points: Option<u64>,
priority_fee: Option<PriorityFee>,
) -> Result<Signature, ClientError> {
let global_account = self.get_global_account()?;
let bonding_curve_account = self.get_bonding_curve_account(mint)?;
let buy_amount = bonding_curve_account
.get_buy_price(amount_sol)
.map_err(ClientError::BondingCurveError)?;
let buy_amount_with_slippage =
utils::calculate_with_slippage_buy(amount_sol, slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE));
let mut instructions = self.create_priority_fee_instructions(priority_fee);
let ata = get_associated_token_address(&self.payer.pubkey(), mint);
if self.rpc.get_account(&ata).is_err() {
instructions.push(create_associated_token_account(
&self.payer.pubkey(),
&self.payer.pubkey(),
mint,
));
}
instructions.push(instruction::buy(
&self.payer.clone(),
mint,
&global_account.fee_recipient,
instruction::Buy {
_amount: buy_amount,
_max_sol_cost: buy_amount_with_slippage,
},
));
let recent_blockhash = self.rpc.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&instructions,
Some(&self.payer.pubkey()),
&[&self.payer.clone()],
recent_blockhash,
);
let signature = self.rpc.send_transaction(&transaction)?;
Ok(signature)
}
/// Buy tokens using Jito
pub async fn buy_with_jito(
&self,
mint: &Pubkey,
amount_sol: u64,
slippage_basis_points: Option<u64>,
) -> Result<String, ClientError> {
let start_time = Instant::now();
let jito_client = self.jito_client.as_ref()
.ok_or_else(|| ClientError::Other("Jito client not found".to_string()))?;
let global_account = self.get_global_account()?;
let bonding_curve_account = self.get_bonding_curve_account(mint)?;
let buy_amount = bonding_curve_account
.get_buy_price(amount_sol)
.map_err(ClientError::BondingCurveError)?;
let buy_amount_with_slippage =
utils::calculate_with_slippage_buy(amount_sol, slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE));
let mut instructions = vec![];
let tip_account = jito_client.get_tip_account().await?;
let ata = get_associated_token_address(&self.payer.pubkey(), mint);
if self.rpc.get_account(&ata).is_err() {
instructions.push(create_associated_token_account(
&self.payer.pubkey(),
&self.payer.pubkey(),
mint,
));
}
instructions.push(instruction::buy(
self.payer.as_ref(),
mint,
&global_account.fee_recipient,
instruction::Buy {
_amount: buy_amount,
_max_sol_cost: buy_amount_with_slippage,
},
));
instructions.push(
system_instruction::transfer(
&self.payer.pubkey(),
&tip_account,
JITO_TIP_AMOUNT,
),
);
let recent_blockhash = self.rpc.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&instructions,
Some(&self.payer.pubkey()),
&[&self.payer.clone()],
recent_blockhash,
);
let signature = jito_client.send_transaction(&transaction).await?;
println!("Total Jito buy operation time: {:?}ms", start_time.elapsed().as_millis());
Ok(signature)
}
/// Sell tokens
pub async fn sell(
&self,
mint: &Pubkey,
amount_token: Option<u64>,
slippage_basis_points: Option<u64>,
priority_fee: Option<PriorityFee>,
) -> Result<Signature, ClientError> {
let ata = get_associated_token_address(&self.payer.pubkey(), mint);
let balance = self.rpc.get_token_account_balance(&ata)?;
let balance_u64 = balance.amount.parse::<u64>()
.map_err(|_| ClientError::Other("Failed to parse token balance".to_string()))?;
let amount = amount_token.unwrap_or(balance_u64);
if amount == 0 {
return Err(ClientError::Other("Balance is 0".to_string()));
}
let global_account = self.get_global_account()?;
let bonding_curve_account = self.get_bonding_curve_account(mint)?;
let min_sol_output = bonding_curve_account
.get_sell_price(amount, global_account.fee_basis_points)
.map_err(ClientError::BondingCurveError)?;
let min_sol_output_with_slippage = utils::calculate_with_slippage_sell(
min_sol_output,
slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE),
);
let mut instructions = self.create_priority_fee_instructions(priority_fee);
instructions.push(instruction::sell(
&self.payer.clone(),
mint,
&global_account.fee_recipient,
instruction::Sell {
_amount: amount,
_min_sol_output: min_sol_output_with_slippage,
},
));
let recent_blockhash = self.rpc.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&instructions,
Some(&self.payer.pubkey()),
&[&self.payer.clone()],
recent_blockhash,
);
let signature = self.rpc.send_and_confirm_transaction(&transaction)?;
Ok(signature)
}
/// Sell tokens by percentage
pub async fn sell_by_percent(
&self,
mint: &Pubkey,
percent: u64,
slippage_basis_points: Option<u64>,
priority_fee: Option<PriorityFee>,
) -> Result<Signature, ClientError> {
if percent > 100 {
return Err(ClientError::Other("Percentage must be between 0 and 100".to_string()));
}
let ata = get_associated_token_address(&self.payer.pubkey(), mint);
let balance = self.rpc.get_token_account_balance(&ata)?;
let balance_u64 = balance.amount.parse::<u64>()
.map_err(|_| ClientError::Other("Failed to parse token balance".to_string()))?;
if balance_u64 == 0 {
return Err(ClientError::Other("Balance is 0".to_string()));
}
let amount = balance_u64 * percent / 100;
self.sell(mint, Some(amount), slippage_basis_points, priority_fee).await
}
pub async fn sell_by_percent_with_jito(
&self,
mint: &Pubkey,
percent: u64,
slippage_basis_points: Option<u64>,
) -> Result<String, ClientError> {
if percent > 100 {
return Err(ClientError::Other("Percentage must be between 0 and 100".to_string()));
}
let ata = get_associated_token_address(&self.payer.pubkey(), mint);
let balance = self.rpc.get_token_account_balance(&ata)?;
let balance_u64 = balance.amount.parse::<u64>()
.map_err(|_| ClientError::Other("Failed to parse token balance".to_string()))?;
if balance_u64 == 0 {
return Err(ClientError::Other("Balance is 0".to_string()));
}
let amount = balance_u64 * percent / 100;
self.sell_with_jito(mint, Some(amount), slippage_basis_points).await
}
/// Sell tokens using Jito
pub async fn sell_with_jito(
&self,
mint: &Pubkey,
amount_token: Option<u64>,
slippage_basis_points: Option<u64>,
) -> Result<String, ClientError> {
let start_time = Instant::now();
let jito_client = self.jito_client.as_ref()
.ok_or_else(|| ClientError::Other("Jito client not found".to_string()))?;
let ata = get_associated_token_address(&self.payer.pubkey(), mint);
let balance = self.rpc.get_token_account_balance(&ata)?;
let balance_u64 = balance.amount.parse::<u64>()
.map_err(|_| ClientError::Other("Failed to parse token balance".to_string()))?;
let amount = amount_token.unwrap_or(balance_u64);
if amount == 0 {
return Err(ClientError::Other("Amount cannot be zero".to_string()));
}
let global_account = self.get_global_account()?;
let bonding_curve_account = self.get_bonding_curve_account(mint)?;
let min_sol_output = bonding_curve_account
.get_sell_price(amount, global_account.fee_basis_points)
.map_err(ClientError::BondingCurveError)?;
let min_sol_output_with_slippage = utils::calculate_with_slippage_sell(
min_sol_output,
slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE),
);
let mut instructions = vec![];
let tip_account = jito_client.get_tip_account().await?;
instructions.push(instruction::sell(
&self.payer.clone(),
mint,
&global_account.fee_recipient,
instruction::Sell {
_amount: amount,
_min_sol_output: min_sol_output_with_slippage,
},
));
instructions.push(
system_instruction::transfer(
&self.payer.pubkey(),
&tip_account,
JITO_TIP_AMOUNT,
),
);
let recent_blockhash = self.rpc.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&instructions,
Some(&self.payer.pubkey()),
&[&self.payer.clone()],
recent_blockhash,
);
let signature = jito_client.send_transaction(&transaction).await?;
println!("Total Jito sell operation time: {:?}ms", start_time.elapsed().as_millis());
Ok(signature)
}
// Helper methods
fn create_priority_fee_instructions(&self, priority_fee: Option<PriorityFee>) -> Vec<Instruction> {
let mut instructions = Vec::new();
let fee = priority_fee.unwrap_or(PriorityFee::default());
if let Some(limit) = fee.limit {
instructions.push(ComputeBudgetInstruction::set_compute_unit_limit(limit));
}
if let Some(price) = fee.price {
instructions.push(ComputeBudgetInstruction::set_compute_unit_price(price));
}
instructions
}
// Public interface methods
pub fn get_payer_pubkey(&self) -> Pubkey {
self.payer.pubkey()
}
pub fn get_token_balance(&self, account: &Pubkey, mint: &Pubkey) -> Result<u64, ClientError> {
let ata = get_associated_token_address(account, mint);
if self.rpc.get_account(&ata).is_err() {
return Ok(0);
}
let balance = self.rpc.get_token_account_balance(&ata)?;
balance.amount.parse::<u64>()
.map_err(|_| ClientError::Other("Failed to parse token balance".to_string()))
}
pub fn get_sol_balance(&self, account: &Pubkey) -> Result<u64, ClientError> {
self.rpc.get_balance(account).map_err(ClientError::SolanaClientError)
}
pub fn get_payer_token_balance(&self, mint: &Pubkey) -> Result<u64, ClientError> {
self.get_token_balance(&self.payer.pubkey(), mint)
}
pub fn get_payer_sol_balance(&self) -> Result<u64, ClientError> {
self.get_sol_balance(&self.payer.pubkey())
}
// PDA related methods
pub fn get_global_pda() -> Pubkey {
Pubkey::find_program_address(&[constants::seeds::GLOBAL_SEED], &constants::accounts::PUMPFUN).0
}
pub fn get_mint_authority_pda() -> Pubkey {
Pubkey::find_program_address(&[constants::seeds::MINT_AUTHORITY_SEED], &constants::accounts::PUMPFUN).0
}
pub fn get_bonding_curve_pda(mint: &Pubkey) -> Option<Pubkey> {
Pubkey::try_find_program_address(
&[constants::seeds::BONDING_CURVE_SEED, mint.as_ref()],
&constants::accounts::PUMPFUN
).map(|(pubkey, _)| pubkey)
}
pub fn get_metadata_pda(mint: &Pubkey) -> Pubkey {
Pubkey::find_program_address(
&[
constants::seeds::METADATA_SEED,
constants::accounts::MPL_TOKEN_METADATA.as_ref(),
mint.as_ref(),
],
&constants::accounts::MPL_TOKEN_METADATA
).0
}
// Account related methods
pub fn get_global_account(&self) -> Result<accounts::GlobalAccount, ClientError> {
let global = Self::get_global_pda();
let account = self.rpc.get_account(&global)?;
accounts::GlobalAccount::try_from_slice(&account.data)
.map_err(ClientError::BorshError)
}
pub fn get_bonding_curve_account(
&self,
mint: &Pubkey,
) -> Result<accounts::BondingCurveAccount, ClientError> {
let bonding_curve_pda = Self::get_bonding_curve_pda(mint)
.ok_or(ClientError::BondingCurveNotFound)?;
let account = self.rpc.get_account(&bonding_curve_pda)?;
accounts::BondingCurveAccount::try_from_slice(&account.data)
.map_err(ClientError::BorshError)
}
// Subscription related methods
pub async fn tokens_subscription<F>(
&self,
ws_url: &str,
commitment: CommitmentConfig,
callback: F,
bot_wallet: Option<Pubkey>,
) -> Result<SubscriptionHandle, Box<dyn std::error::Error>>
where
F: Fn(DexEvent) + Send + Sync + 'static,
{
logs_subscribe::tokens_subscription(ws_url, commitment, callback, bot_wallet).await
}
pub async fn stop_subscription(&self, subscription_handle: SubscriptionHandle) {
subscription_handle.shutdown().await;
}
pub fn get_buy_amount_with_slippage(&self, amount_sol: u64, slippage_basis_points: Option<u64>) -> u64 {
utils::calculate_with_slippage_buy(amount_sol, slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE))
}
pub fn get_token_price(&self, virtual_sol_reserves: u64, virtual_token_reserves: u64) -> f64 {
let v_sol = virtual_sol_reserves as f64 / 100_000_000.0;
let v_tokens = virtual_token_reserves as f64 / 100_000.0;
let token_price = v_sol / v_tokens;
token_price
}
pub async fn get_token_price_in_usdc(&self, token_amount: f64) -> Result<f64, ClientError> {
if token_amount == 0.0 {
return Ok(0.0);
}
let url = "https://api.jup.ag/price/v2?ids=So11111111111111111111111111111111111111112";
let response: serde_json::Value = reqwest::get(url)
.await
.map_err(|e: reqwest::Error| ClientError::Other(Box::new(e).to_string()))?
.json()
.await
.map_err(|e: reqwest::Error| ClientError::Other(Box::new(e).to_string()))?;
let sol_price_str = response["data"]["So11111111111111111111111111111111111111112"]["price"]
.as_str()
.ok_or(ClientError::Other("Failed to find SOL price as a string".into()))?;
let sol_price_in_usdc: f64 = sol_price_str
.parse()
.map_err(|e: std::num::ParseFloatError| ClientError::Other(Box::new(e).to_string()))?;
let token_price_in_usdc = sol_price_in_usdc * token_amount;
Ok(token_price_in_usdc)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_client() {
let payer = Arc::new(Keypair::new());
let client = PumpFun::new(Cluster::Devnet, None, Arc::clone(&payer), None);
assert_eq!(client.payer.pubkey(), payer.pubkey());
}
#[test]
fn test_get_pdas() {
let mint = Keypair::new();
let global_pda = PumpFun::get_global_pda();
let mint_authority_pda = PumpFun::get_mint_authority_pda();
let bonding_curve_pda = PumpFun::get_bonding_curve_pda(&mint.pubkey());
let metadata_pda = PumpFun::get_metadata_pda(&mint.pubkey());
assert!(global_pda != Pubkey::default());
assert!(mint_authority_pda != Pubkey::default());
assert!(bonding_curve_pda.is_some());
assert!(metadata_pda != Pubkey::default());
}
}
Executable
+49
View File
@@ -0,0 +1,49 @@
use mai3_pumpfun_sdk::instruction::{
logs_events::DexEvent,
logs_subscribe::{tokens_subscription, stop_subscription}
};
use solana_sdk::commitment_config::CommitmentConfig;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Starting token subscription\n");
let ws_url = "wss://api.mainnet-beta.solana.com";
// Set commitment
let commitment = CommitmentConfig::confirmed();
// Define callback function
let callback = |event: DexEvent| {
match event {
DexEvent::NewToken(token_info) => {
println!("Received new token event: {:?}", token_info);
},
DexEvent::NewUserTrade(trade_info) => {
println!("Received new trade event: {:?}", trade_info);
},
DexEvent::NewBotTrade(trade_info) => {
println!("Received new bot trade event: {:?}", trade_info);
},
DexEvent::Error(err) => {
println!("Received error: {}", err);
}
}
};
// Start subscription
let subscription = tokens_subscription(
ws_url,
commitment,
callback,
None
).await.unwrap();
// Wait for a while to receive events
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
// Stop subscription
stop_subscription(subscription).await;
Ok(())
}
+270
View File
@@ -0,0 +1,270 @@
//! Utilities for working with token metadata and IPFS uploads.
//!
//! This module provides functionality for creating and managing token metadata,
//! including uploading image and metadata to IPFS via the Pump.fun API.
use isahc::AsyncReadResponseExt;
use serde::{Deserialize, Serialize};
use std::{fs::File, io::Read};
/// Metadata structure for a token, matching the format expected by Pump.fun.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenMetadata {
/// Name of the token
pub name: String,
/// Token symbol (e.g. "BTC")
pub symbol: String,
/// Description of the token
pub description: String,
/// IPFS URL of the token's image
pub image: String,
/// Whether to display the token's name
pub show_name: bool,
/// Creation timestamp/source
pub created_on: String,
/// Twitter handle
pub twitter: Option<String>,
/// Telegram handle
pub telegram: Option<String>,
/// Website URL
pub website: Option<String>,
}
/// Response received after successfully uploading token metadata.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenMetadataResponse {
/// The uploaded token metadata
pub metadata: TokenMetadata,
/// IPFS URI where the metadata is stored
pub metadata_uri: String,
}
/// Parameters for creating new token metadata.
#[derive(Debug, Clone)]
pub struct CreateTokenMetadata {
/// Name of the token
pub name: String,
/// Token symbol (e.g. "BTC")
pub symbol: String,
/// Description of the token
pub description: String,
/// Path to the token's image file
pub file: String,
/// Optional Twitter handle
pub twitter: Option<String>,
/// Optional Telegram group
pub telegram: Option<String>,
/// Optional website URL
pub website: Option<String>,
}
/// Creates and uploads token metadata to IPFS via the Pump.fun API.
///
/// This function takes token metadata and an image file, constructs a multipart form request,
/// and uploads it to the Pump.fun IPFS API endpoint. The metadata and image are stored on IPFS
/// and the function returns the IPFS locations.
///
/// # Arguments
///
/// * `metadata` - Token metadata and image file information
///
/// # Returns
///
/// Returns a `Result` containing the `TokenMetadataResponse` with IPFS locations on success,
/// or an error if the upload fails.
///
/// # Examples
///
/// ```rust,no_run
/// use mai3_pumpfun_sdk::utils::{CreateTokenMetadata, create_token_metadata};
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let metadata = CreateTokenMetadata {
/// name: "My Token".to_string(),
/// symbol: "MT".to_string(),
/// description: "A test token".to_string(),
/// file: "path/to/image.png".to_string(),
/// twitter: None,
/// telegram: None,
/// website: Some("https://example.com".to_string()),
/// };
///
/// let response = create_token_metadata(metadata).await?;
/// println!("Metadata URI: {}", response.metadata_uri);
/// # Ok(())
/// # }
/// ```
pub async fn create_token_metadata(
metadata: CreateTokenMetadata,
) -> Result<TokenMetadataResponse, Box<dyn std::error::Error>> {
let boundary = "------------------------f4d9c2e8b7a5310f";
let mut body = Vec::new();
// Helper function to append form data
fn append_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n", name).as_bytes(),
);
body.extend_from_slice(value.as_bytes());
body.extend_from_slice(b"\r\n");
}
// Append form fields
append_text_field(&mut body, boundary, "name", &metadata.name);
append_text_field(&mut body, boundary, "symbol", &metadata.symbol);
append_text_field(&mut body, boundary, "description", &metadata.description);
if let Some(twitter) = metadata.twitter {
append_text_field(&mut body, boundary, "twitter", &twitter);
}
if let Some(telegram) = metadata.telegram {
append_text_field(&mut body, boundary, "telegram", &telegram);
}
if let Some(website) = metadata.website {
append_text_field(&mut body, boundary, "website", &website);
}
append_text_field(&mut body, boundary, "showName", "true");
// Append file part
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(b"Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n");
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
// Read the file contents
let mut file = File::open(&metadata.file)?;
let mut file_contents = Vec::new();
file.read_to_end(&mut file_contents)?;
body.extend_from_slice(&file_contents);
// Close the boundary
body.extend_from_slice(b"\r\n--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"--\r\n");
let client = isahc::HttpClient::new()?;
let request = isahc::Request::builder()
.method("POST")
.uri("https://pump.fun/api/ipfs")
.header(
"Content-Type",
format!("multipart/form-data; boundary={}", boundary),
)
.header("Content-Length", body.len() as u64)
.body(isahc::AsyncBody::from(body))?;
// Send request and print response
let mut response = client.send_async(request).await?;
let text = response.text().await?;
let json: TokenMetadataResponse = serde_json::from_str(&text)?;
Ok(json)
}
/// Calculates the maximum amount to pay when buying tokens, accounting for slippage tolerance
///
/// # Arguments
/// * `amount` - The base amount in lamports (1 SOL = 1,000,000,000 lamports)
/// * `basis_points` - The slippage tolerance in basis points (1% = 100 basis points)
///
/// # Returns
/// The maximum amount to pay, including slippage tolerance
///
/// # Example
/// ```rust
/// use mai3_pumpfun_sdk::utils;
///
/// let amount = 1_000_000_000; // 1 SOL in lamports
/// let slippage = 100; // 1% slippage tolerance
///
/// let max_amount = utils::calculate_with_slippage_buy(amount, slippage);
/// assert_eq!(max_amount, 1_010_000_000); // 1.01 SOL
/// ```
pub fn calculate_with_slippage_buy(amount: u64, basis_points: u64) -> u64 {
amount + (amount * basis_points) / 10000
}
/// Calculates the minimum amount to receive when selling tokens, accounting for slippage tolerance
///
/// # Arguments
/// * `amount` - The base amount in lamports (1 SOL = 1,000,000,000 lamports)
/// * `basis_points` - The slippage tolerance in basis points (1% = 100 basis points)
///
/// # Returns
/// The minimum amount to receive, accounting for slippage tolerance
///
/// # Example
/// ```rust
/// use mai3_pumpfun_sdk::utils;
///
/// let amount = 1_000_000_000; // 1 SOL in lamports
/// let slippage = 100; // 1% slippage tolerance
///
/// let min_amount = utils::calculate_with_slippage_sell(amount, slippage);
/// assert_eq!(min_amount, 990_000_000); // 0.99 SOL
/// ```
pub fn calculate_with_slippage_sell(amount: u64, basis_points: u64) -> u64 {
amount - (amount * basis_points) / 10000
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::write;
#[tokio::test]
async fn test_create_token_metadata() {
// Create a temporary file
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("test_image.png");
write(&file_path, b"fake image data").unwrap();
// Create test metadata
let metadata = CreateTokenMetadata {
name: "Test Token".to_string(),
symbol: "TEST".to_string(),
description: "Test Description".to_string(),
file: file_path.to_str().unwrap().to_string(),
twitter: None,
telegram: None,
website: Some("https://example.com".to_string()),
};
// Call the function
let result = create_token_metadata(metadata).await;
// Assert the result
assert!(result.is_ok());
let response = result.unwrap();
// Verify response fields
assert_eq!(response.metadata.name, "Test Token");
assert_eq!(response.metadata.symbol, "TEST");
assert_eq!(response.metadata.description, "Test Description");
assert!(response.metadata.image.starts_with("https://ipfs.io/ipfs/"));
assert!(response.metadata.show_name);
assert_eq!(response.metadata.created_on, "https://pump.fun");
assert!(response.metadata_uri.starts_with("https://ipfs.io/ipfs/"));
}
#[test]
fn test_calculate_with_slippage_buy() {
let amount = 1_000_000_000; // 1 SOL in lamports
let slippage = 100; // 1% slippage tolerance
let max_amount = calculate_with_slippage_buy(amount, slippage);
assert_eq!(max_amount, 1_010_000_000); // 1.01 SOL
}
#[test]
fn test_calculate_with_slippage_sell() {
let amount = 1_000_000_000; // 1 SOL in lamports
let slippage = 100; // 1% slippage tolerance
let min_amount = calculate_with_slippage_sell(amount, slippage);
assert_eq!(min_amount, 990_000_000); // 0.99 SOL
}
}