feat: Cache transparent UTXOs

Whenever an `Announcement` is parsed as transparent transaction info, we obtain
information about the UTXOs involved in that transaction. It would be nice to
display this information when we look up the UTXO, but there is no way to query
it from neptune-core.

This commit caches that information in order to display it.

Specifically, it extends `AppState` with a (smart pointer to) vector of
`TransparentUtxoInfo` (a new type). The vector, or its existing entries, are
extended with new information when it becomes available.

At this point the vector is rather silly because it is not persisted, and the
app reboots more often than not, resulting in a clear cache. A future commit
will persist this data.
This commit is contained in:
Alan Szepieniec 2025-08-13 19:34:02 +02:00
parent d840347b0f
commit 55b9b8792c
8 changed files with 274 additions and 57 deletions

View File

@ -4,6 +4,7 @@ use crate::http_util::rpc_method_err;
use crate::model::announcement_selector::AnnouncementSelector; use crate::model::announcement_selector::AnnouncementSelector;
use crate::model::announcement_type::AnnouncementType; use crate::model::announcement_type::AnnouncementType;
use crate::model::app_state::AppState; use crate::model::app_state::AppState;
use crate::model::transparent_utxo_tuple::TransparentUtxoTuple;
use axum::extract::rejection::PathRejection; use axum::extract::rejection::PathRejection;
use axum::extract::Path; use axum::extract::Path;
use axum::extract::State; use axum::extract::State;
@ -15,6 +16,8 @@ use neptune_cash::api::export::BlockHeight;
use neptune_cash::prelude::tasm_lib::prelude::Digest; use neptune_cash::prelude::tasm_lib::prelude::Digest;
use neptune_cash::prelude::triton_vm::prelude::BFieldCodec; use neptune_cash::prelude::triton_vm::prelude::BFieldCodec;
use neptune_cash::prelude::twenty_first::tip5::Tip5; use neptune_cash::prelude::twenty_first::tip5::Tip5;
use neptune_cash::util_types::mutator_set::addition_record::AdditionRecord;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tarpc::context; use tarpc::context;
@ -32,6 +35,7 @@ pub async fn announcement_page(
block_hash: Digest, block_hash: Digest,
block_height: BlockHeight, block_height: BlockHeight,
announcement_type: AnnouncementType, announcement_type: AnnouncementType,
addition_record_indices: HashMap<AdditionRecord, Option<u64>>,
} }
let state = &state_rw.load(); let state = &state_rw.load();
@ -73,6 +77,58 @@ pub async fn announcement_page(
.clone(); .clone();
let announcement_type = AnnouncementType::parse(announcement); let announcement_type = AnnouncementType::parse(announcement);
let mut addition_record_indices = HashMap::<AdditionRecord, Option<u64>>::new();
if let AnnouncementType::TransparentTxInfo(tx_info) = announcement_type.clone() {
let addition_records = tx_info
.outputs
.iter()
.map(|output| output.addition_record())
.collect::<Vec<_>>();
addition_record_indices = state.rpc_client.addition_record_indices_for_block(context::current(), state.token(), block_selector, &addition_records).await
.map_err(|e| not_found_html_response(state, Some(e.to_string())))?
.map_err(rpc_method_err)?
.expect(
"block guaranteed to exist because we got here; getting its announcements should work",
);
let mut transparent_utxos_cache = state.transparent_utxos_cache.lock().await;
for input in &tx_info.inputs {
let addition_record = input.addition_record();
if let Some(existing_entry) = transparent_utxos_cache
.iter_mut()
.find(|tu| tu.addition_record() == addition_record)
{
existing_entry.upgrade_with_transparent_input(input, block_hash);
} else {
tracing::info!("Adding transparent UTXO (input side) to cache.");
transparent_utxos_cache.push(TransparentUtxoTuple::new_from_transparent_input(
input, block_hash,
));
}
}
for output in &tx_info.outputs {
let addition_record = output.addition_record();
if let Some(existing_entry) = transparent_utxos_cache
.iter_mut()
.find(|tu| tu.addition_record() == addition_record)
{
existing_entry.upgrade_with_transparent_output(block_hash);
} else {
tracing::info!("Adding transparent UTXO (output side) to cache.");
transparent_utxos_cache.push(TransparentUtxoTuple::new_from_transparent_output(
output,
addition_record_indices
.get(&addition_record)
.cloned()
.unwrap_or(None),
block_hash,
));
}
}
}
let header = HeaderHtml { state }; let header = HeaderHtml { state };
let utxo_page = AnnouncementHtmlPage { let utxo_page = AnnouncementHtmlPage {
@ -82,6 +138,7 @@ pub async fn announcement_page(
block_height, block_height,
num_announcements, num_announcements,
announcement_type, announcement_type,
addition_record_indices,
}; };
Ok(Html(utxo_page.to_string())) Ok(Html(utxo_page.to_string()))
} }

View File

@ -2,6 +2,7 @@ use crate::html::component::header::HeaderHtml;
use crate::html::page::not_found::not_found_html_response; use crate::html::page::not_found::not_found_html_response;
use crate::http_util::rpc_method_err; use crate::http_util::rpc_method_err;
use crate::model::app_state::AppState; use crate::model::app_state::AppState;
use crate::model::transparent_utxo_tuple::TransparentUtxoTuple;
use axum::extract::rejection::PathRejection; use axum::extract::rejection::PathRejection;
use axum::extract::Path; use axum::extract::Path;
use axum::extract::State; use axum::extract::State;
@ -9,6 +10,7 @@ use axum::response::Html;
use axum::response::Response; use axum::response::Response;
use html_escaper::Escape; use html_escaper::Escape;
use html_escaper::Trusted; use html_escaper::Trusted;
use neptune_cash::api::export::Tip5;
use neptune_cash::prelude::tasm_lib::prelude::Digest; use neptune_cash::prelude::tasm_lib::prelude::Digest;
use std::sync::Arc; use std::sync::Arc;
use tarpc::context; use tarpc::context;
@ -24,16 +26,18 @@ pub async fn utxo_page(
header: HeaderHtml<'a>, header: HeaderHtml<'a>,
index: u64, index: u64,
digest: Digest, digest: Digest,
transparent_utxo_info: Option<TransparentUtxoTuple>,
} }
let state = &state_rw.load(); let state = &state_rw.load();
let cache = state.transparent_utxos_cache.clone();
let Path(index) = let Path(index) =
index_maybe.map_err(|e| not_found_html_response(state, Some(e.to_string())))?; index_maybe.map_err(|e| not_found_html_response(state, Some(e.to_string())))?;
let digest = match state let digest = match state
.rpc_client .rpc_client
.utxo_digest(context::current(), state.token(), index) .utxo_digest(context::current(), state.token(), index, cache)
.await .await
.map_err(|e| not_found_html_response(state, Some(e.to_string())))? .map_err(|e| not_found_html_response(state, Some(e.to_string())))?
.map_err(rpc_method_err)? .map_err(rpc_method_err)?
@ -49,10 +53,19 @@ pub async fn utxo_page(
let header = HeaderHtml { state }; let header = HeaderHtml { state };
let transparent_utxo_info = state
.transparent_utxos_cache
.lock()
.await
.iter()
.find(|tu| tu.aocl_leaf_index().is_some_and(|li| li == index))
.cloned();
let utxo_page = UtxoHtmlPage { let utxo_page = UtxoHtmlPage {
index, index,
header, header,
digest, digest,
transparent_utxo_info,
}; };
Ok(Html(utxo_page.to_string())) Ok(Html(utxo_page.to_string()))
} }

View File

@ -1,4 +1,5 @@
use crate::model::config::Config; use crate::model::config::Config;
use crate::model::transparent_utxo_tuple::TransparentUtxoTuple;
use crate::neptune_rpc; use crate::neptune_rpc;
use anyhow::Context; use anyhow::Context;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
@ -8,6 +9,7 @@ use neptune_cash::models::blockchain::block::block_selector::BlockSelector;
use neptune_cash::prelude::twenty_first::tip5::Digest; use neptune_cash::prelude::twenty_first::tip5::Digest;
use neptune_cash::rpc_auth; use neptune_cash::rpc_auth;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AppStateInner { pub struct AppStateInner {
@ -15,6 +17,12 @@ pub struct AppStateInner {
pub config: Config, pub config: Config,
pub rpc_client: neptune_rpc::AuthenticatedClient, pub rpc_client: neptune_rpc::AuthenticatedClient,
pub genesis_digest: Digest, pub genesis_digest: Digest,
/// Whenever an announcement of type transparent transaction info is fetched
/// from the RPC endpoint, we learn information about UTXOs. Since we expect
/// transparent transactions to be rare, it is okay to cache this in RAM
/// instead of storing it on disk.
pub transparent_utxos_cache: Arc<Mutex<Vec<TransparentUtxoTuple>>>,
} }
impl AppStateInner { impl AppStateInner {
@ -26,6 +34,12 @@ impl AppStateInner {
#[derive(Clone)] #[derive(Clone)]
pub struct AppState(Arc<ArcSwap<AppStateInner>>); pub struct AppState(Arc<ArcSwap<AppStateInner>>);
impl AppState {
fn new(app_state_inner: AppStateInner) -> Self {
Self(Arc::new(ArcSwap::from_pointee(app_state_inner)))
}
}
impl std::ops::Deref for AppState { impl std::ops::Deref for AppState {
type Target = Arc<ArcSwap<AppStateInner>>; type Target = Arc<ArcSwap<AppStateInner>>;
@ -34,24 +48,6 @@ impl std::ops::Deref for AppState {
} }
} }
impl From<(Network, Config, neptune_rpc::AuthenticatedClient, Digest)> for AppState {
fn from(
(network, config, rpc_client, genesis_digest): (
Network,
Config,
neptune_rpc::AuthenticatedClient,
Digest,
),
) -> Self {
Self(Arc::new(ArcSwap::from_pointee(AppStateInner {
network,
config,
rpc_client,
genesis_digest,
})))
}
}
impl AppState { impl AppState {
pub async fn init() -> Result<Self, anyhow::Error> { pub async fn init() -> Result<Self, anyhow::Error> {
let rpc_client = neptune_rpc::gen_authenticated_rpc_client() let rpc_client = neptune_rpc::gen_authenticated_rpc_client()
@ -68,12 +64,13 @@ impl AppState {
.with_context(|| "Failed calling neptune-core api method: block_digest")? .with_context(|| "Failed calling neptune-core api method: block_digest")?
.with_context(|| "neptune-core failed to provide a genesis block")?; .with_context(|| "neptune-core failed to provide a genesis block")?;
Ok(Self::from(( Ok(AppState::new(AppStateInner {
rpc_client.network, network: rpc_client.network,
Config::parse(), config: Config::parse(),
rpc_client, rpc_client,
genesis_digest, genesis_digest,
))) transparent_utxos_cache: Arc::new(Mutex::new(vec![])),
}))
} }
/// Sets the rpc_client /// Sets the rpc_client
@ -95,6 +92,7 @@ impl AppState {
rpc_client, rpc_client,
config: inner.config.clone(), config: inner.config.clone(),
genesis_digest: inner.genesis_digest, genesis_digest: inner.genesis_digest,
transparent_utxos_cache: Arc::new(Mutex::new(vec![])),
}; };
self.0.store(Arc::new(new_inner)); self.0.store(Arc::new(new_inner));
} }

View File

@ -4,3 +4,4 @@ pub mod app_state;
pub mod block_selector_extended; pub mod block_selector_extended;
pub mod config; pub mod config;
pub mod height_or_digest; pub mod height_or_digest;
pub mod transparent_utxo_tuple;

View File

@ -1,6 +1,7 @@
use crate::alert_email; use crate::alert_email;
use crate::model::app_state::AppState; use crate::model::app_state::AppState;
use crate::model::config::Config; use crate::model::config::Config;
use crate::model::transparent_utxo_tuple::TransparentUtxoTuple;
use anyhow::Context; use anyhow::Context;
use chrono::DateTime; use chrono::DateTime;
use chrono::TimeDelta; use chrono::TimeDelta;
@ -17,11 +18,15 @@ use neptune_cash::rpc_auth;
use neptune_cash::rpc_server::error::RpcError; use neptune_cash::rpc_server::error::RpcError;
use neptune_cash::rpc_server::RPCClient; use neptune_cash::rpc_server::RPCClient;
use neptune_cash::rpc_server::RpcResult; use neptune_cash::rpc_server::RpcResult;
use neptune_cash::util_types::mutator_set::addition_record::AdditionRecord;
use std::collections::HashMap;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use tarpc::client; use tarpc::client;
use tarpc::context; use tarpc::context;
use tarpc::tokio_serde::formats::Json as RpcJson; use tarpc::tokio_serde::formats::Json as RpcJson;
use tokio::sync::Mutex;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
#[cfg(feature = "mock")] #[cfg(feature = "mock")]
@ -92,8 +97,32 @@ impl AuthenticatedClient {
ctx: ::tarpc::context::Context, ctx: ::tarpc::context::Context,
token: rpc_auth::Token, token: rpc_auth::Token,
leaf_index: u64, leaf_index: u64,
_transparent_utxos_cache: Arc<Mutex<Vec<TransparentUtxoTuple>>>,
) -> ::core::result::Result<RpcResult<Option<Digest>>, ::tarpc::client::RpcError> { ) -> ::core::result::Result<RpcResult<Option<Digest>>, ::tarpc::client::RpcError> {
self.client.utxo_digest(ctx, token, leaf_index).await let rpc_result = self.client.utxo_digest(ctx, token, leaf_index).await;
if let Ok(Ok(Some(_))) = rpc_result {
return rpc_result;
}
// if MOCK environment variable is set and feature is enabled,
// imagine some mock UTXO info
#[cfg(feature = "mock")]
if std::env::var(MOCK_KEY).is_ok() {
let cache = _transparent_utxos_cache.lock().await;
tracing::warn!(
"RPC query failed and MOCK flag set, so seeing if we can return a cached utxo; cache has {} objects", cache.len()
);
if let Some(entry) = cache
.iter()
.find(|tu| tu.aocl_leaf_index().is_some_and(|li| li == leaf_index))
{
tracing::warn!("returning a cached utxo");
return Ok(Ok(Some(entry.addition_record().canonical_commitment)));
}
}
rpc_result
} }
/// Intercept and relay call to [`RPCClient::announcements_in_block`] /// Intercept and relay call to [`RPCClient::announcements_in_block`]
@ -123,7 +152,9 @@ impl AuthenticatedClient {
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::Rng; use rand::Rng;
use rand::SeedableRng; use rand::SeedableRng;
tracing::warn!("RPC query failed and MOCK flag set, so returning an imagined block"); tracing::warn!(
"RPC query failed and MOCK flag set, so returning an imagined announcement"
);
let mut hasher = Hasher::new(); let mut hasher = Hasher::new();
hasher.update(&block_selector.to_string().bytes().collect::<Vec<_>>()); hasher.update(&block_selector.to_string().bytes().collect::<Vec<_>>());
let mut rng = StdRng::from_seed(*hasher.finalize().as_bytes()); let mut rng = StdRng::from_seed(*hasher.finalize().as_bytes());
@ -156,6 +187,67 @@ impl AuthenticatedClient {
// otherwise, return the original error // otherwise, return the original error
rpc_result rpc_result
} }
/// Intercept and relay call to
/// [`RPCClient::addition_record_indices_for_block`].
///
/// Also take an extra argument for mocking purposes.
pub async fn addition_record_indices_for_block(
&self,
ctx: ::tarpc::context::Context,
token: rpc_auth::Token,
block_selector: BlockSelector,
_addition_records: &[AdditionRecord],
) -> ::core::result::Result<
RpcResult<Option<HashMap<AdditionRecord, Option<u64>>>>,
::tarpc::client::RpcError,
> {
let rpc_result = self
.client
.addition_record_indices_for_block(ctx, token, block_selector)
.await;
// if the RPC call was successful, return that
if let Ok(Ok(Some(_))) = rpc_result {
return rpc_result;
}
// if MOCK environment variable is set and feature is enabled,
// imagine some mock hash map
#[cfg(feature = "mock")]
if std::env::var(MOCK_KEY).is_ok() {
use blake3::Hasher;
use rand::rngs::StdRng;
use rand::Rng;
use rand::SeedableRng;
tracing::warn!(
"RPC query failed and MOCK flag set, so returning an imagined addition records"
);
let mut hasher = Hasher::new();
hasher.update(&block_selector.to_string().bytes().collect::<Vec<_>>());
let mut rng = StdRng::from_seed(*hasher.finalize().as_bytes());
let aocl_offset = rng.random::<u64>() >> 1;
let addition_record_indices = _addition_records
.iter()
.enumerate()
.map(|(i, ar)| {
(
*ar,
if rng.random_bool(0.5_f64) {
Some(i as u64 + aocl_offset)
} else {
None
},
)
})
.collect::<HashMap<_, _>>();
return Ok(Ok(Some(addition_record_indices)));
}
// otherwise, return the original error
rpc_result
}
} }
/// generates RPCClient, for querying neptune-core RPC server. /// generates RPCClient, for querying neptune-core RPC server.

View File

@ -18,9 +18,10 @@ pub async fn utxo_digest(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Digest>, impl IntoResponse> { ) -> Result<Json<Digest>, impl IntoResponse> {
let s = state.load(); let s = state.load();
let cache = s.transparent_utxos_cache.clone();
match s match s
.rpc_client .rpc_client
.utxo_digest(context::current(), s.token(), index) .utxo_digest(context::current(), s.token(), index, cache)
.await .await
.map_err(rpc_err)? .map_err(rpc_err)?
.map_err(rpc_method_err)? .map_err(rpc_method_err)?

View File

@ -87,7 +87,13 @@
<td> <td>
<details> <details>
<summary> <summary>
{% if let Some(Some(aocl_leaf_index)) =
self.addition_record_indices.get(&output.addition_record()) { %}
<a
href='/utxo/{{aocl_leaf_index}}'>{{output.addition_record().canonical_commitment.to_hex()}}</a>
{% } else { %}
{{output.addition_record().canonical_commitment.to_hex()}} {{output.addition_record().canonical_commitment.to_hex()}}
{% } %}
</summary> </summary>
<table> <table>
<tr> <tr>

View File

@ -2,43 +2,92 @@
<head> <head>
<title>{{self.header.state.config.site_name}}: Utxo {{self.index}}</title> <title>{{self.header.state.config.site_name}}: Utxo {{self.index}}</title>
{{html_escaper::Trusted(include_str!( concat!(env!("CARGO_MANIFEST_DIR"), "/templates/web/html/components/head.html")))}} {{html_escaper::Trusted(include_str!( concat!(env!("CARGO_MANIFEST_DIR"),
"/templates/web/html/components/head.html")))}}
</head> </head>
<body> <body>
{{Trusted(self.header.to_string())}} {{Trusted(self.header.to_string())}}
<main class="container"> <main class="container">
<article> <article>
<summary> <summary>
<span class="tooltip"> <span class="tooltip">
<span class="tooltiptext"> <span class="tooltiptext">
UTXO = Unspent Transaction Output. It represents an output of transaction A which can also be an input to transaction B. UTXO = Unspent Transaction Output. It represents an output of transaction A which can also be an
input to transaction B.
</span> </span>
</span> </span>
UTXO Information UTXO Information
</summary> </summary>
<table class="striped"> <table class="striped">
<tr> <tr>
<td>Index</td> <td>AOCL Leaf Index</td>
<td>{{self.index}}</td> <td>{{self.index}}</td>
</tr> </tr>
<tr> <tr>
<td>Digest</td> <td>Addition Record</td>
<td>{{self.digest.to_hex()}}</td> <td>{{self.digest.to_hex()}}</td>
</tr> </tr>
</table> </table>
</article> </article>
<article> {% if let Some(utxo_info) = &self.transparent_utxo_info { %}
<article>
<summary>
<span class="tooltip">
<span class="tooltiptext">
UTXOs consumed or produced by a transparent transaction disclose the information they otherwise
hide.
</span>
</span>
Transparent UTXO Information
</summary>
<table class="striped">
<tr>
<td>UTXO Digest:</td>
<td><span class="mono">{{Tip5::hash(&utxo_info.utxo()).to_hex()}}</span></td>
</tr>
<tr>
<td>Sender Randomness:</td>
<td><span class="mono">{{utxo_info.sender_randomness().to_hex()}}</span></td>
</tr>
<tr>
<td>Receiver Digest</td>
<td><span class="mono">{{utxo_info.receiver_digest().to_hex()}}</span></td>
</tr>
{% if let Some(receiver_preimage) = utxo_info.receiver_preimage() { %}
<tr>
<td>Receiver Preimage</td>
<td><span class="mono">{{receiver_preimage.to_hex()}}</span></td>
</tr>
{% } %}
{% if utxo_info.utxo().has_native_currency() { %}
<tr>
<td>Amount:</td>
<td>{{utxo_info.utxo().get_native_currency_amount().display_n_decimals(5)}} NPT</td>
</tr>
{% } %}
{% if let Some(release_date) = utxo_info.utxo().release_date() { %}
<tr>
<td>Time-Locked Until</td>
<td><span class="mono">{{release_date.standard_format()}}</span></td>
</tr>
{% } %}
</table>
</article>
{% } %}
<article>
<p> <p>
<a href="/">Home</a> <a href="/">Home</a>
| <a href='/block/genesis'>Genesis</a> | <a href='/block/genesis'>Genesis</a>
| <a href='/block/tip'>Tip</a> | <a href='/block/tip'>Tip</a>
</p> </p>
</article> </article>
</main> </main>
</body> </body>
</html> </html>