What we need to keep track of idos.
This commit is contained in:
parent
3996264bd3
commit
b97ec39fdb
6 changed files with 359 additions and 0 deletions
207
src/db/cauldron/ido.rs
Normal file
207
src/db/cauldron/ido.rs
Normal 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, ¶ms, 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)
|
||||
}
|
||||
|
|
@ -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);",
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
143
src/rpc/ido.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue