Merge branch 'ido' into 'master'

ido

See merge request riftenlabs/riftenlabs-indexer!81
This commit is contained in:
Dagur Valberg Johannsson 2026-06-23 13:08:55 +00:00
commit d5fa393870
14 changed files with 4329 additions and 32 deletions

189
Cargo.lock generated
View file

@ -408,6 +408,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.1.1",
]
@ -549,6 +550,40 @@ dependencies = [
"typenum",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.101",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn 2.0.101",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
@ -568,11 +603,12 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.4.0"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
"serde_core",
]
[[package]]
@ -643,6 +679,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
@ -965,7 +1007,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap",
"indexmap 2.9.0",
"slab",
"tokio",
"tokio-util",
@ -1024,6 +1066,9 @@ name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
dependencies = [
"serde",
]
[[package]]
name = "hex-conservative"
@ -1330,6 +1375,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.0.3"
@ -1351,6 +1402,17 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.9.0"
@ -1663,9 +1725,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-integer"
@ -2244,6 +2306,7 @@ dependencies = [
"rust_decimal_macros",
"serde",
"serde_json",
"serde_with",
"sqlx",
"stderrlog",
"tokio",
@ -2306,7 +2369,7 @@ dependencies = [
"either",
"figment",
"futures",
"indexmap",
"indexmap 2.9.0",
"log",
"memchr",
"multer",
@ -2338,7 +2401,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46"
dependencies = [
"devise",
"glob",
"indexmap",
"indexmap 2.9.0",
"proc-macro2",
"quote",
"rocket_http",
@ -2375,7 +2438,7 @@ dependencies = [
"futures",
"http 0.2.12",
"hyper 0.14.32",
"indexmap",
"indexmap 2.9.0",
"log",
"memchr",
"pear",
@ -2527,6 +2590,30 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@ -2567,18 +2654,28 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@ -2587,14 +2684,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@ -2618,6 +2716,37 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "sha1"
version = "0.10.6"
@ -2758,7 +2887,7 @@ dependencies = [
"futures-util",
"hashbrown 0.15.3",
"hashlink",
"indexmap",
"indexmap 2.9.0",
"log",
"memchr",
"once_cell",
@ -2963,6 +3092,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@ -3071,30 +3206,30 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.41"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.22"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@ -3224,7 +3359,7 @@ version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",
@ -4070,3 +4205,9 @@ dependencies = [
"quote",
"syn 2.0.101",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -16,7 +16,7 @@ anyhow = { version = "1.0.94", features = ["backtrace"] }
bitcoincash = "0.32.2"
bitcoin_hashes = { version = "0.14.100", default-features = false } # Same version as used by bitcoincash
electrum-client-netagnostic = { version = "0.21.2", features = ["use-rustls", "use-websocket"] }
hex = "0.4.3"
hex = { version = "0.4.3", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.133"
rocket = { version = "0.5.1", features = ["json"] }
@ -33,6 +33,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature
rand = "0.8.5"
configure_me = "0.4.0"
futures = "0.3.32"
serde_with = { version = "3.18.0", features = ["hex"] }
[build-dependencies]
configure_me_codegen = "0.4.8"

View file

@ -51,3 +51,15 @@ name = "moria_read_slots"
type = "u32"
doc = "Number of read connection pool slots for the Moria lending database (default: 8)"
default = "8"
[[param]]
name = "ido_read_slots"
type = "u32"
doc = "Number of read connection pool slots for the Moria lending database (default: 8)"
default = "8"
[[param]]
name = "debug"
type = "bool"
doc = "Enable debug RPC endpoints (e.g. txchain inspection). Do not use in production."
default = "false"

View file

@ -88,6 +88,9 @@ impl BlockUndoer for StoreBlockUndoer {
{
was_indexed = true;
}
if db::ido::delete_entries(&self.db.ido_w, Some(&blockheader.block_hash())).await? > 0 {
was_indexed = true;
}
if was_indexed {
config_set(

3839
src/db/ido/mod.rs Normal file

File diff suppressed because one or more lines are too long

View file

@ -16,6 +16,7 @@ use crate::db::cauldron::config::check_db_version;
use crate::db::cauldron::prepare_tables as cauldron_prepare_tables;
use crate::db::cauldron::tokentoken::create_table as tokentoken_prepare_tables;
use crate::db::crc20::prepare_tables as crc20_prepare_tables;
use crate::db::ido::prepare_tables as ido_prepare_tables;
use crate::db::moria::prepare_tables as moria_prepare_tables;
use crate::db::oracle::prepare_tables as oracle_prepare_tables;
@ -89,6 +90,7 @@ pub struct ReadSlots {
pub crc20: u32,
pub oracle: u32,
pub moria: u32,
pub ido: u32,
}
/// Initialize all databases and return a DB struct
@ -135,6 +137,13 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul
moria_prepare_tables(&moria_db_write).await;
}
// Initialize ido database
let (db_exists, ido_db_write, ido_db_read) =
create_db_pool(&db_path(db_dir, "ido.db"), read_slots.ido).await;
if !db_exists {
ido_prepare_tables(&ido_db_write).await;
}
Ok(DB {
cauldron_w: cauldron_db_write,
cauldron_r: cauldron_db_read,
@ -146,5 +155,7 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul
oracle_r: oracle_db_read,
moria_w: moria_db_write,
moria_r: moria_db_read,
ido_r: ido_db_read,
ido_w: ido_db_write,
})
}

View file

@ -9,6 +9,7 @@ pub mod bcmr;
pub mod blob;
pub mod cauldron;
pub mod crc20;
pub mod ido;
pub mod init;
pub mod moria;
pub mod oracle;
@ -35,4 +36,7 @@ pub struct DB {
// moria lending protocol
pub moria_w: SqlitePool,
pub moria_r: SqlitePool,
// ido
pub ido_w: SqlitePool,
pub ido_r: SqlitePool,
}

View file

@ -18,6 +18,8 @@ use riftenlabs_defi::{
};
use serde_json::{json, Value};
use crate::db::ido::{IDO_PREINIT_ANNOUNCEMENT_SIGNATURE, IDO_SIGNATURE};
/// Fetch blockchain tip from electrum server
pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> {
let tip: Value =
@ -39,7 +41,9 @@ pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> {
}
/// Fetch defi (cauldron + tokentoken) and oracle mempool transactions
pub fn electrum_fetch_mempool(client: &Client) -> Result<(HashSet<Txid>, HashSet<Txid>)> {
pub fn electrum_fetch_mempool(
client: &Client,
) -> Result<(HashSet<Txid>, HashSet<Txid>, HashSet<Txid>)> {
let cauldron_filter = json!({
"scriptsig": hex::encode(&V2_CONTRACT_TEMPLATE[(V2_CONTRACT_TEMPLATE.len() - 43)..]), // cauldron spends
"scriptpubkey": hex::encode([0x6a /* op_return */, 0x06 /* push */, b'S', b'U', b'M', b'M', b'O', b'N']), // new pools (potentially)
@ -72,6 +76,12 @@ pub fn electrum_fetch_mempool(client: &Client) -> Result<(HashSet<Txid>, HashSet
"scriptsig": hex::encode(DELPHI_V2_REDEEM_SCRIPT_BODY),
});
let ido_filter = json!({
"scriptsig": hex::encode(IDO_SIGNATURE), // ido state machine spends
"scriptpubkey": hex::encode(IDO_PREINIT_ANNOUNCEMENT_SIGNATURE), // preinit announcements (potentially)
"operation": "union"
});
let fetch_txs = |filter: Value| -> Result<HashSet<Txid>> {
let response = client.raw_call("mempool.get", [Param::Value(filter)])?;
@ -104,8 +114,9 @@ pub fn electrum_fetch_mempool(client: &Client) -> Result<(HashSet<Txid>, HashSet
defi_txs.extend(fetch_txs(tokentoken_filter)?);
let mut oracle_txs = fetch_txs(oracle_v1_filter)?;
oracle_txs.extend(fetch_txs(oracle_v2_filter)?);
let ido_txs = fetch_txs(ido_filter)?;
Ok((defi_txs, oracle_txs))
Ok((defi_txs, oracle_txs, ido_txs))
}
/// Fetch blockchain tip from electrum server

View file

@ -26,6 +26,7 @@ use crate::{
crc20::index_crc20,
db::{
self,
blob::ToBlob,
cauldron::{
config::{config_get, config_set},
header::{db_get_header, store_headers},
@ -67,18 +68,22 @@ fn moria_token_ids(network: Option<Network>) -> MoriaTokenIds {
}
}
pub async fn update_mempool(db: &DB, electrum: Arc<Mutex<Client>>) -> Result<()> {
pub async fn update_mempool(
db: &DB,
electrum: Arc<Mutex<Client>>,
network: Option<Network>,
) -> Result<()> {
let our_mempool_txs: HashSet<Txid> =
db::cauldron::mempool::load_mempool(&db.cauldron_w).await?;
let electrum_clone = electrum.clone();
let (defi_txs, oracle_txs) = tokio::task::spawn_blocking(move || {
let (cauldron_txs, oracle_txs, ido_txs) = tokio::task::spawn_blocking(move || {
electrum_fetch_mempool(&electrum_clone.lock().unwrap())
})
.await??;
let txs_to_delete: Vec<Txid> = our_mempool_txs.difference(&defi_txs).cloned().collect();
let txs_to_add: Vec<&Txid> = defi_txs.difference(&our_mempool_txs).collect();
let txs_to_delete: Vec<Txid> = our_mempool_txs.difference(&cauldron_txs).cloned().collect();
let txs_to_add: Vec<&Txid> = cauldron_txs.difference(&our_mempool_txs).collect();
let electrum_for_fetch = electrum.clone();
let txids_to_fetch: Vec<Txid> = txs_to_add.iter().map(|t| **t).collect();
@ -182,6 +187,37 @@ pub async fn update_mempool(db: &DB, electrum: Arc<Mutex<Client>>) -> Result<()>
index_oracle(&db.oracle_w, &txs_to_add, &BlockHash::all_zeros()).await?;
// ido updates
let ido_electrum = electrum.clone();
// filter txs already recorded in an ido's state chain
let mut ido_to_add = Vec::new();
for txid in ido_txs {
if !db::ido::has_indexed_tx(&db.ido_w, &txid.to_blob()).await? {
ido_to_add.push(txid);
}
}
let txs_to_add: Vec<Transaction> = tokio::task::spawn_blocking(move || {
ido_to_add
.into_iter()
.filter_map(
|txid| match electrum_get_tx(&ido_electrum.lock().unwrap(), &txid) {
Ok(tx) => Some(tx),
Err(e) => {
info!("Failed to get mempool tx {txid}: {e}");
None
}
},
)
.collect()
})
.await?;
// an ido chain can have several unconfirmed txs in flight; index parents first
let txs_to_add = ttor_sorted_kahn(txs_to_add);
db::ido::index_txs(network, &db.ido_w, &txs_to_add, None).await?;
Ok(())
}
@ -263,6 +299,14 @@ pub async fn index_blocks(
}
}
// Before applying confirmed blocks, drop any mempool-indexed IDO state
// (blockhash IS NULL). A mempool tx that never confirmed — e.g. one replaced
// by a different on-chain tx — must not leave a stale snapshot that wins
// MAX(seq) or resurfaces after the reorg undo above. Confirmed blocks
// re-create rows for the txs they contain, and the next mempool pass
// (update_mempool) rebuilds the rest.
db::ido::delete_entries(&db.ido_w, None).await?;
let db_cpy = db.clone();
let chain_cpy = chain.clone();
let client_cpy = client.clone();
@ -444,6 +488,8 @@ pub async fn index_blocks(
)
.await?;
db::ido::index_txs(network, &db.ido_w, &sorted_txs, Some(&blockhash)).await?;
let autheader_updates = if bcmr_enabled {
let updates = index_bcmr(&db.bcmr_w, &blockhash, sorted_txs).await?;
updates as i64

View file

@ -169,6 +169,7 @@ async fn start_program(
crc20: config.crc20_read_slots,
oracle: config.oracle_read_slots,
moria: config.moria_read_slots,
ido: config.ido_read_slots,
},
)
.await?;
@ -337,7 +338,7 @@ async fn start_program(
// Avoid overlapping writer while indexer is on
if !indexing_in_progress_clone.load(Ordering::Relaxed) {
if let Err(e) = update_mempool(&db, client.clone()).await {
if let Err(e) = update_mempool(&db, client.clone(), Some(network)).await {
error!("Failed to update mempool: {e}");
}
}
@ -634,6 +635,15 @@ async fn launch() -> _ {
rpc::tokentoken::list_tokens,
],
)
.mount(
"/ido",
routes![
rpc::ido::list_idos,
rpc::ido::get_ido_by_id,
rpc::ido::get_ido_by_offering_token,
rpc::ido::list_ido_entries,
],
)
.mount("/", routes![rpc::health::health])
.attach(cors)
}

View file

@ -35,6 +35,7 @@ pub enum ApiErrorCode {
// 404 Not Found codes
PriceNotFound,
PoolNotFound,
IdoNotFound,
// 500 Internal Server Error codes
InternalError,
@ -61,6 +62,7 @@ impl fmt::Display for ApiErrorCode {
// 404 codes
Self::PriceNotFound => "PRICE_NOT_FOUND",
Self::PoolNotFound => "POOL_NOT_FOUND",
Self::IdoNotFound => "IDO_NOT_FOUND",
// 500 codes
Self::InternalError => "INTERNAL_ERROR",
// 503 codes

214
src/rpc/ido.rs Normal file
View file

@ -0,0 +1,214 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use crate::db::blob::{blob_to_display_hex, display_hex_to_blob};
use crate::db::DB;
use crate::rpc::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult};
use crate::rpc::response::{cached_ok, CACHE_NONE};
use bitcoincash::{TokenID, Txid};
use rocket::{get, State};
use serde_json::Value;
const LIST_DEFAULT_LIMIT: i64 = 20;
const LIST_MAX_LIMIT: i64 = 100;
/// Maximum number of comma-delimited owner_nfthash filters accepted per request.
const MAX_OWNER_NFTHASH_FILTERS: usize = 20;
/// Length in bytes of an owner_nfthash.
const OWNER_NFTHASH_LEN: usize = 32;
/// List IDOs with optional filters and pagination.
///
/// Filters:
/// - `is_valid`: boolean filter on the is_valid flag
/// - `status`: one of PREINIT, ACTIVE, DISTRIBUTING, DISTRIBUTED
/// - `offered_token_id`: 64-char hex token ID (reversed display format)
///
/// Pagination: `offset` (default 0), `limit` (default 20, max 100)
#[get("/list?<is_valid>&<status>&<offered_token_id>&<offset>&<limit>")]
pub async fn list_idos(
is_valid: Option<bool>,
status: Option<String>,
offered_token_id: Option<&str>,
offset: Option<i64>,
limit: Option<i64>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let offset = offset.unwrap_or(0).max(0);
let limit = limit.unwrap_or(LIST_DEFAULT_LIMIT).clamp(1, LIST_MAX_LIMIT);
let offered_token_id_blob = offered_token_id
.map(|s| {
display_hex_to_blob::<TokenID>(s).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid offered_token_id: {e}"),
)
})
})
.transpose()?;
let items = crate::db::ido::list_idos(
&db.ido_r,
is_valid,
status,
offered_token_id_blob,
offset,
limit,
)
.await
.map_err(db_error)?;
Ok(cached_ok(serde_json::to_value(items).unwrap(), CACHE_NONE))
}
/// Get a single IDO by id token ID. Returns null if not found.
#[get("/by-id/<id>")]
pub async fn get_ido_by_id(id: &str, db: &State<DB>) -> CachedApiResult<Value> {
let blob = display_hex_to_blob::<TokenID>(id).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid offering_token_id: {e}"),
)
})?;
let item = crate::db::ido::get_ido_by_preinit_txid(&db.ido_r, blob)
.await
.map_err(db_error)?;
Ok(cached_ok(serde_json::to_value(item).unwrap(), CACHE_NONE))
}
/// Get a single IDO by its offering token ID. Returns null if not found.
#[get("/by-offering-token/<offering_token_id>")]
pub async fn get_ido_by_offering_token(
offering_token_id: &str,
db: &State<DB>,
) -> CachedApiResult<Value> {
let blob = display_hex_to_blob::<TokenID>(offering_token_id).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid offering_token_id: {e}"),
)
})?;
let item = crate::db::ido::get_ido_by_offering_token_id(&db.ido_r, blob)
.await
.map_err(db_error)?;
Ok(cached_ok(serde_json::to_value(item).unwrap(), CACHE_NONE))
}
/// Parse the comma-delimited `owner_nfthash` query parameter into raw byte
/// filters. Returns an empty vec when the parameter is absent. Rejects more
/// than [`MAX_OWNER_NFTHASH_FILTERS`] values, blank entries, and invalid hex.
fn parse_owner_nfthash_filters(
owner_nfthash: Option<&str>,
) -> Result<Vec<Vec<u8>>, rocket::response::status::Custom<rocket::serde::json::Json<Value>>> {
let Some(raw) = owner_nfthash else {
return Ok(Vec::new());
};
let parts: Vec<&str> = raw
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if parts.len() > MAX_OWNER_NFTHASH_FILTERS {
return Err(bad_request(
ApiErrorCode::InvalidParameters,
&format!("Too many owner_nfthash filters (max {MAX_OWNER_NFTHASH_FILTERS})"),
));
}
parts
.into_iter()
.map(|s| {
let bytes = hex::decode(s).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid owner_nfthash '{s}': {e}"),
)
})?;
if bytes.len() != OWNER_NFTHASH_LEN {
return Err(bad_request(
ApiErrorCode::InvalidParameters,
&format!(
"Invalid owner_nfthash '{s}': expected {OWNER_NFTHASH_LEN} bytes, got {}",
bytes.len()
),
));
}
Ok(bytes)
})
.collect()
}
/// List entries for an IDO.
///
/// - `id`: the IDO's public id (preinit txid, 64-char hex)
/// - `distributed`: optional boolean filter
/// - `owner_nfthash`: optional filter by owner nfthash (hex). Multiple hashes
/// may be supplied comma-delimited; up to 20 per request. An entry matches
/// if its owner_nfthash equals any of the supplied values.
///
/// Pagination: `offset` (default 0), `limit` (default 20, max 100)
#[get("/<id>/entries?<distributed>&<owner_nfthash>&<offset>&<limit>")]
pub async fn list_ido_entries(
id: &str,
distributed: Option<bool>,
owner_nfthash: Option<&str>,
offset: Option<i64>,
limit: Option<i64>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let offset = offset.unwrap_or(0).max(0);
let limit = limit.unwrap_or(LIST_DEFAULT_LIMIT).clamp(1, LIST_MAX_LIMIT);
let owner_nfthashes = parse_owner_nfthash_filters(owner_nfthash)?;
let (internal_id, preinit_txid_hex) = resolve_ido(id, &db.ido_r).await?;
let items = crate::db::ido::list_ido_entries(
&db.ido_r,
internal_id,
&preinit_txid_hex,
distributed,
&owner_nfthashes,
offset,
limit,
)
.await
.map_err(db_error)?;
Ok(cached_ok(serde_json::to_value(items).unwrap(), CACHE_NONE))
}
async fn resolve_ido(
id: &str,
pool: &sqlx::SqlitePool,
) -> Result<(i64, String), rocket::response::status::Custom<rocket::serde::json::Json<Value>>> {
let preinit_blob = display_hex_to_blob::<Txid>(id).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid IDO id (expected preinit_txid hex): {e}"),
)
})?;
let internal_id = crate::db::ido::lookup_internal_id_by_preinit_txid(pool, &preinit_blob)
.await
.map_err(db_error)?
.ok_or_else(|| not_found(ApiErrorCode::IdoNotFound, "IDO not found"))?;
let preinit_txid_hex = blob_to_display_hex::<Txid>(&preinit_blob).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid IDO id: {e}"),
)
})?;
Ok((internal_id, preinit_txid_hex))
}

View file

@ -17,6 +17,7 @@ pub mod candlesticks;
pub mod contract;
pub mod err;
pub mod health;
pub mod ido;
pub mod moria;
pub mod oracle;
pub mod pool;

View file

@ -43,6 +43,8 @@ mod test_utils {
oracle_r: pool.clone(),
moria_w: pool.clone(),
moria_r: pool.clone(),
ido_w: pool.clone(),
ido_r: pool.clone(),
}
}
}