diff --git a/node/src/account_node_tests.rs b/node/src/account_node_tests.rs index 02f584d2..898fd71d 100644 --- a/node/src/account_node_tests.rs +++ b/node/src/account_node_tests.rs @@ -1582,6 +1582,7 @@ fn history_row_to_item_balance_from_coin_queue_only() { commit_txid: None, block_height: None, pending_status: None, + commit_output_value: None, }; let item = crate::router::history_row_to_item(&row).expect("item produced"); assert_eq!(item.id, 7); diff --git a/node/src/db.rs b/node/src/db.rs index bd91ea64..166ce61e 100644 --- a/node/src/db.rs +++ b/node/src/db.rs @@ -1544,6 +1544,65 @@ pub struct AccountHistoryRow { /// `commit_broadcast`, `reveal_broadcast`, `complete`, `failed`). /// `None` while `commit_txid` is `None`. pub pending_status: Option, + /// `pending_inscriptions.commit_output_value` for the matching + /// commit — the on-chain value (sats) locked in the commit output, + /// if a publisher inscription row exists. `None` for the list + /// (`list_account_history` does not select it to keep the page query + /// lean); populated only by [`get_account_history_item`], which the + /// transaction-detail endpoint uses. + pub commit_output_value: Option, +} + +/// Fetch a single user-facing `account_history` row by its `id`, scoped +/// to `address` so a caller can only read rows for an address it already +/// knows (the same scoping `/api/history` applies to the list). Returns +/// `Ok(None)` when no row matches `(id, address)` *or* the row's source +/// is internal (`scanner` / `recovery`) — the detail endpoint treats +/// both as "not found" so internal mutations stay unexposed. +/// +/// Unlike [`list_account_history`] this also selects +/// `pending_inscriptions.commit_output_value` (the detail endpoint +/// surfaces it; the list does not). +pub async fn get_account_history_item( + pool: &PgPool, + address: &[u8], + id: i64, +) -> sqlx::Result> { + use sqlx::Row; + let row = sqlx::query( + "SELECT ah.id, \ + EXTRACT(EPOCH FROM ah.changed_at)::BIGINT AS ts_secs, \ + ah.source, ah.prev_data, ah.new_data, \ + ah.triggering_commit_txid, \ + oi.block_height, \ + pi.status AS pending_status, \ + pi.commit_output_value \ + FROM account_history ah \ + LEFT JOIN observed_inscriptions oi \ + ON oi.commit_txid = ah.triggering_commit_txid \ + LEFT JOIN pending_inscriptions pi \ + ON pi.commit_txid = ah.triggering_commit_txid \ + WHERE ah.id = $1 \ + AND ah.address = $2 \ + AND ah.source IN ('mint','send','receive') \ + LIMIT 1", + ) + .bind(id) + .bind(address) + .fetch_optional(pool) + .await?; + + Ok(row.map(|r| AccountHistoryRow { + id: r.get("id"), + timestamp_secs: r.get("ts_secs"), + source: r.get("source"), + prev_data: r.get("prev_data"), + new_data: r.get("new_data"), + commit_txid: r.get("triggering_commit_txid"), + block_height: r.get("block_height"), + pending_status: r.get("pending_status"), + commit_output_value: r.get("commit_output_value"), + })) } /// Fetch the `limit` most recent user-facing `account_history` rows for @@ -1651,6 +1710,9 @@ pub async fn list_account_history( commit_txid: r.get("triggering_commit_txid"), block_height: r.get("block_height"), pending_status: r.get("pending_status"), + // The list query omits commit_output_value to stay lean; + // only the detail endpoint surfaces it. + commit_output_value: None, }) }) .collect(); diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index beb39f11..5fbdfd5c 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -1536,3 +1536,95 @@ async fn list_account_history_filters_scanner_and_recovery_in_sql() { "no scanner / recovery rows leak past the SQL filter" ); } + +// ---- get_account_history_item (tx-detail endpoint) ------------------------- + +#[tokio::test] +async fn get_account_history_item_fetches_scoped_row_with_inscription_join() { + let scope = setup_pool().await; + let pool = scope.pool.clone(); + let address = [0x1au8; 32]; + let commit_txid = [0x77u8; 32]; + + // Plant an account_history row that carries a commit_txid, plus the + // matching pending_inscriptions row (commit_output_value = 12_345 via + // `seed_pending_row`) so the detail-only join column lights up. + let mut a = crate::account_node::Account::new(); + a.balance = 9_000; + let new_data = bincode::serialize(&a).expect("serialize account"); + let (id,): (i64,) = sqlx::query_as( + "INSERT INTO account_history \ + (address, prev_data, new_data, source, triggering_commit_txid) \ + VALUES ($1, NULL, $2, 'mint', $3) RETURNING id", + ) + .bind(&address[..]) + .bind(&new_data) + .bind(&commit_txid[..]) + .fetch_one(&pool) + .await + .expect("insert history row"); + seed_pending_row(&pool, &commit_txid, PENDING_STATUS_REVEAL_BROADCAST).await; + + let row = get_account_history_item(&pool, &address[..], id) + .await + .expect("query ok") + .expect("row found"); + assert_eq!(row.id, id); + assert_eq!(row.source, "mint"); + assert_eq!(row.commit_txid.as_deref(), Some(&commit_txid[..])); + assert_eq!( + row.commit_output_value, + Some(12_345), + "detail query surfaces pending_inscriptions.commit_output_value" + ); + assert_eq!(row.pending_status.as_deref(), Some("reveal_broadcast")); + let decoded: crate::account_node::Account = + bincode::deserialize(&row.new_data).expect("decode Account"); + assert_eq!(decoded.balance, 9_000); +} + +#[tokio::test] +async fn get_account_history_item_scopes_by_address_and_filters_internal() { + let scope = setup_pool().await; + let pool = scope.pool.clone(); + let address = [0x2bu8; 32]; + let other = [0x3cu8; 32]; + + plant_history_row(&pool, &address[..], "mint", 100, 10).await; + plant_history_row(&pool, &address[..], "scanner", 110, 5).await; + let (rows, _) = list_account_history(&pool, &address[..], 10, 0) + .await + .unwrap(); + let mint_id = rows[0].id; + + // Fetch with the right address — found. + assert!(get_account_history_item(&pool, &address[..], mint_id) + .await + .unwrap() + .is_some()); + // Same id, different address — scoped out (IDOR guard). + assert!(get_account_history_item(&pool, &other[..], mint_id) + .await + .unwrap() + .is_none()); + // Unknown id — None. + assert!( + get_account_history_item(&pool, &address[..], mint_id + 9_999) + .await + .unwrap() + .is_none() + ); + + // The scanner row exists in the table but is internal — fetch its id + // directly and assert the item query refuses to surface it. + let (scanner_id,): (i64,) = + sqlx::query_as("SELECT id FROM account_history WHERE address = $1 AND source = 'scanner'") + .bind(&address[..]) + .fetch_one(&pool) + .await + .expect("scanner row id"); + assert!(get_account_history_item(&pool, &address[..], scanner_id) + .await + .unwrap() + .is_none()); +} diff --git a/node/src/openapi.rs b/node/src/openapi.rs index 2607b6ce..ebdfca11 100644 --- a/node/src/openapi.rs +++ b/node/src/openapi.rs @@ -45,7 +45,7 @@ use crate::router::{ BalanceResponse, BitcoinNetwork, Capabilities, CommitRequest, HistoryErrorResponse, HistoryItem, HistoryResponse, InfoResponse, JobErrorResponse, JobStatusResponse, LnurlErrorResponse, MintRequest, PublisherHealthErrorResponse, PublisherHealthResponse, - ReadyResponse, RootEndpoints, RootResponse, SendCoinRequest, SendCoinResponse, + ReadyResponse, RootEndpoints, RootResponse, SendCoinRequest, SendCoinResponse, TxDetail, UsernameResponse, }; @@ -115,6 +115,7 @@ pub const DOCS_HTML: &str = concat!( crate::router::info_handler, crate::router::get_balance_handler, crate::router::get_history_handler, + crate::router::get_history_item_handler, crate::router::jobs_mint_handler, crate::router::jobs_send_handler, crate::router::jobs_commit_handler, @@ -139,6 +140,7 @@ pub const DOCS_HTML: &str = concat!( HistoryResponse, HistoryItem, HistoryErrorResponse, + TxDetail, SendCoinRequest, SendCoinResponse, MintRequest, diff --git a/node/src/router.rs b/node/src/router.rs index 38d06ac2..90bff192 100644 --- a/node/src/router.rs +++ b/node/src/router.rs @@ -310,6 +310,66 @@ pub struct HistoryErrorResponse { pub error: &'static str, } +/// Per-transaction detail returned by `GET /api/history/{id}`. +/// +/// Extends the [`HistoryItem`] list shape with everything else the node +/// can derive for one `account_history` row **without a schema change**: +/// the decoded account-state snapshot the mutation produced (usable +/// balance before/after, the post-mutation send counter and commitment +/// public key), the verifier circuit digest every proof on this node is +/// checked against, and the on-chain commit output value when a +/// publisher inscription exists. Fields the current schema cannot +/// populate stay `null` — the same honesty contract as [`HistoryItem`] +/// (`txid` / `block_height` / `commit_output_value` light up only once +/// the publisher threads `triggering_commit_txid`). +#[derive(Serialize, ToSchema)] +pub struct TxDetail { + // --- identity / core (mirrors HistoryItem) --- + /// Server-internal monotonic id (`account_history.id`). + pub id: i64, + /// The queried address, echoed as lower-case hex (32 bytes, no `0x`). + pub address: String, + /// Commit-inscription txid (lower-case hex), or `null` while unlinked. + pub txid: Option, + /// Unix epoch in seconds of the state change. + pub timestamp: i64, + /// `"send"`, `"receive"`, or `"mint"`. + pub direction: &'static str, + /// Absolute balance delta in sats (`|balance_after − balance_before|`). + pub amount: u64, + /// Counterparty address — always `null` in the current schema. + pub counterparty: Option, + /// `"pending"`, `"confirmed"`, or `"failed"`. + pub status: &'static str, + /// Bitcoin block height of the commit, or `null` while unconfirmed. + pub block_height: Option, + /// Free-text memo — always `null` (no memo column exists). + pub memo: Option, + // --- decoded account-state snapshot for this mutation --- + /// Usable balance (settled + queued) AFTER this mutation, in sats. + pub balance_after: u64, + /// Usable balance BEFORE this mutation; `null` for the first row of + /// an address (no prior state to decode). + pub balance_before: Option, + /// The account's own-send counter after this mutation — the wallet's + /// authoritative BIP-32 child index (see `BalanceResponse.num_sends`). + pub num_sends_after: u32, + /// The account's commitment public key after this mutation + /// (compressed secp256k1, 33-byte lower-case hex); `null` before the + /// account has ever sent (genesis / mint-only state). + pub commitment_public_key: Option, + // --- proof / verification --- + /// The verifier circuit digest (lower-case hex) every proof on this + /// node is checked against — the proof-system identity. `null` only + /// before the node has stored its digest (pre-first-proof boot). + pub circuit_digest: Option, + // --- on-chain --- + /// Value (sats) locked in the commit inscription's output, when a + /// publisher inscription row exists for this mutation; `null` + /// otherwise (e.g. a faucet mint before broadcast). + pub commit_output_value: Option, +} + /// Decode the 64-char (or 64 char + 0x prefix) hex `address` argument /// into the raw 32-byte form `account_history.address` is keyed on. /// Reuses the exact decode + length rules `get_balance_handler` applies @@ -499,6 +559,64 @@ pub(crate) fn history_row_to_item(row: &crate::db::AccountHistoryRow) -> Option< }) } +/// Decode the post-mutation `num_sends` + `commitment_public_key` out of +/// an `accounts.data` bincode blob, for the transaction-detail endpoint. +/// Returns `None` on a decode failure (the caller maps that to a 500 — a +/// corrupt blob is a server fault, not a user error). Mirrors +/// [`balance_from_account_blob`], which handles the balance half. +pub(crate) fn account_meta_from_blob(blob: &[u8]) -> Option<(u32, Option)> { + let a = bincode::deserialize::(blob).ok()?; + // `commitment_public_key` is a secp256k1 `PublicKey`; serialize to its + // 33-byte compressed form before hex-encoding (matches the wire form + // the wallet derives and sends in `prev_commitment_pubkey`). + let cpk = a + .commitment_public_key + .as_ref() + .map(|pk| hex::encode(pk.serialize())); + Some((a.num_sends, cpk)) +} + +/// Build a [`TxDetail`] from one history row + the node's circuit digest. +/// +/// Reuses [`history_row_to_item`] for the shared list fields +/// (direction / amount / status / txid …) so the two endpoints can never +/// disagree on the core shape, then layers on the decoded account-state +/// snapshot. Returns `None` when the row's source is internal or any +/// state blob fails to decode — both map to a 500 at the call site (the +/// db query already filtered to user-facing sources, so in practice only +/// a corrupt blob reaches the `None` arm). +pub(crate) fn tx_detail_from_row( + row: &crate::db::AccountHistoryRow, + address_hex: String, + circuit_digest: Option>, +) -> Option { + let item = history_row_to_item(row)?; + let balance_after = balance_from_account_blob(&row.new_data)?; + let balance_before = match row.prev_data.as_deref() { + None => None, + Some(blob) => Some(balance_from_account_blob(blob)?), + }; + let (num_sends_after, commitment_public_key) = account_meta_from_blob(&row.new_data)?; + Some(TxDetail { + id: item.id, + address: address_hex, + txid: item.txid, + timestamp: item.timestamp, + direction: item.direction, + amount: item.amount, + counterparty: item.counterparty, + status: item.status, + block_height: item.block_height, + memo: item.memo, + balance_after, + balance_before, + num_sends_after, + commitment_public_key, + circuit_digest: circuit_digest.map(hex::encode), + commit_output_value: row.commit_output_value, + }) +} + #[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] pub struct SendCoinRequest { /// Sender account address (`0x`-prefixed 32-byte hex). @@ -1121,6 +1239,136 @@ pub(crate) async fn get_history_handler( .into_response() } +#[utoipa::path( + get, + path = "/api/history/{id}", + tag = "Accounts", + params( + ("id" = i64, Path, + description = "Server-internal `account_history.id` of the row (from a `HistoryItem.id`)."), + ("address" = String, Query, + description = "Account address (32-byte hex, with or without `0x` prefix) the row must belong to."), + ), + responses( + (status = 200, description = "Full per-transaction detail.", body = TxDetail), + (status = 404, description = "No user-facing row with that id for the address.", + body = HistoryErrorResponse), + (status = 422, description = "Missing/malformed `address` or non-integer `id`.", + body = HistoryErrorResponse), + (status = 500, description = "Database error / undecodable state blob.", + body = HistoryErrorResponse), + ), +)] +/// `GET /api/history/{id}?address=` — full detail for one +/// transaction (one `account_history` row), scoped to `address`. +/// +/// The list endpoint (`GET /api/history`) returns the lean per-row +/// shape; this returns [`TxDetail`] — the same core fields plus the +/// decoded account-state snapshot (balance before/after, post-mutation +/// `num_sends` + commitment pubkey), the verifier circuit digest, and +/// the on-chain commit output value when present. +/// +/// Scoping: the row must both have `id` AND belong to `address`, and its +/// source must be user-facing (`mint`/`send`/`receive`). A mismatch (or +/// an internal `scanner`/`recovery` row) returns 404 — a caller cannot +/// read another address's rows or the node's internal mutations by +/// guessing ids. +/// +/// Validation: missing/malformed `address` → 422; a non-integer `id` → +/// 422 (parsed from the path as a string so the contract matches the +/// list endpoint's 422-on-bad-input rather than axum's default 400). +pub(crate) async fn get_history_item_handler( + State(state): State, + Path(id_raw): Path, + axum::extract::Query(params): axum::extract::Query>, +) -> impl IntoResponse { + // --- validation: address (required) --- + let address_hex = match params.get("address") { + Some(s) if !s.is_empty() => s.as_str(), + _ => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(HistoryErrorResponse { + error: "Missing required `address` query parameter", + }), + ) + .into_response(); + } + }; + let address_bytes = match decode_history_address(address_hex) { + Ok(b) => b, + Err(msg) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(HistoryErrorResponse { error: msg }), + ) + .into_response(); + } + }; + // --- validation: id (positive integer) --- + let id = match id_raw.parse::() { + Ok(n) if n > 0 => n, + _ => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(HistoryErrorResponse { + error: "id must be a positive integer", + }), + ) + .into_response(); + } + }; + + // --- DB read: the scoped row --- + let row = match db::get_account_history_item(&state.pool, &address_bytes, id).await { + Ok(Some(r)) => r, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(HistoryErrorResponse { + error: "Transaction not found", + }), + ) + .into_response(); + } + Err(e) => { + tracing::warn!("get_history_item_handler: row query failed: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(HistoryErrorResponse { + error: "Database error while reading transaction", + }), + ) + .into_response(); + } + }; + + // The verifier circuit digest is node-global (single row). A read + // failure degrades the field to `null` rather than failing the whole + // detail — it is metadata, not the row itself. + let circuit_digest = db::load_circuit_digest(&state.pool).await.ok().flatten(); + + // Echo the normalised (lower-case, no `0x`) address so the wire form + // is canonical regardless of how the caller spelled it. + let address_norm = hex::encode(address_bytes); + match tx_detail_from_row(&row, address_norm, circuit_digest) { + Some(detail) => (StatusCode::OK, Json(detail)).into_response(), + None => { + tracing::warn!( + "get_history_item_handler: row {} for address could not be decoded", + id + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(HistoryErrorResponse { + error: "Database error while reading transaction", + }), + ) + .into_response() + } + } +} + #[utoipa::path( get, path = "/api/address", @@ -3045,6 +3293,9 @@ pub(crate) fn create_router(state: AppState) -> Router { .route("/api/info", get(info_handler)) .route("/api/balance", get(get_balance_handler)) .route("/api/history", get(get_history_handler)) + // axum 0.7 path-param syntax (`:id`); the OpenAPI annotation uses + // the spec's `{id}` form — both name the same segment. + .route("/api/history/:id", get(get_history_item_handler)) .route("/api/receive", post(receive_coin_handler)) .route("/api/proof/:id", get(get_proof_handler)) // Job-API routes — the only path through which a wallet diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs index 3269de26..3765f443 100644 --- a/node/src/router_tests.rs +++ b/node/src/router_tests.rs @@ -4782,6 +4782,254 @@ async fn history_pagination_walks_mixed_source_dataset_consistently() { assert_eq!(seen_directions, vec!["receive", "send", "receive", "mint"]); } +// ======================================================================= +// GET /api/history/{id} — per-transaction detail (TxDetail) +// +// Validation branches run against the dead pool (`send_request`); the +// found / not-found / decoded-snapshot branches run against the live +// Postgres container, mirroring the list-endpoint tests above. +// ======================================================================= + +#[tokio::test] +async fn history_item_missing_address_returns_422() { + let req = Request::get("/api/history/1").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert!( + v["error"].as_str().unwrap_or("").contains("address"), + "expected address-related error, got {}", + body + ); +} + +#[tokio::test] +async fn history_item_empty_address_returns_422() { + let req = Request::get("/api/history/1?address=") + .body(Body::empty()) + .unwrap(); + let (status, _body) = send_request(req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn history_item_invalid_hex_returns_422() { + let req = Request::get("/api/history/1?address=not_hex") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert!(v["error"] + .as_str() + .unwrap_or("") + .to_lowercase() + .contains("hex")); +} + +#[tokio::test] +async fn history_item_non_integer_id_returns_422() { + // The id is parsed from the path as a string so a malformed id is a + // 422 like every other bad input on the read surface — not axum's + // default 400 for a failed typed-Path extraction. + let address = "00".repeat(32); + let req = Request::get(format!("/api/history/not_a_number?address={}", address)) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert!(v["error"] + .as_str() + .unwrap_or("") + .contains("positive integer")); +} + +#[tokio::test] +async fn history_item_zero_or_negative_id_returns_422() { + let address = "00".repeat(32); + for bad in ["0", "-3"] { + let req = Request::get(format!("/api/history/{}?address={}", bad, address)) + .body(Body::empty()) + .unwrap(); + let (status, _body) = send_request(req).await; + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "id={bad} must 422" + ); + } +} + +#[tokio::test] +async fn history_item_db_error_returns_500() { + // Dead pool: validation passes, the row query fails -> 500 with the + // documented error envelope. + let address = "00".repeat(32); + let req = Request::get(format!("/api/history/1?address={}", address)) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert!(v["error"] + .as_str() + .unwrap_or("") + .to_lowercase() + .contains("database")); +} + +#[tokio::test] +async fn history_item_unknown_id_returns_404() { + let (pool, _pg) = history_live_pool().await; + let state = live_test_state(pool); + let address = "ab".repeat(32); + let req = Request::get(format!("/api/history/424242?address={}", address)) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::NOT_FOUND, "body={}", body); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(v["error"], "Transaction not found"); +} + +#[tokio::test] +async fn history_item_wrong_address_returns_404() { + // Scoping / IDOR guard: a real row id fetched with a different + // address must look identical to a missing row. + let (pool, _pg) = history_live_pool().await; + let address: [u8; 32] = [21u8; 32]; + seed_account_history(&pool, &address, 100, "mint").await; + let (rows, _) = crate::db::list_account_history(&pool, &address[..], 10, 0) + .await + .unwrap(); + let id = rows[0].id; + + let state = live_test_state(pool); + let other = "cd".repeat(32); + let req = Request::get(format!("/api/history/{}?address={}", id, other)) + .body(Body::empty()) + .unwrap(); + let (status, _body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn history_item_happy_path_returns_decoded_snapshot() { + let (pool, _pg) = history_live_pool().await; + let address: [u8; 32] = [23u8; 32]; + + // Two mutations: 0 -> 100 (mint), then 100 -> 40 (send) so the + // detail of the send row carries both balance_before and + // balance_after plus the post-mutation num_sends. + seed_account_history(&pool, &address, 100, "mint").await; + let mut sent = Account::new(); + sent.balance = 40; + sent.num_sends = 1; + let bytes = bincode::serialize(&sent).expect("Account serializable"); + crate::db::upsert_account_with_source(&pool, address.as_slice(), &bytes, "send") + .await + .expect("upsert send mutation"); + + let (rows, _) = crate::db::list_account_history(&pool, &address[..], 10, 0) + .await + .unwrap(); + let send_id = rows[0].id; // newest first + + let state = live_test_state(pool); + let req = Request::get(format!( + "/api/history/{}?address=0x{}", + send_id, + hex::encode(address) + )) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::OK, "body={}", body); + + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(v["id"].as_i64(), Some(send_id)); + assert_eq!( + v["address"], + hex::encode(address), + "address echoed normalised (0x stripped, lower-case)" + ); + assert_eq!(v["direction"], "send"); + assert_eq!(v["amount"], 60, "|40 - 100|"); + assert_eq!(v["status"], "pending", "no inscription link yet"); + assert_eq!(v["balance_after"], 40); + assert_eq!(v["balance_before"], 100); + assert_eq!(v["num_sends_after"], 1); + // The seed path sets no commitment pubkey and the fresh schema has + // no circuit digest row / inscription rows. + assert!(v["commitment_public_key"].is_null()); + assert!(v["circuit_digest"].is_null()); + assert!(v["commit_output_value"].is_null()); + assert!(v["txid"].is_null()); + assert!(v["block_height"].is_null()); + assert!(v["counterparty"].is_null()); + assert!(v["memo"].is_null()); +} + +#[tokio::test] +async fn history_item_surfaces_circuit_digest_when_stored() { + let (pool, _pg) = history_live_pool().await; + let address: [u8; 32] = [27u8; 32]; + seed_account_history(&pool, &address, 100, "mint").await; + crate::db::store_circuit_digest(&pool, &[0xCD; 32]) + .await + .expect("store digest"); + let (rows, _) = crate::db::list_account_history(&pool, &address[..], 10, 0) + .await + .unwrap(); + let id = rows[0].id; + + let state = live_test_state(pool); + let req = Request::get(format!( + "/api/history/{}?address={}", + id, + hex::encode(address) + )) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::OK, "body={}", body); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!( + v["circuit_digest"].as_str(), + Some(hex::encode([0xCD; 32]).as_str()) + ); +} + +#[tokio::test] +async fn history_item_corrupt_blob_returns_500() { + // A row whose new_data is not a valid bincode Account decodes to + // None in tx_detail_from_row — the handler maps that to a 500, never + // a fabricated detail. + let (pool, _pg) = history_live_pool().await; + let address: [u8; 32] = [29u8; 32]; + let (id,): (i64,) = sqlx::query_as( + "INSERT INTO account_history (address, prev_data, new_data, source) \ + VALUES ($1, NULL, $2, 'mint') RETURNING id", + ) + .bind(&address[..]) + .bind(vec![0xFFu8; 4]) + .fetch_one(&*pool) + .await + .expect("insert corrupt row"); + + let state = live_test_state(pool); + let req = Request::get(format!( + "/api/history/{}?address={}", + id, + hex::encode(address) + )) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "body={}", body); +} + // --- Pure-function coverage for the helpers -------------------------------- #[test] @@ -4847,6 +5095,7 @@ fn history_row_to_item_handles_first_row_with_no_prev_data() { commit_txid: None, block_height: None, pending_status: None, + commit_output_value: None, }; let item = history_row_to_item(&row).expect("item produced"); assert_eq!(item.id, 42); @@ -4875,6 +5124,7 @@ fn history_row_to_item_drops_unknown_source() { commit_txid: None, block_height: None, pending_status: None, + commit_output_value: None, }; assert!(history_row_to_item(&row).is_none()); } @@ -4890,6 +5140,7 @@ fn history_row_to_item_drops_undecodable_new_data() { commit_txid: None, block_height: None, pending_status: None, + commit_output_value: None, }; assert!(history_row_to_item(&row).is_none()); } @@ -4908,6 +5159,7 @@ fn history_row_to_item_maps_pending_status_to_wire_status() { commit_txid: Some(vec![0xab; 32]), block_height, pending_status: status.map(str::to_string), + commit_output_value: None, }; // Every enum variant the migration-0003 CHECK constraint allows. assert_eq!( @@ -4980,6 +5232,7 @@ fn history_row_to_item_drops_undecodable_prev_data() { commit_txid: None, block_height: None, pending_status: None, + commit_output_value: None, }; assert!( history_row_to_item(&row).is_none(), @@ -4987,6 +5240,145 @@ fn history_row_to_item_drops_undecodable_prev_data() { ); } +// ── GET /api/history/{id} — TxDetail conversion (issue: tx-detail) ────── + +#[test] +fn account_meta_from_blob_reads_num_sends_and_commitment_pubkey() { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + // Fresh account: num_sends = 0, no commitment pubkey yet. + let fresh = Account::new(); + let (n, cpk) = account_meta_from_blob(&bincode::serialize(&fresh).unwrap()).unwrap(); + assert_eq!(n, 0); + assert!(cpk.is_none(), "genesis account has no commitment pubkey"); + + // Account that has sent: num_sends > 0 and a commitment pubkey set. + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[7u8; 32]).unwrap(); + let pk = PublicKey::from_secret_key(&secp, &sk); + let mut sent = Account::new(); + sent.num_sends = 3; + sent.commitment_public_key = Some(pk); + let (n, cpk) = account_meta_from_blob(&bincode::serialize(&sent).unwrap()).unwrap(); + assert_eq!(n, 3); + assert_eq!( + cpk.as_deref(), + Some(hex::encode(pk.serialize()).as_str()), + "commitment pubkey is the 33-byte compressed form, hex-encoded" + ); + + // Garbage bytes -> None (decode failure → caller 500s). + assert!(account_meta_from_blob(&[0xff; 3]).is_none()); +} + +#[test] +fn tx_detail_from_row_builds_full_detail_with_decoded_snapshot() { + let mut prev = Account::new(); + prev.balance = 10_000; + let mut new = Account::new(); + new.balance = 4_000; + new.num_sends = 1; + + let row = crate::db::AccountHistoryRow { + id: 99, + timestamp_secs: 1_700_000_500, + source: "send".to_string(), + prev_data: Some(bincode::serialize(&prev).unwrap()), + new_data: bincode::serialize(&new).unwrap(), + commit_txid: Some(vec![0xab; 32]), + block_height: Some(900_001), + pending_status: Some("complete".to_string()), + commit_output_value: Some(546), + }; + let digest = vec![0xcd; 32]; + let detail = tx_detail_from_row(&row, "ee".repeat(32), Some(digest.clone())) + .expect("detail produced for a user-facing row"); + + // Core fields mirror history_row_to_item. + assert_eq!(detail.id, 99); + assert_eq!(detail.address, "ee".repeat(32)); + assert_eq!(detail.direction, "send"); + assert_eq!(detail.amount, 6_000, "|4000 - 10000|"); + assert_eq!( + detail.status, "confirmed", + "complete inscription -> confirmed" + ); + assert_eq!(detail.txid.as_deref(), Some("ab".repeat(32).as_str())); + assert_eq!(detail.block_height, Some(900_001)); + // Decoded snapshot. + assert_eq!(detail.balance_after, 4_000); + assert_eq!(detail.balance_before, Some(10_000)); + assert_eq!(detail.num_sends_after, 1); + // Proof + on-chain extras. + assert_eq!( + detail.circuit_digest.as_deref(), + Some(hex::encode(&digest).as_str()) + ); + assert_eq!(detail.commit_output_value, Some(546)); +} + +#[test] +fn tx_detail_from_row_first_row_has_no_balance_before() { + let mut new = Account::new(); + new.balance = 5_000; + let row = crate::db::AccountHistoryRow { + id: 1, + timestamp_secs: 0, + source: "mint".to_string(), + prev_data: None, + new_data: bincode::serialize(&new).unwrap(), + commit_txid: None, + block_height: None, + pending_status: None, + commit_output_value: None, + }; + let detail = tx_detail_from_row(&row, "11".repeat(32), None).unwrap(); + assert_eq!(detail.balance_after, 5_000); + assert_eq!(detail.amount, 5_000, "from-zero mint credits full balance"); + assert!( + detail.balance_before.is_none(), + "first row has no prior state" + ); + assert!(detail.circuit_digest.is_none(), "no digest passed -> null"); + assert!(detail.commit_output_value.is_none()); + assert_eq!(detail.num_sends_after, 0); + assert!(detail.commitment_public_key.is_none()); +} + +#[test] +fn tx_detail_from_row_internal_source_returns_none() { + let mut new = Account::new(); + new.balance = 1; + let row = crate::db::AccountHistoryRow { + id: 5, + timestamp_secs: 0, + source: "scanner".to_string(), // internal — must not surface + prev_data: None, + new_data: bincode::serialize(&new).unwrap(), + commit_txid: None, + block_height: None, + pending_status: None, + commit_output_value: None, + }; + assert!(tx_detail_from_row(&row, "22".repeat(32), None).is_none()); +} + +#[test] +fn tx_detail_from_row_undecodable_new_data_returns_none() { + let row = crate::db::AccountHistoryRow { + id: 5, + timestamp_secs: 0, + source: "mint".to_string(), + prev_data: None, + new_data: vec![0xff; 4], // corrupt -> caller 500s + commit_txid: None, + block_height: None, + pending_status: None, + commit_output_value: None, + }; + assert!(tx_detail_from_row(&row, "33".repeat(32), None).is_none()); +} + #[test] fn pending_inscription_status_from_db_str_round_trips_every_variant() { // Mirrors migration-0003 CHECK constraint. Adding a state to diff --git a/node/tests/api_remote.rs b/node/tests/api_remote.rs index ce30cdc2..cf9bfcae 100644 --- a/node/tests/api_remote.rs +++ b/node/tests/api_remote.rs @@ -645,6 +645,121 @@ async fn history_after_mint_records_mint_row() { assert!(head["memo"].is_null()); } +/// Live contract round-trip for the per-transaction detail endpoint +/// (`GET /api/history/{id}`): mint, read the history list to learn the +/// row id, then fetch the detail and assert it carries the list fields +/// plus the decoded account-state snapshot. State-mutating like +/// `history_after_mint_records_mint_row`; uses a fresh wallet so it is +/// race-free against parallel runs. +#[tokio::test] +async fn history_item_after_mint_returns_full_detail() { + let client = http_client(); + let alice = TestWallet::new(); + assert_minting_balance_in_bounds(&client).await; + + let mint_result = mint_via_job(&client, &alice.address_hex(), MINT_AMOUNT).await; + assert_eq!(mint_result["success"], Value::Bool(true)); + let _ = poll_balance_at_least(&client, &alice.address_hex(), MINT_AMOUNT).await; + + // Learn the row id from the list. + let list: Value = client + .get(url(&format!( + "/api/history?address={}", + alice.address_hex() + ))) + .send() + .await + .expect("GET /api/history") + .json() + .await + .expect("history JSON"); + let id = list["items"][0]["id"].as_i64().expect("row id"); + + // Fetch the detail. + let resp = client + .get(url(&format!( + "/api/history/{}?address={}", + id, + alice.address_hex() + ))) + .send() + .await + .expect("GET /api/history/{id}"); + assert_eq!(resp.status(), StatusCode::OK); + let d: Value = resp.json().await.expect("detail JSON"); + + // Core fields (consistent with the list head). + assert_eq!(d["id"].as_i64(), Some(id)); + assert_eq!(d["direction"], "mint"); + assert_eq!(d["amount"], MINT_AMOUNT); + assert_eq!(d["address"], alice.address_hex().trim_start_matches("0x")); + // Decoded account-state snapshot: a from-genesis mint credits the + // full balance, leaves num_sends at 0, and sets no commitment pubkey. + assert_eq!(d["balance_after"].as_u64(), Some(MINT_AMOUNT)); + assert!( + d["balance_before"].is_null(), + "first row has no prior state" + ); + assert_eq!(d["num_sends_after"].as_u64(), Some(0)); + assert!( + d["commitment_public_key"].is_null(), + "mint-only account has no commitment pubkey" + ); + // The node has warmed a prover, so a verifier circuit digest exists. + assert!( + d["circuit_digest"].is_string(), + "circuit_digest should be populated post-warmup, got {}", + d["circuit_digest"] + ); +} + +/// `GET /api/history/{id}` validation + scoping contract (read-only, no +/// state mutation — safe to run unconditionally). +#[tokio::test] +async fn history_item_validation_and_scoping() { + let client = http_client(); + let some_addr = format!("0x{}", "ab".repeat(32)); + + // Missing address -> 422. + let r = client + .get(url("/api/history/1")) + .send() + .await + .expect("GET no-address"); + assert_eq!(r.status(), StatusCode::UNPROCESSABLE_ENTITY); + + // Non-integer id -> 422 (parsed as string, not axum's default 400). + let r = client + .get(url(&format!( + "/api/history/not_a_number?address={}", + some_addr + ))) + .send() + .await + .expect("GET bad-id"); + assert_eq!(r.status(), StatusCode::UNPROCESSABLE_ENTITY); + + // Bad address hex -> 422. + let r = client + .get(url("/api/history/1?address=not_hex")) + .send() + .await + .expect("GET bad-address"); + assert_eq!(r.status(), StatusCode::UNPROCESSABLE_ENTITY); + + // Well-formed but never-minted address + arbitrary id -> 404. + let fresh = TestWallet::new(); + let r = client + .get(url(&format!( + "/api/history/999999999?address={}", + fresh.address_hex() + ))) + .send() + .await + .expect("GET unknown"); + assert_eq!(r.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn balance_wrong_length_returns_422() { // 16 bytes = 32 hex chars, the handler requires exactly 32 bytes diff --git a/node/tests/openapi_smoke.rs b/node/tests/openapi_smoke.rs index 2a21801b..1726f1bb 100644 --- a/node/tests/openapi_smoke.rs +++ b/node/tests/openapi_smoke.rs @@ -80,6 +80,7 @@ fn spec_lists_every_always_on_route() { "/api/info", "/api/balance", "/api/history", + "/api/history/{id}", "/api/jobs/mint", "/api/jobs/send", "/api/jobs/{job_id}", @@ -131,7 +132,12 @@ fn spec_registers_critical_schemas() { // page contract (issue #153). The wallet's transaction list reads // this shape directly; a missing schema here means a wallet build // would have no compile-time check against drift. - for name in ["HistoryResponse", "HistoryItem", "HistoryErrorResponse"] { + for name in [ + "HistoryResponse", + "HistoryItem", + "HistoryErrorResponse", + "TxDetail", + ] { assert!( schemas.contains_key(name), "`{name}` must be registered under components.schemas — \