mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(utxo): report per-transaction amounts in BTC/LTC history
Electrum's get_history is txid, height and fee, and we passed exactly that through. The wallet read total_received and total_sent off those rows, got undefined for both, defaulted them to zero, and rendered every Bitcoin and Litecoin row as a confident "sent 0". Silent, so it read as an empty wallet rather than as a bug. The amounts come from the verbose transaction, and the exactness helpers this needed already existed on VerboseTransaction and simply had no caller: total_received_at is unambiguous from the outputs, and total_sent_from answers only when the server resolved EVERY input's prevout, so a server that resolves none leaves the sent side unknown rather than reporting a partial figure that looks exact. Best effort throughout. A transaction that cannot be fetched keeps null, not zero, because the client distinguishes "not determined" from "nothing moved" and one slow upstream must not fail the whole history request. Resolution is bounded to the newest rows: each one costs a transaction fetch, so an unbounded history would turn a single request into hundreds. Rows past the bound arrive without amounts and say so. The multi-address path resolves each distinct txid once and computes against the whole address set, so a transfer between two of the user's own addresses nets out instead of reading as a payment to a stranger. openapi.json is regenerated, not edited.
This commit is contained in:
@@ -3781,6 +3781,24 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"description": "Block height (`0`/negative for unconfirmed)."
|
"description": "Block height (`0`/negative for unconfirmed)."
|
||||||
},
|
},
|
||||||
|
"total_received": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Satoshis this transaction paid to the queried address, when known.\n\nElectrum's `get_history` carries no amounts at all, so these are filled\nin from the verbose transaction. Absent means \"not determined\", never\nzero: a client that defaults a missing amount to 0 renders every row as\na confident \"sent 0\", which is what this wallet used to do.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"total_sent": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Satoshis this transaction spent FROM the queried address, when the\nserver resolved every input's prevout. Absent on a server that resolves\nnone (public ElectrumX), because a partial answer would look exact.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"txid": {
|
"txid": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -5112,6 +5130,24 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"description": "Block height (`0`/negative for unconfirmed)."
|
"description": "Block height (`0`/negative for unconfirmed)."
|
||||||
},
|
},
|
||||||
|
"total_received": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "See [`HistoryEntry::total_received`].",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"total_sent": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "See [`HistoryEntry::total_sent`].",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"txid": {
|
"txid": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-15
@@ -164,6 +164,17 @@ pub struct HistoryEntry {
|
|||||||
pub height: i64,
|
pub height: i64,
|
||||||
/// Fee in satoshis (mempool entries only).
|
/// Fee in satoshis (mempool entries only).
|
||||||
pub fee: Option<u64>,
|
pub fee: Option<u64>,
|
||||||
|
/// Satoshis this transaction paid to the queried address, when known.
|
||||||
|
///
|
||||||
|
/// Electrum's `get_history` carries no amounts at all, so these are filled
|
||||||
|
/// in from the verbose transaction. Absent means "not determined", never
|
||||||
|
/// zero: a client that defaults a missing amount to 0 renders every row as
|
||||||
|
/// a confident "sent 0", which is what this wallet used to do.
|
||||||
|
pub total_received: Option<u64>,
|
||||||
|
/// Satoshis this transaction spent FROM the queried address, when the
|
||||||
|
/// server resolved every input's prevout. Absent on a server that resolves
|
||||||
|
/// none (public ElectrumX), because a partial answer would look exact.
|
||||||
|
pub total_sent: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
@@ -220,6 +231,10 @@ pub struct TaggedHistoryEntry {
|
|||||||
pub height: i64,
|
pub height: i64,
|
||||||
/// Fee in satoshis (mempool entries only).
|
/// Fee in satoshis (mempool entries only).
|
||||||
pub fee: Option<u64>,
|
pub fee: Option<u64>,
|
||||||
|
/// See [`HistoryEntry::total_received`].
|
||||||
|
pub total_received: Option<u64>,
|
||||||
|
/// See [`HistoryEntry::total_sent`].
|
||||||
|
pub total_sent: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
@@ -370,23 +385,105 @@ pub async fn history(
|
|||||||
) -> Result<Json<HistoryResponse>, AppError> {
|
) -> Result<Json<HistoryResponse>, AppError> {
|
||||||
extract_user_id_from_token(&state, &headers).await?;
|
extract_user_id_from_token(&state, &headers).await?;
|
||||||
let asset = req.asset.to_lowercase();
|
let asset = req.asset.to_lowercase();
|
||||||
let entries = electrum_for(&state, &asset)?
|
let client = electrum_for(&state, &asset)?;
|
||||||
.get_history(&req.address)
|
let mut entries = client.get_history(&req.address).await?;
|
||||||
.await?;
|
// Newest first, so the bounded amount resolution below spends its budget on
|
||||||
|
// the rows the user actually sees. Electrum returns ascending height, and
|
||||||
|
// unconfirmed rows carry height 0 or less.
|
||||||
|
entries.sort_by_key(|e| std::cmp::Reverse(if e.height > 0 { e.height } else { i64::MAX }));
|
||||||
|
let txids: Vec<String> = entries.iter().map(|e| e.tx_hash.clone()).collect();
|
||||||
|
let amounts = resolve_amounts(client, &txids, std::slice::from_ref(&req.address)).await;
|
||||||
Ok(Json(HistoryResponse {
|
Ok(Json(HistoryResponse {
|
||||||
asset,
|
asset,
|
||||||
address: req.address,
|
address: req.address,
|
||||||
transactions: entries
|
transactions: entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| HistoryEntry {
|
.map(|e| {
|
||||||
txid: e.tx_hash,
|
let (total_received, total_sent) =
|
||||||
height: e.height,
|
amounts.get(&e.tx_hash).copied().unwrap_or((None, None));
|
||||||
fee: e.fee,
|
HistoryEntry {
|
||||||
|
txid: e.tx_hash,
|
||||||
|
height: e.height,
|
||||||
|
fee: e.fee,
|
||||||
|
total_received,
|
||||||
|
total_sent,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many history rows get their amounts resolved, newest first.
|
||||||
|
///
|
||||||
|
/// Each resolution is one `blockchain.transaction.get`, so an unbounded wallet
|
||||||
|
/// history would turn one request into hundreds. The wallet shows a scrolling
|
||||||
|
/// activity list; rows past this simply arrive without amounts, which the
|
||||||
|
/// client already renders honestly rather than as zero.
|
||||||
|
const AMOUNT_RESOLUTION_LIMIT: usize = 60;
|
||||||
|
|
||||||
|
/// Resolve `(total_received, total_sent)` for the newest rows of a history.
|
||||||
|
///
|
||||||
|
/// Electrum's `get_history` is txid + height + fee and nothing else, so the
|
||||||
|
/// amounts have to come from the verbose transaction. BEST EFFORT by design: a
|
||||||
|
/// row whose transaction cannot be fetched or parsed keeps `None`, because the
|
||||||
|
/// client distinguishes "not determined" from zero, and one slow or hostile
|
||||||
|
/// upstream response must not fail the whole history request.
|
||||||
|
///
|
||||||
|
/// `addresses` is the set the caller asked about, so a transaction between two
|
||||||
|
/// of the user's own addresses nets correctly instead of reading as a payment
|
||||||
|
/// to a stranger.
|
||||||
|
async fn resolve_amounts(
|
||||||
|
client: &crate::infra::electrum::ElectrumClient,
|
||||||
|
txids: &[String],
|
||||||
|
addresses: &[String],
|
||||||
|
) -> std::collections::HashMap<String, (Option<u64>, Option<u64>)> {
|
||||||
|
use futures::stream::{self, StreamExt};
|
||||||
|
|
||||||
|
let fetched = stream::iter(txids.iter().take(AMOUNT_RESOLUTION_LIMIT).cloned())
|
||||||
|
.map(|txid| async move {
|
||||||
|
let tx = client.get_transaction_verbose(&txid).await.ok();
|
||||||
|
(txid, tx)
|
||||||
|
})
|
||||||
|
// A small window: enough to hide per-call latency, not enough to look
|
||||||
|
// like a burst to the upstream server.
|
||||||
|
.buffer_unordered(8)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut out = std::collections::HashMap::new();
|
||||||
|
for (txid, tx) in fetched {
|
||||||
|
let Some(tx) = tx else {
|
||||||
|
out.insert(txid, (None, None));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut received: u64 = 0;
|
||||||
|
let mut received_ok = true;
|
||||||
|
let mut sent: u64 = 0;
|
||||||
|
// `total_sent_from` answers only when EVERY input carries a resolved
|
||||||
|
// prevout, so a server that resolves none leaves sent unknown for the
|
||||||
|
// whole row rather than reporting a partial figure as exact.
|
||||||
|
let mut sent_ok = true;
|
||||||
|
for address in addresses {
|
||||||
|
match tx.total_received_at(address) {
|
||||||
|
Ok(v) => received = received.saturating_add(v),
|
||||||
|
Err(_) => received_ok = false,
|
||||||
|
}
|
||||||
|
match tx.total_sent_from(address) {
|
||||||
|
Ok(Some(v)) => sent = sent.saturating_add(v),
|
||||||
|
Ok(None) | Err(_) => sent_ok = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.insert(
|
||||||
|
txid,
|
||||||
|
(
|
||||||
|
if received_ok { Some(received) } else { None },
|
||||||
|
if sent_ok { Some(sent) } else { None },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-chain tip height (for client-side confirmation counting).
|
/// Best-chain tip height (for client-side confirmation counting).
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
security(("bearer_auth" = [])),
|
security(("bearer_auth" = [])),
|
||||||
@@ -573,18 +670,35 @@ pub async fn history_multi(
|
|||||||
extract_user_id_from_token(&state, &headers).await?;
|
extract_user_id_from_token(&state, &headers).await?;
|
||||||
let asset = req.asset.to_lowercase();
|
let asset = req.asset.to_lowercase();
|
||||||
validate_address_batch(&req.addresses)?;
|
validate_address_batch(&req.addresses)?;
|
||||||
let tagged = electrum_for(&state, &asset)?
|
let client = electrum_for(&state, &asset)?;
|
||||||
.get_history_tagged(&req.addresses)
|
let mut tagged = client.get_history_tagged(&req.addresses).await?;
|
||||||
.await?;
|
tagged.sort_by_key(|(_, e)| std::cmp::Reverse(if e.height > 0 { e.height } else { i64::MAX }));
|
||||||
|
// One transaction can touch several of the queried addresses and so appear
|
||||||
|
// once per address. Resolve each DISTINCT txid once, and compute its amounts
|
||||||
|
// against the whole address set so a transfer between the user's own
|
||||||
|
// addresses nets out instead of reading as a payment.
|
||||||
|
let mut txids: Vec<String> = Vec::new();
|
||||||
|
for (_, e) in &tagged {
|
||||||
|
if !txids.contains(&e.tx_hash) {
|
||||||
|
txids.push(e.tx_hash.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let amounts = resolve_amounts(client, &txids, &req.addresses).await;
|
||||||
Ok(Json(MultiHistoryResponse {
|
Ok(Json(MultiHistoryResponse {
|
||||||
asset,
|
asset,
|
||||||
transactions: tagged
|
transactions: tagged
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(address, e)| TaggedHistoryEntry {
|
.map(|(address, e)| {
|
||||||
address,
|
let (total_received, total_sent) =
|
||||||
txid: e.tx_hash,
|
amounts.get(&e.tx_hash).copied().unwrap_or((None, None));
|
||||||
height: e.height,
|
TaggedHistoryEntry {
|
||||||
fee: e.fee,
|
address,
|
||||||
|
txid: e.tx_hash,
|
||||||
|
height: e.height,
|
||||||
|
fee: e.fee,
|
||||||
|
total_received,
|
||||||
|
total_sent,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user