What we need to keep track of idos.

This commit is contained in:
Jakob Notland 2026-03-31 10:41:28 +02:00
parent 3996264bd3
commit b97ec39fdb
6 changed files with 359 additions and 0 deletions

207
src/db/cauldron/ido.rs Normal file
View file

@ -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<u8>)> {
// 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, &params, mtp).await?;
}
}
Ok(())
}
pub async fn list_idos(
pool: &SqlitePool,
) -> Result<Vec<(String, String, Vec<u8>, 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<u8> = row.get(0);
let category_blob: Vec<u8> = row.get(1);
let params_raw: Vec<u8> = row.get(2);
let mtp: i64 = row.get(3);
let phase: String = row.get(4);
result.push((
blob_to_display_hex::<Txid>(&txid_blob)?,
blob_to_display_hex::<TokenID>(&category_blob)?,
params_raw,
mtp,
phase,
));
}
Ok(result)
}
pub async fn get_ido(
pool: &SqlitePool,
category: &TokenID,
) -> Result<Option<(String, String, Vec<u8>, 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<u8> = row.get(0);
let category_blob: Vec<u8> = row.get(1);
let params_raw: Vec<u8> = row.get(2);
let mtp: i64 = row.get(3);
let phase: String = row.get(4);
Ok(Some((
blob_to_display_hex::<Txid>(&txid_blob)?,
blob_to_display_hex::<TokenID>(&category_blob)?,
params_raw,
mtp,
phase,
)))
}
None => Ok(None),
}
}
pub async fn get_ido_entries(
pool: &SqlitePool,
category: &TokenID,
) -> Result<Vec<(String, u32, i64, i64)>> {
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<u8> = 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>(&txid_blob)?,
n as u32,
sats,
token_amount,
));
}
Ok(result)
}

View file

@ -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);",

View file

@ -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?;

View file

@ -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,

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

@ -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<DB>) -> CachedApiResult<Value> {
let idos = ido::list_idos(&conn.cauldron_r).await.map_err(db_error)?;
let idos_json: Vec<Value> = 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/<category>")]
pub async fn get_ido(category: &str, conn: &State<DB>) -> CachedApiResult<Value> {
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/<category>/entries")]
pub async fn get_ido_entries(category: &str, conn: &State<DB>) -> CachedApiResult<Value> {
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<Value> = 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))
}

View file

@ -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;