ido: hide txchain_entrypoint, expose txchain_head as head txid

The IDO RPC record no longer leaks internal txchain row ids. Drop the
txchain_entrypoint field entirely, and change txchain_head from the
internal ido_txchain.id to the head record's txid (display hex).

list_idos and get_ido_by_offering_token_id resolve this in a single
query via LEFT JOIN ido_txchain ON head_tx.id = ido.txchain_head; a
NULL head yields null. Adds tests for both the resolved and null cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hossein Zoda 2026-05-27 23:38:52 +00:00
parent 6728753db0
commit e86de7d5f5

View file

@ -2271,8 +2271,8 @@ pub struct IdoRpcRecord {
pub status: String, pub status: String,
pub parameters: serde_json::Value, pub parameters: serde_json::Value,
pub state: serde_json::Value, pub state: serde_json::Value,
pub txchain_entrypoint: Option<i64>, /// Txid (display hex) of the txchain head record, or null if there is no head.
pub txchain_head: Option<i64>, pub txchain_head: Option<String>,
pub is_valid: bool, pub is_valid: bool,
} }
@ -2307,7 +2307,10 @@ pub struct IdoTrackerMapRpcRecord {
} }
impl IdoDBRecord { impl IdoDBRecord {
fn into_rpc_record(self) -> Result<IdoRpcRecord> { /// Build the public RPC record. `txchain_head_txid` is the resolved txid blob of the
/// txchain head record (joined in by the query); the internal `txchain_head` id and
/// `txchain_entrypoint` are intentionally not exposed.
fn into_rpc_record(self, txchain_head_txid: Option<Vec<u8>>) -> Result<IdoRpcRecord> {
let preinit_txid_hex = blob_to_display_hex::<Txid>(&self.preinit_txid)?; let preinit_txid_hex = blob_to_display_hex::<Txid>(&self.preinit_txid)?;
Ok(IdoRpcRecord { Ok(IdoRpcRecord {
id: preinit_txid_hex.clone(), id: preinit_txid_hex.clone(),
@ -2319,8 +2322,7 @@ impl IdoDBRecord {
status: self.status, status: self.status,
parameters: serde_json::from_slice(&self.parameters)?, parameters: serde_json::from_slice(&self.parameters)?,
state: serde_json::from_slice(&self.state)?, state: serde_json::from_slice(&self.state)?,
txchain_entrypoint: self.txchain_entrypoint, txchain_head: txchain_head_txid.as_deref().map(blob_to_display_hex::<Txid>).transpose()?,
txchain_head: self.txchain_head,
is_valid: self.is_valid, is_valid: self.is_valid,
}) })
} }
@ -2420,23 +2422,24 @@ pub async fn list_idos(
limit: i64, limit: i64,
) -> Result<Vec<IdoRpcRecord>> { ) -> Result<Vec<IdoRpcRecord>> {
let mut qb: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new( let mut qb: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(
"SELECT internal_id, preinit_txid, init_txid, launch_txid, offering_token_id, offered_token_id, \ "SELECT ido.internal_id, ido.preinit_txid, ido.init_txid, ido.launch_txid, ido.offering_token_id, ido.offered_token_id, \
status, parameters, state, txchain_entrypoint, txchain_head, is_valid, is_token_created_at_preinit ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, \
FROM ido WHERE 1=1", head_tx.txid \
FROM ido LEFT JOIN ido_txchain head_tx ON head_tx.id = ido.txchain_head WHERE 1=1",
); );
if let Some(v) = is_valid { if let Some(v) = is_valid {
qb.push(" AND is_valid = "); qb.push(" AND ido.is_valid = ");
qb.push_bind(v as i64); qb.push_bind(v as i64);
} }
if let Some(v) = status { if let Some(v) = status {
qb.push(" AND status = "); qb.push(" AND ido.status = ");
qb.push_bind(v); qb.push_bind(v);
} }
if let Some(v) = offered_token_id_blob { if let Some(v) = offered_token_id_blob {
qb.push(" AND offered_token_id = "); qb.push(" AND ido.offered_token_id = ");
qb.push_bind(v); qb.push_bind(v);
} }
qb.push(" ORDER BY internal_id ASC LIMIT "); qb.push(" ORDER BY ido.internal_id ASC LIMIT ");
qb.push_bind(limit); qb.push_bind(limit);
qb.push(" OFFSET "); qb.push(" OFFSET ");
qb.push_bind(offset); qb.push_bind(offset);
@ -2445,7 +2448,10 @@ pub async fn list_idos(
.fetch_all(pool) .fetch_all(pool)
.await? .await?
.into_iter() .into_iter()
.map(|row| IdoDBRecord::from_row(&row)?.into_rpc_record()) .map(|row| {
let txchain_head_txid: Option<Vec<u8>> = row.get(13);
IdoDBRecord::from_row(&row)?.into_rpc_record(txchain_head_txid)
})
.collect() .collect()
} }
@ -2454,14 +2460,19 @@ pub async fn get_ido_by_offering_token_id(
offering_token_id_blob: Vec<u8>, offering_token_id_blob: Vec<u8>,
) -> Result<Option<IdoRpcRecord>> { ) -> Result<Option<IdoRpcRecord>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT internal_id, preinit_txid, init_txid, launch_txid, offering_token_id, offered_token_id, \ "SELECT ido.internal_id, ido.preinit_txid, ido.init_txid, ido.launch_txid, ido.offering_token_id, ido.offered_token_id, \
status, parameters, state, txchain_entrypoint, txchain_head, is_valid, is_token_created_at_preinit \ ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, \
FROM ido WHERE offering_token_id = ?", head_tx.txid \
FROM ido LEFT JOIN ido_txchain head_tx ON head_tx.id = ido.txchain_head WHERE ido.offering_token_id = ?",
) )
.bind(offering_token_id_blob) .bind(offering_token_id_blob)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
row.map(|r| IdoDBRecord::from_row(&r)?.into_rpc_record()).transpose() row.map(|r| {
let txchain_head_txid: Option<Vec<u8>> = r.get(13);
IdoDBRecord::from_row(&r)?.into_rpc_record(txchain_head_txid)
})
.transpose()
} }
pub async fn list_ido_entries( pub async fn list_ido_entries(
@ -3040,4 +3051,36 @@ mod querytests {
let chain = list_ido_txchain(&pool, 9999, "deadbeef", 0, 100).await.unwrap(); let chain = list_ido_txchain(&pool, 9999, "deadbeef", 0, 100).await.unwrap();
assert_eq!(chain.len(), 0); assert_eq!(chain.len(), 0);
} }
// ─── DB: txchain_head is exposed as the head record's txid ────────────────
#[rocket::async_test]
async fn ido_record_resolves_txchain_head_to_txid() {
let pool = make_pool().await;
let tok = vec![0xCC; 32];
let ido_id = insert_test_ido(&pool, txid(1), "ACTIVE", true, true, None, Some(tok.clone())).await;
let id_a = insert_txchain_item(&pool, ido_id, txid(10), None).await;
let head_id = insert_txchain_item(&pool, ido_id, txid(11), Some(id_a)).await;
set_txchain_head(&pool, ido_id, head_id).await;
// list_idos exposes txchain_head as the head record's txid (display hex), not its id.
let listed = list_idos(&pool, None, None, None, 0, 100).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].txchain_head, Some(hex::encode(txid(11))));
// get_ido_by_offering_token_id resolves it the same way.
let one = get_ido_by_offering_token_id(&pool, tok).await.unwrap().unwrap();
assert_eq!(one.txchain_head, Some(hex::encode(txid(11))));
}
#[rocket::async_test]
async fn ido_record_txchain_head_null_is_none() {
let pool = make_pool().await;
let ido_id = insert_test_ido(&pool, txid(1), "ACTIVE", true, true, None, None).await;
// A txchain row exists, but txchain_head is NULL: the join must not match it.
let _ = insert_txchain_item(&pool, ido_id, txid(10), None).await;
let listed = list_idos(&pool, None, None, None, 0, 100).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].txchain_head, None);
}
} }