Merge branch 'dontPanich' into 'master'
A malformed token id must come back as a 400, not take the process down. See merge request riftenlabs/riftenlabs-indexer!100
This commit is contained in:
commit
03d9fa1f30
7 changed files with 251 additions and 8 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use bitcoincash::{TokenID, Txid};
|
use bitcoincash::{TokenID, Txid};
|
||||||
use riftenlabs_defi::chainutil::OutPointHash;
|
use riftenlabs_defi::chainutil::OutPointHash;
|
||||||
|
|
||||||
|
|
@ -190,12 +190,16 @@ pub(crate) async fn db_visit_pool_entries<T: PoolVisitor>(
|
||||||
q = q.bind(*tp);
|
q = q.bind(*tp);
|
||||||
}
|
}
|
||||||
q = q.bind(withdraw_time_filter as i64);
|
q = q.bind(withdraw_time_filter as i64);
|
||||||
|
// These filters carry user-supplied strings. Never panic on them: a malformed
|
||||||
|
// token id or pkh must surface as an error the route can turn into a 400,
|
||||||
|
// not take down the process.
|
||||||
if let Some(token_id) = &filters.token_id {
|
if let Some(token_id) = &filters.token_id {
|
||||||
let token_blob = display_hex_to_blob::<TokenID>(token_id).expect("valid token hex");
|
let token_blob = display_hex_to_blob::<TokenID>(token_id)
|
||||||
|
.context("token_id filter is not a 32-byte hex token id")?;
|
||||||
q = q.bind(token_blob);
|
q = q.bind(token_blob);
|
||||||
}
|
}
|
||||||
if let Some(owner) = &filters.owner {
|
if let Some(owner) = &filters.owner {
|
||||||
let owner_blob = hex::decode(owner).expect("valid owner hex");
|
let owner_blob = hex::decode(owner).context("owner filter is not valid hex")?;
|
||||||
q = q.bind(owner_blob);
|
q = q.bind(owner_blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::{
|
||||||
def::PoolID,
|
def::PoolID,
|
||||||
rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult},
|
rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult},
|
||||||
rpc::response::{cached_ok, CACHE_AGGREGATE},
|
rpc::response::{cached_ok, CACHE_AGGREGATE},
|
||||||
|
rpc::validate::{parse_pkh, parse_token_id},
|
||||||
timeutil::time_now,
|
timeutil::time_now,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
@ -103,7 +104,10 @@ pub async fn aggregate_apy(
|
||||||
.await
|
.await
|
||||||
.map_err(db_error)?
|
.map_err(db_error)?
|
||||||
} else {
|
} else {
|
||||||
get_pool_period_snapshot(&db.cauldron_r, token, pkh, start, end)
|
let token = token.map(parse_token_id).transpose()?;
|
||||||
|
let pkh = pkh.map(parse_pkh).transpose()?;
|
||||||
|
|
||||||
|
get_pool_period_snapshot(&db.cauldron_r, token.as_deref(), pkh.as_deref(), start, end)
|
||||||
.await
|
.await
|
||||||
.map_err(db_error)?
|
.map_err(db_error)?
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use crate::{
|
||||||
db::{blob::display_hex_to_blob, DB},
|
db::{blob::display_hex_to_blob, DB},
|
||||||
rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult},
|
rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult},
|
||||||
rpc::response::{cached_ok, CACHE_AGGREGATE},
|
rpc::response::{cached_ok, CACHE_AGGREGATE},
|
||||||
|
rpc::validate::parse_token_id,
|
||||||
timeutil::time_now,
|
timeutil::time_now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -108,7 +109,9 @@ pub async fn contract_count_all(conn: &State<DB>) -> CachedApiResult<Value> {
|
||||||
/// ```
|
/// ```
|
||||||
#[get("/contract/count/<token>")]
|
#[get("/contract/count/<token>")]
|
||||||
pub async fn contract_count_token(token: &str, conn: &State<DB>) -> CachedApiResult<Value> {
|
pub async fn contract_count_token(token: &str, conn: &State<DB>) -> CachedApiResult<Value> {
|
||||||
let count = db_contract_count_by_token(&conn.cauldron_r, token)
|
let token = parse_token_id(token)?;
|
||||||
|
|
||||||
|
let count = db_contract_count_by_token(&conn.cauldron_r, &token)
|
||||||
.await
|
.await
|
||||||
.map_err(db_error)?;
|
.map_err(db_error)?;
|
||||||
Ok(cached_ok(json!(count), CACHE_AGGREGATE))
|
Ok(cached_ok(json!(count), CACHE_AGGREGATE))
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ pub mod tokentoken;
|
||||||
pub mod tvl;
|
pub mod tvl;
|
||||||
pub mod tx;
|
pub mod tx;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod validate;
|
||||||
pub mod volume;
|
pub mod volume;
|
||||||
|
|
||||||
async fn all_time_volume(pool: &SqlitePool, end_timestamp: u64) -> Result<Vec<(String, i64)>> {
|
async fn all_time_volume(pool: &SqlitePool, end_timestamp: u64) -> Result<Vec<(String, i64)>> {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::{
|
||||||
def::PoolID,
|
def::PoolID,
|
||||||
rpc::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult},
|
rpc::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult},
|
||||||
rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_NONE},
|
rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_NONE},
|
||||||
|
rpc::validate::{parse_pkh, parse_token_id},
|
||||||
timeutil::time_now,
|
timeutil::time_now,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
@ -110,13 +111,16 @@ pub async fn list_active_pools(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let token = token.map(parse_token_id).transpose()?;
|
||||||
|
let pkh = pkh.map(parse_pkh).transpose()?;
|
||||||
|
|
||||||
let mut active_pool_list = ActivePoolList::default();
|
let mut active_pool_list = ActivePoolList::default();
|
||||||
db_visit_pool_entries(
|
db_visit_pool_entries(
|
||||||
&conn.cauldron_r,
|
&conn.cauldron_r,
|
||||||
&mut active_pool_list,
|
&mut active_pool_list,
|
||||||
PoolFilters {
|
PoolFilters {
|
||||||
token_id: token.map(|s| s.to_string()),
|
token_id: token,
|
||||||
owner: pkh.map(|s| s.to_string()),
|
owner: pkh,
|
||||||
timestamp_lt: None,
|
timestamp_lt: None,
|
||||||
timestamp_lte: None,
|
timestamp_lte: None,
|
||||||
timestamp_gt: None,
|
timestamp_gt: None,
|
||||||
|
|
@ -233,3 +237,75 @@ pub async fn pool_id_from_utxo(txid: &str, n: u32, conn: &State<DB>) -> CachedAp
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::db::cauldron::{pool as pooldb, tx, utxo_funding, utxo_spending};
|
||||||
|
use crate::utiltest::mock_db_pool;
|
||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::local::asynchronous::Client;
|
||||||
|
use rocket::routes;
|
||||||
|
|
||||||
|
async fn setup_empty_db(pool: sqlx::SqlitePool) {
|
||||||
|
utxo_funding::create_table(&pool).await;
|
||||||
|
utxo_spending::create_table(&pool).await;
|
||||||
|
tx::create_table(&pool).await;
|
||||||
|
pooldb::create_table(&pool).await;
|
||||||
|
pooldb::dummy_init_seq();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Malformed `token` / `pkh` must come back as 400s, not take the process down.
|
||||||
|
///
|
||||||
|
/// Regression: both were fed straight into the query builder, which
|
||||||
|
/// `.expect()`ed on the hex. Combined with the global panic hook
|
||||||
|
/// (`process::exit(1)`), one bad query string killed the daemon.
|
||||||
|
#[rocket::async_test]
|
||||||
|
async fn test_list_active_pools_rejects_malformed_params() {
|
||||||
|
let mock_db = mock_db_pool(setup_empty_db).await;
|
||||||
|
|
||||||
|
let rocket = rocket::build()
|
||||||
|
.manage(mock_db)
|
||||||
|
.mount("/api", routes![super::list_active_pools]);
|
||||||
|
let client = Client::tracked(rocket)
|
||||||
|
.await
|
||||||
|
.expect("valid rocket instance");
|
||||||
|
|
||||||
|
let valid_token = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
let valid_pkh = "36c0020dd39e7cd66c21f237dc53d384661a557f";
|
||||||
|
let double_encoded = hex::encode(valid_token);
|
||||||
|
|
||||||
|
for bad in [double_encoded.as_str(), "notahex", "abcd"] {
|
||||||
|
let response = client
|
||||||
|
.get(format!("/api/pool/active?token={bad}"))
|
||||||
|
.dispatch()
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
Status::BadRequest,
|
||||||
|
"token `{bad}` should be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-hex, and hex of the wrong length for a 20-byte pkh.
|
||||||
|
for bad in ["zz", "36c0020d", ""] {
|
||||||
|
let response = client
|
||||||
|
.get(format!("/api/pool/active?pkh={bad}"))
|
||||||
|
.dispatch()
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
Status::BadRequest,
|
||||||
|
"pkh `{bad}` should be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Well-formed params reach the query layer and return an empty list.
|
||||||
|
for good in [
|
||||||
|
format!("/api/pool/active?token={valid_token}"),
|
||||||
|
format!("/api/pool/active?pkh={valid_pkh}"),
|
||||||
|
] {
|
||||||
|
let response = client.get(good.clone()).dispatch().await;
|
||||||
|
assert_eq!(response.status(), Status::Ok, "`{good}` should succeed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ use crate::db::{
|
||||||
};
|
};
|
||||||
use crate::rpc::err::{db_error, CachedApiResult};
|
use crate::rpc::err::{db_error, CachedApiResult};
|
||||||
use crate::rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_IMMUTABLE};
|
use crate::rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_IMMUTABLE};
|
||||||
|
use crate::rpc::validate::parse_token_id;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct TvlByTokenVisitor {
|
pub struct TvlByTokenVisitor {
|
||||||
|
|
@ -226,7 +227,9 @@ pub async fn valuelocked_token(
|
||||||
time: Option<usize>,
|
time: Option<usize>,
|
||||||
conn: &State<DB>,
|
conn: &State<DB>,
|
||||||
) -> CachedApiResult<Value> {
|
) -> CachedApiResult<Value> {
|
||||||
let (sats, token_amount) = get_token_tvl(&conn.cauldron_r, time, token)
|
let token = parse_token_id(token)?;
|
||||||
|
|
||||||
|
let (sats, token_amount) = get_token_tvl(&conn.cauldron_r, time, &token)
|
||||||
.await
|
.await
|
||||||
.map_err(db_error)?;
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
|
@ -453,4 +456,50 @@ pub mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result, (0, 0)); // No data for non-existent token
|
assert_eq!(result, (0, 0)); // No data for non-existent token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A malformed token id must come back as a 400, not take the process down.
|
||||||
|
///
|
||||||
|
/// Regression: the query builder used to `.expect()` on this hex, and the
|
||||||
|
/// global panic hook turns any handler panic into `process::exit(1)`, so a
|
||||||
|
/// single bad request killed the daemon.
|
||||||
|
#[rocket::async_test]
|
||||||
|
async fn test_valuelocked_token_rejects_malformed_token_id() {
|
||||||
|
let mock_db = mock_db_pool(setup_mock_db).await;
|
||||||
|
|
||||||
|
let rocket = rocket::build()
|
||||||
|
.manage(mock_db)
|
||||||
|
.mount("/api", routes![super::valuelocked_token]);
|
||||||
|
let client = Client::tracked(rocket)
|
||||||
|
.await
|
||||||
|
.expect("valid rocket instance");
|
||||||
|
|
||||||
|
let valid = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
// The exact shape seen in production: a token id hex-encoded a second time.
|
||||||
|
let double_encoded = hex::encode(valid);
|
||||||
|
|
||||||
|
for bad in [double_encoded.as_str(), "notahex", "abcd", "%20"] {
|
||||||
|
let response = client
|
||||||
|
.get(format!("/api/valuelocked/{bad}"))
|
||||||
|
.dispatch()
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
Status::BadRequest,
|
||||||
|
"token `{bad}` should be rejected"
|
||||||
|
);
|
||||||
|
|
||||||
|
let body: Value = serde_json::from_str(&response.into_string().await.unwrap()).unwrap();
|
||||||
|
assert_eq!(body["error"]["code"], "INVALID_TOKEN_ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A well-formed token id is unaffected.
|
||||||
|
let response = client
|
||||||
|
.get(format!("/api/valuelocked/{valid}"))
|
||||||
|
.dispatch()
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), Status::Ok);
|
||||||
|
|
||||||
|
let body: Value = serde_json::from_str(&response.into_string().await.unwrap()).unwrap();
|
||||||
|
assert_eq!(body["satoshis"], 600_000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
src/rpc/validate.rs
Normal file
106
src/rpc/validate.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// Copyright (C) 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
|
||||||
|
|
||||||
|
//! Route-boundary validation for user-supplied hex parameters.
|
||||||
|
//!
|
||||||
|
//! Any route that forwards a raw token id or pkh into a DB query runs it through
|
||||||
|
//! here first, so malformed input becomes a 400 with a useful message instead of
|
||||||
|
//! surfacing as an opaque 500 from the query layer.
|
||||||
|
|
||||||
|
use bitcoincash::TokenID;
|
||||||
|
use rocket::{response::status::Custom, serde::json::Json};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::err::{bad_request, ApiErrorCode};
|
||||||
|
|
||||||
|
/// Length of a P2PKH public key hash, in bytes.
|
||||||
|
const PKH_LEN: usize = 20;
|
||||||
|
|
||||||
|
/// Validate a display-format token id, returning it normalised to lowercase hex.
|
||||||
|
pub fn parse_token_id(token: &str) -> Result<String, Custom<Json<Value>>> {
|
||||||
|
token
|
||||||
|
.parse::<TokenID>()
|
||||||
|
.map(|t| t.to_string())
|
||||||
|
.map_err(|e| {
|
||||||
|
bad_request(
|
||||||
|
ApiErrorCode::InvalidTokenId,
|
||||||
|
&format!("Invalid token ID: {e}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a 20-byte public key hash in hex, returning it normalised to lowercase hex.
|
||||||
|
pub fn parse_pkh(pkh: &str) -> Result<String, Custom<Json<Value>>> {
|
||||||
|
let bytes = hex::decode(pkh).map_err(|e| {
|
||||||
|
bad_request(
|
||||||
|
ApiErrorCode::InvalidParameters,
|
||||||
|
&format!("Invalid pkh: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if bytes.len() != PKH_LEN {
|
||||||
|
return Err(bad_request(
|
||||||
|
ApiErrorCode::InvalidParameters,
|
||||||
|
&format!("Invalid pkh: expected {PKH_LEN} bytes, got {}", bytes.len()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rocket::http::Status;
|
||||||
|
|
||||||
|
const VALID_TOKEN: &str = "ae15ad3916c2eb61339be49355020d918906569f0e97b7f265d83558f2c16f40";
|
||||||
|
const VALID_PKH: &str = "36c0020dd39e7cd66c21f237dc53d384661a557f";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_id_roundtrips_and_normalises_case() {
|
||||||
|
assert_eq!(parse_token_id(VALID_TOKEN).unwrap(), VALID_TOKEN);
|
||||||
|
assert_eq!(
|
||||||
|
parse_token_id(&VALID_TOKEN.to_uppercase()).unwrap(),
|
||||||
|
VALID_TOKEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_id_rejects_malformed_input() {
|
||||||
|
// The shape that crashed the server: a token id hex-encoded a second time.
|
||||||
|
let double_encoded = hex::encode(VALID_TOKEN);
|
||||||
|
assert_eq!(double_encoded.len(), 128);
|
||||||
|
|
||||||
|
for bad in [
|
||||||
|
double_encoded.as_str(),
|
||||||
|
"notahex",
|
||||||
|
"",
|
||||||
|
"ae15ad39", // too short
|
||||||
|
"zz15ad3916c2eb61339be49355020d918906569f0e97b7f265d83558f2c16f40",
|
||||||
|
] {
|
||||||
|
let err = parse_token_id(bad).expect_err("should reject {bad}");
|
||||||
|
assert_eq!(err.0, Status::BadRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pkh_roundtrips_and_normalises_case() {
|
||||||
|
assert_eq!(parse_pkh(VALID_PKH).unwrap(), VALID_PKH);
|
||||||
|
assert_eq!(parse_pkh(&VALID_PKH.to_uppercase()).unwrap(), VALID_PKH);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pkh_rejects_malformed_input() {
|
||||||
|
for bad in [
|
||||||
|
"zz",
|
||||||
|
"",
|
||||||
|
"36c0020d", // wrong length
|
||||||
|
"36c0020dd39e7cd66c21f237dc53d384661a557f00", // wrong length
|
||||||
|
] {
|
||||||
|
let err = parse_pkh(bad).expect_err("should reject {bad}");
|
||||||
|
assert_eq!(err.0, Status::BadRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue