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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@1.98.1
with:
components: clippy, rustfmt

Expand Down Expand Up @@ -101,7 +101,7 @@ jobs:
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@1.98.1

- name: Cache cargo registry
uses: actions/cache@v4
Expand Down
112 changes: 89 additions & 23 deletions crates/randscan-api/src/handlers/bridge.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use crate::{error::AppError, state::AppState, ApiResult};
use axum::{extract::State, Json};
use randscan_core::{approved_token, ApprovedToken, BridgeAsset, BridgeAssetActivity, BridgeState, Supply, APPROVED_TOKENS};
use randscan_core::{
approved_token, ApprovedToken, BridgeAsset, BridgeAssetActivity, BridgeState, Supply,
APPROVED_TOKENS,
};
use randscan_db::{BridgeAssetFlowRow, BridgeBackingBurnRow};

/// GET /api/v1/bridge — the bridge's public state as last read from the node, plus `endpoints`:
Expand All @@ -17,7 +20,9 @@ pub async fn get_bridge(State(state): State<AppState>) -> ApiResult<Json<BridgeS
/// of it. The registry comes from the node, the flows from the indexed transactions; a backing
/// with no flow yet lists zeros, and a flow whose token the node has not (yet) registered is
/// skipped. Empty on a chain without a bridge.
pub async fn bridge_assets(State(state): State<AppState>) -> ApiResult<Json<Vec<BridgeAssetActivity>>> {
pub async fn bridge_assets(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<BridgeAssetActivity>>> {
let Some(bridge) = state.indexer.bridge().await.filter(|b| b.enabled) else {
return Ok(Json(vec![]));
};
Expand Down Expand Up @@ -48,11 +53,14 @@ fn backing_rows(
let token = flows.iter().find(|f| f.asset_index == a.index);
let token_deposited = token.map_or_else(|| "0".to_string(), |f| f.deposited.clone());
let token_burned = token.map_or_else(|| "0".to_string(), |f| f.burned.clone());
let token_deposit_fees = token.map_or_else(|| "0".to_string(), |f| f.deposit_fees.clone());
let token_deposit_fees =
token.map_or_else(|| "0".to_string(), |f| f.deposit_fees.clone());
let token_burn_fees = token.map_or_else(|| "0".to_string(), |f| f.burn_fees.clone());
let own = burns
.iter()
.find(|b| b.asset_index == a.index && b.chain == a.chain && b.token.eq_ignore_ascii_case(&a.token));
let own = burns.iter().find(|b| {
b.asset_index == a.index
&& b.chain == a.chain
&& b.token.eq_ignore_ascii_case(&a.token)
});
let burned = own.map_or_else(|| "0".to_string(), |b| b.burned.clone());
let burn_fees = own.map_or_else(|| "0".to_string(), |b| b.burn_fees.clone());
let sole = backings == 1;
Expand All @@ -69,7 +77,10 @@ fn backing_rows(
deposits: sole.then(|| token.map_or(0, |f| f.deposits)),
deposited: sole.then(|| token_deposited.clone()),
burns: own.map_or(0, |b| b.burns),
outstanding: a.locked.clone().unwrap_or_else(|| units_sub(&token_deposited, &burned)),
outstanding: a
.locked
.clone()
.unwrap_or_else(|| units_sub(&token_deposited, &burned)),
burned,
burn_fees,
token_deposit_fees,
Expand Down Expand Up @@ -128,7 +139,13 @@ mod tests {
.unwrap()
}

fn token_flow(index: i64, deposits: i64, deposited: &str, burns: i64, burned: &str) -> BridgeAssetFlowRow {
fn token_flow(
index: i64,
deposits: i64,
deposited: &str,
burns: i64,
burned: &str,
) -> BridgeAssetFlowRow {
BridgeAssetFlowRow {
asset_index: index,
deposits,
Expand Down Expand Up @@ -161,18 +178,36 @@ mod tests {
assert_eq!(rows.len(), 7);
for (row, a) in rows.iter().zip(&assets) {
assert_eq!(row.backings, 7);
assert_eq!((row.deposits, row.deposited.as_deref()), (None, None), "not attributable to one coin");
assert_eq!(Some(&row.outstanding), a.locked.as_ref(), "a backing holds what the registry says");
assert_eq!((row.token_deposits, row.token_deposited.as_str()), (3, "300000000"));
assert_eq!(
(row.deposits, row.deposited.as_deref()),
(None, None),
"not attributable to one coin"
);
assert_eq!(
Some(&row.outstanding),
a.locked.as_ref(),
"a backing holds what the registry says"
);
assert_eq!(
(row.token_deposits, row.token_deposited.as_str()),
(3, "300000000")
);
}
// What the rows hold adds up to what the token's deposits brought in, once.
let held: u128 = rows.iter().map(|r| r.outstanding.parse::<u128>().unwrap()).sum();
let held: u128 = rows
.iter()
.map(|r| r.outstanding.parse::<u128>().unwrap())
.sum();
assert_eq!(held, 300_000_000);
}

#[test]
fn a_burn_is_laid_on_the_backing_it_names_and_on_no_other() {
let assets = vec![backing(1, 2, "AA", "600"), backing(1, 3, "aa", "0"), backing(2, 2, "aa", "0")];
let assets = vec![
backing(1, 2, "AA", "600"),
backing(1, 3, "aa", "0"),
backing(2, 2, "aa", "0"),
];
let burns = vec![BridgeBackingBurnRow {
asset_index: 1,
chain: 2,
Expand All @@ -196,12 +231,30 @@ mod tests {
fn a_sole_backing_owns_its_tokens_deposits() {
let mut old_node = backing(1, 2, "cc", "0");
old_node.locked = None; // a node before chain 14 serves no `locked`
let rows = backing_rows(&[old_node, backing(2, 2, "dd", "0")], &[token_flow(1, 1, "1000", 1, "400")], &[
BridgeBackingBurnRow { asset_index: 1, chain: 2, token: "cc".into(), burns: 1, burned: "400".into(), burn_fees: "0".into() },
]);
assert_eq!((rows[0].deposits, rows[0].deposited.as_deref()), (Some(1), Some("1000")));
assert_eq!(rows[0].outstanding, "600", "rebuilt from the flows only without the registry's figure");
assert_eq!((rows[1].deposits, rows[1].deposited.as_deref()), (Some(0), Some("0")));
let rows = backing_rows(
&[old_node, backing(2, 2, "dd", "0")],
&[token_flow(1, 1, "1000", 1, "400")],
&[BridgeBackingBurnRow {
asset_index: 1,
chain: 2,
token: "cc".into(),
burns: 1,
burned: "400".into(),
burn_fees: "0".into(),
}],
);
assert_eq!(
(rows[0].deposits, rows[0].deposited.as_deref()),
(Some(1), Some("1000"))
);
assert_eq!(
rows[0].outstanding, "600",
"rebuilt from the flows only without the registry's figure"
);
assert_eq!(
(rows[1].deposits, rows[1].deposited.as_deref()),
(Some(0), Some("0"))
);
}

/// Chain 20 (`bridge.fees` 10/10 bps): one 1 zUSD deposit through Ethereum USDT and a 0.5
Expand All @@ -223,11 +276,24 @@ mod tests {
}];
let rows = backing_rows(&assets, &[flow], &burns);
let r = &rows[0];
assert_eq!(r.deposited.as_deref(), Some("100000000"), "the gross the guardians signed");
assert_eq!((r.burned.as_str(), r.burn_fees.as_str()), ("49950000", "50000"));
assert_eq!((r.token_deposit_fees.as_str(), r.token_burn_fees.as_str()), ("100000", "50000"));
assert_eq!(
r.deposited.as_deref(),
Some("100000000"),
"the gross the guardians signed"
);
assert_eq!(
(r.burned.as_str(), r.burn_fees.as_str()),
("49950000", "50000")
);
assert_eq!(
(r.token_deposit_fees.as_str(), r.token_burn_fees.as_str()),
("100000", "50000")
);
assert_eq!(r.outstanding, "50050000");
// deposited − released == locked, and supply (net notes + fee notes) is the same figure.
assert_eq!(units_sub(r.deposited.as_deref().unwrap(), &r.burned), r.outstanding);
assert_eq!(
units_sub(r.deposited.as_deref().unwrap(), &r.burned),
r.outstanding
);
}
}
6 changes: 5 additions & 1 deletion crates/randscan-api/src/handlers/envelopes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ pub async fn transaction_envelopes(
) -> ApiResult<Json<TransactionEnvelopes>> {
let hash = match classify_query(&hash) {
QueryKind::Hash(h) => h,
_ => return Err(AppError::BadRequest("transaction hash must be 64 hex characters".into())),
_ => {
return Err(AppError::BadRequest(
"transaction hash must be 64 hex characters".into(),
))
}
};
let pool = state.db.inner();
let tx = db::get_transaction_summary(pool, &hash)
Expand Down
4 changes: 3 additions & 1 deletion crates/randscan-api/src/handlers/nodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pub async fn list_nodes(State(state): State<AppState>) -> ApiResult<Json<Vec<Nod
/// GET /api/v1/provers — the delegated provers the explorer knows of (`KNOWN_PROVERS`), each with
/// its last `prover_info` (whether it answers, its queue, its fee) and its member hosts placed on
/// the map by geolocation; the hosts' addresses are not served.
pub async fn list_provers(State(state): State<AppState>) -> ApiResult<Json<Vec<randscan_core::ProverView>>> {
pub async fn list_provers(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<randscan_core::ProverView>>> {
Ok(Json(state.indexer.provers().await))
}
21 changes: 17 additions & 4 deletions crates/randscan-api/src/handlers/programs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,15 @@ pub async fn get_program(
// order. `None` on a chain without the section (and on an older node); a failed read is
// logged and served as `None` rather than failing the page.
let rpc = state.indexer.rpc();
let program_state = match (rpc.program_vault(&id).await, rpc.program_cells(&id, None, CELLS_FIRST_PAGE).await) {
(Ok(Some(vault)), Ok(Some(page))) => Some(ProgramState { vault, cells: page.cells, cells_next: page.next }),
let program_state = match (
rpc.program_vault(&id).await,
rpc.program_cells(&id, None, CELLS_FIRST_PAGE).await,
) {
(Ok(Some(vault)), Ok(Some(page))) => Some(ProgramState {
vault,
cells: page.cells,
cells_next: page.next,
}),
(Ok(_), Ok(_)) => None,
(Err(e), _) | (_, Err(e)) => {
warn!("program state read for {id} failed: {e:#}");
Expand Down Expand Up @@ -92,7 +99,11 @@ pub async fn program_cells(
let after = match q.after.as_deref().filter(|a| !a.is_empty()) {
Some(a) => match classify_query(a) {
QueryKind::Hash(h) => Some(h),
_ => return Err(AppError::BadRequest("after must be a 64 hex cell key".into())),
_ => {
return Err(AppError::BadRequest(
"after must be a 64 hex cell key".into(),
))
}
},
None => None,
};
Expand All @@ -103,6 +114,8 @@ pub async fn program_cells(
.program_cells(&id, after.as_deref(), limit)
.await
.map_err(|e| AppError::Internal(format!("program cells: {e:#}")))?
.ok_or_else(|| AppError::NotFound("program state (this chain has no program_state section)".into()))?;
.ok_or_else(|| {
AppError::NotFound("program state (this chain has no program_state section)".into())
})?;
Ok(Json(page))
}
29 changes: 24 additions & 5 deletions crates/randscan-api/src/handlers/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ fn find_token<'a>(tokens: &'a [TokenInfo], key: &str) -> Option<&'a TokenInfo> {
return Some(t);
}
}
let hex_key = key.strip_prefix("0x").or_else(|| key.strip_prefix("0X")).unwrap_or(key);
let hex_key = key
.strip_prefix("0x")
.or_else(|| key.strip_prefix("0X"))
.unwrap_or(key);
tokens
.iter()
.find(|t| t.id.eq_ignore_ascii_case(hex_key) || t.id_text.eq_ignore_ascii_case(key))
Expand All @@ -36,7 +39,10 @@ fn find_token<'a>(tokens: &'a [TokenInfo], key: &str) -> Option<&'a TokenInfo> {
/// GET /api/v1/tokens/:id — one token by index, 64-hex id or `rpl1…` text form, its deploy
/// transaction and its public supply history (every `register_token`/`token_mint`/`token_burn`
/// naming it, oldest first).
pub async fn get_token(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult<Json<TokenDetail>> {
pub async fn get_token(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<TokenDetail>> {
let list = state.indexer.tokens().await.unwrap_or_default();
let info = find_token(&list.tokens, &id)
.cloned()
Expand All @@ -62,11 +68,21 @@ pub async fn get_token(State(state): State<AppState>, Path(id): Path<String>) ->
"token_burn" => e.amount.as_deref().map(|a| format!("-{a}")),
_ => None,
}?;
Some(TokenSupplyEvent { tx_hash: e.tx_hash, height: e.height, timestamp_ms: e.timestamp_ms, kind: e.kind, delta })
Some(TokenSupplyEvent {
tx_hash: e.tx_hash,
height: e.height,
timestamp_ms: e.timestamp_ms,
kind: e.kind,
delta,
})
})
.collect();

Ok(Json(TokenDetail { info, deploy_tx, supply_history }))
Ok(Json(TokenDetail {
info,
deploy_tx,
supply_history,
}))
}

#[cfg(test)]
Expand All @@ -93,7 +109,10 @@ mod tests {
let id = "aa".repeat(32);
let tokens = vec![token(3, &id, "rpl1zusd")];
assert_eq!(find_token(&tokens, "3").unwrap().symbol, "zUSD");
assert_eq!(find_token(&tokens, &id.to_ascii_uppercase()).unwrap().index, 3);
assert_eq!(
find_token(&tokens, &id.to_ascii_uppercase()).unwrap().index,
3
);
assert_eq!(find_token(&tokens, &format!("0x{id}")).unwrap().index, 3);
assert_eq!(find_token(&tokens, "RPL1ZUSD").unwrap().index, 3);
assert!(find_token(&tokens, "9").is_none());
Expand Down
26 changes: 20 additions & 6 deletions crates/randscan-api/tests/common/mock_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ pub fn h(seed: &str) -> String {
/// realistic `token_mint`/`register_token` fixture and, by the indexer under test, to link that
/// leaf back to its transaction (`randscan_core::notecommit::mint_commitment`).
pub fn mint_note_cm(recipient: &str, amount: u64, index: u32, time: u32, r_hex: &str) -> String {
notecommit::mint_commitment_hex(recipient, amount, index, time, r_hex).expect("a well-formed shielded address")
notecommit::mint_commitment_hex(recipient, amount, index, time, r_hex)
.expect("a well-formed shielded address")
}

/// A register entry as the S2 node serves it.
Expand Down Expand Up @@ -192,7 +193,8 @@ impl MockChain {
Some("invoke") => {
for side in ["pays", "mints"] {
for p in a["transition"][side].as_array().into_iter().flatten() {
self.leaves.push((p["cm"].as_str().unwrap().to_string(), height));
self.leaves
.push((p["cm"].as_str().unwrap().to_string(), height));
}
}
}
Expand Down Expand Up @@ -227,7 +229,12 @@ impl MockChain {
fn committed(&self) -> impl Iterator<Item = (u64, usize, &Value)> + '_ {
self.blocks.iter().flat_map(|b| {
let height = b["height"].as_u64().unwrap_or(0);
b["transactions"].as_array().into_iter().flatten().enumerate().map(move |(i, t)| (height, i, t))
b["transactions"]
.as_array()
.into_iter()
.flatten()
.enumerate()
.map(move |(i, t)| (height, i, t))
})
}

Expand Down Expand Up @@ -527,9 +534,16 @@ fn matches_token_key(row: &Value, key: &Value) -> bool {
return true;
}
}
let hex_key = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
row["id"].as_str().is_some_and(|id| id.eq_ignore_ascii_case(hex_key))
|| row["id_text"].as_str().is_some_and(|t| t.eq_ignore_ascii_case(s))
let hex_key = s
.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.unwrap_or(s);
row["id"]
.as_str()
.is_some_and(|id| id.eq_ignore_ascii_case(hex_key))
|| row["id_text"]
.as_str()
.is_some_and(|t| t.eq_ignore_ascii_case(s))
}

/// The public fields of the chain-14 hidden-asset bundle, unique per `seed`: four slots, no
Expand Down
14 changes: 12 additions & 2 deletions crates/randscan-api/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ impl LiveApp {
}

/// Poll `GET path` until `pred(body)` holds; panics with the last body after `timeout`.
pub async fn wait_for(&self, path: &str, timeout: Duration, pred: impl Fn(&Value) -> bool) -> Value {
pub async fn wait_for(
&self,
path: &str,
timeout: Duration,
pred: impl Fn(&Value) -> bool,
) -> Value {
let start = std::time::Instant::now();
let mut last = Value::Null;
while start.elapsed() < timeout {
Expand Down Expand Up @@ -189,5 +194,10 @@ pub async fn live_app(cfg: ApiConfig, rpc_url: &str) -> Option<LiveApp> {
limiter: Arc::new(RateLimiter::new()),
mailer: None,
};
Some(LiveApp { app: create_router(state), pool, indexer, broadcaster })
Some(LiveApp {
app: create_router(state),
pool,
indexer,
broadcaster,
})
}
Loading
Loading