Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions node/src/account_node_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
62 changes: 62 additions & 0 deletions node/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,65 @@ pub struct AccountHistoryRow {
/// `commit_broadcast`, `reveal_broadcast`, `complete`, `failed`).
/// `None` while `commit_txid` is `None`.
pub pending_status: Option<String>,
/// `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<i64>,
}

/// 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<Option<AccountHistoryRow>> {
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
Expand Down Expand Up @@ -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();
Expand Down
92 changes: 92 additions & 0 deletions node/src/db_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
4 changes: 3 additions & 1 deletion node/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
Expand All @@ -139,6 +140,7 @@ pub const DOCS_HTML: &str = concat!(
HistoryResponse,
HistoryItem,
HistoryErrorResponse,
TxDetail,
SendCoinRequest,
SendCoinResponse,
MintRequest,
Expand Down
Loading
Loading