1196 lines
44 KiB
Rust
1196 lines
44 KiB
Rust
// file: kb_lib/src/token_metadata.rs
|
|
|
|
//! Token metadata resolution and backfill.
|
|
//!
|
|
//! This module enriches already discovered token mints with stable metadata.
|
|
//! It intentionally stays independent from DEX-specific decoding so every DEX
|
|
//! benefits from the same metadata path.
|
|
|
|
const NATIVE_SOL_MINT_ALIAS: &str = "SOL";
|
|
const NATIVE_SOL_SYMBOL: &str = "SOL";
|
|
const NATIVE_SOL_NAME: &str = "Solana";
|
|
const WRAPPED_SOL_SYMBOL: &str = "WSOL";
|
|
const WRAPPED_SOL_NAME: &str = "Wrapped SOL";
|
|
const METAPLEX_TOKEN_METADATA_PROGRAM_ID: &str = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s";
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct KnownLocalTokenMetadata {
|
|
mint: &'static str,
|
|
symbol: &'static str,
|
|
name: &'static str,
|
|
decimals: u8,
|
|
token_program: &'static str,
|
|
is_quote_token: std::option::Option<bool>,
|
|
}
|
|
|
|
const KNOWN_LOCAL_TOKEN_METADATA: &[KnownLocalTokenMetadata] = &[
|
|
KnownLocalTokenMetadata {
|
|
mint: crate::WSOL_MINT_ID,
|
|
symbol: WRAPPED_SOL_SYMBOL,
|
|
name: WRAPPED_SOL_NAME,
|
|
decimals: 9,
|
|
token_program: crate::SPL_TOKEN_PROGRAM_ID,
|
|
is_quote_token: Some(true),
|
|
},
|
|
KnownLocalTokenMetadata {
|
|
mint: crate::USDC_MINT_ID,
|
|
symbol: "USDC",
|
|
name: "USD Coin",
|
|
decimals: 6,
|
|
token_program: crate::SPL_TOKEN_PROGRAM_ID,
|
|
is_quote_token: Some(true),
|
|
},
|
|
KnownLocalTokenMetadata {
|
|
mint: crate::USDT_MINT_ID,
|
|
symbol: "USDT",
|
|
name: "Tether USD",
|
|
decimals: 6,
|
|
token_program: crate::SPL_TOKEN_PROGRAM_ID,
|
|
is_quote_token: Some(true),
|
|
},
|
|
KnownLocalTokenMetadata {
|
|
mint: crate::JUP_MINT_ID,
|
|
symbol: "JUP",
|
|
name: "Jupiter",
|
|
decimals: 6,
|
|
token_program: crate::SPL_TOKEN_PROGRAM_ID,
|
|
is_quote_token: None,
|
|
},
|
|
KnownLocalTokenMetadata {
|
|
mint: crate::RAY_MINT_ID,
|
|
symbol: "RAY",
|
|
name: "Raydium",
|
|
decimals: 6,
|
|
token_program: crate::SPL_TOKEN_PROGRAM_ID,
|
|
is_quote_token: None,
|
|
},
|
|
KnownLocalTokenMetadata {
|
|
mint: crate::BONK_MINT_ID,
|
|
symbol: "BONK",
|
|
name: "Bonk",
|
|
decimals: 5,
|
|
token_program: crate::SPL_TOKEN_PROGRAM_ID,
|
|
is_quote_token: None,
|
|
},
|
|
];
|
|
|
|
/// Summary produced by a token metadata backfill pass.
|
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TokenMetadataBackfillResult {
|
|
/// Number of tokens considered by this pass.
|
|
pub total_token_count: usize,
|
|
/// Number of tokens for which a resolution attempt was made.
|
|
pub attempted_token_count: usize,
|
|
/// Number of tokens enriched from local deterministic metadata.
|
|
pub local_metadata_count: usize,
|
|
/// Number of tokens enriched from Pump.fun decoded payloads.
|
|
pub pump_fun_payload_metadata_count: usize,
|
|
/// Number of tokens enriched from the SPL mint account.
|
|
pub mint_account_metadata_count: usize,
|
|
/// Number of tokens enriched from the Metaplex metadata account.
|
|
pub metaplex_metadata_count: usize,
|
|
/// Number of token rows updated.
|
|
pub updated_token_count: usize,
|
|
/// Number of token rows already complete or not resolvable without error.
|
|
pub skipped_token_count: usize,
|
|
/// Number of non-fatal per-token errors.
|
|
pub error_count: usize,
|
|
/// Number of pair display symbols updated after metadata enrichment.
|
|
pub pair_symbol_updated_count: usize,
|
|
/// Number of pair display symbols already correct after metadata enrichment.
|
|
pub pair_symbol_skipped_count: usize,
|
|
/// Number of pair display symbols skipped because a related token row was missing.
|
|
pub pair_symbol_missing_token_count: usize,
|
|
}
|
|
|
|
impl TokenMetadataBackfillResult {
|
|
fn merge(&mut self, other: &TokenMetadataBackfillResult) {
|
|
self.total_token_count += other.total_token_count;
|
|
self.attempted_token_count += other.attempted_token_count;
|
|
self.local_metadata_count += other.local_metadata_count;
|
|
self.pump_fun_payload_metadata_count += other.pump_fun_payload_metadata_count;
|
|
self.mint_account_metadata_count += other.mint_account_metadata_count;
|
|
self.metaplex_metadata_count += other.metaplex_metadata_count;
|
|
self.updated_token_count += other.updated_token_count;
|
|
self.skipped_token_count += other.skipped_token_count;
|
|
self.error_count += other.error_count;
|
|
self.pair_symbol_updated_count += other.pair_symbol_updated_count;
|
|
self.pair_symbol_skipped_count += other.pair_symbol_skipped_count;
|
|
self.pair_symbol_missing_token_count += other.pair_symbol_missing_token_count;
|
|
}
|
|
}
|
|
|
|
/// Service that enriches persisted token rows with mint and display metadata.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TokenMetadataBackfillService {
|
|
database: std::sync::Arc<crate::Database>,
|
|
http_pool: std::option::Option<std::sync::Arc<crate::HttpEndpointPool>>,
|
|
http_role: std::string::String,
|
|
}
|
|
|
|
impl TokenMetadataBackfillService {
|
|
/// Creates a metadata backfill service backed by Solana HTTP RPC.
|
|
pub fn new(
|
|
http_pool: std::sync::Arc<crate::HttpEndpointPool>,
|
|
database: std::sync::Arc<crate::Database>,
|
|
http_role: std::string::String,
|
|
) -> Self {
|
|
return Self {
|
|
database,
|
|
http_pool: Some(http_pool),
|
|
http_role,
|
|
};
|
|
}
|
|
|
|
/// Creates a metadata backfill service that can only apply local and DB-derived metadata.
|
|
pub fn new_local(database: std::sync::Arc<crate::Database>) -> Self {
|
|
return Self {
|
|
database,
|
|
http_pool: None,
|
|
http_role: "local_metadata".to_string(),
|
|
};
|
|
}
|
|
|
|
/// Enriches all tokens whose metadata is incomplete.
|
|
pub async fn backfill_missing_token_metadata(
|
|
&self,
|
|
limit: std::option::Option<i64>,
|
|
) -> Result<crate::TokenMetadataBackfillResult, crate::Error> {
|
|
let tokens_result =
|
|
crate::query_tokens_list_missing_metadata(self.database.as_ref(), limit).await;
|
|
let tokens = match tokens_result {
|
|
Ok(tokens) => tokens,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let mut result = crate::TokenMetadataBackfillResult::default();
|
|
for token in tokens {
|
|
let token_result = self.backfill_token_metadata_by_mint(token.mint.as_str()).await;
|
|
match token_result {
|
|
Ok(token_result) => result.merge(&token_result),
|
|
Err(error) => {
|
|
result.total_token_count += 1;
|
|
result.attempted_token_count += 1;
|
|
result.error_count += 1;
|
|
tracing::warn!(
|
|
mint = %token.mint,
|
|
error = %error,
|
|
"token metadata backfill failed for one mint"
|
|
);
|
|
},
|
|
}
|
|
}
|
|
let pair_symbol_result = crate::refresh_pair_symbols(self.database.as_ref()).await;
|
|
match pair_symbol_result {
|
|
Ok(pair_symbol_result) => {
|
|
result.pair_symbol_updated_count += pair_symbol_result.updated_pair_count;
|
|
result.pair_symbol_skipped_count += pair_symbol_result.skipped_pair_count;
|
|
result.pair_symbol_missing_token_count += pair_symbol_result.missing_token_count;
|
|
},
|
|
Err(error) => {
|
|
result.error_count += 1;
|
|
tracing::warn!(
|
|
error = %error,
|
|
"pair symbol refresh failed after token metadata backfill"
|
|
);
|
|
},
|
|
}
|
|
return Ok(result);
|
|
}
|
|
|
|
/// Enriches one token mint with local, DEX-payload, mint-account, and Metaplex metadata.
|
|
pub async fn backfill_token_metadata_by_mint(
|
|
&self,
|
|
mint: &str,
|
|
) -> Result<crate::TokenMetadataBackfillResult, crate::Error> {
|
|
let trimmed_mint = mint.trim();
|
|
if trimmed_mint.is_empty() {
|
|
return Err(crate::Error::Config("token metadata mint must not be empty".to_string()));
|
|
}
|
|
let mut result = crate::TokenMetadataBackfillResult {
|
|
total_token_count: 1,
|
|
attempted_token_count: 1,
|
|
local_metadata_count: 0,
|
|
pump_fun_payload_metadata_count: 0,
|
|
mint_account_metadata_count: 0,
|
|
metaplex_metadata_count: 0,
|
|
updated_token_count: 0,
|
|
skipped_token_count: 0,
|
|
error_count: 0,
|
|
pair_symbol_updated_count: 0,
|
|
pair_symbol_skipped_count: 0,
|
|
pair_symbol_missing_token_count: 0,
|
|
};
|
|
let token_result =
|
|
crate::query_tokens_get_by_mint(self.database.as_ref(), trimmed_mint).await;
|
|
let token_option = match token_result {
|
|
Ok(token_option) => token_option,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let token_existed = token_option.is_some();
|
|
let mut token = match token_option {
|
|
Some(token) => token,
|
|
None => build_empty_token_dto(trimmed_mint),
|
|
};
|
|
let original_token = token.clone();
|
|
let mut resolved = ResolvedTokenMetadata::default();
|
|
let local_metadata_option = resolve_local_token_metadata(trimmed_mint);
|
|
if let Some(local_metadata) = local_metadata_option {
|
|
result.local_metadata_count += 1;
|
|
resolved.merge(local_metadata);
|
|
}
|
|
let pump_fun_metadata_result =
|
|
resolve_pump_fun_metadata_from_decoded_events(self.database.as_ref(), trimmed_mint)
|
|
.await;
|
|
match pump_fun_metadata_result {
|
|
Ok(pump_fun_metadata_option) => {
|
|
if let Some(pump_fun_metadata) = pump_fun_metadata_option {
|
|
result.pump_fun_payload_metadata_count += 1;
|
|
resolved.merge(pump_fun_metadata);
|
|
}
|
|
},
|
|
Err(error) => {
|
|
result.error_count += 1;
|
|
tracing::warn!(
|
|
mint = %trimmed_mint,
|
|
error = %error,
|
|
"pump.fun metadata payload resolution failed"
|
|
);
|
|
},
|
|
}
|
|
if let Some(http_pool) = &self.http_pool {
|
|
let mint_account_result = fetch_mint_account_metadata(
|
|
http_pool.as_ref(),
|
|
self.http_role.as_str(),
|
|
trimmed_mint,
|
|
)
|
|
.await;
|
|
match mint_account_result {
|
|
Ok(mint_account_option) => {
|
|
if let Some(mint_account) = mint_account_option {
|
|
result.mint_account_metadata_count += 1;
|
|
resolved.merge(mint_account);
|
|
}
|
|
},
|
|
Err(error) => {
|
|
result.error_count += 1;
|
|
tracing::warn!(
|
|
mint = %trimmed_mint,
|
|
error = %error,
|
|
"mint account metadata resolution failed"
|
|
);
|
|
},
|
|
}
|
|
if should_try_metaplex_metadata(&token, &resolved) {
|
|
let metaplex_result = fetch_metaplex_metadata(
|
|
http_pool.as_ref(),
|
|
self.http_role.as_str(),
|
|
trimmed_mint,
|
|
)
|
|
.await;
|
|
match metaplex_result {
|
|
Ok(metaplex_option) => {
|
|
if let Some(metaplex_metadata) = metaplex_option {
|
|
result.metaplex_metadata_count += 1;
|
|
resolved.merge(metaplex_metadata);
|
|
}
|
|
},
|
|
Err(error) => {
|
|
result.error_count += 1;
|
|
tracing::warn!(
|
|
mint = %trimmed_mint,
|
|
error = %error,
|
|
"metaplex metadata resolution failed"
|
|
);
|
|
},
|
|
}
|
|
}
|
|
}
|
|
let changed = apply_resolved_metadata_to_token(&mut token, &resolved);
|
|
if !token_existed && !changed {
|
|
result.skipped_token_count += 1;
|
|
return Ok(result);
|
|
}
|
|
if changed || token.id.is_none() {
|
|
token.updated_at = chrono::Utc::now();
|
|
let upsert_result = crate::query_tokens_upsert(self.database.as_ref(), &token).await;
|
|
match upsert_result {
|
|
Ok(_) => {
|
|
if changed || original_token.id.is_none() {
|
|
result.updated_token_count += 1;
|
|
} else {
|
|
result.skipped_token_count += 1;
|
|
}
|
|
},
|
|
Err(error) => return Err(error),
|
|
}
|
|
} else {
|
|
result.skipped_token_count += 1;
|
|
}
|
|
return Ok(result);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct ResolvedTokenMetadata {
|
|
symbol: std::option::Option<std::string::String>,
|
|
name: std::option::Option<std::string::String>,
|
|
decimals: std::option::Option<u8>,
|
|
token_program: std::option::Option<std::string::String>,
|
|
is_quote_token: std::option::Option<bool>,
|
|
}
|
|
|
|
impl ResolvedTokenMetadata {
|
|
fn merge(&mut self, other: ResolvedTokenMetadata) {
|
|
if option_string_is_missing(self.symbol.as_deref()) && other.symbol.is_some() {
|
|
self.symbol = other.symbol;
|
|
}
|
|
if option_string_is_missing(self.name.as_deref()) && other.name.is_some() {
|
|
self.name = other.name;
|
|
}
|
|
if self.decimals.is_none() && other.decimals.is_some() {
|
|
self.decimals = other.decimals;
|
|
}
|
|
if option_string_is_missing(self.token_program.as_deref()) && other.token_program.is_some()
|
|
{
|
|
self.token_program = other.token_program;
|
|
}
|
|
if self.is_quote_token.is_none() && other.is_quote_token.is_some() {
|
|
self.is_quote_token = other.is_quote_token;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_empty_token_dto(mint: &str) -> crate::TokenDto {
|
|
return crate::TokenDto::new(
|
|
mint.to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
crate::SPL_TOKEN_PROGRAM_ID.to_string(),
|
|
false,
|
|
);
|
|
}
|
|
|
|
fn resolve_local_token_metadata(mint: &str) -> std::option::Option<ResolvedTokenMetadata> {
|
|
if mint == NATIVE_SOL_MINT_ALIAS {
|
|
return Some(ResolvedTokenMetadata {
|
|
symbol: Some(NATIVE_SOL_SYMBOL.to_string()),
|
|
name: Some(NATIVE_SOL_NAME.to_string()),
|
|
decimals: Some(9),
|
|
token_program: Some(crate::SYSTEM_PROGRAM_ID.to_string()),
|
|
is_quote_token: Some(true),
|
|
});
|
|
}
|
|
for known_token in KNOWN_LOCAL_TOKEN_METADATA {
|
|
if mint == known_token.mint {
|
|
return Some(ResolvedTokenMetadata {
|
|
symbol: Some(known_token.symbol.to_string()),
|
|
name: Some(known_token.name.to_string()),
|
|
decimals: Some(known_token.decimals),
|
|
token_program: Some(known_token.token_program.to_string()),
|
|
is_quote_token: known_token.is_quote_token,
|
|
});
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
async fn resolve_pump_fun_metadata_from_decoded_events(
|
|
database: &crate::Database,
|
|
mint: &str,
|
|
) -> Result<std::option::Option<ResolvedTokenMetadata>, crate::Error> {
|
|
let payload_result =
|
|
crate::query_dex_decoded_events_get_latest_pump_fun_create_payload_by_mint(database, mint)
|
|
.await;
|
|
let payload_option = match payload_result {
|
|
Ok(payload_option) => payload_option,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let payload_text = match payload_option {
|
|
Some(payload_text) => payload_text,
|
|
None => return Ok(None),
|
|
};
|
|
let payload_result = serde_json::from_str::<serde_json::Value>(payload_text.as_str());
|
|
let payload = match payload_result {
|
|
Ok(payload) => payload,
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot parse pump.fun metadata payload for mint '{}': {}",
|
|
mint, error
|
|
)));
|
|
},
|
|
};
|
|
return Ok(extract_pump_fun_metadata_from_payload(&payload));
|
|
}
|
|
|
|
fn extract_pump_fun_metadata_from_payload(
|
|
payload: &serde_json::Value,
|
|
) -> std::option::Option<ResolvedTokenMetadata> {
|
|
let symbol = extract_string_by_candidate_keys(payload, &["symbol", "tokenSymbol"]);
|
|
let name = extract_string_by_candidate_keys(payload, &["name", "tokenName"]);
|
|
let decimals = extract_u8_by_candidate_keys(payload, &["decimals", "tokenDecimals"]);
|
|
if symbol.is_none() && name.is_none() && decimals.is_none() {
|
|
return None;
|
|
}
|
|
return Some(ResolvedTokenMetadata {
|
|
symbol,
|
|
name,
|
|
decimals,
|
|
token_program: None,
|
|
is_quote_token: None,
|
|
});
|
|
}
|
|
|
|
fn should_try_metaplex_metadata(token: &crate::TokenDto, resolved: &ResolvedTokenMetadata) -> bool {
|
|
let missing_symbol = option_string_is_missing(token.symbol.as_deref())
|
|
&& option_string_is_missing(resolved.symbol.as_deref());
|
|
let missing_name = option_string_is_missing(token.name.as_deref())
|
|
&& option_string_is_missing(resolved.name.as_deref());
|
|
return missing_symbol || missing_name;
|
|
}
|
|
|
|
fn apply_resolved_metadata_to_token(
|
|
token: &mut crate::TokenDto,
|
|
resolved: &ResolvedTokenMetadata,
|
|
) -> bool {
|
|
let mut changed = false;
|
|
if option_string_is_missing(token.symbol.as_deref()) {
|
|
if let Some(symbol) = &resolved.symbol {
|
|
if !symbol.trim().is_empty() {
|
|
token.symbol = Some(symbol.trim().to_string());
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if option_string_is_missing(token.name.as_deref()) {
|
|
if let Some(name) = &resolved.name {
|
|
if !name.trim().is_empty() {
|
|
token.name = Some(name.trim().to_string());
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if token.decimals.is_none() {
|
|
if let Some(decimals) = resolved.decimals {
|
|
token.decimals = Some(decimals);
|
|
changed = true;
|
|
}
|
|
}
|
|
if let Some(token_program) = &resolved.token_program {
|
|
if !token_program.trim().is_empty() && token.token_program != *token_program {
|
|
token.token_program = token_program.clone();
|
|
changed = true;
|
|
}
|
|
}
|
|
if let Some(is_quote_token) = resolved.is_quote_token {
|
|
if token.is_quote_token != is_quote_token {
|
|
token.is_quote_token = is_quote_token;
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
async fn fetch_mint_account_metadata(
|
|
http_pool: &crate::HttpEndpointPool,
|
|
http_role: &str,
|
|
mint: &str,
|
|
) -> Result<std::option::Option<ResolvedTokenMetadata>, crate::Error> {
|
|
let config = serde_json::json!({
|
|
"encoding": "jsonParsed",
|
|
"commitment": "confirmed"
|
|
});
|
|
let account_result = http_pool
|
|
.get_account_info_raw_for_role(http_role, mint.to_string(), Some(config))
|
|
.await;
|
|
let account_value = match account_result {
|
|
Ok(account_value) => account_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let value_option = account_value.get("value");
|
|
let value = match value_option {
|
|
Some(value) => value,
|
|
None => return Ok(None),
|
|
};
|
|
if value.is_null() {
|
|
return Ok(None);
|
|
}
|
|
let owner = value
|
|
.get("owner")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(|value| return value.to_string());
|
|
let decimals = value
|
|
.get("data")
|
|
.and_then(|data| return data.get("parsed"))
|
|
.and_then(|parsed| return parsed.get("info"))
|
|
.and_then(|info| return info.get("decimals"))
|
|
.and_then(serde_json::Value::as_u64)
|
|
.and_then(u64_to_u8);
|
|
let token_2022_metadata = extract_token_2022_metadata_from_mint_account_value(value);
|
|
let mut resolved = ResolvedTokenMetadata {
|
|
symbol: None,
|
|
name: None,
|
|
decimals,
|
|
token_program: owner,
|
|
is_quote_token: None,
|
|
};
|
|
if let Some(token_2022_metadata) = token_2022_metadata {
|
|
resolved.merge(token_2022_metadata);
|
|
}
|
|
if resolved.symbol.is_none()
|
|
&& resolved.name.is_none()
|
|
&& resolved.decimals.is_none()
|
|
&& resolved.token_program.is_none()
|
|
{
|
|
return Ok(None);
|
|
}
|
|
return Ok(Some(resolved));
|
|
}
|
|
|
|
fn extract_token_2022_metadata_from_mint_account_value(
|
|
value: &serde_json::Value,
|
|
) -> std::option::Option<ResolvedTokenMetadata> {
|
|
let extensions_option = value
|
|
.get("data")
|
|
.and_then(|data| return data.get("parsed"))
|
|
.and_then(|parsed| return parsed.get("info"))
|
|
.and_then(|info| return info.get("extensions"))
|
|
.and_then(serde_json::Value::as_array);
|
|
let extensions = match extensions_option {
|
|
Some(extensions) => extensions,
|
|
None => return None,
|
|
};
|
|
for extension in extensions {
|
|
let extension_name_option = extension.get("extension").and_then(serde_json::Value::as_str);
|
|
let extension_name = match extension_name_option {
|
|
Some(extension_name) => extension_name,
|
|
None => continue,
|
|
};
|
|
if extension_name != "tokenMetadata" {
|
|
continue;
|
|
}
|
|
let state_option = extension
|
|
.get("state")
|
|
.or_else(|| return extension.get("metadata"))
|
|
.or_else(|| return extension.get("value"));
|
|
let state = match state_option {
|
|
Some(state) => state,
|
|
None => continue,
|
|
};
|
|
let symbol = extract_string_by_candidate_keys(state, &["symbol", "tokenSymbol"]);
|
|
let name = extract_string_by_candidate_keys(state, &["name", "tokenName"]);
|
|
if symbol.is_none() && name.is_none() {
|
|
continue;
|
|
}
|
|
return Some(ResolvedTokenMetadata {
|
|
symbol,
|
|
name,
|
|
decimals: None,
|
|
token_program: None,
|
|
is_quote_token: None,
|
|
});
|
|
}
|
|
return None;
|
|
}
|
|
|
|
async fn fetch_metaplex_metadata(
|
|
http_pool: &crate::HttpEndpointPool,
|
|
http_role: &str,
|
|
mint: &str,
|
|
) -> Result<std::option::Option<ResolvedTokenMetadata>, crate::Error> {
|
|
let metadata_address_result = derive_metaplex_metadata_address(mint);
|
|
let metadata_address = match metadata_address_result {
|
|
Ok(metadata_address) => metadata_address,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let config = serde_json::json!({
|
|
"encoding": "base64",
|
|
"commitment": "confirmed"
|
|
});
|
|
let account_result = http_pool
|
|
.get_account_info_raw_for_role(http_role, metadata_address, Some(config))
|
|
.await;
|
|
let account_value = match account_result {
|
|
Ok(account_value) => account_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let value_option = account_value.get("value");
|
|
let value = match value_option {
|
|
Some(value) => value,
|
|
None => return Ok(None),
|
|
};
|
|
if value.is_null() {
|
|
return Ok(None);
|
|
}
|
|
let data_text_option = extract_base64_account_data(value);
|
|
let data_text = match data_text_option {
|
|
Some(data_text) => data_text,
|
|
None => return Ok(None),
|
|
};
|
|
let bytes_result = decode_base64_standard(data_text.as_str());
|
|
let bytes = match bytes_result {
|
|
Ok(bytes) => bytes,
|
|
Err(error) => return Err(error),
|
|
};
|
|
return Ok(parse_metaplex_token_metadata_account(&bytes));
|
|
}
|
|
|
|
fn derive_metaplex_metadata_address(mint: &str) -> Result<std::string::String, crate::Error> {
|
|
let program_id_result = <solana_sdk::pubkey::Pubkey as std::str::FromStr>::from_str(
|
|
METAPLEX_TOKEN_METADATA_PROGRAM_ID,
|
|
);
|
|
let program_id = match program_id_result {
|
|
Ok(program_id) => program_id,
|
|
Err(error) => {
|
|
return Err(crate::Error::Config(format!(
|
|
"invalid metaplex metadata program id '{}': {}",
|
|
METAPLEX_TOKEN_METADATA_PROGRAM_ID, error
|
|
)));
|
|
},
|
|
};
|
|
let mint_pubkey_result = <solana_sdk::pubkey::Pubkey as std::str::FromStr>::from_str(mint);
|
|
let mint_pubkey = match mint_pubkey_result {
|
|
Ok(mint_pubkey) => mint_pubkey,
|
|
Err(error) => {
|
|
return Err(crate::Error::Config(format!("invalid token mint '{}': {}", mint, error)));
|
|
},
|
|
};
|
|
let (metadata_address, _) = solana_sdk::pubkey::Pubkey::find_program_address(
|
|
&[b"metadata", program_id.as_ref(), mint_pubkey.as_ref()],
|
|
&program_id,
|
|
);
|
|
return Ok(metadata_address.to_string());
|
|
}
|
|
|
|
fn extract_base64_account_data(
|
|
value: &serde_json::Value,
|
|
) -> std::option::Option<std::string::String> {
|
|
let data = match value.get("data") {
|
|
Some(data) => data,
|
|
None => return None,
|
|
};
|
|
if let Some(data_array) = data.as_array() {
|
|
let first = match data_array.first() {
|
|
Some(first) => first,
|
|
None => return None,
|
|
};
|
|
return first.as_str().map(|value| return value.to_string());
|
|
}
|
|
return data.as_str().map(|value| return value.to_string());
|
|
}
|
|
|
|
fn parse_metaplex_token_metadata_account(
|
|
bytes: &[u8],
|
|
) -> std::option::Option<ResolvedTokenMetadata> {
|
|
if bytes.len() < 69 {
|
|
return None;
|
|
}
|
|
let mut offset = 65usize;
|
|
let name_option = read_borsh_string(bytes, &mut offset);
|
|
let symbol_option = read_borsh_string(bytes, &mut offset);
|
|
let _uri_option = read_borsh_string(bytes, &mut offset);
|
|
let name = match name_option {
|
|
Some(name) => clean_metaplex_string(name.as_str()),
|
|
None => None,
|
|
};
|
|
let symbol = match symbol_option {
|
|
Some(symbol) => clean_metaplex_string(symbol.as_str()),
|
|
None => None,
|
|
};
|
|
if name.is_none() && symbol.is_none() {
|
|
return None;
|
|
}
|
|
return Some(ResolvedTokenMetadata {
|
|
symbol,
|
|
name,
|
|
decimals: None,
|
|
token_program: None,
|
|
is_quote_token: None,
|
|
});
|
|
}
|
|
|
|
fn read_borsh_string(bytes: &[u8], offset: &mut usize) -> std::option::Option<std::string::String> {
|
|
if bytes.len() < *offset + 4 {
|
|
return None;
|
|
}
|
|
let length = u32::from_le_bytes([
|
|
bytes[*offset],
|
|
bytes[*offset + 1],
|
|
bytes[*offset + 2],
|
|
bytes[*offset + 3],
|
|
]) as usize;
|
|
*offset += 4;
|
|
if bytes.len() < *offset + length {
|
|
return None;
|
|
}
|
|
let data = &bytes[*offset..*offset + length];
|
|
*offset += length;
|
|
let text_result = std::string::String::from_utf8(data.to_vec());
|
|
match text_result {
|
|
Ok(text) => return Some(text),
|
|
Err(_) => return None,
|
|
}
|
|
}
|
|
|
|
fn clean_metaplex_string(value: &str) -> std::option::Option<std::string::String> {
|
|
let cleaned = value.trim_matches(char::from(0)).trim().to_string();
|
|
if cleaned.is_empty() { return None } else { return Some(cleaned) }
|
|
}
|
|
|
|
fn decode_base64_standard(text: &str) -> Result<std::vec::Vec<u8>, crate::Error> {
|
|
use base64::Engine;
|
|
let decoded_result = base64::engine::general_purpose::STANDARD.decode(text);
|
|
match decoded_result {
|
|
Ok(decoded) => return Ok(decoded),
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot decode standard base64 payload: {}",
|
|
error
|
|
)));
|
|
},
|
|
}
|
|
}
|
|
|
|
fn extract_string_by_candidate_keys(
|
|
payload: &serde_json::Value,
|
|
candidate_keys: &[&str],
|
|
) -> std::option::Option<std::string::String> {
|
|
for key in candidate_keys {
|
|
let value_option = payload.get(*key);
|
|
let value = match value_option {
|
|
Some(value) => value,
|
|
None => continue,
|
|
};
|
|
if let Some(text) = value.as_str() {
|
|
let trimmed = text.trim();
|
|
if !trimmed.is_empty() {
|
|
return Some(trimmed.to_string());
|
|
}
|
|
continue;
|
|
}
|
|
if value.is_number() || value.is_boolean() {
|
|
return Some(value.to_string());
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn extract_u8_by_candidate_keys(
|
|
payload: &serde_json::Value,
|
|
candidate_keys: &[&str],
|
|
) -> std::option::Option<u8> {
|
|
for key in candidate_keys {
|
|
let value_option = payload.get(*key);
|
|
let value = match value_option {
|
|
Some(value) => value,
|
|
None => continue,
|
|
};
|
|
if let Some(number) = value.as_u64() {
|
|
let converted = u64_to_u8(number);
|
|
if converted.is_some() {
|
|
return converted;
|
|
}
|
|
}
|
|
if let Some(text) = value.as_str() {
|
|
let parsed_result = text.trim().parse::<u64>();
|
|
let parsed = match parsed_result {
|
|
Ok(parsed) => parsed,
|
|
Err(_) => continue,
|
|
};
|
|
let converted = u64_to_u8(parsed);
|
|
if converted.is_some() {
|
|
return converted;
|
|
}
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn option_string_is_missing(value: std::option::Option<&str>) -> bool {
|
|
match value {
|
|
Some(value) => return value.trim().is_empty(),
|
|
None => return true,
|
|
}
|
|
}
|
|
|
|
fn u64_to_u8(value: u64) -> std::option::Option<u8> {
|
|
let converted_result = u8::try_from(value);
|
|
match converted_result {
|
|
Ok(converted) => return Some(converted),
|
|
Err(_) => return None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
fn build_test_metadata_account(name: &str, symbol: &str, uri: &str) -> std::vec::Vec<u8> {
|
|
let mut bytes = std::vec::Vec::new();
|
|
bytes.push(4_u8);
|
|
bytes.extend_from_slice(&[1_u8; 32]);
|
|
bytes.extend_from_slice(&[2_u8; 32]);
|
|
push_borsh_string(&mut bytes, name);
|
|
push_borsh_string(&mut bytes, symbol);
|
|
push_borsh_string(&mut bytes, uri);
|
|
return bytes;
|
|
}
|
|
|
|
fn push_borsh_string(bytes: &mut std::vec::Vec<u8>, value: &str) {
|
|
let length = value.len() as u32;
|
|
bytes.extend_from_slice(&length.to_le_bytes());
|
|
bytes.extend_from_slice(value.as_bytes());
|
|
}
|
|
|
|
async fn make_database() -> std::sync::Arc<crate::Database> {
|
|
let tempdir_result = tempfile::tempdir();
|
|
let tempdir = match tempdir_result {
|
|
Ok(tempdir) => tempdir,
|
|
Err(error) => panic!("tempdir must succeed: {}", error),
|
|
};
|
|
let database_path = tempdir.path().join("token_metadata.sqlite3");
|
|
let config = crate::DatabaseConfig {
|
|
enabled: true,
|
|
backend: crate::DatabaseBackend::Sqlite,
|
|
sqlite: crate::SqliteDatabaseConfig {
|
|
path: database_path.to_string_lossy().to_string(),
|
|
create_if_missing: true,
|
|
busy_timeout_ms: 5000,
|
|
max_connections: 1,
|
|
auto_initialize_schema: true,
|
|
use_wal: true,
|
|
},
|
|
};
|
|
let database_result = crate::Database::connect_and_initialize(&config).await;
|
|
let database = match database_result {
|
|
Ok(database) => database,
|
|
Err(error) => panic!("database init must succeed: {}", error),
|
|
};
|
|
return std::sync::Arc::new(database);
|
|
}
|
|
|
|
#[test]
|
|
fn local_metadata_resolves_wsol() {
|
|
let mint = crate::WSOL_MINT_ID.to_string();
|
|
let metadata_option = super::resolve_local_token_metadata(mint.as_str());
|
|
let metadata = match metadata_option {
|
|
Some(metadata) => metadata,
|
|
None => panic!("WSOL metadata must resolve"),
|
|
};
|
|
assert_eq!(metadata.symbol.as_deref(), Some("WSOL"));
|
|
assert_eq!(metadata.name.as_deref(), Some("Wrapped SOL"));
|
|
assert_eq!(metadata.decimals, Some(9));
|
|
assert_eq!(metadata.is_quote_token, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn local_metadata_resolves_known_ecosystem_tokens_without_forcing_quote_flag() {
|
|
let cases = [
|
|
(crate::JUP_MINT_ID, "JUP", "Jupiter", 6_u8),
|
|
(crate::RAY_MINT_ID, "RAY", "Raydium", 6_u8),
|
|
(crate::BONK_MINT_ID, "BONK", "Bonk", 5_u8),
|
|
];
|
|
for (mint, expected_symbol, expected_name, expected_decimals) in cases {
|
|
let metadata_option = super::resolve_local_token_metadata(mint);
|
|
let metadata = match metadata_option {
|
|
Some(metadata) => metadata,
|
|
None => panic!("known ecosystem token metadata must resolve"),
|
|
};
|
|
assert_eq!(metadata.symbol.as_deref(), Some(expected_symbol));
|
|
assert_eq!(metadata.name.as_deref(), Some(expected_name));
|
|
assert_eq!(metadata.decimals, Some(expected_decimals));
|
|
assert_eq!(metadata.is_quote_token, None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pump_fun_payload_metadata_extracts_name_and_symbol() {
|
|
let payload = serde_json::json!({
|
|
"mint": "PumpMint111",
|
|
"symbol": "PUMPX",
|
|
"name": "Pump Example",
|
|
"uri": "https://example.invalid/pump.json"
|
|
});
|
|
let metadata_option = super::extract_pump_fun_metadata_from_payload(&payload);
|
|
let metadata = match metadata_option {
|
|
Some(metadata) => metadata,
|
|
None => panic!("pump.fun metadata must parse"),
|
|
};
|
|
assert_eq!(metadata.symbol.as_deref(), Some("PUMPX"));
|
|
assert_eq!(metadata.name.as_deref(), Some("Pump Example"));
|
|
}
|
|
|
|
#[test]
|
|
fn token_2022_inline_metadata_extracts_name_and_symbol() {
|
|
let account = serde_json::json!({
|
|
"data": {
|
|
"parsed": {
|
|
"info": {
|
|
"extensions": [
|
|
{
|
|
"extension": "tokenMetadata",
|
|
"state": {
|
|
"symbol": "T22",
|
|
"name": "Token 2022 Example"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let metadata_option = super::extract_token_2022_metadata_from_mint_account_value(&account);
|
|
let metadata = match metadata_option {
|
|
Some(metadata) => metadata,
|
|
None => panic!("token-2022 metadata must parse"),
|
|
};
|
|
assert_eq!(metadata.symbol.as_deref(), Some("T22"));
|
|
assert_eq!(metadata.name.as_deref(), Some("Token 2022 Example"));
|
|
}
|
|
|
|
#[test]
|
|
fn base64_decoder_decodes_standard_payload() {
|
|
let decoded_result = super::decode_base64_standard("AQIDBA==");
|
|
let decoded = match decoded_result {
|
|
Ok(decoded) => decoded,
|
|
Err(error) => panic!("base64 decode must succeed: {}", error),
|
|
};
|
|
assert_eq!(decoded, vec![1_u8, 2_u8, 3_u8, 4_u8]);
|
|
}
|
|
|
|
#[test]
|
|
fn metaplex_parser_extracts_name_and_symbol() {
|
|
let bytes = build_test_metadata_account(
|
|
"Example Token\0\0\0",
|
|
"EXM\0\0",
|
|
"https://example.invalid/token.json",
|
|
);
|
|
let metadata_option = super::parse_metaplex_token_metadata_account(&bytes);
|
|
let metadata = match metadata_option {
|
|
Some(metadata) => metadata,
|
|
None => panic!("metadata must parse"),
|
|
};
|
|
assert_eq!(metadata.name.as_deref(), Some("Example Token"));
|
|
assert_eq!(metadata.symbol.as_deref(), Some("EXM"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_backfill_updates_wsol_without_http() {
|
|
let database = make_database().await;
|
|
let token = crate::TokenDto::new(
|
|
crate::WSOL_MINT_ID.to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
crate::SPL_TOKEN_PROGRAM_ID.to_string(),
|
|
false,
|
|
);
|
|
let upsert_result = crate::query_tokens_upsert(database.as_ref(), &token).await;
|
|
if let Err(error) = upsert_result {
|
|
panic!("token upsert must succeed: {}", error);
|
|
}
|
|
let service = crate::TokenMetadataBackfillService::new_local(database.clone());
|
|
let result = service.backfill_missing_token_metadata(Some(10)).await;
|
|
let result = match result {
|
|
Ok(result) => result,
|
|
Err(error) => panic!("metadata backfill must succeed: {}", error),
|
|
};
|
|
assert_eq!(result.updated_token_count, 1);
|
|
let fetched_result = crate::query_tokens_get_by_mint(
|
|
database.as_ref(),
|
|
crate::WSOL_MINT_ID.to_string().as_str(),
|
|
)
|
|
.await;
|
|
let fetched_option = match fetched_result {
|
|
Ok(fetched_option) => fetched_option,
|
|
Err(error) => panic!("token fetch must succeed: {}", error),
|
|
};
|
|
let fetched = match fetched_option {
|
|
Some(fetched) => fetched,
|
|
None => panic!("token must exist"),
|
|
};
|
|
assert_eq!(fetched.symbol.as_deref(), Some("WSOL"));
|
|
assert_eq!(fetched.name.as_deref(), Some("Wrapped SOL"));
|
|
assert_eq!(fetched.decimals, Some(9));
|
|
assert!(fetched.is_quote_token);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_backfill_updates_stable_quotes_without_http() {
|
|
let database = make_database().await;
|
|
let usdc = crate::TokenDto::new(
|
|
crate::USDC_MINT_ID.to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
crate::SPL_TOKEN_PROGRAM_ID.to_string(),
|
|
false,
|
|
);
|
|
let usdt = crate::TokenDto::new(
|
|
crate::USDT_MINT_ID.to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
crate::SPL_TOKEN_PROGRAM_ID.to_string(),
|
|
false,
|
|
);
|
|
let usdc_upsert_result = crate::query_tokens_upsert(database.as_ref(), &usdc).await;
|
|
if let Err(error) = usdc_upsert_result {
|
|
panic!("usdc token upsert must succeed: {}", error);
|
|
}
|
|
let usdt_upsert_result = crate::query_tokens_upsert(database.as_ref(), &usdt).await;
|
|
if let Err(error) = usdt_upsert_result {
|
|
panic!("usdt token upsert must succeed: {}", error);
|
|
}
|
|
let service = crate::TokenMetadataBackfillService::new_local(database.clone());
|
|
let result = service.backfill_missing_token_metadata(Some(10)).await;
|
|
let result = match result {
|
|
Ok(result) => result,
|
|
Err(error) => panic!("metadata backfill must succeed: {}", error),
|
|
};
|
|
assert_eq!(result.updated_token_count, 2);
|
|
|
|
let usdc_result =
|
|
crate::query_tokens_get_by_mint(database.as_ref(), crate::USDC_MINT_ID).await;
|
|
let usdc_option = match usdc_result {
|
|
Ok(usdc_option) => usdc_option,
|
|
Err(error) => panic!("usdc token fetch must succeed: {}", error),
|
|
};
|
|
let usdc = match usdc_option {
|
|
Some(usdc) => usdc,
|
|
None => panic!("usdc token must exist"),
|
|
};
|
|
assert_eq!(usdc.symbol.as_deref(), Some("USDC"));
|
|
assert_eq!(usdc.name.as_deref(), Some("USD Coin"));
|
|
assert_eq!(usdc.decimals, Some(6));
|
|
assert!(usdc.is_quote_token);
|
|
|
|
let usdt_result =
|
|
crate::query_tokens_get_by_mint(database.as_ref(), crate::USDT_MINT_ID).await;
|
|
let usdt_option = match usdt_result {
|
|
Ok(usdt_option) => usdt_option,
|
|
Err(error) => panic!("usdt token fetch must succeed: {}", error),
|
|
};
|
|
let usdt = match usdt_option {
|
|
Some(usdt) => usdt,
|
|
None => panic!("usdt token must exist"),
|
|
};
|
|
assert_eq!(usdt.symbol.as_deref(), Some("USDT"));
|
|
assert_eq!(usdt.name.as_deref(), Some("Tether USD"));
|
|
assert_eq!(usdt.decimals, Some(6));
|
|
assert!(usdt.is_quote_token);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_backfill_updates_known_ecosystem_tokens_without_http() {
|
|
let database = make_database().await;
|
|
let token_mints = [crate::JUP_MINT_ID, crate::RAY_MINT_ID, crate::BONK_MINT_ID];
|
|
for mint in token_mints {
|
|
let token = crate::TokenDto::new(
|
|
mint.to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
crate::SPL_TOKEN_PROGRAM_ID.to_string(),
|
|
false,
|
|
);
|
|
let upsert_result = crate::query_tokens_upsert(database.as_ref(), &token).await;
|
|
if let Err(error) = upsert_result {
|
|
panic!("known token upsert must succeed: {}", error);
|
|
}
|
|
}
|
|
let service = crate::TokenMetadataBackfillService::new_local(database.clone());
|
|
let result = service.backfill_missing_token_metadata(Some(10)).await;
|
|
let result = match result {
|
|
Ok(result) => result,
|
|
Err(error) => panic!("metadata backfill must succeed: {}", error),
|
|
};
|
|
assert_eq!(result.updated_token_count, 3);
|
|
|
|
let jup_result =
|
|
crate::query_tokens_get_by_mint(database.as_ref(), crate::JUP_MINT_ID).await;
|
|
let jup_option = match jup_result {
|
|
Ok(jup_option) => jup_option,
|
|
Err(error) => panic!("jup token fetch must succeed: {}", error),
|
|
};
|
|
let jup = match jup_option {
|
|
Some(jup) => jup,
|
|
None => panic!("jup token must exist"),
|
|
};
|
|
assert_eq!(jup.symbol.as_deref(), Some("JUP"));
|
|
assert_eq!(jup.name.as_deref(), Some("Jupiter"));
|
|
assert_eq!(jup.decimals, Some(6));
|
|
assert!(!jup.is_quote_token);
|
|
|
|
let ray_result =
|
|
crate::query_tokens_get_by_mint(database.as_ref(), crate::RAY_MINT_ID).await;
|
|
let ray_option = match ray_result {
|
|
Ok(ray_option) => ray_option,
|
|
Err(error) => panic!("ray token fetch must succeed: {}", error),
|
|
};
|
|
let ray = match ray_option {
|
|
Some(ray) => ray,
|
|
None => panic!("ray token must exist"),
|
|
};
|
|
assert_eq!(ray.symbol.as_deref(), Some("RAY"));
|
|
assert_eq!(ray.name.as_deref(), Some("Raydium"));
|
|
assert_eq!(ray.decimals, Some(6));
|
|
assert!(!ray.is_quote_token);
|
|
|
|
let bonk_result =
|
|
crate::query_tokens_get_by_mint(database.as_ref(), crate::BONK_MINT_ID).await;
|
|
let bonk_option = match bonk_result {
|
|
Ok(bonk_option) => bonk_option,
|
|
Err(error) => panic!("bonk token fetch must succeed: {}", error),
|
|
};
|
|
let bonk = match bonk_option {
|
|
Some(bonk) => bonk,
|
|
None => panic!("bonk token must exist"),
|
|
};
|
|
assert_eq!(bonk.symbol.as_deref(), Some("BONK"));
|
|
assert_eq!(bonk.name.as_deref(), Some("Bonk"));
|
|
assert_eq!(bonk.decimals, Some(5));
|
|
assert!(!bonk.is_quote_token);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_backfill_does_not_overwrite_existing_display_metadata() {
|
|
let database = make_database().await;
|
|
let token = crate::TokenDto::new(
|
|
crate::WSOL_MINT_ID.to_string(),
|
|
Some("SOL".to_string()),
|
|
Some("Custom Sol".to_string()),
|
|
None,
|
|
crate::SPL_TOKEN_PROGRAM_ID.to_string(),
|
|
false,
|
|
);
|
|
let upsert_result = crate::query_tokens_upsert(database.as_ref(), &token).await;
|
|
if let Err(error) = upsert_result {
|
|
panic!("token upsert must succeed: {}", error);
|
|
}
|
|
let service = crate::TokenMetadataBackfillService::new_local(database.clone());
|
|
let result = service
|
|
.backfill_token_metadata_by_mint(crate::WSOL_MINT_ID.to_string().as_str())
|
|
.await;
|
|
if let Err(error) = result {
|
|
panic!("metadata backfill must succeed: {}", error);
|
|
}
|
|
let fetched_result = crate::query_tokens_get_by_mint(
|
|
database.as_ref(),
|
|
crate::WSOL_MINT_ID.to_string().as_str(),
|
|
)
|
|
.await;
|
|
let fetched_option = match fetched_result {
|
|
Ok(fetched_option) => fetched_option,
|
|
Err(error) => panic!("token fetch must succeed: {}", error),
|
|
};
|
|
let fetched = match fetched_option {
|
|
Some(fetched) => fetched,
|
|
None => panic!("token must exist"),
|
|
};
|
|
assert_eq!(fetched.symbol.as_deref(), Some("SOL"));
|
|
assert_eq!(fetched.name.as_deref(), Some("Custom Sol"));
|
|
assert_eq!(fetched.decimals, Some(9));
|
|
assert!(fetched.is_quote_token);
|
|
}
|
|
}
|