diff --git a/src/db/cauldron/ido.rs b/src/db/cauldron/ido.rs new file mode 100644 index 0000000..483772e --- /dev/null +++ b/src/db/cauldron/ido.rs @@ -0,0 +1,207 @@ +// 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 anyhow::Result; +use bitcoin_hashes::hex::ToHex; +use bitcoincash::{TokenID, Transaction, Txid}; +use log::debug; +use sqlx::{Row, SqliteConnection, SqlitePool}; + +use crate::db::blob::{blob_to_display_hex, ToBlob}; + +// OP_RETURN (0x6a) | PUSHDATA1 (0x4c) | 0xd7 (215 bytes) | "CauldronIDO2026Q1" (17 bytes) +const IDO_OPRETURN_PREFIX: &[u8] = &[ + 0x6a, // OP_RETURN + 0x4c, // OP_PUSHDATA1 + 0xd7, // 215 bytes follow + 0x43, 0x61, 0x75, 0x6c, 0x64, 0x72, 0x6f, 0x6e, // "Cauldron" + 0x49, 0x44, 0x4f, 0x32, 0x30, 0x32, 0x36, 0x51, 0x31, // "IDO2026Q1" +]; + +const PARAMS_OFFSET: usize = 20; // 1 (OP_RETURN) + 1 (PUSHDATA1) + 1 (len) + 17 (signature) +const PARAMS_LEN: usize = 198; +const TOTAL_SCRIPT_LEN: usize = PARAMS_OFFSET + PARAMS_LEN; // 218 + +pub async fn create_tables(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE ido ( + creation_txid BLOB PRIMARY KEY, + category BLOB NOT NULL UNIQUE, + params_raw BLOB NOT NULL, + discovered_at_mtp BIGINT NOT NULL + )", + ) + .execute(pool) + .await + .expect("failed to create ido table"); + + sqlx::query("CREATE INDEX idx_ido_category ON ido(category)") + .execute(pool) + .await + .expect("failed to create idx_ido_category"); + + sqlx::query( + "CREATE TABLE ido_state ( + category BLOB PRIMARY KEY REFERENCES ido(category) ON DELETE CASCADE, + distributor_utxo_txid BLOB, + distributor_utxo_n INT, + phase TEXT NOT NULL DEFAULT 'fundraising' + )", + ) + .execute(pool) + .await + .expect("failed to create ido_state table"); +} + +fn parse_ido_from_tx(tx: &Transaction) -> Option<(TokenID, Vec)> { + // Find OP_RETURN output with IDO signature and extract params + let params = tx.output.iter().find_map(|o| { + let script = o.script_pubkey.as_bytes(); + if script.starts_with(IDO_OPRETURN_PREFIX) && script.len() >= TOTAL_SCRIPT_LEN { + Some(script[PARAMS_OFFSET..PARAMS_OFFSET + PARAMS_LEN].to_vec()) + } else { + None + } + })?; + + // The offered token category is on another output in the same tx + let category = tx.output.iter().find_map(|o| o.token.as_ref().map(|t| t.id))?; + + Some((category, params)) +} + +async fn insert_ido( + conn: &mut SqliteConnection, + creation_txid: &Txid, + category: &TokenID, + params_raw: &[u8], + mtp: i64, +) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO ido (creation_txid, category, params_raw, discovered_at_mtp) + VALUES (?, ?, ?, ?)", + ) + .bind(creation_txid.to_blob()) + .bind(category.to_blob()) + .bind(params_raw) + .bind(mtp) + .execute(&mut *conn) + .await?; + + sqlx::query("INSERT OR IGNORE INTO ido_state (category, phase) VALUES (?, 'fundraising')") + .bind(category.to_blob()) + .execute(&mut *conn) + .await?; + + Ok(()) +} + +pub async fn index_ido( + conn: &mut SqliteConnection, + txs: &[Transaction], + mtp: i64, +) -> Result<()> { + for tx in txs { + if let Some((category, params)) = parse_ido_from_tx(tx) { + let txid = tx.txid(); + debug!("Found IDO announcement in tx {}", txid.to_hex()); + insert_ido(conn, &txid, &category, ¶ms, mtp).await?; + } + } + Ok(()) +} + +pub async fn list_idos( + pool: &SqlitePool, +) -> Result, i64, String)>> { + let rows = sqlx::query( + "SELECT i.creation_txid, i.category, i.params_raw, i.discovered_at_mtp, s.phase + FROM ido i + JOIN ido_state s ON s.category = i.category + ORDER BY i.discovered_at_mtp DESC", + ) + .fetch_all(pool) + .await?; + + let mut result = Vec::new(); + for row in rows { + let txid_blob: Vec = row.get(0); + let category_blob: Vec = row.get(1); + let params_raw: Vec = row.get(2); + let mtp: i64 = row.get(3); + let phase: String = row.get(4); + result.push(( + blob_to_display_hex::(&txid_blob)?, + blob_to_display_hex::(&category_blob)?, + params_raw, + mtp, + phase, + )); + } + Ok(result) +} + +pub async fn get_ido( + pool: &SqlitePool, + category: &TokenID, +) -> Result, i64, String)>> { + let row = sqlx::query( + "SELECT i.creation_txid, i.category, i.params_raw, i.discovered_at_mtp, s.phase + FROM ido i + JOIN ido_state s ON s.category = i.category + WHERE i.category = ?", + ) + .bind(category.to_blob()) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => { + let txid_blob: Vec = row.get(0); + let category_blob: Vec = row.get(1); + let params_raw: Vec = row.get(2); + let mtp: i64 = row.get(3); + let phase: String = row.get(4); + Ok(Some(( + blob_to_display_hex::(&txid_blob)?, + blob_to_display_hex::(&category_blob)?, + params_raw, + mtp, + phase, + ))) + } + None => Ok(None), + } +} + +pub async fn get_ido_entries( + pool: &SqlitePool, + category: &TokenID, +) -> Result> { + let rows = sqlx::query( + "SELECT uf.new_utxo_txid, uf.new_utxo_n, uf.sats, uf.token_amount + FROM utxo_funding uf + WHERE uf.token_id = ? + AND uf.new_utxo_hash NOT IN (SELECT spent_utxo_hash FROM utxo_spending)", + ) + .bind(category.to_blob()) + .fetch_all(pool) + .await?; + + let mut result = Vec::new(); + for row in rows { + let txid_blob: Vec = row.get(0); + let n: i64 = row.get(1); + let sats: i64 = row.get(2); + let token_amount: i64 = row.get(3); + result.push(( + blob_to_display_hex::(&txid_blob)?, + n as u32, + sats, + token_amount, + )); + } + Ok(result) +} diff --git a/src/db/cauldron/mod.rs b/src/db/cauldron/mod.rs index 977c1d0..c6989da 100644 --- a/src/db/cauldron/mod.rs +++ b/src/db/cauldron/mod.rs @@ -15,6 +15,7 @@ use crate::db::cauldron::tokenlist::db_utils::{ pub mod config; pub mod header; +pub mod ido; pub mod mempool; pub mod ohlcv; pub mod pool; @@ -45,6 +46,7 @@ pub async fn prepare_tables(pool: &SqlitePool) { .unwrap(); pool::create_table(pool).await; + ido::create_tables(pool).await; sqlx::query( "CREATE INDEX idx_utxo_funding_join ON utxo_funding(new_utxo_hash, sats, token_id);", diff --git a/src/index.rs b/src/index.rs index 64f05f8..7a346e3 100644 --- a/src/index.rs +++ b/src/index.rs @@ -356,6 +356,9 @@ pub async fn index_blocks( // last successful block indexed was. It must commit atomically with block data. config_set(&mut *db_tx, KEY_LAST_INDEXED, &blockhash.to_hex()).await; + // ido + db::cauldron::ido::index_ido(&mut *db_tx, &sorted_txs, mtp as i64).await?; + // crc20 index_crc20(&db.crc20_w, &sorted_txs).await?; diff --git a/src/main.rs b/src/main.rs index a6612fe..591e96f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -588,6 +588,9 @@ async fn launch() -> _ { rpc::pool::list_active_pools, rpc::pool::pool_history, rpc::pool::pool_id_from_utxo, + rpc::ido::list_idos, + rpc::ido::get_ido, + rpc::ido::get_ido_entries, rpc::apy::aggregate_apy, rpc::contract::contract_count_token, rpc::contract::contract_count_all, diff --git a/src/rpc/ido.rs b/src/rpc/ido.rs new file mode 100644 index 0000000..38488d8 --- /dev/null +++ b/src/rpc/ido.rs @@ -0,0 +1,143 @@ +// 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::{cauldron::ido, DB}, + rpc::{ + err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult}, + response::{cached_ok, CACHE_NONE}, + }, +}; +use bitcoin_hashes::hex::FromHex; +use bitcoincash::TokenID; +use rocket::{get, State}; +use serde_json::{json, Value}; + +/// List all discovered IDOs. +/// +/// **Response Example:** +/// ```json +/// { +/// "idos": [ +/// { +/// "creation_txid": "abc123...", +/// "category": "def456...", +/// "params_raw": "...", +/// "discovered_at_mtp": 1700000000, +/// "phase": "fundraising" +/// } +/// ] +/// } +/// ``` +#[get("/ido")] +pub async fn list_idos(conn: &State) -> CachedApiResult { + let idos = ido::list_idos(&conn.cauldron_r).await.map_err(db_error)?; + + let idos_json: Vec = idos + .into_iter() + .map(|(txid, category, params_raw, mtp, phase)| { + json!({ + "creation_txid": txid, + "category": category, + "params_raw": hex::encode(params_raw), + "discovered_at_mtp": mtp, + "phase": phase, + }) + }) + .collect(); + + Ok(cached_ok(json!({ "idos": idos_json }), CACHE_NONE)) +} + +/// Get a specific IDO by token category. +/// +/// - category: Token category hex (32 bytes) +/// +/// **Response Example:** +/// ```json +/// { +/// "creation_txid": "abc123...", +/// "category": "def456...", +/// "params_raw": "...", +/// "discovered_at_mtp": 1700000000, +/// "phase": "fundraising" +/// } +/// ``` +#[get("/ido/")] +pub async fn get_ido(category: &str, conn: &State) -> CachedApiResult { + let category = TokenID::from_hex(category).map_err(|e| { + bad_request( + ApiErrorCode::InvalidTokenId, + &format!("Invalid category: {e}"), + ) + })?; + + let result = ido::get_ido(&conn.cauldron_r, &category) + .await + .map_err(db_error)?; + + match result { + Some((txid, category_hex, params_raw, mtp, phase)) => Ok(cached_ok( + json!({ + "creation_txid": txid, + "category": category_hex, + "params_raw": hex::encode(params_raw), + "discovered_at_mtp": mtp, + "phase": phase, + }), + CACHE_NONE, + )), + None => Err(not_found( + ApiErrorCode::PoolNotFound, + &format!("No IDO found for category: {category}"), + )), + } +} + +/// Get active (unspent) entry NFT UTXOs for an IDO. +/// Used by the distribution robot to find which entries still need to be processed. +/// +/// - category: Token category hex (32 bytes) +/// +/// **Response Example:** +/// ```json +/// { +/// "entries": [ +/// { +/// "txid": "abc123...", +/// "n": 0, +/// "sats": 10000, +/// "token_amount": 100 +/// } +/// ] +/// } +/// ``` +#[get("/ido//entries")] +pub async fn get_ido_entries(category: &str, conn: &State) -> CachedApiResult { + let category = TokenID::from_hex(category).map_err(|e| { + bad_request( + ApiErrorCode::InvalidTokenId, + &format!("Invalid category: {e}"), + ) + })?; + + let entries = ido::get_ido_entries(&conn.cauldron_r, &category) + .await + .map_err(db_error)?; + + let entries_json: Vec = entries + .into_iter() + .map(|(txid, n, sats, token_amount)| { + json!({ + "txid": txid, + "n": n, + "sats": sats, + "token_amount": token_amount, + }) + }) + .collect(); + + Ok(cached_ok(json!({ "entries": entries_json }), CACHE_NONE)) +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 9700928..ecb9e13 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -17,6 +17,7 @@ pub mod candlesticks; pub mod contract; pub mod err; pub mod health; +pub mod ido; pub mod oracle; pub mod pool; pub mod price;