Compare commits

...

10 commits

Author SHA1 Message Date
Hossein Zoda
bb4e489904 Merge branch 'ido-preinit-zero-remainder' into 'master'
ido: rebake preinit for the zero-remainder oToken genesis

See merge request riftenlabs/riftenlabs-indexer!106
2026-08-27 11:34:52 +00:00
Hossein Zoda
5861c412c8 ido: rebake preinit for the zero-remainder oToken genesis
libriften 00ff654 relaxed ido/preinit.cash's
`offeredTokenTotalSupply > fundingOTokenAmount` to `>=`, so a token can
be minted with its whole supply committed to the IDO. At a zero
remainder the oToken authbase cannot hold a zero-amount token output, so
it carries an immutable NFT with the OTOKEN_AUTHBASE_NFT_COMMITMENT
("BCMR") marker instead; preinit grows 1419 -> 1433 bytes.

- Swap the baked IDO_PREINIT_CONTRACT hex to the new bytecode from
  templates/ido.json (contracts.IDOPreInit). The only bytecode changes
  are a069 -> a269 and the output#0 commitment check becoming a branch
  on the remainder; without the swap no new preinit's rebuilt p2sh32
  would match and no IDO would be admitted.
- Relax the announcement-validation mirror of that require from `<=` to
  `<`, so a whole-supply genesis is no longer marked invalid.

The state machine never reads output#0's commitment or amount, and
build_preinit_bytecode bakes no contract length (the size feeds only the
unlock bytecode, which the indexer does not build), so nothing else
moves. The other 14 baked contracts still match the current templates,
and the schema and announcement format are unchanged, so IDO_DB_VERSION
stays at 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:39:57 +00:00
jakobsn
cc0ab0b16f Merge branch 'feeHistory' into 'master'
# Store the LP fee per history entry; bound and cache

See merge request riftenlabs/riftenlabs-indexer!105
2026-08-25 12:55:22 +00:00
jakobsn
b5e8c45487 # Store the LP fee per history entry; bound and cache 2026-08-25 12:55:21 +00:00
Hossein Zoda
7f424c79d0 Merge branch 'update-ido-contracts' into 'master'
ido: rebake contract bytecode from the cashscript-only rebuild

See merge request riftenlabs/riftenlabs-indexer!104
2026-08-24 20:48:19 +00:00
Hossein Zoda
bafaad5965 ido: rebake contract bytecode from the cashscript-only rebuild
libriften migrated the cauldron contracts to pure CashScript sources
(a6a8d86) and rebuilt them on 0.14 next.4, recompiling all nine
ido/offering contracts. Swap the baked hex constants to the new
bytecode (preinit 1419B, postlaunch 2190B, ido initiator 230B,
offering initiator 637B, offering 441B, entry 50B, launcher 618B,
distdeploy 620B, distrefund 288B); the common contracts, layouts,
slots, and the announcement format are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 19:42:06 +00:00
Dagur Valberg Johannsson
bbcbe0354c Merge branch 'moria-v11-test18' into 'master'
moria v1-1: TEST18 categories and riftenlabs-defi 0.5.1

See merge request riftenlabs/riftenlabs-indexer!103
2026-08-21 12:02:41 +00:00
Dagur Valberg Johannsson
499275eaef moria v1-1: TEST18 categories and riftenlabs-defi 0.5.1
Switch the indexer off the local path dep onto crates.io 0.5.1 (75-byte
loan, 3-byte principal). Point chipnet deploy constants at TEST18 and
keep continuation UTXOs on partial redeem/repay.
2026-08-21 13:36:30 +02:00
Dagur Valberg Johannsson
90ad5def9a Merge branch 'v11' into 'master'
Cut over to Moria v1-1; drop v1 indexing

See merge request riftenlabs/riftenlabs-indexer!102
2026-08-18 07:21:46 +00:00
Dagur Valberg Johannsson
a5e91afea8 Cut over to Moria v1-1; drop v1 indexing
Replace the v1 moria indexer with v1-1 tables and RPC. Chipnet tracks the
live deployment only; mainnet stays idle until a v1-1 deployment exists.

v1-1 rows carry delegate hash, borrow-time allowance deposits, crank fee
takes, and allowance-close recoveries. Policy lives on the parked
allowance (verified nSequence hint or first spend), not on the loan NFT.
Bar/pool history and realized NAV APR are confirmed-blocks only.

Bind the API before IBD so Rocket is up during sync. Empty electrum
header batches shrink the chain instead of panicking.

Depend on published riftenlabs-defi 0.4.6.
2026-08-18 09:15:19 +02:00
27 changed files with 5276 additions and 1120 deletions

4
Cargo.lock generated
View file

@ -2283,7 +2283,9 @@ dependencies = [
[[package]]
name = "riftenlabs-defi"
version = "0.4.5"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7541d5d2101a03925b9de89846c1e9a66bcdcdf6a4435a7bcf30bf294bb236f"
dependencies = [
"anyhow",
"bitcoin_hashes",

View file

@ -22,7 +22,7 @@ serde_json = "1.0.133"
rocket = { version = "0.5.1", features = ["json"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
rayon = "1.10.0"
riftenlabs-defi = "0.4.5"
riftenlabs-defi = "0.5.1"
rocket_cors = "0.6.0"
log = "0.4"
stderrlog = "0.6.0"

View file

@ -67,5 +67,5 @@ default = "false"
[[param]]
name = "riften_ipfs_gateway"
type = "String"
doc = "Optional IPFS gateway prefix for the local riften-ipfs pinning node (e.g. 'http://127.0.0.1:3002/ipfs/'). When set, ipfs:// BCMR content is fetched from here first, before the public gateways — so content pinned on the riften node is loadable without waiting for public DHT propagation. Empty disables it."
doc = "IPFS gateway prefix for the local riften-ipfs pinning node. ipfs:// BCMR content is fetched from here first, before the public gateways — so content pinned on the riften node is loadable without waiting for public DHT propagation. When empty, defaults to the local node for the active network ('http://127.0.0.1:3002/ipfs/' mainnet, 'http://127.0.0.1:3001/ipfs/' chipnet). Set explicitly to override — containerized (bridge-networked) deployments must point this at a reachable host address (e.g. 'http://host.docker.internal:3001/ipfs/'), just like rostrum_addr."
default = "\"\".to_string()"

View file

@ -422,7 +422,7 @@ mod tests {
#[test]
fn ipfs_path_validation_accepts_real_cids_and_rejects_injection() {
// Real shapes seen on-chain: bare CIDv0/v1, directory + sub-path, and the libriften
// Real shapes seen on-chain: bare CIDv0/v1, directory + sub-path, and a
// base64url CID with a filename extension appended directly.
assert!(is_safe_ipfs_path("QmAbc123"));
assert!(is_safe_ipfs_path("bafybeig4n5ut/icon.png"));

View file

@ -90,7 +90,7 @@ impl BlockUndoer for StoreBlockUndoer {
{
was_indexed = true;
}
if db::moria::delete_entries_for_block(&self.db.moria_w, &blockheader.block_hash())
if db::moria_v11::delete_entries_for_block(&self.db.moria_w, &blockheader.block_hash())
.await?
> 0
{
@ -277,7 +277,12 @@ impl Chain {
let rewind = match new_headers.first() {
Some(first) => first.height,
None => tip_height.expect("empty new_headers and no tip height") + 1,
None => {
let Some(h) = tip_height else {
bail!("empty new_headers and no tip height");
};
h + 1
}
};
for (hash, header) in headers.drain(rewind as usize..) {
@ -575,4 +580,46 @@ mod tests {
"0e16637fe0700a7c52e9a6eaa58bd6ac7202652103be8f778680c66f51ad2e9b"
);
}
#[test]
fn empty_headers_without_tip_height_is_error() {
let regtest = Chain::new_regtest();
let err = regtest
.update(DummyBlockUndoer::new(true), vec![], None)
.unwrap_err();
assert!(err
.to_string()
.contains("empty new_headers and no tip height"));
}
#[test]
fn empty_headers_with_tip_height_shrinks() {
let hex_headers = ["0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f1d14d3c7ff12d6adf494ebbcfba69baa915a066358b68a2b8c37126f74de396b1d61cc60ffff7f2000000000",
"00000020d700ae5d3c705702e0a5d9ababd22ded079f8a63b880b1866321d6bfcb028c3fc816efcf0e84ccafa1dda26be337f58d41b438170c357cda33a68af5550590bc1e61cc60ffff7f2004000000"];
let headers: Vec<(BlockHeader, u64)> = hex_headers
.iter()
.enumerate()
.map(|(height, hex_header)| {
(
deserialize(&Vec::from_hex(hex_header).unwrap()).unwrap(),
1 + height as u64,
)
})
.collect();
let mut regtest = Chain::new_regtest();
let genesis = Chain::new_regtest().get_block_header(0).unwrap();
let mut chain = headers.clone();
chain.push((genesis, 0_u64));
regtest.load(chain).unwrap();
assert_eq!(regtest.height(), 2);
let keep = headers[0].0.block_hash();
regtest
.update(DummyBlockUndoer::new(true), vec![], Some(1))
.unwrap();
assert_eq!(regtest.height(), 1);
assert_eq!(regtest.tip_hash(), keep);
assert!(regtest.contains(&keep));
assert!(!regtest.contains(&headers[1].0.block_hash()));
}
}

297
src/db/cauldron/fees.rs Normal file
View file

@ -0,0 +1,297 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
//! Per-step trading fee for a constant-product pool.
//!
//! Fees are not recorded on chain. They are inferred from what a trade leaves
//! behind: a constant-product swap charges the trader by moving the pool to a
//! slightly *higher* invariant than the one it left, and that increase is the
//! fee, shared among the liquidity providers.
//!
//! What this measures is the **liquidity provider's** fee — the 0.3 % the
//! contract charges (`feeRate = 300 / 100_000`), not the miner fee. The two do
//! not mix: the trader funds the transaction fee from their own inputs, so it
//! never touches the pool's reserves. Verified against 194 real trades from the
//! busiest pool, where this formula recovers a median rate of 0.3000 %
//! (0.29990.3006 %). A miner fee leaking into the reserves would scatter that
//! with transaction size instead of pinning to the contract rate.
//!
//! A closed position's fees end at its last trade. A withdrawal writes no
//! history entry — it only sets `pool.withdrawn_in_utxo` — and the withdrawal
//! itself earns nothing, so there is nothing to add.
//!
//! This mirrors `cauldron-beta/src/crypto/stats/lifetimeFees.ts`, which the
//! frontend has been running against downloaded history. Any disagreement
//! between the two is a wrong number on someone's screen, so the two must be
//! checked against the same vectors — see `FEE_TEST_VECTORS` below and the
//! matching table in that file.
use malachite::base::num::arithmetic::traits::FloorSqrt;
use malachite::Integer;
/// Fees are stored as micro-satoshis: one step's fee is a small fraction of a
/// satoshi on a large pool, and integers keep the column exact and summable.
pub const FEE_SCALE: i64 = 1_000_000;
/// Fixed-point scale for the invariant.
///
/// `floor_sqrt` on its own is not good enough here. The fee depends on
/// `L_next - L_prev`, a difference of a few units against values near 1e6 or
/// larger, so truncating each root before subtracting throws away most of the
/// quantity being measured — measured at ~3 % low on a realistic trade. Taking
/// the root of `x * SQRT_SCALE^2` instead yields `floor(sqrt(x) * SQRT_SCALE)`,
/// keeping nine decimal places of each root before the subtraction.
const SQRT_SCALE: u64 = 1_000_000_000;
/// What happened between two consecutive pool states.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepKind {
/// Not a step we can read: a reserve is missing, zero, or unchanged.
Unreadable,
/// Both reserves moved the same way — a deposit or a withdrawal.
LiquidityChange,
/// The reserves moved in opposite directions — a swap.
Trade,
}
/// A pool's reserves at one point in time.
#[derive(Debug, Clone, Copy)]
pub struct Reserves {
pub sats: u64,
pub tokens: u64,
}
impl Reserves {
fn usable(&self) -> bool {
self.sats > 0 && self.tokens > 0
}
/// The constant-product invariant, as a fixed-point value scaled by
/// `SQRT_SCALE`. Callers only ever compare or subtract two of these, so the
/// scale cancels.
fn liquidity_scaled(&self) -> Integer {
let scale_squared = Integer::from(SQRT_SCALE) * Integer::from(SQRT_SCALE);
(Integer::from(self.sats) * Integer::from(self.tokens) * scale_squared).floor_sqrt()
}
}
/// Classify one step.
///
/// A trade always pushes one reserve up and pulls the other down, so the sign
/// pair is the whole test. A step where only one side moves is not something the
/// constant product explains, so it is refused rather than guessed at.
pub fn classify_step(prev: Reserves, next: Reserves) -> StepKind {
if !prev.usable() || !next.usable() {
return StepKind::Unreadable;
}
let sats_dir = next.sats.cmp(&prev.sats);
let tokens_dir = next.tokens.cmp(&prev.tokens);
if sats_dir.is_eq() || tokens_dir.is_eq() {
return StepKind::Unreadable;
}
if sats_dir == tokens_dir {
StepKind::LiquidityChange
} else {
StepKind::Trade
}
}
/// Fee earned by one step, in micro-satoshis. Zero for anything but a trade that
/// grew the invariant.
///
/// The form is `next.sats * 2 * (L_next - L_prev) / L_next`, deliberately not
/// `1 - L_prev/L_next`: one trade's fee is a tiny fraction of a large pool, and
/// subtracting before dividing keeps digits the division would round away.
///
/// Rounding in the recorded reserves can make a trade look like it *shrank* the
/// pool. That contributes nothing rather than a negative fee, which would
/// silently reduce a real total.
pub fn step_fee_e6(prev: Reserves, next: Reserves) -> i64 {
if classify_step(prev, next) != StepKind::Trade {
return 0;
}
let l_prev = prev.liquidity_scaled();
let l_next = next.liquidity_scaled();
if l_next <= l_prev {
return 0;
}
let numerator = Integer::from(next.sats)
* Integer::from(2)
* (&l_next - &l_prev)
* Integer::from(FEE_SCALE);
let fee = numerator / l_next;
// A single step cannot plausibly exceed i64 micro-satoshis (that would be a
// ~92 BCH fee on one trade), but saturate rather than panic on absurd input:
// a wrong figure is better than an indexer that stops.
i64::try_from(&fee).unwrap_or(i64::MAX)
}
/// Shared with the frontend's test suite. Any change here must be mirrored in
/// `cauldron-beta/src/crypto/stats/lifetimeFees.test.ts`.
///
/// The expected figures were produced by running that TypeScript implementation
/// (BigNumber, 40 decimal places) over the same inputs, so these vectors pin
/// agreement between the two languages rather than this file against itself.
#[cfg(test)]
const FEE_TEST_VECTORS: &[(u64, u64, u64, u64, i64, StepKind)] = &[
// prev_sats, prev_tokens, next_sats, next_tokens, fee_e6, kind
// A swap of BCH in for tokens out, invariant grows by the fee.
(
1_000_000,
1_000_000,
1_010_000,
990_150,
52_012_991,
StepKind::Trade,
),
// The same proportional trade on a pool 1000x larger.
(
1_000_000_000,
1_000_000_000,
1_010_000_000,
990_150_000,
52_012_991_007,
StepKind::Trade,
),
// Both sides up: a deposit, no fee.
(
1_000_000,
1_000_000,
2_000_000,
2_000_000,
0,
StepKind::LiquidityChange,
),
// Both sides down: a withdrawal, no fee.
(
2_000_000,
2_000_000,
1_000_000,
1_000_000,
0,
StepKind::LiquidityChange,
),
// One side unchanged: not readable as a trade.
(
1_000_000,
1_000_000,
1_000_000,
990_000,
0,
StepKind::Unreadable,
),
// A zero reserve is not usable.
(0, 1_000_000, 10_000, 990_000, 0, StepKind::Unreadable),
];
#[cfg(test)]
mod tests {
use super::*;
fn r(sats: u64, tokens: u64) -> Reserves {
Reserves { sats, tokens }
}
/// How far this may sit from the frontend's figure: one part per million,
/// or one micro-satoshi, whichever is larger.
///
/// Equality is not achievable and asking for it would be a bug in the test.
/// `sqrt` is irrational; the two implementations round at different scales
/// (fixed-point big integers here, 40-decimal BigNumber there) and land one
/// micro-satoshi apart on a 52 BCH fee. What matters is that the gap stays
/// far below anything a user could see — a micro-satoshi is 1e-14 BCH.
fn tolerance(reference: i64) -> i64 {
(reference / 1_000_000).max(1)
}
#[test]
fn matches_shared_vectors() {
for &(ps, pt, ns, nt, fee, kind) in FEE_TEST_VECTORS {
let prev = r(ps, pt);
let next = r(ns, nt);
assert_eq!(
classify_step(prev, next),
kind,
"kind for {ps},{pt} -> {ns},{nt}"
);
let got = step_fee_e6(prev, next);
assert!(
(got - fee).abs() <= tolerance(fee),
"fee for {ps},{pt} -> {ns},{nt}: got {got}, reference {fee}"
);
}
}
#[test]
fn a_shrinking_trade_earns_nothing_rather_than_a_negative() {
// Recorded reserves round, so a trade can look like it shrank the pool.
// A negative here would quietly eat real income from the total.
assert_eq!(
step_fee_e6(r(1_000_000, 1_000_000), r(1_010_000, 989_000)),
0
);
}
#[test]
fn keeps_precision_through_the_subtraction() {
// The regression this pins. Rooting each invariant with a plain
// `floor_sqrt` before subtracting gave 50_498_737 here against the
// frontend's 52_012_991 — 2.9 % low, because the difference being
// measured is smaller than the truncation. Within one part per million
// of the reference is the standard; exact agreement is not achievable
// (sqrt is irrational and the two sides round at different scales).
let got = step_fee_e6(r(1_000_000, 1_000_000), r(1_010_000, 990_150));
let reference = 52_012_991_i64;
assert!(
(got - reference).abs() <= tolerance(reference),
"got={got} reference={reference}"
);
}
#[test]
fn survives_reserves_near_the_top_of_the_range() {
// sats * tokens overflows u64 and even i128 headroom is thin, which is
// why the intermediate is a big integer. The sats side is the entire
// 21M BCH supply, so no real pool can exceed it.
let huge = r(2_100_000_000_000_000, 9_000_000_000_000_000_000);
let after = r(2_100_000_000_000_001, 8_999_999_999_999_000_000);
let _ = step_fee_e6(huge, after); // must not panic
}
#[test]
fn recovers_the_contracts_lp_fee_rate() {
// The contract charges feeRate/100_000 = 0.3% of the amount traded in.
// Recovering exactly that from the invariant is what says we are
// measuring the LP's fee and not something else — a miner fee taken
// from the pool would show up here as a larger, size-dependent rate.
// Reserves and swap sized like a real mid-size pool.
let prev = r(5_000_000_000, 5_000_000_000);
let sats_in = 50_000_000u64;
// Constant product with a 0.3% fee on the way in.
let effective_in = sats_in * 99_700 / 100_000;
let tokens_out = (5_000_000_000u128 * effective_in as u128
/ (5_000_000_000 + effective_in) as u128) as u64;
let next = r(5_000_000_000 + sats_in, 5_000_000_000 - tokens_out);
let fee_sats = step_fee_e6(prev, next) as f64 / FEE_SCALE as f64;
let rate = fee_sats / sats_in as f64;
assert!(
(rate - 0.003).abs() < 0.0001,
"implied fee rate {rate:.6}, expected ~0.003"
);
}
#[test]
fn an_unchanged_pool_is_unreadable_not_a_free_trade() {
assert_eq!(
classify_step(r(1_000, 1_000), r(1_000, 1_000)),
StepKind::Unreadable
);
assert_eq!(step_fee_e6(r(1_000, 1_000), r(1_000, 1_000)), 0);
}
}

View file

@ -15,6 +15,7 @@ use crate::db::cauldron::tokenlist::db_utils::{
pub mod candlestick;
pub mod config;
pub mod fees;
pub mod header;
pub mod mempool;
pub mod ohlcv;

View file

@ -9,6 +9,7 @@ use std::{
};
use crate::db::blob::{blob_to_display_hex, display_hex_to_blob, FromBlob, ToBlob};
use crate::db::cauldron::fees::{classify_step, step_fee_e6, Reserves, StepKind, FEE_SCALE};
use crate::def::PoolID;
use anyhow::{Context, Result};
@ -656,14 +657,41 @@ pub async fn get_pool_period_snapshot_by_pool_ids(
Ok(pools)
}
/// Fees for one pool over a window.
#[derive(Serialize)]
pub struct PoolFees {
pub pool_id: String,
pub fee_sats: String,
pub trades: i64,
pub liquidity_changes: i64,
pub unreadable: i64,
}
#[derive(Serialize)]
pub struct PoolHistoryEntry {
txid: String,
sats: u64,
token_amount: u64,
timestamp: u64,
pub txid: String,
pub sats: u64,
pub token_amount: u64,
pub timestamp: u64,
#[serde(serialize_with = "serialize_integer_as_string")]
k: Integer,
pub k: Integer,
/// Where this row sits in the paging order. Not serialised — the route
/// publishes only the last one, as `next_cursor`.
#[serde(skip)]
pub cursor: HistoryCursor,
}
impl PoolHistoryEntry {
/// Just the reserves and the time, for `?fields=reserves`. `txid` is 64 hex
/// characters and `k` a long decimal, together most of the payload, and both
/// are derivable or unused by every consumer we know of.
pub fn reserves_only(&self) -> serde_json::Value {
serde_json::json!({
"sats": self.sats,
"token_amount": self.token_amount,
"timestamp": self.timestamp,
})
}
}
async fn get_pool_history_entry(
@ -691,31 +719,162 @@ async fn get_pool_history_entry(
token_amount,
timestamp: timestamp as u64,
k: Integer::from(sats) * Integer::from(token_amount),
// Single-row lookup, never part of a page.
cursor: HistoryCursor::default(),
})
}
/// Where a page of history left off.
///
/// Ordering is by `sequence`, the order states were observed. For a pool that is
/// a chain of spends, and a parent is always observed before the child that
/// spends it — so this is the true order of events.
///
/// Timestamps are not, despite appearances. `effective_timestamp` is
/// `COALESCE(first_seen, mtp)`, which mixes two clocks: a mempool-observed entry
/// carries wall-clock time, a confirm-only entry carries median-time-past, which
/// lags by design. Ordering by it puts those two kinds of row in the wrong
/// relative position — every one of the six intra-pool disagreements in the
/// current database is exactly that, an MTP row appearing ~10 minutes "before" a
/// first-seen row it actually followed.
///
/// `utxo` breaks ties and makes the cursor total; it is the primary key and
/// never changes. `sequence` no longer changes either — see the `ON CONFLICT`
/// clause in `insert_pool_history_entry`.
#[derive(Debug, Clone, Default)]
pub struct HistoryCursor {
pub sequence: i64,
pub utxo: Vec<u8>,
}
/// Summed fees per pool since `start`, for a batch of pools.
///
/// Walks each pool's reserves and values every step at the point it happened.
/// Deliberately computed rather than stored: a `fee_e6` column per row was
/// measured at only ~2x faster on the worst realistic query, which is cached
/// anyway, in exchange for 146 MB, a startup backfill, and a precision decision
/// frozen at write time. The win over the old design is doing this on the server
/// at all — clients used to download every pool's whole history to do it
/// themselves.
///
/// Pools with no rows in the window are absent from the result rather than
/// reported as zero: a caller cannot otherwise tell "earned nothing" from "does
/// not exist", and on a money figure those must not look alike.
pub async fn db_pool_fees(
pool: &SqlitePool,
pool_ids: &[PoolID],
start_time: u64,
) -> Result<Vec<PoolFees>> {
let mut out = Vec::with_capacity(pool_ids.len());
for pool_id in pool_ids {
// The row before the window seeds the walk, so the step landing on the
// window's first entry is counted. That fee was earned at the moment of
// the later entry, which is inside the window.
let seed = sqlx::query(
"SELECT sats, token_amount
FROM pool_history_entry
WHERE pool = ?1 AND effective_timestamp < ?2
ORDER BY sequence DESC
LIMIT 1",
)
.bind(pool_id.to_blob())
.bind(start_time as i64)
.fetch_optional(pool)
.await?;
let rows = sqlx::query(
"SELECT sats, token_amount
FROM pool_history_entry
WHERE pool = ?1 AND effective_timestamp >= ?2
ORDER BY sequence ASC",
)
.bind(pool_id.to_blob())
.bind(start_time as i64)
.fetch_all(pool)
.await?;
if rows.is_empty() {
continue;
}
let reserves_of = |row: &sqlx::sqlite::SqliteRow| {
let sats: i64 = row.get(0);
let tokens: i64 = row.get(1);
Reserves {
sats: sats.max(0) as u64,
tokens: tokens.max(0) as u64,
}
};
let mut fee_e6: i64 = 0;
let mut trades = 0i64;
let mut liquidity_changes = 0i64;
let mut unreadable = 0i64;
let mut prev = seed.as_ref().map(reserves_of);
for row in &rows {
let next = reserves_of(row);
match prev {
Some(prev) => {
fee_e6 = fee_e6.saturating_add(step_fee_e6(prev, next));
match classify_step(prev, next) {
StepKind::Trade => trades += 1,
StepKind::LiquidityChange => liquidity_changes += 1,
StepKind::Unreadable => unreadable += 1,
}
}
// No step behind the first entry we can see.
None => unreadable += 1,
}
prev = Some(next);
}
out.push(PoolFees {
pool_id: pool_id.to_string(),
// A decimal string, not a float: this is money, and the consumer
// reads it into a BigNumber.
fee_sats: format!("{}.{:06}", fee_e6 / FEE_SCALE, (fee_e6 % FEE_SCALE).abs()),
trades,
liquidity_changes,
unreadable,
});
}
Ok(out)
}
pub async fn db_pool_history(
pool: &SqlitePool,
pool_id: &PoolID,
start_time: u64,
limit: i64,
after: Option<&HistoryCursor>,
) -> Result<Vec<PoolHistoryEntry>> {
let query = "SELECT
phe.txid,
phe.sats,
phe.token_amount,
phe.effective_timestamp as timestamp
phe.effective_timestamp as timestamp,
phe.utxo,
phe.sequence
FROM
pool_history_entry phe
WHERE
phe.pool = ?1
AND timestamp >= ?2
AND (?4 IS NULL OR (phe.sequence, phe.utxo) > (?4, ?5))
ORDER BY
phe.sequence ASC;
phe.sequence ASC, phe.utxo ASC
LIMIT ?3;
";
let rows = sqlx::query(query)
.bind(pool_id.to_blob())
.bind(start_time as i64)
.bind(limit)
.bind(after.map(|c| c.sequence))
.bind(after.map(|c| c.utxo.clone()))
.fetch_all(pool)
.await?;
@ -727,6 +886,8 @@ pub async fn db_pool_history(
let sats: i64 = row.get(1);
let token_amount: i64 = row.get(2);
let timestamp: i64 = row.get(3);
let utxo: Vec<u8> = row.get(4);
let sequence: i64 = row.get(5);
let sats = sats as u64;
let token_amount = token_amount as u64;
@ -738,6 +899,7 @@ pub async fn db_pool_history(
token_amount,
timestamp: timestamp as u64,
k,
cursor: HistoryCursor { sequence, utxo },
});
}
@ -956,6 +1118,170 @@ mod tests {
pool_utxo_0
}
/// Build a pool whose second entry is a genuine trade, so a fee is recorded.
async fn insert_traded_pool(pool: &SqlitePool, seed: u8, t0: i64, t1: i64) -> ([u8; 32], i64) {
let owner_pkh = PubkeyHash::all_zeros();
let token = TokenID::from_byte_array([seed; 32]);
let txid0 = Txid::from_byte_array([seed; 32]);
let txid1 = Txid::from_byte_array([seed.wrapping_add(1); 32]);
let utxo0_bytes = [seed.wrapping_add(2); 32];
let utxo0 = OutPointHash::from_byte_array(utxo0_bytes);
let utxo1 = OutPointHash::from_byte_array([seed.wrapping_add(3); 32]);
let (sats0, tokens0) = (1_000_000u64, 1_000_000i64);
// Opposite directions: a swap, which is the only step that earns.
let (sats1, tokens1) = (1_010_000u64, 990_150i64);
let mut contract = ParsedContract {
pkh: owner_pkh,
is_withdrawn: false,
spent_utxo_hash: OutPointHash::all_zeros(),
new_utxo_hash: Some(utxo0),
new_utxo_txid: Some(txid0),
new_utxo_n: Some(0),
token_id: Some(token),
sats: Some(sats0),
token_amount: Some(tokens0),
};
let mut conn = pool.acquire().await.unwrap();
insert_new_pool(&mut conn, &contract).await.unwrap();
insert_utxo_funding(&mut conn, &vec![contract.clone()], &txid0)
.await
.unwrap();
insert_block_tx(&mut conn, &txid0, &BlockHash::all_zeros(), t0)
.await
.unwrap();
insert_pool_history_entry(
&mut conn,
&utxo0,
&contract,
Some(t0 as u64),
Some(t0 as u64),
0,
0,
)
.await
.unwrap();
contract.spent_utxo_hash = utxo0;
contract.new_utxo_hash = Some(utxo1);
contract.new_utxo_txid = Some(txid1);
contract.sats = Some(sats1);
contract.token_amount = Some(tokens1);
insert_utxo_funding(&mut conn, &vec![contract.clone()], &txid1)
.await
.unwrap();
insert_block_tx(&mut conn, &txid1, &BlockHash::all_zeros(), t1)
.await
.unwrap();
insert_pool_history_entry(
&mut conn,
&utxo0,
&contract,
Some(t1 as u64),
Some(t1 as u64),
(sats1 - sats0) as i64,
tokens1 - tokens0,
)
.await
.unwrap();
let expected = step_fee_e6(
Reserves {
sats: sats0,
tokens: tokens0 as u64,
},
Reserves {
sats: sats1,
tokens: tokens1 as u64,
},
);
(utxo0_bytes, expected)
}
#[tokio::test]
async fn paging_covers_every_row_exactly_once() {
// The property that matters: follow `next_cursor` to the end and you see
// each row once. A cursor that skipped would under-report history; one
// that repeated would make anything summing pages count a fee twice.
let pool = test_pool().await;
setup_test_db(&pool).await;
let (utxo, _) = insert_traded_pool(&pool, 80, 1_000, 2_000).await;
let pool_id = PoolID::from_byte_array(utxo);
let all = db_pool_history(&pool, &pool_id, 0, 100, None)
.await
.unwrap();
assert_eq!(all.len(), 2, "fixture writes two entries");
// One row at a time, following the cursor.
let mut seen = Vec::new();
let mut cursor: Option<HistoryCursor> = None;
loop {
let page = db_pool_history(&pool, &pool_id, 0, 1, cursor.as_ref())
.await
.unwrap();
let Some(entry) = page.into_iter().next() else {
break;
};
cursor = Some(entry.cursor.clone());
seen.push(entry.txid);
assert!(seen.len() <= 8, "cursor is not advancing");
}
assert_eq!(
seen,
all.iter().map(|e| e.txid.clone()).collect::<Vec<_>>(),
"paged one-at-a-time must equal the whole list, in order"
);
}
#[tokio::test]
async fn pool_fees_sums_the_recorded_steps() {
let pool = test_pool().await;
setup_test_db(&pool).await;
let (utxo, expected_e6) = insert_traded_pool(&pool, 40, 1_000, 2_000).await;
let pool_id = PoolID::from_byte_array(utxo);
let fees = db_pool_fees(&pool, &[pool_id], 0).await.unwrap();
assert_eq!(fees.len(), 1);
assert_eq!(fees[0].trades, 1, "one swap");
// The birth entry has no step behind it, so it is unreadable, not a trade.
assert_eq!(fees[0].unreadable, 1);
assert_eq!(
fees[0].fee_sats,
format!("{}.{:06}", expected_e6 / FEE_SCALE, expected_e6 % FEE_SCALE)
);
}
#[tokio::test]
async fn pool_fees_respects_the_start_window() {
let pool = test_pool().await;
setup_test_db(&pool).await;
let (utxo, _) = insert_traded_pool(&pool, 60, 1_000, 2_000).await;
let pool_id = PoolID::from_byte_array(utxo);
// A window that begins after every entry has nothing to sum, and the
// pool drops out entirely rather than reporting zero.
let fees = db_pool_fees(&pool, &[pool_id], 9_000).await.unwrap();
assert!(fees.is_empty());
}
#[tokio::test]
async fn pool_fees_omits_unknown_pools_rather_than_zeroing_them() {
// "Earned nothing" and "no such pool" must not look alike to a caller
// adding these up.
let pool = test_pool().await;
setup_test_db(&pool).await;
let unknown = PoolID::from_byte_array([99u8; 32]);
let fees = db_pool_fees(&pool, &[unknown], 0).await.unwrap();
assert!(fees.is_empty());
}
#[tokio::test]
async fn test_volume_no_trades_in_period() {
let pool = test_pool().await;

View file

@ -86,38 +86,38 @@ static PERMANENT_LIQUIDITY_SHARE_DENOMINATOR: LazyLock<Integer> =
pub const IDO_SIGNATURE: &[u8] = &[0x08, 0x43, 0x6c, 0x64, 0x49, 0x64, 0x6f, 0x30, 0x30, 0x75];
static OFFERING_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0ce01207f75c0519c637600ce8800cf517f755f84518867c0009d00d000d394765479a269765579950500e876481796005c7900a063577900a069587900a0695c79587995048033e1015a7995a169785d7995587995048033e1010400e1f5059596776e947b757c7800a0696854790087535e7900a06376608577687863760120857768005f7900a0635f795680547958807e77686e7e51d28851d15779517e8851d356799d5800cf557f77547f757e557958807e567958807e547958807e607956807e5f7960798277009c637859797eaa776776827701209d6802aa20012052797e60797eaa7e01877e51cd8854796351cc5779a26960798277009c6352d159798852d3009d52d252798853d100876453d101207f75597987916968c4549d6752d100876452d101207f75597987916968c4539d686752d15a798852d35779a269525152807e60797e52cd8860798277009c6353d159798853d3009d53d252798854d100876454d101207f7559798791696855d100876455d101207f75597987916968c4569d6753d100876453d101207f7559798791696854d100876454d101207f75597987916968c4559d686800cf517f77547f758100cc00c6527993a26900cd00c78800cf557f77547f758100cf557f75788b54807e00d28800ce00d1886d6d6d6d6d686d6d6d6d6d51").unwrap()
hex::decode("1178d100876478d101207f7578879169686d0089c0ce01207f75c0519c637600ce8800cf517f755f84518867c0009d00d000d394765479a269765579950500e876481796005c7900a063577900a069587900a0695c79587995048033e1015a7995a169785d7995587995048033e1010400e1f5059596776e947b757c7800a0696854790087535e7900a06376608577687863760120857768005f7900a0635f795680547958807e77686e7e51d28851d15779517e8851d356799d5800cf557f77547f757e557958807e567958807e547958807e607956807e5f7960798277009c637859797eaa776776827701209d680120787e5f797e02aa207caa7e01877e51cd8854796351cc5779a26960798277009c6352d159798852d3009d52d2527988535979008ac4549d67525979008ac4539d686752d15a798852d35779a269525152807e60797e52cd8860798277009c6353d159798853d3009d53d2527988545979008a555979008ac4569d67535979008a545979008ac4559d686800cf517f77547f758100cc00c6527993a26900cd00c78800cf557f77547f758100cf557f75788b54807e00d28800ce00d1886d6d6d6d6d686d6d6d6d6d51").unwrap()
});
static OFFERING_ENTRY_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("827701209dc0519dc0ce01207f7500ce01207f758800cf517f755f845288c0cf517f7501208401008763c0c878c88876c9529d687551").unwrap()
hex::decode("75c0519dc0ce01207f7500ce01207f758800cf517f755f845288c0cf517f7501208401008763c0c878c88876c9529d687551").unwrap()
});
static LAUNCHER_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0009d00c852c88852c9529d00c854c88854c9549d00c855c88855c9559d00ce01207f7551ce78527e8851cf517f755f8401008853ce01207f7555798853cf567f75817600a06953d100876453d101207f7552798791696851d0547aa07651d0557aa09b63537952799f696851cf557f77547f758100a16301205c797e56797e556085537956807e0058807e52d058807e0058807e00d28800d154798800d3009d00cd788851cd788851cc52c6a26951d152ce8851d352d09d51d2008852d10088c4549d75675279517e00d18800d3009d766355ca827755ca7853947f77527f758155ca527953945279947f77787f75526085557956807e51cf557f77547f757e0058807e52d058807e0058807e00d28802aa20c15a7f755a79827751807e5a797e01207e60797e52797eaa7e01877e00cd8801205f797e59797e52cd8852cc52c6a26952d152ce8852d352d09d52d2008854d10088c4559d6d756754ca827754ca7853947f77527f758154ca527953945279947f77787f7551d07600a06354d152ce8854d3789d54d20088012060797e5a797e54cd8855d10088c4569d6754d10088c4559d6852567956807e51cf557f77547f757e0058807e7858807e0058807e00d28801205f797e5e7981009c630100776802aa20c15a7f755e79827751807e5e797e5d79827751807e5d797e5c79827751807e5c797e01207e5b797e547e60797e52797e01207e0112797e54797eaa7e01877e00cd8852d152ce8852d352d05379949d52cc52c6a269520052807e5d797e52cd8852d200886d6d756802aa2056798277518057797e5a797eaa7e01877e51cd88545b797e51d28851cc51c6a26951d153798851d3009d686d6d6d6d6d6d51").unwrap()
hex::decode("07cf557f77547f7500890902aa207caa7e01877e5189c0009d00c852c88852c9529d00c854c88854c9549d00c855c88855c9559d00ce01207f7551ce78527e8851cf517f755f8401008853ce01207f7555798853cf567f75817600a06953527978d100876478d101207f7578879169686d51d0547aa07651d0557aa09b63537952799f696851008a8100a16301205c797e56797e556085537956807e0058807e52d058807e0058807e00d28800d154798800d3009d00cd788851cd788851cc52c6a26951d152ce8851d352d09d51d2008852d10088c4549d75675279517e00d18800d3009d766355ca827755ca7853947f77527f758155ca527953945279947f77787f75526085557956807e51008a7e0058807e52d058807e0058807e00d288c15a7f755979827751807e59797e01207e5f797e787e518a00cd8801205f797e59797e52cd8852cc52c6a26952d152ce8852d352d09d52d2008854d10088c4559d6d756754ca827754ca7853947f77527f758154ca527953945279947f77787f7551d07600a06354d152ce8854d3789d54d20088012060797e5a797e54cd8855d10088c4569d6754d10088c4559d6852567956807e51008a7e0058807e7858807e0058807e00d28801205f797e5e7981009c6301007768c15a7f755d79827751807e5d797e5c79827751807e5c797e5b79827751807e5b797e01207e5a797e547e5f797e787e01207e0111797e53797e518a00cd8852d152ce8852d352d05379949d52cc52c6a269520052807e5d797e52cd8852d200886d6d756855798277518056797e59797e518a51cd88545b797e51d28851cc51c6a26951d153798851d3009d686d6d6d6d6d6d51").unwrap()
});
static DISTRIBUTOR_DEPLOY_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0009d00c852c88852c9529d00cf517f77567f758100ce01207f7551ce01207f75788851cf517f755f84538851cf517f7551ca51ca82770136940120947f7701207f750000537960840100876451cf517f77567f75817b757c51cf577f77587f758177687800a2697600a26952d352d051d0949d587a810000567901208401008764527900a26951c6765479950400e1f50596537a757c6b7c6c765379947b757c53cc5279a26953d1008854cc5379a26954d10088756753d05379950400e1f505967b757c53d0527994777600a06353d3789d53d153ce886753d10088687800a06354d352799d54d153ce886754d100886868547900a063012056797e5e797e51cd8851d1587988565551807e5c797e597956799356807e51d28851d3009d55d351d09d55d152ce88525152807e5f797e55cd8855d200886751d351d09d51d152ce88012056797e5d797e51cd8851d20088687600a06301205a797e5d797e53cd886753cd016a88687c00a06301205a797e5c797e54cd886754cd016a886800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800ce00d18800c700cd8800d000d39d52cf52d28852ce52d18852c752cd88675500cf517f77567f757e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800d158798800d3009d01205a797e5d797e00cd8852d100885457790120840100876475536876ce59798876cf517f755f845488756855557a00a063755668c4789e6376d10088c4788b9d686d6d6d6d6d6d6d7551").unwrap()
hex::decode("07cf517f77567f75008908cf5b7f77587f7581518908cf01137f77587f75528909cf011b7f77587f75815389c0009d00c852c88852c9529d00008a8100ce01207f7551ce01207f75788851cf517f755f84538851cf517f755176ca7cca82770132940120947f7701207f750000537960840100876451cf517f77567f75817b757c51cf577f77587f758177687800a2697600a26952d352d051d0949d587a810000567901208401008764527900a26951c6765479950400e1f50596537a757c6b7c6c765379947b757c53cc5279a26953d1008854cc5379a26954d10088756753d05379950400e1f505967b757c53d0527994777600a06353d3789d53d153ce886753d10088687800a06354d352799d54d153ce886754d100886868547900a063012056797e5e797e51cd8851d1587988565551807e5c797e597956799356807e51d28851d3009d55d351d09d55d152ce88525152807e5f797e55cd8855d200886751d351d09d51d152ce88012056797e5d797e51cd8851d20088687600a06301205a797e5d797e53cd886753cd016a88687c00a06301205a797e5c797e54cd886754cd016a886800cf577f77547f75817651a06300cf517f7500008a7e788c54807e00518a53799358807e00528a7e00538a55799358807e00d28800ce00d18800c700cd8800d000d39d52cf52d28852ce52d18852c752cd88675500008a7e00518a53799358807e00528a7e00538a55799358807e00d28800d158798800d3009d01205a797e5d797e00cd8852d100885457790120840100876475536876ce59798876cf517f755f845488756855557a00a063755668c4789e6376d10088c4788b9d686d6d6d6d6d6d6d7551").unwrap()
});
static DISTRIBUTOR_REFUND_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0009d00ce01207f7551ce01207f75788851cf517f755f84538851ca51ca82770136940120947f7701207f7551cf517f75760120840100876451cc51c6a26951d100886751d352d09d51d152ce886801207b7e54797e51cd8800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d28800ce00d18800c700cd88c4529e6352d10088c4539d686755608500cf517f77567f757e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d288527900d18800d3009d012054797e55797e00cd8852d100885352790120840100876475526876ce54798876cf517f755f845488c4539e6353d10088c4549d6875686d6d7551").unwrap()
hex::decode("08cf01137f77587f75008908cf011b7f77587f755189c0009d00ce01207f7551ce01207f75788851cf517f755f8453885176ca7cca82770132940120947f7701207f7551cf517f75760120840100876451cc51c6a26951d100886751d352d09d51d152ce886801207b7e54797e51cd8800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f757e00008a7e00518a7e00d28800ce00d18800c700cd88c4529e6352d10088c4539d686755608500cf517f77567f757e00cf5b7f77587f757e00008a7e00518a7e00d288527900d18800d3009d012054797e55797e00cd8852d100885352790120840100876475526876ce54798876cf517f755f845488c4539e6353d10088c4549d6875686d6d7551").unwrap()
});
static EXECUTION_FEE_PAYOUT_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7501207c7e7c7e52cd8852ccc0c6a2").unwrap()
});
static OFFERING_INITIATOR_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0c9009dc0c85b79c8885a79c9577a9d5979ce827701209d567900a263c0c85b79c8885a79c957799d68c0c800d1788800d2578800d3009d00cd5a7a88c08bc0c878c88876c9547a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05293c0c878c88876c9557a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05393c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5b797e7b7eaa7e01877e54cd8854cc7cc6a26954d10088c05493c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5a797e7b7eaa7e01877e55cd8855cc7cc6a26955d100885779d07600a0690100557a7e04000000007e51d28851d15479527e8851d3789d51cd02aa20547aaa7e01877e885153d28853d153798853d3009d53cd02aa20537aaa7e01877e88525352807e557a7e52cd8852cc5579c6a26952d1557ace8852d39d52d20088c05593c0c878c88876c9537a9d76c7827778c77853947f77527f75817bc77b53945279947f777c7f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a776856cd8856d1008857d100876457d101207f75788791696857cd567f75066a0442434d52879169c4589e6358d100876458d101207f75788791696858cd567f75066a0442434d52879169c4599e6359d100876459d101207f75788791696859cd567f75066a0442434d52879169c45a9e635ad10087645ad101207f7578879169685acd567f75066a0442434d52879169c45b9e635bd10087645bd101207f7578879169685bcd567f75066a0442434d52879169c45c9d686868686d7551").unwrap()
hex::decode("0902aa207caa7e01877e00891178d100876478d101207f7578879169686d51890ecd567f75066a0442434d528791695289c0c9009dc0c85b79c8885a79c9577a9d5979ce827701209d567900a263c0c85b79c8885a79c957799d68c0c800d1788800d2578800d3009d00cd5a7a88c08bc0c878c88876c9547a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05293c0c878c88876c9557a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05393c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f77525352807e5a797e7c7e008a54cd8854cc7cc6a26954d10088c05493c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f77525352807e59797e7c7e008a55cd8855cc7cc6a26955d100885779d07600a0690100557a7e04000000007e51d28851d15479527e8851d3789d51cd537a008a885153d28853d153798853d3009d53cd7b008a88525352807e557a7e52cd8852cc5579c6a26952d1557ace8852d39d52d20088c05593c0c878c88876c9537a9d76c7827778c77853947f77527f75817bc77b53945279947f777c7f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a776856cd8856d100885778518a57528ac4589e635878518a58528ac4599e635978518a59528ac45a9e635a78518a5a528ac45b9e635b78518a5b528ac45c9d686868686d7551").unwrap()
});
static OFFERING_INTEGRATED_TIMELOCKED_P2NFTH_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978cf7bce7eaa88c0cf517f77517f7581c0c85279c8887cc99c").unwrap()
});
static IDO_INITIATOR_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f755b7f7701207f75c0cfc0ce7eaa88547ac852d1827701209d52d300a06902aa20c15a7f755479827751807e54797e5379827751807e537a7e01207e7b7e01207e52d17e01207e547a7e587e52d358807e537a7eaa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d525752807e7c7e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c").unwrap()
hex::decode("c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f755b7f7701207f75c0cfc0ce7eaa88547ac852d1827701209d52d300a069c15a7f755379827751807e53797e5279827751807e7b7e01207e7c7e01207e52d17e01207e537a7e587e52d358807e7b7e02aa207caa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d525752807e7c7e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c").unwrap()
});
static IDO_POSTLAUNCH_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("5e79009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf88520052807e5e7a7ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09d7652cd8852d10088c0c852c88852c9599d53cd8853cc52c6a26953d152ce8853d352d09d54d10088c4559d006576c39f766b6376ce00876476ce01207f755d7987916968768b77686c91666d6d6d6d6d6d6d7551675e79519c63c0009dc0c9009d57790087c0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c853c88853c9539d53cd53c78853cc53c6a26953d153ce8853d353d09d0120c0cfc0ce7eaa7e5e7a7e000000546576ce00876476ce01207f750112798791696876c75579876355796376ce0111798763537978d093547a757c6b7c6b7c6c6c6ec6937b757c6776ce0088527978c693537a757c6b7c6c686776ce5e798763527978d093537a757c6b7c6c6776ce0111798763537978d093547a757c6b7c6b7c6c6c6776ce008868686ec6937b757c686776ce008868768b7776c3a266c0ccc0c6537a93a269c0ccc0c6577a8193a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d15f798851d378a26951d200886751d1008868c0c852c88852c9529d52cd52c788547a6352cc52c6547993a26952d100886752cc52c6a26952d05379937600a06352d15c798852d378a26952d200886752d100886875686d6d6d6d6d6d6d6d7551675e7a529dc0009dc0c9009d5979827701209d5779008754ce5d7a8854cf517f755f845588c0c851c88851c9519d54cf5f7f77587f75817600a06351d078a26951ce5d79886851cf0088c0c852c88852c9529d54cf577f77587f75817600a06352796352c678a2696752d078a26952ce5b7988686852cf0088c0c853c88853c9539d53ce5e7988000054cf517f756084010087635d7981547994765d79950400e1f505967653d0a06353d07768765d79950500e87648179654cf01177f77587f758194760500e8764817955e79967800a07800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d6852d052c656796352c67b75770068c0c651c6939353c6937c53799451d053d093537994012001127a7e0113797e5c7900a26952795d7a950400e1f50596537a7894567900a0567900a09a6359796300cc5779a26900d10113798800d356799d00d200884ce7c0c776517f77587f527f527f527f587f587f01207f77517f7501008791567a81567a81567a81567a81567a81567a81c0cec0d188c0c6c0ccc0d0c0d3557955795c7a63c0d0567a757c6b7c6b7c6b7c6b7c6c6c6c6cc0d3557a757c6b7c6b7c6b7c6c6c6cc0c6547a757c6b7c6b7c6c6cc0cc537a757c6b7c6c56797b757c5779776855795579949003a08601765a9552795e7a9578938c7c967b5c7a955279938c7b965b795279937b5279935b7aa269013f7858807e5c7a597f777ec0cd88567a7c947651a269547951a269567a597a94547993567a547993957c7b94537a93729395a17777205c797e4cab78cf7bce7eaac0c776517f77587f75817600a26978013f7f77517f75010087915379557987637663c0d3c0d05379949dc0ccc0c6a26967c0ccc0c6537994a269c0d3c0d09d68c0cec0d188c0cd597f77527f75768100a269013f0058807e787e54795b7f777ec0cd78886d675279011f7f7701207f7554797888527900a063c0cd012057797e0778cf7bce7eaa877e887863c0d1c0ce88c0d353799d67c0cc5379a269686875686d6d75517eaa07ca537f7776aa20787e0f8802e6007f7b63756777680089008a7e0800000000000000000200007e0200007e5f797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e01007e00cd013f52797e01757e53797e8851d1008851cd016a886d756700d10111798800d357799d00d2008851d10113798851d356799d51d200884cbac0c8c08bc888c08bc9c0c98b9dc08bc7517f77587f527f527f527f587f587f75557a81557a81557a81557a81557a81557a81c0cec0d188c0c6c0cc9dc08bcec08bd188c08bc6c08bcc9dc0d0c0d3949003a08601765a955279587a9578938c7c967b567a955279938c7b9655795279937b527993557aa269c0cdc0c788013e7858807ec08bc7597f777ec08bcd88c0d37c947651a269c08bd351a269c0d0557a94547993c08bd0547993957c7b94537a93c08bd3537a9395a1205c797e4ca678cf7bce7eaac0c8c08bc888c08bc9c0c98b9dc08bc776517f77587f75817600a269708763c0d3c0d05279949dc08bd3c08bd09dc0cec0d188c0c6c0cc9dc08bcec08bd188c08bc6c08bcc9dc0cdc0c788c08bcd597f77527f75768100a269013e0058807e787e53795b7f777ec08bcd78886d6778011f7f7701207f75537978887800a063c0cd012056797e0778cf7bce7eaa877e88c0d1c0ce88c0d352799d6875686d6d517eaa00cd07ca537f7776aa2052797e0f8802b9007f7b63756777680089008a7e880800000000000000000200007e0200007e5e797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e013e787e0e75c08c76c8c0c888c0c97cc98b9c7e51cd78886d75686700d1008800cd016a8851d1008851cd016a8868537900a063527952cd8852d35479a26952d10113798852d200886752cd016a8852d10088687600a063527953cd8859796353cc78a26953d100886753d3789d53d10111798853d20088686753cd016a8853d100886801205e7a7e01137a7e54cd88597a6354cc5579537993a26954d100886754cc5579a2697800a06354d35279a26954d15f798854d200886754d100886868556576c49f766b6376d100876476d101207f757654ce01207f7587916976c0ce01207f758791697568768b77686c91666d6d6d6d6d6d6d6d6d75516868").unwrap()
hex::decode("0401207f7500895e79009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf88520052807e5e7a7ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09d7652cd8852d10088c0c852c88852c9599d53cd8853cc52c6a26953d152ce8853d352d09d54d10088c4559d006576c39f766b6376ce00876476ce008a5d7987916968768b77686c91666d6d6d6d6d6d6d7551675e79519c63c0009dc0c9009d57790087c0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c853c88853c9539d53cd53c78853cc53c6a26953d153ce8853d353d09d0120c0cfc0ce7eaa7e5e7a7e000000546576ce00876476ce008a0112798791696876c75579876355796376ce0111798763537978d093547a757c6b7c6b7c6c6c6ec6937b757c6776ce0088527978c693537a757c6b7c6c686776ce5e798763527978d093537a757c6b7c6c6776ce0111798763537978d093547a757c6b7c6b7c6c6c6776ce008868686ec6937b757c686776ce008868768b7776c3a266c0ccc0c6537a93a269c0ccc0c6577a8193a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d15f798851d378a26951d200886751d1008868c0c852c88852c9529d52cd52c788547a6352cc52c6547993a26952d100886752cc52c6a26952d05379937600a06352d15c798852d378a26952d200886752d100886875686d6d6d6d6d6d6d6d7551675e7a529dc0009dc0c9009d5979827701209d5779008754ce5d7a8854cf517f755f845588c0c851c88851c9519d54cf5f7f77587f75817600a06351d078a26951ce5d79886851cf0088c0c852c88852c9529d54cf577f77587f75817600a06352796352c678a2696752d078a26952ce5b7988686852cf0088c0c853c88853c9539d53ce5e7988000054cf517f756084010087635d7981547994765d79950400e1f505967653d0a06353d07768765d79950500e87648179654cf01177f77587f758194760500e8764817955e79967800a07800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d6852d052c656796352c67b75770068c0c651c6939353c6937c53799451d053d093537994012001127a7e0113797e5c7900a26952795d7a950400e1f50596537a7894567900a0567900a09a6359796300cc5779a26900d10113798800d356799d00d200884ce7c0c776517f77587f527f527f527f587f587f01207f77517f7501008791567a81567a81567a81567a81567a81567a81c0cec0d188c0c6c0ccc0d0c0d3557955795c7a63c0d0567a757c6b7c6b7c6b7c6b7c6c6c6c6cc0d3557a757c6b7c6b7c6b7c6c6c6cc0c6547a757c6b7c6b7c6c6cc0cc537a757c6b7c6c56797b757c5779776855795579949003a08601765a9552795e7a9578938c7c967b5c7a955279938c7b965b795279937b5279935b7aa269013f7858807e5c7a597f777ec0cd88567a7c947651a269547951a269567a597a94547993567a547993957c7b94537a93729395a17777205c797e4cab78cf7bce7eaac0c776517f77587f75817600a26978013f7f77517f75010087915379557987637663c0d3c0d05379949dc0ccc0c6a26967c0ccc0c6537994a269c0d3c0d09d68c0cec0d188c0cd597f77527f75768100a269013f0058807e787e54795b7f777ec0cd78886d675279011f7f7701207f7554797888527900a063c0cd012057797e0778cf7bce7eaa877e887863c0d1c0ce88c0d353799d67c0cc5379a269686875686d6d75517eaa07ca537f7776aa20787e0f8802e6007f7b63756777680089008a7e0800000000000000000200007e0200007e5f797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e01007e00cd013f52797e01757e53797e8851d1008851cd016a886d756700d10111798800d357799d00d2008851d10113798851d356799d51d200884cbac0c8c08bc888c08bc9c0c98b9dc08bc7517f77587f527f527f527f587f587f75557a81557a81557a81557a81557a81557a81c0cec0d188c0c6c0cc9dc08bcec08bd188c08bc6c08bcc9dc0d0c0d3949003a08601765a955279587a9578938c7c967b567a955279938c7b9655795279937b527993557aa269c0cdc0c788013e7858807ec08bc7597f777ec08bcd88c0d37c947651a269c08bd351a269c0d0557a94547993c08bd0547993957c7b94537a93c08bd3537a9395a1205c797e4ca678cf7bce7eaac0c8c08bc888c08bc9c0c98b9dc08bc776517f77587f75817600a269708763c0d3c0d05279949dc08bd3c08bd09dc0cec0d188c0c6c0cc9dc08bcec08bd188c08bc6c08bcc9dc0cdc0c788c08bcd597f77527f75768100a269013e0058807e787e53795b7f777ec08bcd78886d6778011f7f7701207f75537978887800a063c0cd012056797e0778cf7bce7eaa877e88c0d1c0ce88c0d352799d6875686d6d517eaa00cd07ca537f7776aa2052797e0f8802b9007f7b63756777680089008a7e880800000000000000000200007e0200007e5e797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e013e787e0e75c08c76c8c0c888c0c97cc98b9c7e51cd78886d75686700d1008800cd016a8851d1008851cd016a8868537900a063527952cd8852d35479a26952d10113798852d200886752cd016a8852d10088687600a063527953cd8859796353cc78a26953d100886753d3789d53d10111798853d20088686753cd016a8853d100886801205e7a7e01137a7e54cd88597a6354cc5579537993a26954d100886754cc5579a2697800a06354d35279a26954d15f798854d200886754d100886868556576c49f766b6376d100876476d1008a7654ce008a87916976c0ce008a8791697568768b77686c91666d6d6d6d6d6d6d6d6d75516868").unwrap()
});
static IDO_PREINIT_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d100880302010054797ec1014e7f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a06902aa20012060797e5d797e5c797eaa7e01877e5c798277009c6302a91401200111797e5c797ea97e01877e776800cd788800d1c0c88800d352799d00d2008859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e7768686802aa20c15a7f7552797eaa7e01877ec0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012302aa2001205f797e5d797e5b797eaa7e01877e7e5b798277009c63011702a914012060797e5b797ea97e01877e7e776800cd02aa20c15a7f7553797e54797eaa7e01877e8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f7702aa20030200005d797e52797eaa7e01877e51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f7702aa20030200000111797e707c5a937f757e01207e01ff0119797eaa7e707c012b937f777eaa7e01877e52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f7702aa20030200000115797e52797eaa7e01877e53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f7702aa20030200000119797e52797eaa7e01877e54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008803020000011c797e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cd02aa20c15a7f7501207e01000128797eaa7e53797eaa7e01877e8857cc59c6a26957d159ce8857d359d09d57d259cf88525752807e011f797e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551").unwrap()
hex::decode("0902aa207caa7e01877e00890902a9147ca97e01877e5189c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d100880302010054797ec1014e7f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a26901205f797e5c797e5b797e008a5c798277009c63012060797e5b797e518a776800cd788800d1c0c88800d352799d78009c6300d20442434d52886700d200886859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e77686868c15a7f75787e008ac0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012301205e797e5c797e5a797e008a7e5b798277009c63011701205f797e5a797e518a7e776800cdc15a7f7552797e53797e008a8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f77030200005c797e787e008a51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f770302000060797e7853795a937f757e01207e01ff0118797eaa7e785379012b937f777e008a52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f77030200000114797e787e008a53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f77030200000118797e787e008a54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008803020000011c797e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cdc15a7f7501207e01000127797eaa7e52797e008a8857cc59c6a26957d159ce8857d359d09d57d259cf88525752807e011f797e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551").unwrap()
});
static STORAGE_CONTRACT: LazyLock<Vec<u8>> =
@ -131,8 +131,7 @@ static REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT: LazyLo
hex::decode("0902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800cf00d28800d000d38800c600cca26952d10088c45387").unwrap()
});
/// The 127-byte commitment of the ORB IdoParams NFT (libriften
/// packages/cauldron/src/orb/v0). Byte layout:
/// The 127-byte commitment of the ORB IdoParams NFT. Byte layout:
/// \[0\] version (must equal IDO_PARAMS_VERSION)
/// [1..33] permanentPoolPlatformNfth
/// [33..65] delphiCategory
@ -151,9 +150,9 @@ static REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT: LazyLo
/// nfths are hashes and are kept in their on-chain order.
const IDO_PARAMS_COMMITMENT_SIZE: usize = 127;
/// The only ORB IdoParams NFT commitment version this indexer understands
/// (libriften `ORB_IDO_PARAMS_VERSION`). Any other value means the params NFT
/// was minted for a newer/incompatible layout and the IDO is not indexed.
/// The only ORB IdoParams NFT commitment version this indexer understands.
/// Any other value means the params NFT was minted for a newer/incompatible
/// layout and the IDO is not indexed.
const IDO_PARAMS_VERSION: u8 = 0x00;
pub struct IdoParamsNftCommitment {
@ -597,7 +596,7 @@ fn build_offering_bytecode(
minOffer: &Integer,
// display byte order; baked into the bytecode in VM (reversed) order. None
// is native BCH: the contract branches on an empty (0x) category, so an
// empty push is baked (matching libriften encodeIdoXTokenCategory).
// empty push is baked.
xTokenCategory: Option<&[u8]>,
) -> Vec<u8> {
let rev_xtoken_cat: Vec<u8> = xTokenCategory
@ -688,8 +687,7 @@ fn build_partial_offering_initiator_bytecode(
}
// The partial postlaunch bytecode stored in preinit output #7 (via the partial
// ido initiator bytecode). Composition per libriften templates/ido.json
// `postLaunchPartialBytecode`:
// ido initiator bytecode). Composition:
// <xTokenCategory rev> <permanentLiquidityShare> <price> <platformFeeNFTH>
// <platformFee> <permanentLiquidityMinFee 2B> <permanentPoolPlatformNfth 32B>
// <executionFee 4B> contracts.IDOPostLaunch
@ -1521,8 +1519,7 @@ fn parse_ido_preinit_tx_params(
minOffer: vm_number_to_bigint(&data[122..130]),
// On-chain in VM order; kept in display/UI order. An
// all-zero category is the native-BCH sentinel and
// normalizes to None (libriften
// isNativeXTokenCategory).
// normalizes to None.
xTokenCategory: if data[130..162].iter().all(|&b| b == 0) {
None
} else {
@ -1550,15 +1547,23 @@ fn parse_ido_preinit_tx_params(
// all-zero on-chain sentinel): a native IDO takes payment via
// the offering's XWNT path and its permanent pool is a
// single-UTXO tokenbch pool. No rejection here.
// The genesis transaction mints the whole supply: the funded
// amount (offeredTokenAmount + the permanent liquidity reserve)
// plus the remainder held by the oToken authbase. Equal is
// allowed — the authbase then carries no tokens and holds an
// immutable NFT with the OTOKEN_AUTHBASE_NFT_COMMITMENT ("BCMR")
// marker instead, since a token output cannot carry zero
// fungible tokens and no NFT. Below the funded amount the
// preinit contract rejects the genesis step.
if !offered_token_is_in_supply {
let permanentLiquidityOTokenReserve: Integer = &preinit_parameters
.offeredTokenAmount
* &preinit_parameters.permanentLiquidityShareNumerator
/ &PERMANENT_LIQUIDITY_SHARE_DENOMINATOR.clone();
if preinit_parameters.offeredTokenTotalSupply
<= &preinit_parameters.offeredTokenAmount + &permanentLiquidityOTokenReserve
< &preinit_parameters.offeredTokenAmount + &permanentLiquidityOTokenReserve
{
invalid_ido_reasons.push(anyhow::anyhow!("ido parse, not a valid ido, preinit_parameters.offeredTokenTotalSupply <= preinit_parameters.offeredTokenAmount + permanentLiquidityOTokenReserve"));
invalid_ido_reasons.push(anyhow::anyhow!("ido parse, not a valid ido, preinit_parameters.offeredTokenTotalSupply < preinit_parameters.offeredTokenAmount + permanentLiquidityOTokenReserve"));
is_valid_ido = false;
}
}
@ -1870,7 +1875,7 @@ fn parse_ido_preinit_tx(
// In an IDO the offering layer never charges a platform fee;
// the fee is collected by the postlaunch covenant instead, so
// every offering-layer contract is instantiated with a zero
// platformFee (libriften idoOfferingLayerParameters).
// platformFee.
&Integer::from(0i64),
&params.offering.delphiCategory,
&params.offering.launchConditions.expiresAt,
@ -3526,7 +3531,7 @@ mod tests {
}
// A native-BCH xToken bakes an EMPTY push (the contract branches on
// xTokenCategory == 0x), matching libriften encodeIdoXTokenCategory.
// xTokenCategory == 0x).
#[test]
fn partial_postlaunch_bakes_empty_push_for_native_x_token() {
let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {

View file

@ -18,7 +18,7 @@ use crate::db::cauldron::tokenbch::create_table as tokenbch_prepare_tables;
use crate::db::cauldron::tokentoken::create_table as tokentoken_prepare_tables;
use crate::db::crc20::prepare_tables as crc20_prepare_tables;
use crate::db::ido::prepare_tables as ido_prepare_tables;
use crate::db::moria::prepare_tables as moria_prepare_tables;
use crate::db::moria_v11::prepare_tables as moria_prepare_tables;
use crate::db::oracle::prepare_tables as oracle_prepare_tables;
/// Create read and write database pools for a given database path
@ -134,12 +134,12 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul
oracle_prepare_tables(&oracle_db_write).await;
}
// Initialize moria lending database
// Initialize moria lending database (v1-1 tables; IF NOT EXISTS so existing
// moria.db files pick up the cutover schema without a full wipe).
let (db_exists, moria_db_write, moria_db_read) =
create_db_pool(&db_path(db_dir, "moria.db"), read_slots.moria).await;
if !db_exists {
moria_prepare_tables(&moria_db_write).await;
}
let _ = db_exists; // schema is additive via IF NOT EXISTS
moria_prepare_tables(&moria_db_write).await;
// Initialize ido database
let (db_exists, ido_db_write, ido_db_read) =

View file

@ -11,7 +11,7 @@ pub mod cauldron;
pub mod crc20;
pub mod ido;
pub mod init;
pub mod moria;
pub mod moria_v11;
pub mod oracle;
pub mod orbconstants;
pub mod search;

View file

@ -1,821 +0,0 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use anyhow::Result;
use bitcoin_hashes::Hash;
use bitcoincash::{BlockHash, Transaction, Txid};
use log::debug;
use riftenlabs_defi::moria::{
parse_moria_from_tx, MoriaActionType, MoriaTokenIds, ParsedMoriaAction,
};
use sqlx::{Row, SqlitePool};
use crate::db::blob::{blob_to_display_hex, ToBlob};
#[derive(Debug, Clone, serde::Serialize)]
pub struct MoriaEntry {
pub txid: String,
pub blockhash: String,
pub action_type: &'static str,
pub borrower_hash: Option<String>,
pub principal: Option<i64>,
pub interest_rate: Option<i64>,
pub loan_timestamp: Option<i64>,
pub collateral_sats: Option<i64>,
pub tokens_amount: Option<i64>,
pub mtp_timestamp: i64,
// Refinance new terms
pub new_principal: Option<i64>,
pub new_interest_rate: Option<i64>,
pub new_loan_timestamp: Option<i64>,
pub new_collateral_sats: Option<i64>,
}
fn action_type_to_str(action: MoriaActionType) -> &'static str {
match action {
MoriaActionType::Borrow => "borrow",
MoriaActionType::Repay => "repay",
MoriaActionType::Redeem => "redeem",
MoriaActionType::Refinance => "refinance",
MoriaActionType::AddCollateral => "add_collateral",
}
}
fn action_type_to_int(action: MoriaActionType) -> i64 {
action as i64
}
fn int_to_action_type(val: i64) -> &'static str {
match val {
0 => "borrow",
1 => "repay",
2 => "redeem",
3 => "refinance",
4 => "add_collateral",
_ => "unknown",
}
}
impl MoriaEntry {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let action_type: i64 = row.get("action_type");
let borrower_blob: Option<Vec<u8>> = row.get("borrower_hash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&blockhash_blob)?,
action_type: int_to_action_type(action_type),
borrower_hash: borrower_blob.map(hex::encode),
principal: row.get("principal"),
interest_rate: row.get("interest_rate"),
loan_timestamp: row.get("loan_timestamp"),
collateral_sats: row.get("collateral_sats"),
tokens_amount: row.get("tokens_amount"),
mtp_timestamp: row.get("mtp_timestamp"),
new_principal: row.get("new_principal"),
new_interest_rate: row.get("new_interest_rate"),
new_loan_timestamp: row.get("new_loan_timestamp"),
new_collateral_sats: row.get("new_collateral_sats"),
})
}
}
pub async fn prepare_tables(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE moria_action (
txid BLOB PRIMARY KEY,
blockhash BLOB NOT NULL,
action_type INTEGER NOT NULL,
borrower_hash BLOB,
principal INTEGER,
interest_rate INTEGER,
loan_timestamp INTEGER,
collateral_sats BIGINT,
tokens_amount BIGINT,
mtp_timestamp BIGINT NOT NULL,
first_seen_timestamp BIGINT,
new_principal INTEGER,
new_interest_rate INTEGER,
new_loan_timestamp INTEGER,
new_collateral_sats BIGINT
)",
)
.execute(pool)
.await
.expect("failed to create moria_action table");
sqlx::query("CREATE INDEX idx_moria_borrower_hash ON moria_action(borrower_hash)")
.execute(pool)
.await
.expect("failed to create borrower_hash index");
sqlx::query("CREATE INDEX idx_moria_blockhash ON moria_action(blockhash)")
.execute(pool)
.await
.expect("failed to create blockhash index");
sqlx::query("CREATE INDEX idx_moria_timestamp ON moria_action(mtp_timestamp)")
.execute(pool)
.await
.expect("failed to create timestamp index");
// Track loan UTXOs for looking up borrower_hash when a loan is spent
sqlx::query(
"CREATE TABLE moria_loan_utxo (
txid BLOB NOT NULL,
vout INTEGER NOT NULL,
borrower_hash BLOB NOT NULL,
principal INTEGER NOT NULL,
interest_rate INTEGER NOT NULL,
loan_timestamp INTEGER NOT NULL,
collateral_sats BIGINT NOT NULL,
blockhash BLOB NOT NULL,
PRIMARY KEY (txid, vout)
)",
)
.execute(pool)
.await
.expect("failed to create moria_loan_utxo table");
}
#[allow(clippy::too_many_arguments)]
async fn insert_loan_utxo(
pool: &SqlitePool,
txid: &Txid,
vout: u32,
borrower_hash: &[u8; 32],
principal: u16,
interest_rate: u16,
loan_timestamp: u32,
collateral_sats: u64,
blockhash: &BlockHash,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO moria_loan_utxo (txid, vout, borrower_hash, principal, interest_rate, loan_timestamp, collateral_sats, blockhash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(vout as i64)
.bind(borrower_hash.as_slice())
.bind(principal as i64)
.bind(interest_rate as i64)
.bind(loan_timestamp as i64)
.bind(collateral_sats as i64)
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert loan utxo: {}", e))?;
Ok(())
}
async fn delete_loan_utxo(pool: &SqlitePool, txid: &Txid, vout: u32) -> Result<()> {
sqlx::query("DELETE FROM moria_loan_utxo WHERE txid = ? AND vout = ?")
.bind(txid.to_blob())
.bind(vout as i64)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete loan utxo: {}", e))?;
Ok(())
}
/// Look up the borrower_hash for a spent loan UTXO
async fn lookup_loan_utxo(
pool: &SqlitePool,
txid: &Txid,
vout: u32,
) -> Result<Option<(Vec<u8>, i64, i64, i64, i64)>> {
let row: Option<(Vec<u8>, i64, i64, i64, i64)> = sqlx::query_as(
"SELECT borrower_hash, principal, interest_rate, loan_timestamp, collateral_sats
FROM moria_loan_utxo WHERE txid = ? AND vout = ?",
)
.bind(txid.to_blob())
.bind(vout as i64)
.fetch_optional(pool)
.await?;
Ok(row)
}
async fn insert_moria_entry(
pool: &SqlitePool,
txid: &Txid,
blockhash: &BlockHash,
mtp: i64,
first_seen: Option<i64>,
action: &ParsedMoriaAction,
borrower_hash: Option<&[u8]>,
) -> Result<()> {
let commitment = action.loan_commitment.as_ref();
let bh = borrower_hash.or_else(|| commitment.map(|c| c.borrower_nft_hash.as_slice()));
sqlx::query(
"INSERT OR REPLACE INTO moria_action
(txid, blockhash, action_type, borrower_hash, principal, interest_rate,
loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp, first_seen_timestamp,
new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(blockhash.to_blob())
.bind(action_type_to_int(action.action_type))
.bind(bh)
.bind(commitment.map(|c| c.principal as i64))
.bind(commitment.map(|c| c.annual_interest_rate_bp as i64))
.bind(commitment.map(|c| c.timestamp as i64))
.bind(action.collateral_sats.map(|s| s as i64))
.bind(action.tokens_amount)
.bind(mtp)
.bind(first_seen)
.bind(
action
.new_loan_commitment
.as_ref()
.map(|c| c.principal as i64),
)
.bind(
action
.new_loan_commitment
.as_ref()
.map(|c| c.annual_interest_rate_bp as i64),
)
.bind(
action
.new_loan_commitment
.as_ref()
.map(|c| c.timestamp as i64),
)
.bind(
action
.new_loan_commitment
.as_ref()
.and_then(|_| action.collateral_sats.map(|s| s as i64)),
)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert moria_action: {}", e))?;
Ok(())
}
/// Index moria transactions from a block
pub async fn index_moria(
pool: &SqlitePool,
txs: &[Transaction],
blockhash: &BlockHash,
mtp: i64,
token_ids: &MoriaTokenIds,
) -> Result<usize> {
let mut count = 0;
for tx in txs {
let mut action = match parse_moria_from_tx(tx, token_ids) {
Some(a) => a,
None => continue,
};
let txid = tx.compute_txid();
// If the parser returned Borrow but the spent outpoint is a known loan UTXO,
// reclassify as Refinance
let mut looked_up_borrower: Option<Vec<u8>> = None;
if let Some((spent_txid, spent_vout)) = &action.spent_loan_outpoint {
if let Some((bh, principal, interest_rate, loan_ts, collateral)) =
lookup_loan_utxo(pool, spent_txid, *spent_vout).await?
{
// This outpoint is a known loan UTXO
if action.action_type == MoriaActionType::Borrow {
// Reclassify: a Borrow that spends a known loan is actually a Refinance
action.action_type = MoriaActionType::Refinance;
action.new_loan_commitment = action.loan_commitment.clone();
action.loan_commitment = Some(riftenlabs_defi::moria::LoanCommitment {
borrower_nft_hash: bh.as_slice().try_into().unwrap_or([0u8; 32]),
principal: principal as u16,
annual_interest_rate_bp: interest_rate as u16,
timestamp: loan_ts as u32,
});
}
if action.action_type == MoriaActionType::Repay
|| action.action_type == MoriaActionType::Redeem
{
// Fill in the loan commitment from UTXO lookup
action.loan_commitment = Some(riftenlabs_defi::moria::LoanCommitment {
borrower_nft_hash: bh.as_slice().try_into().unwrap_or([0u8; 32]),
principal: principal as u16,
annual_interest_rate_bp: interest_rate as u16,
timestamp: loan_ts as u32,
});
action.collateral_sats = Some(collateral as u64);
}
looked_up_borrower = Some(bh);
// Remove the spent loan UTXO
delete_loan_utxo(pool, spent_txid, *spent_vout).await?;
}
}
// Store new loan UTXO if one was created
if let Some(output_idx) = action.loan_output_index {
if let Some(commitment) = &action
.new_loan_commitment
.as_ref()
.or(action.loan_commitment.as_ref())
{
insert_loan_utxo(
pool,
&txid,
output_idx,
&commitment.borrower_nft_hash,
commitment.principal,
commitment.annual_interest_rate_bp,
commitment.timestamp,
action.collateral_sats.unwrap_or(0),
blockhash,
)
.await?;
}
}
debug!(
"moria: {} {} (borrower: {})",
action_type_to_str(action.action_type),
txid,
action
.loan_commitment
.as_ref()
.map(|c| hex::encode(c.borrower_nft_hash))
.unwrap_or_else(|| "n/a".to_string()),
);
insert_moria_entry(
pool,
&txid,
blockhash,
mtp,
None,
&action,
looked_up_borrower.as_deref(),
)
.await?;
count += 1;
}
Ok(count)
}
pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result<usize> {
// Also clean up loan UTXOs from this block
sqlx::query("DELETE FROM moria_loan_utxo WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete loan utxos for block: {}", e))?;
let r = sqlx::query("DELETE FROM moria_action WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to delete moria_action for block {}: {}",
blockhash,
e
)
})?;
Ok(r.rows_affected() as usize)
}
#[allow(dead_code)] // Will be used for mempool support
pub async fn has_entry(pool: &SqlitePool, txid: &Txid) -> Result<bool> {
let row: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM moria_action WHERE txid = ?")
.bind(txid.to_blob())
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
pub async fn clear_mempool(pool: &SqlitePool) -> Result<usize> {
let r = sqlx::query("DELETE FROM moria_action WHERE blockhash = ?")
.bind(BlockHash::all_zeros().to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to clear mempool entries: {}", e))?;
Ok(r.rows_affected() as usize)
}
/// Get full loan history for a borrower_hash
pub async fn get_loan_history(pool: &SqlitePool, borrower_hash: &[u8]) -> Result<Vec<MoriaEntry>> {
let rows = sqlx::query(
"SELECT txid, blockhash, action_type, borrower_hash, principal, interest_rate,
loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp,
new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats
FROM moria_action
WHERE borrower_hash = ?
ORDER BY mtp_timestamp ASC, rowid ASC",
)
.bind(borrower_hash)
.fetch_all(pool)
.await?;
let mut entries = Vec::new();
for row in rows {
entries.push(MoriaEntry::from_row(&row)?);
}
Ok(entries)
}
/// Get all active loans (borrowed but not yet repaid/redeemed)
pub async fn get_active_loans(pool: &SqlitePool) -> Result<Vec<MoriaEntry>> {
let rows = sqlx::query(
"SELECT m.txid, m.blockhash, m.action_type, m.borrower_hash, m.principal, m.interest_rate,
m.loan_timestamp, m.collateral_sats, m.tokens_amount, m.mtp_timestamp,
m.new_principal, m.new_interest_rate, m.new_loan_timestamp, m.new_collateral_sats
FROM moria_action m
WHERE m.action_type IN (0, 3)
AND m.borrower_hash IS NOT NULL
AND m.borrower_hash NOT IN (
SELECT borrower_hash FROM moria_action
WHERE action_type IN (1, 2) AND borrower_hash IS NOT NULL
)
ORDER BY m.mtp_timestamp DESC",
)
.fetch_all(pool)
.await?;
let mut entries = Vec::new();
for row in rows {
entries.push(MoriaEntry::from_row(&row)?);
}
Ok(entries)
}
/// Get global history with pagination and optional nfth filter
pub async fn get_global_history(
pool: &SqlitePool,
nfth_filter: &[Vec<u8>],
offset: i64,
limit: i64,
) -> Result<Vec<MoriaEntry>> {
let rows = if nfth_filter.is_empty() {
sqlx::query(
"SELECT txid, blockhash, action_type, borrower_hash, principal, interest_rate,
loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp,
new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats
FROM moria_action
ORDER BY mtp_timestamp DESC, rowid DESC
LIMIT ? OFFSET ?",
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
} else {
// Build query with IN clause for nfth filter
let placeholders: Vec<&str> = nfth_filter.iter().map(|_| "?").collect();
let sql = format!(
"SELECT txid, blockhash, action_type, borrower_hash, principal, interest_rate,
loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp,
new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats
FROM moria_action
WHERE borrower_hash IN ({})
ORDER BY mtp_timestamp DESC, rowid DESC
LIMIT ? OFFSET ?",
placeholders.join(",")
);
let mut query = sqlx::query(&sql);
for nfth in nfth_filter {
query = query.bind(nfth);
}
query = query.bind(limit).bind(offset);
query.fetch_all(pool).await?
};
let mut entries = Vec::new();
for row in rows {
entries.push(MoriaEntry::from_row(&row)?);
}
Ok(entries)
}
/// Get protocol statistics
pub async fn get_stats(pool: &SqlitePool) -> Result<serde_json::Value> {
let total_loans: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM moria_action WHERE action_type = 0")
.fetch_one(pool)
.await?;
let active_loans: (i64,) = sqlx::query_as(
"SELECT COUNT(DISTINCT borrower_hash) FROM moria_action
WHERE action_type IN (0, 3) AND borrower_hash IS NOT NULL
AND borrower_hash NOT IN (
SELECT borrower_hash FROM moria_action
WHERE action_type IN (1, 2) AND borrower_hash IS NOT NULL
)",
)
.fetch_one(pool)
.await?;
let total_actions: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_action")
.fetch_one(pool)
.await?;
Ok(serde_json::json!({
"total_borrows": total_loans.0,
"active_loans": active_loans.0,
"total_actions": total_actions.0,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin_hashes::Hash;
use bitcoincash::BlockHash;
async fn setup_moria_db(pool: SqlitePool) {
prepare_tables(&pool).await;
}
fn test_blockhash() -> BlockHash {
BlockHash::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[0xAA; 32]).unwrap())
}
fn test_txid(n: u8) -> Txid {
Txid::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[n; 32]).unwrap())
}
fn borrower_a() -> [u8; 32] {
[0x01; 32]
}
fn borrower_b() -> [u8; 32] {
[0x02; 32]
}
async fn insert_test_action(
pool: &SqlitePool,
txid: &Txid,
action_type: MoriaActionType,
borrower: &[u8; 32],
principal: i64,
mtp: i64,
) {
let action = ParsedMoriaAction {
action_type,
loan_commitment: Some(riftenlabs_defi::moria::LoanCommitment {
borrower_nft_hash: *borrower,
principal: principal as u16,
annual_interest_rate_bp: 500,
timestamp: mtp as u32,
}),
new_loan_commitment: None,
collateral_sats: Some(1_000_000),
tokens_amount: Some(principal * 100),
spent_loan_outpoint: None,
loan_output_index: Some(2),
};
insert_moria_entry(pool, txid, &test_blockhash(), mtp, None, &action, None)
.await
.unwrap();
}
#[rocket::async_test]
async fn test_loan_history() {
let db = crate::utiltest::mock_db_pool(setup_moria_db).await;
let pool = &db.moria_w;
let txid1 = test_txid(1);
let txid2 = test_txid(2);
insert_test_action(
pool,
&txid1,
MoriaActionType::Borrow,
&borrower_a(),
1000,
100,
)
.await;
insert_test_action(
pool,
&txid2,
MoriaActionType::Repay,
&borrower_a(),
1000,
200,
)
.await;
let history = get_loan_history(pool, &borrower_a()).await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].action_type, "borrow");
assert_eq!(history[0].principal, Some(1000));
assert_eq!(history[1].action_type, "repay");
assert_eq!(history[1].mtp_timestamp, 200);
}
#[rocket::async_test]
async fn test_loan_history_filters_by_borrower() {
let db = crate::utiltest::mock_db_pool(setup_moria_db).await;
let pool = &db.moria_w;
insert_test_action(
pool,
&test_txid(1),
MoriaActionType::Borrow,
&borrower_a(),
1000,
100,
)
.await;
insert_test_action(
pool,
&test_txid(2),
MoriaActionType::Borrow,
&borrower_b(),
500,
200,
)
.await;
let history_a = get_loan_history(pool, &borrower_a()).await.unwrap();
assert_eq!(history_a.len(), 1);
assert_eq!(history_a[0].principal, Some(1000));
let history_b = get_loan_history(pool, &borrower_b()).await.unwrap();
assert_eq!(history_b.len(), 1);
assert_eq!(history_b[0].principal, Some(500));
}
#[rocket::async_test]
async fn test_active_loans() {
let db = crate::utiltest::mock_db_pool(setup_moria_db).await;
let pool = &db.moria_w;
// Borrower A borrows and repays
insert_test_action(
pool,
&test_txid(1),
MoriaActionType::Borrow,
&borrower_a(),
1000,
100,
)
.await;
insert_test_action(
pool,
&test_txid(2),
MoriaActionType::Repay,
&borrower_a(),
1000,
200,
)
.await;
// Borrower B borrows and stays active
insert_test_action(
pool,
&test_txid(3),
MoriaActionType::Borrow,
&borrower_b(),
500,
150,
)
.await;
let active = get_active_loans(pool).await.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(
active[0].borrower_hash.as_ref().unwrap(),
&hex::encode(borrower_b())
);
}
#[rocket::async_test]
async fn test_global_history_pagination() {
let db = crate::utiltest::mock_db_pool(setup_moria_db).await;
let pool = &db.moria_w;
for i in 0..5 {
insert_test_action(
pool,
&test_txid(i),
MoriaActionType::Borrow,
&borrower_a(),
1000,
(i as i64) * 100,
)
.await;
}
// limit
let page = get_global_history(pool, &[], 0, 2).await.unwrap();
assert_eq!(page.len(), 2);
// offset
let page2 = get_global_history(pool, &[], 2, 2).await.unwrap();
assert_eq!(page2.len(), 2);
assert_ne!(page[0].txid, page2[0].txid);
// all
let all = get_global_history(pool, &[], 0, 100).await.unwrap();
assert_eq!(all.len(), 5);
}
#[rocket::async_test]
async fn test_global_history_nfth_filter() {
let db = crate::utiltest::mock_db_pool(setup_moria_db).await;
let pool = &db.moria_w;
insert_test_action(
pool,
&test_txid(1),
MoriaActionType::Borrow,
&borrower_a(),
1000,
100,
)
.await;
insert_test_action(
pool,
&test_txid(2),
MoriaActionType::Borrow,
&borrower_b(),
500,
200,
)
.await;
insert_test_action(
pool,
&test_txid(3),
MoriaActionType::Repay,
&borrower_a(),
1000,
300,
)
.await;
// Filter to borrower_a only
let filtered = get_global_history(pool, &[borrower_a().to_vec()], 0, 100)
.await
.unwrap();
assert_eq!(filtered.len(), 2);
// Filter to both
let both = get_global_history(
pool,
&[borrower_a().to_vec(), borrower_b().to_vec()],
0,
100,
)
.await
.unwrap();
assert_eq!(both.len(), 3);
// No filter
let all = get_global_history(pool, &[], 0, 100).await.unwrap();
assert_eq!(all.len(), 3);
}
#[rocket::async_test]
async fn test_stats() {
let db = crate::utiltest::mock_db_pool(setup_moria_db).await;
let pool = &db.moria_w;
insert_test_action(
pool,
&test_txid(1),
MoriaActionType::Borrow,
&borrower_a(),
1000,
100,
)
.await;
insert_test_action(
pool,
&test_txid(2),
MoriaActionType::Borrow,
&borrower_b(),
500,
200,
)
.await;
insert_test_action(
pool,
&test_txid(3),
MoriaActionType::Repay,
&borrower_a(),
1000,
300,
)
.await;
let stats = get_stats(pool).await.unwrap();
assert_eq!(stats["total_borrows"], 2);
assert_eq!(stats["active_loans"], 1);
assert_eq!(stats["total_actions"], 3);
}
}

View file

@ -0,0 +1,54 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
//! Per-network Moria v1-1 TEST18 deployment constants.
//!
//! Chipnet is TEST18. Mainnet is unconfigured until a v1-1 deployment exists
//! (fail-closed).
use bitcoincash::{Network, TokenID};
use riftenlabs_defi::moria_v1_1::MoriaV11TokenIds;
/// Unique tail of the moria v1-1 TEST18 redeem script. Partial scriptsig match —
/// every moria method spends this body. Last 20 bytes of REDEEMSCRIPT_PROD.
const REDEEM_TAIL: [u8; 20] = [
0xd2, 0x51, 0xcf, 0x88, 0x52, 0xd1, 0x00, 0x88, 0xc4, 0x53, 0x9d, 0x6d, 0x6d, 0x51, 0x68, 0x68,
0x68, 0x68, 0x68, 0x68,
];
/// Chipnet TEST18 token categories and loan P2SH32, or `None` on other networks.
pub fn token_ids(network: Option<Network>) -> Option<MoriaV11TokenIds> {
match network {
Some(Network::Chipnet) => Some(MoriaV11TokenIds {
moria: "1c6dc7c7ad3e7e37fa1c84e715cd22a28177f52a2c49853e6e0b638505c29637"
.parse::<TokenID>()
.expect("valid chipnet TEST18 moria token_id"),
bp_oracle: "640d2c75cb1d5d15dbaf26022b37661d3578485a6788736893c1331ed2384905"
.parse::<TokenID>()
.expect("valid chipnet TEST18 bp_oracle token_id"),
pool: "8aaa8012b206f45b33472a55b5c3c328bae0eb33e4ee466f7f9cbade4750225c"
.parse::<TokenID>()
.expect("valid chipnet TEST18 pool token_id"),
bar: "779df3e91141f0ea414c477f61d08a3c55b69cd91eb58e42953d8c7eaec84bb9"
.parse::<TokenID>()
.expect("valid chipnet TEST18 bar token_id"),
loan_locking_bytecode: hex::decode(
"aa208bbc3b3f65b354e69a93175c70f3cbb619154ae2902878521c17edcbd83cc12f87",
)
.expect("valid chipnet TEST18 loan locking bytecode"),
}),
_ => None,
}
}
/// Electrum `mempool.get` filter for this network's loan lock + redeem tail.
pub fn mempool_filter(network: Option<Network>) -> Option<serde_json::Value> {
let ids = token_ids(network)?;
Some(serde_json::json!({
"scriptsig": hex::encode(REDEEM_TAIL),
"scriptpubkey": hex::encode(&ids.loan_locking_bytecode),
"operation": "union",
}))
}

869
src/db/moria_v11/ingest.rs Normal file
View file

@ -0,0 +1,869 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
//! Write path: index a block or mempool batch, mutate UTXO tables, roll back.
use anyhow::Result;
use bitcoin_hashes::Hash;
use bitcoincash::{BlockHash, TokenID, Transaction, Txid};
use log::debug;
use riftenlabs_defi::moria_v1_1::{
crank_allowance_locking_bytecode, crank_hint_to_policy, decode_crank_hint,
parse_moria_v11_from_tx, CrankPolicy, LoanCommitmentV11, MoriaV11ActionType, MoriaV11TokenIds,
ParsedMoriaV11Action,
};
use sqlx::{Row, SqlitePool};
use crate::db::blob::ToBlob;
use super::{action_type_to_int, action_type_to_str, staking};
const BP_MESSAGE_SIZE: usize = 26;
/// `BP_TIMESTAMP_OFFSET` — u48 LE seconds.
const BP_TIMESTAMP_OFFSET: usize = 0;
/// `BP_BP_OFFSET` — u16 LE threshold in basis points.
const BP_BP_OFFSET: usize = 8;
/// Decode threshold_bp + oracle_timestamp from a 26-byte BP oracle commitment.
///
/// Layout: timestamp u48 LE @ 0, threshold u16 LE @ 8.
pub fn parse_bp_commitment(commitment: &[u8]) -> Option<(u16, u64)> {
if commitment.len() != BP_MESSAGE_SIZE {
return None;
}
let mut ts_bytes = [0u8; 8];
ts_bytes[..6].copy_from_slice(&commitment[BP_TIMESTAMP_OFFSET..BP_TIMESTAMP_OFFSET + 6]);
let oracle_timestamp = u64::from_le_bytes(ts_bytes);
let threshold_bp = u16::from_le_bytes([commitment[BP_BP_OFFSET], commitment[BP_BP_OFFSET + 1]]);
Some((threshold_bp, oracle_timestamp))
}
/// Build a minimal 26-byte BP commitment for tests (only timestamp + threshold filled).
#[cfg(test)]
pub fn encode_bp_commitment(threshold_bp: u16, oracle_timestamp: u64) -> Vec<u8> {
let mut out = vec![0u8; BP_MESSAGE_SIZE];
out[BP_TIMESTAMP_OFFSET..BP_TIMESTAMP_OFFSET + 6]
.copy_from_slice(&oracle_timestamp.to_le_bytes()[..6]);
out[BP_BP_OFFSET..BP_BP_OFFSET + 2].copy_from_slice(&threshold_bp.to_le_bytes());
out
}
fn commitment_fields(c: &LoanCommitmentV11) -> (i64, i64, i64) {
(
c.principal as i64,
c.annual_interest_rate_bp as i64,
c.timestamp as i64,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn insert_loan_utxo(
pool: &SqlitePool,
txid: &Txid,
vout: u32,
commitment: &LoanCommitmentV11,
collateral_sats: u64,
blockhash: &BlockHash,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO moria_v11_loan_utxo
(txid, vout, borrower_hash, delegate_hash, principal, interest_rate,
loan_timestamp, collateral_sats, blockhash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(vout as i64)
.bind(commitment.borrower_nft_hash.as_slice())
.bind(commitment.delegate_nft_hash.as_slice())
.bind(commitment.principal as i64)
.bind(commitment.annual_interest_rate_bp as i64)
.bind(commitment.timestamp as i64)
.bind(collateral_sats as i64)
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert v11 loan utxo: {}", e))?;
Ok(())
}
async fn delete_loan_utxo(pool: &SqlitePool, txid: &Txid, vout: u32) -> Result<()> {
sqlx::query("DELETE FROM moria_v11_loan_utxo WHERE txid = ? AND vout = ?")
.bind(txid.to_blob())
.bind(vout as i64)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete v11 loan utxo: {}", e))?;
Ok(())
}
/// Look up a spent loan UTXO.
type LoanUtxoRow = (Vec<u8>, Vec<u8>, i64, i64, i64, i64);
async fn lookup_loan_utxo(
pool: &SqlitePool,
txid: &Txid,
vout: u32,
) -> Result<Option<LoanUtxoRow>> {
let row: Option<LoanUtxoRow> = sqlx::query_as(
"SELECT borrower_hash, delegate_hash, principal, interest_rate, loan_timestamp,
collateral_sats
FROM moria_v11_loan_utxo WHERE txid = ? AND vout = ?",
)
.bind(txid.to_blob())
.bind(vout as i64)
.fetch_optional(pool)
.await?;
Ok(row)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn insert_allowance_utxo(
pool: &SqlitePool,
txid: &Txid,
vout: u32,
owner_hash: &[u8],
delegate_hash: &[u8],
locking_bytecode: &[u8],
token_amount: i64,
sats: u64,
blockhash: &BlockHash,
policy: Option<&CrankPolicy>,
fee_sats: Option<i64>,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO moria_v11_allowance_utxo
(txid, vout, owner_hash, delegate_hash, locking_bytecode, token_amount, sats, blockhash,
policy_delta, policy_max_bp, policy_rescue_lead_bp,
policy_up_leg_seconds, policy_down_leg_seconds, fee_sats)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(vout as i64)
.bind(owner_hash)
.bind(delegate_hash)
.bind(locking_bytecode)
.bind(token_amount)
.bind(sats as i64)
.bind(blockhash.to_blob())
.bind(policy.map(|p| p.delta as i64))
.bind(policy.map(|p| p.max_bp as i64))
.bind(policy.map(|p| p.rescue_lead_bp as i64))
.bind(policy.map(|p| p.up_leg_seconds as i64))
.bind(policy.map(|p| p.down_leg_seconds as i64))
.bind(fee_sats)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert v11 allowance utxo: {}", e))?;
Ok(())
}
async fn delete_allowance_utxo(pool: &SqlitePool, txid: &Txid, vout: u32) -> Result<()> {
sqlx::query("DELETE FROM moria_v11_allowance_utxo WHERE txid = ? AND vout = ?")
.bind(txid.to_blob())
.bind(vout as i64)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete v11 allowance utxo: {}", e))?;
Ok(())
}
struct AllowanceUtxoRow {
owner_hash: Vec<u8>,
delegate_hash: Vec<u8>,
policy: Option<CrankPolicy>,
fee_sats: Option<i64>,
}
fn crank_policy_from_cols(
delta: Option<i64>,
max_bp: Option<i64>,
rescue_lead_bp: Option<i64>,
up_leg_seconds: Option<i64>,
down_leg_seconds: Option<i64>,
) -> Option<CrankPolicy> {
Some(CrankPolicy {
delta: u32::try_from(delta?).ok()?,
max_bp: u32::try_from(max_bp?).ok()?,
rescue_lead_bp: u32::try_from(rescue_lead_bp?).ok()?,
up_leg_seconds: u32::try_from(up_leg_seconds?).ok()?,
down_leg_seconds: u32::try_from(down_leg_seconds?).ok()?,
})
}
async fn lookup_allowance_utxo(
pool: &SqlitePool,
txid: &Txid,
vout: u32,
) -> Result<Option<AllowanceUtxoRow>> {
let row = sqlx::query(
"SELECT owner_hash, delegate_hash, locking_bytecode, token_amount, sats,
policy_delta, policy_max_bp, policy_rescue_lead_bp,
policy_up_leg_seconds, policy_down_leg_seconds, fee_sats
FROM moria_v11_allowance_utxo WHERE txid = ? AND vout = ?",
)
.bind(txid.to_blob())
.bind(vout as i64)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| AllowanceUtxoRow {
owner_hash: r.get("owner_hash"),
delegate_hash: r.get("delegate_hash"),
policy: crank_policy_from_cols(
r.get("policy_delta"),
r.get("policy_max_bp"),
r.get("policy_rescue_lead_bp"),
r.get("policy_up_leg_seconds"),
r.get("policy_down_leg_seconds"),
),
fee_sats: r.get("fee_sats"),
}))
}
/// Known allowance locking scripts already seen as live UTXOs (first-spend
/// or borrow deposit). Used so later top-ups to the same P2SH32 are indexed.
/// Policy+fee ride along so a top-up does not wipe a verified hint.
struct KnownAllowanceScript {
locking_bytecode: Vec<u8>,
owner_hash: Vec<u8>,
delegate_hash: Vec<u8>,
policy: Option<CrankPolicy>,
fee_sats: Option<i64>,
}
async fn known_allowance_scripts(pool: &SqlitePool) -> Result<Vec<KnownAllowanceScript>> {
let mut out: Vec<KnownAllowanceScript> = Vec::new();
let mut seen: std::collections::HashMap<Vec<u8>, usize> = std::collections::HashMap::new();
let utxo_rows = sqlx::query(
"SELECT locking_bytecode, owner_hash, delegate_hash,
policy_delta, policy_max_bp, policy_rescue_lead_bp,
policy_up_leg_seconds, policy_down_leg_seconds, fee_sats
FROM moria_v11_allowance_utxo",
)
.fetch_all(pool)
.await?;
for row in utxo_rows {
let script: Vec<u8> = row.get("locking_bytecode");
let policy = crank_policy_from_cols(
row.get("policy_delta"),
row.get("policy_max_bp"),
row.get("policy_rescue_lead_bp"),
row.get("policy_up_leg_seconds"),
row.get("policy_down_leg_seconds"),
);
let fee_sats: Option<i64> = row.get("fee_sats");
if let Some(&idx) = seen.get(&script) {
if out[idx].policy.is_none() && policy.is_some() {
out[idx].policy = policy;
}
if out[idx].fee_sats.is_none() && fee_sats.is_some() {
out[idx].fee_sats = fee_sats;
}
continue;
}
seen.insert(script.clone(), out.len());
out.push(KnownAllowanceScript {
locking_bytecode: script,
owner_hash: row.get("owner_hash"),
delegate_hash: row.get("delegate_hash"),
policy,
fee_sats,
});
}
Ok(out)
}
async fn insert_bp_threshold(
pool: &SqlitePool,
txid: &Txid,
blockhash: &BlockHash,
threshold_bp: u16,
oracle_timestamp: u64,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO moria_v11_bp_threshold
(txid, blockhash, threshold_bp, oracle_timestamp)
VALUES (?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(blockhash.to_blob())
.bind(threshold_bp as i64)
.bind(oracle_timestamp as i64)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert v11 bp threshold: {}", e))?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn insert_action(
pool: &SqlitePool,
txid: &Txid,
blockhash: &BlockHash,
mtp: i64,
first_seen: Option<i64>,
action: &ParsedMoriaV11Action,
borrower_hash: Option<&[u8]>,
delegate_hash: Option<&[u8]>,
) -> Result<()> {
let old_c = action.loan_commitment.as_ref();
let new_c = action.new_loan_commitment.as_ref();
let bh = borrower_hash
.or_else(|| old_c.map(|c| c.borrower_nft_hash.as_slice()))
.or_else(|| new_c.map(|c| c.borrower_nft_hash.as_slice()));
let dh = delegate_hash
.or_else(|| old_c.map(|c| c.delegate_nft_hash.as_slice()))
.or_else(|| new_c.map(|c| c.delegate_nft_hash.as_slice()));
let has_old = old_c.is_some();
let (principal, interest_rate, loan_timestamp) =
old_c.map(commitment_fields).unwrap_or((0, 0, 0));
let has_new = new_c.is_some();
let (new_principal, new_interest_rate, new_loan_timestamp) =
new_c.map(commitment_fields).unwrap_or((0, 0, 0));
let allowance_token_amount = action
.allowance_deposit
.as_ref()
.map(|d| d.token_amount)
.or_else(|| {
action
.crank_allowance
.as_ref()
.map(|c| c.output_token_amount)
});
let allowance_sats = action
.allowance_deposit
.as_ref()
.map(|d| d.sats as i64)
.or_else(|| {
action
.crank_allowance
.as_ref()
.map(|c| c.output_sats as i64)
});
let fee_take_sats = action.crank_fee_sats.map(|s| s as i64);
let recovered_token_amount = action.allowance_close.as_ref().map(|c| c.token_amount);
let recovered_sats = action.allowance_close.as_ref().map(|c| c.sats as i64);
let new_collateral_sats = if has_new {
action.collateral_sats.map(|s| s as i64)
} else {
None
};
sqlx::query(
"INSERT INTO moria_v11_action
(txid, blockhash, action_type, borrower_hash, delegate_hash,
principal, interest_rate, loan_timestamp,
collateral_sats, tokens_amount, mtp_timestamp, first_seen_timestamp,
new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats,
allowance_token_amount, allowance_sats, fee_take_sats,
recovered_token_amount, recovered_sats, loan_output_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(blockhash.to_blob())
.bind(action_type_to_int(action.action_type))
.bind(bh)
.bind(dh)
.bind(if has_old { Some(principal) } else { None })
.bind(if has_old { Some(interest_rate) } else { None })
.bind(if has_old { Some(loan_timestamp) } else { None })
.bind(action.collateral_sats.map(|s| s as i64))
.bind(action.tokens_amount)
.bind(mtp)
.bind(first_seen)
.bind(if has_new { Some(new_principal) } else { None })
.bind(if has_new {
Some(new_interest_rate)
} else {
None
})
.bind(if has_new {
Some(new_loan_timestamp)
} else {
None
})
.bind(new_collateral_sats)
.bind(allowance_token_amount)
.bind(allowance_sats)
.bind(fee_take_sats)
.bind(recovered_token_amount)
.bind(recovered_sats)
.bind(action.loan_output_index.map(|i| i as i64))
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert moria_v11_action: {}", e))?;
Ok(())
}
fn index_bp_oracle_outputs(tx: &Transaction, bp_oracle: &TokenID) -> Option<(u16, u64)> {
for out in &tx.output {
let Some(tok) = out.token.as_ref() else {
continue;
};
if &tok.id != bp_oracle {
continue;
}
if let Some(parsed) = parse_bp_commitment(&tok.commitment) {
return Some(parsed);
}
}
None
}
fn verified_borrow_hint_policy(
tx: &Transaction,
token_ids: &MoriaV11TokenIds,
commitment: &LoanCommitmentV11,
) -> Option<(CrankPolicy, u64)> {
let seq = tx.input.get(4)?.sequence.to_consensus_u32();
let hint = decode_crank_hint(seq)?;
if token_ids.loan_locking_bytecode.is_empty() {
return None;
}
let script = tx.output.get(8)?.script_pubkey.as_bytes();
let policy = crank_hint_to_policy(&hint);
let bp = token_ids.bp_oracle.to_byte_array();
let mut bp_rev = bp;
bp_rev.reverse();
for cat in [&bp, &bp_rev] {
let derived = crank_allowance_locking_bytecode(
&token_ids.loan_locking_bytecode,
&commitment.borrower_nft_hash,
&commitment.delegate_nft_hash,
hint.fee_sats,
cat,
&policy,
);
if script == derived.as_slice() || script.ends_with(derived.as_slice()) {
return Some((policy, hint.fee_sats));
}
}
None
}
/// Index moria v1-1 transactions from a block (or mempool batch).
///
/// Returns the number of loan/allowance actions recorded (BP threshold rows
/// are not included in the count).
pub async fn index_moria_v11(
pool: &SqlitePool,
txs: &[Transaction],
blockhash: &BlockHash,
mtp: i64,
token_ids: &MoriaV11TokenIds,
) -> Result<usize> {
let mut count = 0;
for tx in txs {
let txid = tx.compute_txid();
if *blockhash != BlockHash::all_zeros() {
delete_unconfirmed_tx(pool, &txid).await?;
}
// BP oracle threshold updates (independent of loan actions).
if let Some((threshold_bp, oracle_ts)) = index_bp_oracle_outputs(tx, &token_ids.bp_oracle) {
insert_bp_threshold(pool, &txid, blockhash, threshold_bp, oracle_ts).await?;
debug!(
"moria_v11 bp threshold: {} bp={} ts={}",
txid, threshold_bp, oracle_ts
);
}
// Discover allowances from borrow optional-MUSD or first spend.
// Previously-seen UTXO scripts remain recognizable for top-ups.
let known_scripts = known_allowance_scripts(pool).await?;
if !known_scripts.is_empty() {
for (vout, out) in tx.output.iter().enumerate() {
let script = out.script_pubkey.as_bytes();
let Some(known) = known_scripts
.iter()
.find(|k| k.locking_bytecode.as_slice() == script)
else {
continue;
};
let Some(tok) = out.token.as_ref() else {
continue;
};
if tok.id != token_ids.moria || tok.has_nft() {
continue;
}
let amount = tok.amount.to_int();
if amount <= 0 {
continue;
}
insert_allowance_utxo(
pool,
&txid,
vout as u32,
&known.owner_hash,
&known.delegate_hash,
script,
amount,
out.value.to_sat(),
blockhash,
known.policy.as_ref(),
known.fee_sats,
)
.await?;
}
}
let actions = parse_moria_v11_from_tx(tx, token_ids);
// Track which allowance outpoints this tx's actions explicitly handle,
// so the generic spend cleanup does not double-delete mid-action.
let mut handled_allowance_spends: Vec<(Txid, u32)> = Vec::new();
for mut action in actions {
let mut looked_up_borrower: Option<Vec<u8>> = None;
let mut looked_up_delegate: Option<Vec<u8>> = None;
// Fill prior commitment from the live loan UTXO set when the parser
// could not recover it (bare txs lack input token payloads).
if let Some((spent_txid, spent_vout)) = &action.spent_loan_outpoint {
if let Some((bh, dh, principal, interest_rate, loan_ts, collateral)) =
lookup_loan_utxo(pool, spent_txid, *spent_vout).await?
{
if action.loan_commitment.is_none() {
let mut borrower = [0u8; 32];
let mut delegate = [0u8; 32];
if bh.len() == 32 {
borrower.copy_from_slice(&bh);
}
if dh.len() == 32 {
delegate.copy_from_slice(&dh);
}
action.loan_commitment = Some(LoanCommitmentV11 {
borrower_nft_hash: borrower,
delegate_nft_hash: delegate,
principal: principal as u32,
annual_interest_rate_bp: interest_rate as u16,
timestamp: loan_ts as u64,
});
}
if action.collateral_sats.is_none()
&& matches!(
action.action_type,
MoriaV11ActionType::Repay
| MoriaV11ActionType::Redeem
| MoriaV11ActionType::Liquidate
)
{
action.collateral_sats = Some(collateral as u64);
}
looked_up_borrower = Some(bh);
looked_up_delegate = Some(dh);
delete_loan_utxo(pool, spent_txid, *spent_vout).await?;
}
}
// Create / replace continuing loan UTXO. Refinance, borrow, retarget,
// add-collateral, and partial redeem/repay (continuation NFT via
// `new_loan_commitment` + `loan_output_index`) all land here. Full
// close has no continuation and the spent UTXO stays deleted.
if let Some(output_idx) = action.loan_output_index {
if let Some(commitment) = action
.new_loan_commitment
.as_ref()
.or(action.loan_commitment.as_ref())
{
insert_loan_utxo(
pool,
&txid,
output_idx,
commitment,
action.collateral_sats.unwrap_or(0),
blockhash,
)
.await?;
}
}
// Allowance deposit on borrow (parser: optional MUSD at out 8/9).
if let Some(dep) = &action.allowance_deposit {
if let Some(c) = action
.loan_commitment
.as_ref()
.or(action.new_loan_commitment.as_ref())
{
let verified = verified_borrow_hint_policy(tx, token_ids, c);
let fee_sats = verified.as_ref().map(|(_, f)| *f as i64);
insert_allowance_utxo(
pool,
&txid,
dep.output_index,
&c.borrower_nft_hash,
&c.delegate_nft_hash,
&dep.locking_bytecode,
dep.token_amount,
dep.sats,
blockhash,
verified.as_ref().map(|(p, _)| p),
fee_sats,
)
.await?;
}
}
// Crank: spend prior allowance + insert the purse continuation
// (not same-index as the METHOD_CRANK input).
if let Some(info) = &action.crank_allowance {
let cont_vout = tx
.output
.iter()
.enumerate()
.find(|(_, o)| o.script_pubkey.as_bytes() == info.locking_bytecode.as_slice())
.map(|(i, _)| i as u32)
.unwrap_or(info.io_index);
if let Some(inp) = tx.input.get(info.io_index as usize) {
let prev = &inp.previous_output;
if let Some(spent) = lookup_allowance_utxo(pool, &prev.txid, prev.vout).await? {
delete_allowance_utxo(pool, &prev.txid, prev.vout).await?;
handled_allowance_spends.push((prev.txid, prev.vout));
insert_allowance_utxo(
pool,
&txid,
cont_vout,
&spent.owner_hash,
&spent.delegate_hash,
&info.locking_bytecode,
info.output_token_amount,
info.output_sats,
blockhash,
spent.policy.as_ref(),
spent.fee_sats,
)
.await?;
} else if let Some(c) = action
.new_loan_commitment
.as_ref()
.or(action.loan_commitment.as_ref())
{
// First spend of an unregistered allowance — classify now.
// Policy/fee stay null until a verified borrow hint (or a
// later spend of a row that already had them).
insert_allowance_utxo(
pool,
&txid,
cont_vout,
&c.borrower_nft_hash,
&c.delegate_nft_hash,
&info.locking_bytecode,
info.output_token_amount,
info.output_sats,
blockhash,
None,
None,
)
.await?;
} else if let (Some(owner), Some(delegate)) =
(&looked_up_borrower, &looked_up_delegate)
{
insert_allowance_utxo(
pool,
&txid,
cont_vout,
owner,
delegate,
&info.locking_bytecode,
info.output_token_amount,
info.output_sats,
blockhash,
None,
None,
)
.await?;
}
}
}
// AllowanceClose: look up owner before deleting the spent UTXO.
if let Some(close) = &action.allowance_close {
if let Some(inp) = tx.input.get(close.input_index as usize) {
let prev = &inp.previous_output;
if let Some(spent) = lookup_allowance_utxo(pool, &prev.txid, prev.vout).await? {
looked_up_borrower = Some(spent.owner_hash);
looked_up_delegate = Some(spent.delegate_hash);
delete_allowance_utxo(pool, &prev.txid, prev.vout).await?;
handled_allowance_spends.push((prev.txid, prev.vout));
}
}
}
debug!(
"moria_v11: {} {} (borrower: {})",
action_type_to_str(action.action_type),
txid,
action
.loan_commitment
.as_ref()
.or(action.new_loan_commitment.as_ref())
.map(|c| hex::encode(c.borrower_nft_hash))
.or_else(|| looked_up_borrower.as_ref().map(hex::encode))
.unwrap_or_else(|| "n/a".to_string()),
);
insert_action(
pool,
&txid,
blockhash,
mtp,
None,
&action,
looked_up_borrower.as_deref(),
looked_up_delegate.as_deref(),
)
.await?;
count += 1;
}
// Remove any remaining spent known-allowance UTXOs (e.g. closed without
// parser match, or reorg-safe cleanup of pure spends).
for inp in &tx.input {
let prev = &inp.previous_output;
if handled_allowance_spends
.iter()
.any(|(t, v)| t == &prev.txid && *v == prev.vout)
{
continue;
}
if lookup_allowance_utxo(pool, &prev.txid, prev.vout)
.await?
.is_some()
{
delete_allowance_utxo(pool, &prev.txid, prev.vout).await?;
}
}
}
Ok(count)
}
pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result<usize> {
sqlx::query("DELETE FROM moria_v11_loan_utxo WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete v11 loan utxos for block: {}", e))?;
sqlx::query("DELETE FROM moria_v11_allowance_utxo WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete v11 allowance utxos for block: {}", e))?;
sqlx::query("DELETE FROM moria_v11_bp_threshold WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete v11 bp thresholds for block: {}", e))?;
let r = sqlx::query("DELETE FROM moria_v11_action WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to delete moria_v11_action for block {}: {}",
blockhash,
e
)
})?;
let staking_deleted = staking::delete_entries_for_block(pool, blockhash).await?;
Ok(r.rows_affected() as usize + staking_deleted)
}
pub async fn has_entry(pool: &SqlitePool, txid: &Txid) -> Result<bool> {
let row: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM moria_v11_action WHERE txid = ? LIMIT 1")
.bind(txid.to_blob())
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
/// Txids currently stored under the mempool sentinel (`BlockHash::all_zeros`).
pub async fn get_unconfirmed_txids(pool: &SqlitePool) -> Result<Vec<Txid>> {
let zero = BlockHash::all_zeros().to_blob();
let rows = sqlx::query(
"SELECT txid FROM moria_v11_action WHERE blockhash = ?
UNION
SELECT txid FROM moria_v11_loan_utxo WHERE blockhash = ?
UNION
SELECT txid FROM moria_v11_allowance_utxo WHERE blockhash = ?
UNION
SELECT txid FROM moria_v11_bp_threshold WHERE blockhash = ?",
)
.bind(&zero)
.bind(&zero)
.bind(&zero)
.bind(&zero)
.fetch_all(pool)
.await?;
let mut out = Vec::new();
for row in rows {
let blob: Vec<u8> = row.get("txid");
if blob.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(&blob);
out.push(Txid::from_byte_array(arr));
}
}
Ok(out)
}
/// Drop a mempool-only row set (evicted / replaced). Confirmed rows stay.
pub async fn delete_unconfirmed_tx(pool: &SqlitePool, txid: &Txid) -> Result<usize> {
let zero = BlockHash::all_zeros().to_blob();
let id = txid.to_blob();
let mut n = 0usize;
for table in [
"moria_v11_loan_utxo",
"moria_v11_allowance_utxo",
"moria_v11_bp_threshold",
"moria_v11_action",
] {
let sql = format!("DELETE FROM {table} WHERE txid = ? AND blockhash = ?");
let r = sqlx::query(&sql)
.bind(&id)
.bind(&zero)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete unconfirmed {table}: {e}"))?;
n += r.rows_affected() as usize;
}
Ok(n)
}
pub async fn clear_mempool(pool: &SqlitePool) -> Result<usize> {
let zero = BlockHash::all_zeros().to_blob();
sqlx::query("DELETE FROM moria_v11_loan_utxo WHERE blockhash = ?")
.bind(&zero)
.execute(pool)
.await?;
sqlx::query("DELETE FROM moria_v11_allowance_utxo WHERE blockhash = ?")
.bind(&zero)
.execute(pool)
.await?;
sqlx::query("DELETE FROM moria_v11_bp_threshold WHERE blockhash = ?")
.bind(&zero)
.execute(pool)
.await?;
let r = sqlx::query("DELETE FROM moria_v11_action WHERE blockhash = ?")
.bind(&zero)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to clear v11 mempool entries: {}", e))?;
Ok(r.rows_affected() as usize)
}

457
src/db/moria_v11/mod.rs Normal file
View file

@ -0,0 +1,457 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
//! Moria v1-1 indexer: actions, live loan/allowance UTXO sets, and BP threshold history.
//!
//! Clean cutover from v1 — these are new tables (`moria_v11_*`). Old `moria.db`
//! instances are rebuilt on resync.
use anyhow::Result;
use bitcoincash::{BlockHash, Txid};
use riftenlabs_defi::moria_v1_1::MoriaV11ActionType;
use sqlx::{Row, SqlitePool};
use crate::db::blob::blob_to_display_hex;
pub mod deploy;
pub mod staking;
mod ingest;
mod query;
#[cfg(test)]
mod tests;
pub use deploy::{mempool_filter, token_ids};
pub use ingest::{
clear_mempool, delete_entries_for_block, delete_unconfirmed_tx, get_unconfirmed_txids,
has_entry, index_moria_v11,
};
pub use query::{
get_active_loans, get_allowances_by_owner, get_crankable, get_global_history, get_loan_history,
get_stats,
};
#[cfg(test)]
use ingest::{encode_bp_commitment, insert_allowance_utxo, insert_loan_utxo, parse_bp_commitment};
#[cfg(test)]
use query::{get_latest_bp_threshold, retarget_in_band};
#[derive(Debug, Clone, serde::Serialize)]
pub struct MoriaV11Entry {
pub txid: String,
pub blockhash: String,
pub action_type: &'static str,
pub borrower_hash: Option<String>,
pub delegate_hash: Option<String>,
/// Whole-MUSD principal (`i64`, not `u16`; 128_000 fits). Multiply by
/// [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units.
pub principal: Option<i64>,
pub interest_rate: Option<i64>,
pub loan_timestamp: Option<i64>,
pub collateral_sats: Option<i64>,
pub tokens_amount: Option<i64>,
pub mtp_timestamp: i64,
pub new_principal: Option<i64>,
pub new_interest_rate: Option<i64>,
pub new_loan_timestamp: Option<i64>,
pub new_collateral_sats: Option<i64>,
pub allowance_token_amount: Option<i64>,
pub allowance_sats: Option<i64>,
pub fee_take_sats: Option<i64>,
pub recovered_token_amount: Option<i64>,
pub recovered_sats: Option<i64>,
pub loan_output_index: Option<i64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MoriaV11LoanUtxo {
pub txid: String,
pub vout: i64,
pub borrower_hash: String,
pub delegate_hash: String,
/// Whole-MUSD principal (`i64`, not `u16`; 128_000 fits). Multiply by
/// [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units.
pub principal: i64,
pub interest_rate: i64,
pub loan_timestamp: i64,
pub collateral_sats: i64,
pub blockhash: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MoriaV11AllowanceUtxo {
pub txid: String,
pub vout: i64,
pub owner_hash: String,
pub delegate_hash: String,
pub locking_bytecode: String,
pub token_amount: i64,
pub sats: i64,
pub blockhash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_delta: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_max_bp: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_rescue_lead_bp: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_up_leg_seconds: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_down_leg_seconds: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fee_sats: Option<i64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MoriaV11BpThreshold {
pub txid: String,
pub blockhash: String,
pub threshold_bp: i64,
pub oracle_timestamp: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MoriaV11Crankable {
pub txid: String,
pub vout: i64,
pub borrower_hash: String,
pub delegate_hash: String,
/// Whole-MUSD principal (`i64`, not `u16`; 128_000 fits). Multiply by
/// [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units.
pub principal: i64,
pub interest_rate: i64,
pub loan_timestamp: i64,
/// From the parked allowance's CrankPolicy (not the loan NFT).
pub tracking_delta: i64,
/// From the parked allowance's CrankPolicy (not the loan NFT).
pub tracking_max_bp: i64,
pub collateral_sats: i64,
pub threshold_bp: i64,
pub target_bp: i64,
pub leg: &'static str,
pub allowance_txid: String,
pub allowance_vout: i64,
pub allowance_token_amount: i64,
pub allowance_sats: i64,
}
fn action_type_to_str(action: MoriaV11ActionType) -> &'static str {
match action {
MoriaV11ActionType::Borrow => "borrow",
MoriaV11ActionType::Repay => "repay",
MoriaV11ActionType::Redeem => "redeem",
MoriaV11ActionType::Refinance => "refinance",
MoriaV11ActionType::AddCollateral => "add_collateral",
MoriaV11ActionType::Retarget => "retarget",
MoriaV11ActionType::Crank => "crank",
MoriaV11ActionType::AllowanceClose => "allowance_close",
MoriaV11ActionType::Liquidate => "liquidate",
}
}
fn action_type_to_int(action: MoriaV11ActionType) -> i64 {
action as i64
}
fn int_to_action_type(val: i64) -> &'static str {
match val {
0 => "borrow",
1 => "repay",
2 => "redeem",
3 => "refinance",
4 => "add_collateral",
5 => "retarget",
6 => "crank",
7 => "allowance_close",
8 => "liquidate",
_ => "unknown",
}
}
impl MoriaV11Entry {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let action_type: i64 = row.get("action_type");
let borrower_blob: Option<Vec<u8>> = row.get("borrower_hash");
let delegate_blob: Option<Vec<u8>> = row.get("delegate_hash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&blockhash_blob)?,
action_type: int_to_action_type(action_type),
borrower_hash: borrower_blob.map(hex::encode),
delegate_hash: delegate_blob.map(hex::encode),
principal: row.get("principal"),
interest_rate: row.get("interest_rate"),
loan_timestamp: row.get("loan_timestamp"),
collateral_sats: row.get("collateral_sats"),
tokens_amount: row.get("tokens_amount"),
mtp_timestamp: row.get("mtp_timestamp"),
new_principal: row.get("new_principal"),
new_interest_rate: row.get("new_interest_rate"),
new_loan_timestamp: row.get("new_loan_timestamp"),
new_collateral_sats: row.get("new_collateral_sats"),
allowance_token_amount: row.get("allowance_token_amount"),
allowance_sats: row.get("allowance_sats"),
fee_take_sats: row.get("fee_take_sats"),
recovered_token_amount: row.get("recovered_token_amount"),
recovered_sats: row.get("recovered_sats"),
loan_output_index: row.get("loan_output_index"),
})
}
}
impl MoriaV11LoanUtxo {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let borrower_blob: Vec<u8> = row.get("borrower_hash");
let delegate_blob: Vec<u8> = row.get("delegate_hash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
vout: row.get("vout"),
borrower_hash: hex::encode(borrower_blob),
delegate_hash: hex::encode(delegate_blob),
principal: row.get("principal"),
interest_rate: row.get("interest_rate"),
loan_timestamp: row.get("loan_timestamp"),
collateral_sats: row.get("collateral_sats"),
blockhash: blob_to_display_hex::<BlockHash>(&blockhash_blob)?,
})
}
}
impl MoriaV11AllowanceUtxo {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let owner_blob: Vec<u8> = row.get("owner_hash");
let delegate_blob: Vec<u8> = row.get("delegate_hash");
let script: Vec<u8> = row.get("locking_bytecode");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
vout: row.get("vout"),
owner_hash: hex::encode(owner_blob),
delegate_hash: hex::encode(delegate_blob),
locking_bytecode: hex::encode(script),
token_amount: row.get("token_amount"),
sats: row.get("sats"),
blockhash: blob_to_display_hex::<BlockHash>(&blockhash_blob)?,
policy_delta: row.get("policy_delta"),
policy_max_bp: row.get("policy_max_bp"),
policy_rescue_lead_bp: row.get("policy_rescue_lead_bp"),
policy_up_leg_seconds: row.get("policy_up_leg_seconds"),
policy_down_leg_seconds: row.get("policy_down_leg_seconds"),
fee_sats: row.get("fee_sats"),
})
}
}
pub async fn prepare_tables(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS moria_v11_action (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txid BLOB NOT NULL,
blockhash BLOB NOT NULL,
action_type INTEGER NOT NULL,
borrower_hash BLOB,
delegate_hash BLOB,
principal INTEGER,
interest_rate INTEGER,
loan_timestamp INTEGER,
collateral_sats BIGINT,
tokens_amount BIGINT,
mtp_timestamp BIGINT NOT NULL,
first_seen_timestamp BIGINT,
new_principal INTEGER,
new_interest_rate INTEGER,
new_loan_timestamp INTEGER,
new_collateral_sats BIGINT,
allowance_token_amount BIGINT,
allowance_sats BIGINT,
fee_take_sats BIGINT,
recovered_token_amount BIGINT,
recovered_sats BIGINT,
loan_output_index INTEGER
)",
)
.execute(pool)
.await
.expect("failed to create moria_v11_action table");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_borrower_hash ON moria_v11_action(borrower_hash)",
)
.execute(pool)
.await
.expect("failed to create v11 borrower_hash index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_blockhash ON moria_v11_action(blockhash)",
)
.execute(pool)
.await
.expect("failed to create v11 blockhash index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_timestamp ON moria_v11_action(mtp_timestamp)",
)
.execute(pool)
.await
.expect("failed to create v11 timestamp index");
sqlx::query(
"CREATE TABLE IF NOT EXISTS moria_v11_loan_utxo (
txid BLOB NOT NULL,
vout INTEGER NOT NULL,
borrower_hash BLOB NOT NULL,
delegate_hash BLOB NOT NULL,
principal INTEGER NOT NULL,
interest_rate INTEGER NOT NULL,
loan_timestamp INTEGER NOT NULL,
collateral_sats BIGINT NOT NULL,
blockhash BLOB NOT NULL,
PRIMARY KEY (txid, vout)
)",
)
.execute(pool)
.await
.expect("failed to create moria_v11_loan_utxo table");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_loan_borrower ON moria_v11_loan_utxo(borrower_hash)",
)
.execute(pool)
.await
.expect("failed to create v11 loan borrower index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_loan_blockhash ON moria_v11_loan_utxo(blockhash)",
)
.execute(pool)
.await
.expect("failed to create v11 loan blockhash index");
sqlx::query(
"CREATE TABLE IF NOT EXISTS moria_v11_allowance_utxo (
txid BLOB NOT NULL,
vout INTEGER NOT NULL,
owner_hash BLOB NOT NULL,
delegate_hash BLOB NOT NULL,
locking_bytecode BLOB NOT NULL,
token_amount BIGINT NOT NULL,
sats BIGINT NOT NULL,
blockhash BLOB NOT NULL,
policy_delta INTEGER,
policy_max_bp INTEGER,
policy_rescue_lead_bp INTEGER,
policy_up_leg_seconds INTEGER,
policy_down_leg_seconds INTEGER,
fee_sats INTEGER,
PRIMARY KEY (txid, vout)
)",
)
.execute(pool)
.await
.expect("failed to create moria_v11_allowance_utxo table");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_allowance_owner ON moria_v11_allowance_utxo(owner_hash)",
)
.execute(pool)
.await
.expect("failed to create v11 allowance owner index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_allowance_script ON moria_v11_allowance_utxo(locking_bytecode)",
)
.execute(pool)
.await
.expect("failed to create v11 allowance script index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_allowance_blockhash ON moria_v11_allowance_utxo(blockhash)",
)
.execute(pool)
.await
.expect("failed to create v11 allowance blockhash index");
// Existing DBs created before constructor fee lived on the live UTXO row.
let has_fee: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM pragma_table_info('moria_v11_allowance_utxo') WHERE name = 'fee_sats'",
)
.fetch_one(pool)
.await
.expect("failed to inspect moria_v11_allowance_utxo columns");
if has_fee.0 == 0 {
sqlx::query("ALTER TABLE moria_v11_allowance_utxo ADD COLUMN fee_sats INTEGER")
.execute(pool)
.await
.expect("failed to add fee_sats to moria_v11_allowance_utxo");
}
// ALTER leaves existing rows fee_sats=NULL. Copy from a sibling at the
// same vault script when one already has the constructor fields.
sqlx::query(
"UPDATE moria_v11_allowance_utxo
SET fee_sats = COALESCE(fee_sats, (
SELECT s.fee_sats FROM moria_v11_allowance_utxo s
WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode
AND s.fee_sats IS NOT NULL
LIMIT 1)),
policy_delta = COALESCE(policy_delta, (
SELECT s.policy_delta FROM moria_v11_allowance_utxo s
WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode
AND s.policy_delta IS NOT NULL
LIMIT 1)),
policy_max_bp = COALESCE(policy_max_bp, (
SELECT s.policy_max_bp FROM moria_v11_allowance_utxo s
WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode
AND s.policy_max_bp IS NOT NULL
LIMIT 1)),
policy_rescue_lead_bp = COALESCE(policy_rescue_lead_bp, (
SELECT s.policy_rescue_lead_bp FROM moria_v11_allowance_utxo s
WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode
AND s.policy_rescue_lead_bp IS NOT NULL
LIMIT 1)),
policy_up_leg_seconds = COALESCE(policy_up_leg_seconds, (
SELECT s.policy_up_leg_seconds FROM moria_v11_allowance_utxo s
WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode
AND s.policy_up_leg_seconds IS NOT NULL
LIMIT 1)),
policy_down_leg_seconds = COALESCE(policy_down_leg_seconds, (
SELECT s.policy_down_leg_seconds FROM moria_v11_allowance_utxo s
WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode
AND s.policy_down_leg_seconds IS NOT NULL
LIMIT 1))
WHERE fee_sats IS NULL OR policy_delta IS NULL",
)
.execute(pool)
.await
.expect("failed to backfill allowance policy/fee from sibling scripts");
sqlx::query(
"CREATE TABLE IF NOT EXISTS moria_v11_bp_threshold (
txid BLOB NOT NULL,
blockhash BLOB NOT NULL,
threshold_bp INTEGER NOT NULL,
oracle_timestamp INTEGER NOT NULL,
PRIMARY KEY (txid)
)",
)
.execute(pool)
.await
.expect("failed to create moria_v11_bp_threshold table");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_bp_blockhash ON moria_v11_bp_threshold(blockhash)",
)
.execute(pool)
.await
.expect("failed to create v11 bp blockhash index");
staking::prepare_tables(pool).await;
}

308
src/db/moria_v11/query.rs Normal file
View file

@ -0,0 +1,308 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
//! Read path: history, live UTXOs, crankable feed, stats.
use anyhow::Result;
use bitcoincash::{BlockHash, Txid};
use riftenlabs_defi::moria_v1_1::{DEFAULT_DOWN_LEG_SECONDS, DEFAULT_UP_LEG_SECONDS};
use sqlx::{Row, SqlitePool};
use crate::db::blob::blob_to_display_hex;
use super::{
MoriaV11AllowanceUtxo, MoriaV11BpThreshold, MoriaV11Crankable, MoriaV11Entry, MoriaV11LoanUtxo,
};
const ACTION_SELECT: &str = "SELECT txid, blockhash, action_type, borrower_hash, delegate_hash,
principal, interest_rate, loan_timestamp,
collateral_sats, tokens_amount, mtp_timestamp,
new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats,
allowance_token_amount, allowance_sats, fee_take_sats,
recovered_token_amount, recovered_sats, loan_output_index
FROM moria_v11_action";
/// Get full loan history for a borrower_hash.
pub async fn get_loan_history(
pool: &SqlitePool,
borrower_hash: &[u8],
) -> Result<Vec<MoriaV11Entry>> {
let sql = format!(
"{ACTION_SELECT}
WHERE borrower_hash = ?
ORDER BY mtp_timestamp ASC, id ASC"
);
let rows = sqlx::query(&sql)
.bind(borrower_hash)
.fetch_all(pool)
.await?;
let mut entries = Vec::new();
for row in rows {
entries.push(MoriaV11Entry::from_row(&row)?);
}
Ok(entries)
}
/// Active loans = live rows in the loan UTXO set.
pub async fn get_active_loans(pool: &SqlitePool) -> Result<Vec<MoriaV11LoanUtxo>> {
let rows = sqlx::query(
"SELECT txid, vout, borrower_hash, delegate_hash, principal, interest_rate,
loan_timestamp, collateral_sats, blockhash
FROM moria_v11_loan_utxo
ORDER BY loan_timestamp DESC",
)
.fetch_all(pool)
.await?;
let mut entries = Vec::new();
for row in rows {
entries.push(MoriaV11LoanUtxo::from_row(&row)?);
}
Ok(entries)
}
/// Global history with pagination and optional nfth filter (borrower hashes).
pub async fn get_global_history(
pool: &SqlitePool,
nfth_filter: &[Vec<u8>],
offset: i64,
limit: i64,
) -> Result<Vec<MoriaV11Entry>> {
let rows = if nfth_filter.is_empty() {
let sql = format!(
"{ACTION_SELECT}
ORDER BY mtp_timestamp DESC, id DESC
LIMIT ? OFFSET ?"
);
sqlx::query(&sql)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
} else {
let placeholders: Vec<&str> = nfth_filter.iter().map(|_| "?").collect();
let sql = format!(
"{ACTION_SELECT}
WHERE borrower_hash IN ({})
ORDER BY mtp_timestamp DESC, id DESC
LIMIT ? OFFSET ?",
placeholders.join(",")
);
let mut query = sqlx::query(&sql);
for nfth in nfth_filter {
query = query.bind(nfth);
}
query = query.bind(limit).bind(offset);
query.fetch_all(pool).await?
};
let mut entries = Vec::new();
for row in rows {
entries.push(MoriaV11Entry::from_row(&row)?);
}
Ok(entries)
}
/// Live allowance UTXOs for an owner NFT hash (includes dead-loan salvage).
pub async fn get_allowances_by_owner(
pool: &SqlitePool,
owner_hash: &[u8],
) -> Result<Vec<MoriaV11AllowanceUtxo>> {
let rows = sqlx::query(
"SELECT txid, vout, owner_hash, delegate_hash, locking_bytecode,
token_amount, sats, blockhash,
policy_delta, policy_max_bp, policy_rescue_lead_bp,
policy_up_leg_seconds, policy_down_leg_seconds, fee_sats
FROM moria_v11_allowance_utxo
WHERE owner_hash = ?
ORDER BY token_amount DESC",
)
.bind(owner_hash)
.fetch_all(pool)
.await?;
let mut entries = Vec::new();
for row in rows {
entries.push(MoriaV11AllowanceUtxo::from_row(&row)?);
}
Ok(entries)
}
/// Latest BP oracle threshold row, if any.
pub async fn get_latest_bp_threshold(pool: &SqlitePool) -> Result<Option<MoriaV11BpThreshold>> {
let row = sqlx::query(
"SELECT txid, blockhash, threshold_bp, oracle_timestamp
FROM moria_v11_bp_threshold
ORDER BY oracle_timestamp DESC, rowid DESC
LIMIT 1",
)
.fetch_optional(pool)
.await?;
match row {
Some(row) => {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
Ok(Some(MoriaV11BpThreshold {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&blockhash_blob)?,
threshold_bp: row.get("threshold_bp"),
oracle_timestamp: row.get("oracle_timestamp"),
}))
}
None => Ok(None),
}
}
/// Whether a loan's current rate sits inside the retarget refusal band
/// `(threshold + rescue_lead, threshold + 2·delta]`. Loans at or below
/// `threshold + rescue_lead` are up-leg crankable.
pub fn retarget_in_band(rate: i64, threshold: i64, delta: i64, rescue_lead: i64) -> bool {
rate > threshold + rescue_lead && rate <= threshold + 2 * delta
}
fn crank_leg(rate: i64, threshold: i64, rescue_lead: i64) -> &'static str {
if rate <= threshold + rescue_lead {
"up"
} else {
"down"
}
}
/// Loans that are currently legal + funded to crank, evaluated at `now_ts`
/// (delphi-clock seconds; callers typically pass tip MTP or oracle time).
///
/// Cadence and tracking knobs live on the parked allowance's CrankPolicy,
/// not on the loan NFT.
///
/// - funded allowance for (owner, delegate) with `token_amount > 1`
/// - policy: `policy_delta > 0` and `policy_max_bp > 0` (revealed on first spend
/// or stored when known; otherwise default cadence 3600/86400 and skip if
/// delta/max unknown)
/// - band: crankable iff NOT in `(t + rescue_lead, t+2δ]`
/// - target `t+δ` must be `<= policy_max_bp`
/// - cadence: up-leg / down-leg seconds from the allowance policy
pub async fn get_crankable(pool: &SqlitePool, now_ts: i64) -> Result<Vec<MoriaV11Crankable>> {
let Some(bp) = get_latest_bp_threshold(pool).await? else {
return Ok(Vec::new());
};
let threshold = bp.threshold_bp;
let loans = sqlx::query(
"SELECT txid, vout, borrower_hash, delegate_hash, principal, interest_rate,
loan_timestamp, collateral_sats
FROM moria_v11_loan_utxo",
)
.fetch_all(pool)
.await?;
let mut out = Vec::new();
for row in loans {
let rate: i64 = row.get("interest_rate");
let loan_ts: i64 = row.get("loan_timestamp");
let owner: Vec<u8> = row.get("borrower_hash");
let delegate: Vec<u8> = row.get("delegate_hash");
let allowance = sqlx::query(
"SELECT txid, vout, token_amount, sats,
policy_delta, policy_max_bp, policy_rescue_lead_bp,
policy_up_leg_seconds, policy_down_leg_seconds
FROM moria_v11_allowance_utxo
WHERE owner_hash = ? AND delegate_hash = ? AND token_amount > 1
ORDER BY token_amount DESC
LIMIT 1",
)
.bind(&owner)
.bind(&delegate)
.fetch_optional(pool)
.await?;
let Some(allowance) = allowance else {
continue;
};
let delta: Option<i64> = allowance.get("policy_delta");
let max_bp: Option<i64> = allowance.get("policy_max_bp");
let (Some(delta), Some(max_bp)) = (delta, max_bp) else {
continue;
};
if delta <= 0 || max_bp <= 0 {
continue;
}
let rescue_lead: i64 = allowance
.get::<Option<i64>, _>("policy_rescue_lead_bp")
.unwrap_or(0);
let up_leg: i64 = allowance
.get::<Option<i64>, _>("policy_up_leg_seconds")
.unwrap_or(i64::from(DEFAULT_UP_LEG_SECONDS));
let down_leg: i64 = allowance
.get::<Option<i64>, _>("policy_down_leg_seconds")
.unwrap_or(i64::from(DEFAULT_DOWN_LEG_SECONDS));
let target = threshold + delta;
if target > max_bp {
continue;
}
if retarget_in_band(rate, threshold, delta, rescue_lead) {
continue;
}
let leg = crank_leg(rate, threshold, rescue_lead);
let needed = if leg == "up" { up_leg } else { down_leg };
if now_ts.saturating_sub(loan_ts) < needed {
continue;
}
let txid_blob: Vec<u8> = row.get("txid");
let a_txid_blob: Vec<u8> = allowance.get("txid");
out.push(MoriaV11Crankable {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
vout: row.get("vout"),
borrower_hash: hex::encode(&owner),
delegate_hash: hex::encode(&delegate),
principal: row.get("principal"),
interest_rate: rate,
loan_timestamp: loan_ts,
tracking_delta: delta,
tracking_max_bp: max_bp,
collateral_sats: row.get("collateral_sats"),
threshold_bp: threshold,
target_bp: target,
leg,
allowance_txid: blob_to_display_hex::<Txid>(&a_txid_blob)?,
allowance_vout: allowance.get("vout"),
allowance_token_amount: allowance.get("token_amount"),
allowance_sats: allowance.get("sats"),
});
}
Ok(out)
}
pub async fn get_stats(pool: &SqlitePool) -> Result<serde_json::Value> {
let total_borrows: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM moria_v11_action WHERE action_type = 0")
.fetch_one(pool)
.await?;
let active_loans: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_v11_loan_utxo")
.fetch_one(pool)
.await?;
let total_actions: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_v11_action")
.fetch_one(pool)
.await?;
let active_allowances: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_v11_allowance_utxo")
.fetch_one(pool)
.await?;
let latest_bp = get_latest_bp_threshold(pool).await?;
Ok(serde_json::json!({
"total_borrows": total_borrows.0,
"active_loans": active_loans.0,
"total_actions": total_actions.0,
"active_allowances": active_allowances.0,
"latest_threshold_bp": latest_bp.as_ref().map(|b| b.threshold_bp),
"latest_oracle_timestamp": latest_bp.as_ref().map(|b| b.oracle_timestamp),
}))
}

999
src/db/moria_v11/staking.rs Normal file
View file

@ -0,0 +1,999 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
//! Bar (single-asset MUSD staking) and liquidation-pool state history, and
//! the realized NAV-growth APR derived from it.
//!
//! One row is appended per confirmed tx that **recreates** the respective
//! state NFT (category match + `Capability::Mutable`, mirroring the covenant
//! invariant that the state NFT is minted once at genesis and never changes
//! capability). Decoding itself lives in `riftenlabs_defi::moria_v1_1`;
//! this module only times, stores, and re-derives a rate from those samples.
//!
//! ## APR semantics
//!
//! `apr_nav_bp` is realized **NAV growth for a continuing holder**, GROSS of
//! any exit fee a leaver would pay — never call it a redemption/exit yield.
//! For the bar: `rate = musd_balance / xmusd_outstanding`. For the pool:
//! `rate = xmusd_balance / shares_outstanding`, in xMUSD terms **only** — the
//! BCH pot is deliberately excluded (pricing it needs a point-in-time oracle
//! print the history table doesn't carry), so the pool figure understates
//! the pool's true total return. Every consumer must say so.
//!
//! The time axis is indexed block MTP, not wall-clock time: the APR window's
//! "now" is the most recent history row's `mtp`, so the whole computation is
//! a pure function of stored samples (see [`nav_apr_window`]).
use anyhow::Result;
use bitcoincash::blockdata::token::Capability;
use bitcoincash::{BlockHash, TokenID, Transaction, Txid};
use riftenlabs_defi::moria_v1_1::{
parse_bar_state_v11, parse_pool_state_v11, BarStateV11, MoriaV11TokenIds, PoolStateV11,
};
use sqlx::{Row, SqlitePool};
use crate::db::blob::{blob_to_display_hex, ToBlob};
/// Default `/moria/v11/staking/apr` window: 7 days.
pub const DEFAULT_APR_WINDOW_SECONDS: i64 = 7 * 86_400;
/// Annualization base (simple 365-day convention, no leap-year adjustment —
/// this is a reporting statistic, not a covenant amount).
const SECONDS_PER_YEAR: i64 = 365 * 86_400;
#[derive(Debug, Clone, serde::Serialize)]
pub struct BarHistoryEntry {
pub txid: String,
pub blockhash: String,
pub height: i64,
pub mtp: i64,
pub musd_balance: i64,
pub exit_fee_liability: i64,
pub last_decay_time: i64,
pub xmusd_outstanding: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PoolHistoryEntry {
pub txid: String,
pub blockhash: String,
pub height: i64,
pub mtp: i64,
pub xmusd_balance: i64,
pub exit_fee_liability: i64,
pub kick_time: i64,
pub shares_outstanding: i64,
pub bch_pot_sats: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RateFraction {
pub numerator: String,
pub denominator: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BarAprWindow {
pub apr_nav_bp: i64,
pub from_mtp: i64,
pub to_mtp: i64,
pub elapsed_seconds: i64,
pub from_rate: RateFraction,
pub to_rate: RateFraction,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PoolAprWindow {
pub apr_nav_bp: i64,
pub from_mtp: i64,
pub to_mtp: i64,
pub elapsed_seconds: i64,
pub from_rate: RateFraction,
pub to_rate: RateFraction,
/// Always `true`: this figure is xMUSD-terms NAV growth only and
/// excludes the pool's BCH pot (see module docs) — never present it as
/// the pool's total return.
pub excludes_bch_pot: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StakingAprResponse {
pub bar: Option<BarAprWindow>,
pub pool: Option<PoolAprWindow>,
}
impl BarHistoryEntry {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&blockhash_blob)?,
height: row.get("height"),
mtp: row.get("mtp"),
musd_balance: row.get("musd_balance"),
exit_fee_liability: row.get("exit_fee_liability"),
last_decay_time: row.get("last_decay_time"),
xmusd_outstanding: row.get("xmusd_outstanding"),
})
}
}
impl PoolHistoryEntry {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&blockhash_blob)?,
height: row.get("height"),
mtp: row.get("mtp"),
xmusd_balance: row.get("xmusd_balance"),
exit_fee_liability: row.get("exit_fee_liability"),
kick_time: row.get("kick_time"),
shares_outstanding: row.get("shares_outstanding"),
bch_pot_sats: row.get("bch_pot_sats"),
})
}
}
pub async fn prepare_tables(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS moria_v11_bar_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txid BLOB NOT NULL,
blockhash BLOB NOT NULL,
height BIGINT NOT NULL,
mtp BIGINT NOT NULL,
musd_balance BIGINT NOT NULL,
exit_fee_liability BIGINT NOT NULL,
last_decay_time BIGINT NOT NULL,
xmusd_outstanding BIGINT NOT NULL
)",
)
.execute(pool)
.await
.expect("failed to create moria_v11_bar_history table");
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_moria_v11_bar_history_txid
ON moria_v11_bar_history(txid)",
)
.execute(pool)
.await
.expect("failed to create v11 bar history txid index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_bar_history_mtp ON moria_v11_bar_history(mtp)",
)
.execute(pool)
.await
.expect("failed to create v11 bar history mtp index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_bar_history_blockhash
ON moria_v11_bar_history(blockhash)",
)
.execute(pool)
.await
.expect("failed to create v11 bar history blockhash index");
sqlx::query(
"CREATE TABLE IF NOT EXISTS moria_v11_pool_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txid BLOB NOT NULL,
blockhash BLOB NOT NULL,
height BIGINT NOT NULL,
mtp BIGINT NOT NULL,
xmusd_balance BIGINT NOT NULL,
exit_fee_liability BIGINT NOT NULL,
kick_time BIGINT NOT NULL,
shares_outstanding BIGINT NOT NULL,
bch_pot_sats BIGINT NOT NULL
)",
)
.execute(pool)
.await
.expect("failed to create moria_v11_pool_history table");
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_moria_v11_pool_history_txid
ON moria_v11_pool_history(txid)",
)
.execute(pool)
.await
.expect("failed to create v11 pool history txid index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_pool_history_mtp ON moria_v11_pool_history(mtp)",
)
.execute(pool)
.await
.expect("failed to create v11 pool history mtp index");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_moria_v11_pool_history_blockhash
ON moria_v11_pool_history(blockhash)",
)
.execute(pool)
.await
.expect("failed to create v11 pool history blockhash index");
}
/// Find the (at most one) output recreating `category`'s mutable state NFT
/// in `tx`, returning its raw commitment, token amount, and sats. Matches
/// `Capability::Mutable` specifically: the state NFT is minted once at
/// genesis with that capability and never changes it (see module docs).
fn find_state_output<'a>(tx: &'a Transaction, category: &TokenID) -> Option<(&'a [u8], u64, u64)> {
tx.output.iter().find_map(|out| {
let tok = out.token.as_ref()?;
if &tok.id != category || !tok.has_nft() || tok.capability() != Capability::Mutable as u8 {
return None;
}
// TokenAmount's invariant is 0..=i64::MAX -- always non-negative.
Some((
tok.commitment.as_slice(),
tok.amount.to_int() as u64,
out.value.to_sat(),
))
})
}
async fn insert_bar_history(
pool: &SqlitePool,
txid: &Txid,
blockhash: &BlockHash,
height: i64,
mtp: i64,
state: &BarStateV11,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO moria_v11_bar_history
(txid, blockhash, height, mtp, musd_balance, exit_fee_liability,
last_decay_time, xmusd_outstanding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(blockhash.to_blob())
.bind(height)
.bind(mtp)
.bind(state.musd_balance as i64)
.bind(state.exit_fee_liability as i64)
.bind(state.last_decay_time as i64)
.bind(state.xmusd_outstanding as i64)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert bar history row: {e}"))?;
Ok(())
}
async fn insert_pool_history(
pool: &SqlitePool,
txid: &Txid,
blockhash: &BlockHash,
height: i64,
mtp: i64,
state: &PoolStateV11,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO moria_v11_pool_history
(txid, blockhash, height, mtp, xmusd_balance, exit_fee_liability,
kick_time, shares_outstanding, bch_pot_sats)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(blockhash.to_blob())
.bind(height)
.bind(mtp)
.bind(state.xmusd_balance as i64)
.bind(state.exit_fee_liability as i64)
.bind(state.kick_time as i64)
.bind(state.shares_outstanding as i64)
.bind(state.bch_pot_sats as i64)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to insert pool history row: {e}"))?;
Ok(())
}
/// Index bar/pool state-NFT recreations from a confirmed block's txs.
///
/// Returns the number of history rows inserted (bar + pool combined). Not
/// wired into mempool indexing: history is deliberately confirmed-block-only
/// (`mtp` only has meaning for a mined block).
pub async fn index_bar_pool_history(
pool: &SqlitePool,
txs: &[Transaction],
blockhash: &BlockHash,
height: i64,
mtp: i64,
token_ids: &MoriaV11TokenIds,
) -> Result<usize> {
let mut count = 0;
for tx in txs {
if let Some((commitment, amount, _sats)) = find_state_output(tx, &token_ids.bar) {
if let Some(state) = parse_bar_state_v11(commitment, amount) {
let txid = tx.compute_txid();
insert_bar_history(pool, &txid, blockhash, height, mtp, &state).await?;
count += 1;
}
}
if let Some((commitment, amount, sats)) = find_state_output(tx, &token_ids.pool) {
if let Some(state) = parse_pool_state_v11(commitment, amount, sats) {
let txid = tx.compute_txid();
insert_pool_history(pool, &txid, blockhash, height, mtp, &state).await?;
count += 1;
}
}
}
Ok(count)
}
/// Reorg delete: removes every bar/pool history row recorded in `blockhash`.
/// Returns the total number of rows removed (bar + pool).
pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result<usize> {
let r1 = sqlx::query("DELETE FROM moria_v11_bar_history WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete bar history for block: {e}"))?;
let r2 = sqlx::query("DELETE FROM moria_v11_pool_history WHERE blockhash = ?")
.bind(blockhash.to_blob())
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to delete pool history for block: {e}"))?;
Ok(r1.rows_affected() as usize + r2.rows_affected() as usize)
}
const BAR_HISTORY_SELECT: &str = "SELECT txid, blockhash, height, mtp, musd_balance,
exit_fee_liability, last_decay_time, xmusd_outstanding FROM moria_v11_bar_history";
/// Bar state history, newest first. `since` (when given) restricts to rows
/// with `mtp > since` (indexed block MTP seconds).
pub async fn get_bar_history(
pool: &SqlitePool,
since: Option<i64>,
limit: i64,
) -> Result<Vec<BarHistoryEntry>> {
let rows = if let Some(since) = since {
sqlx::query(&format!(
"{BAR_HISTORY_SELECT} WHERE mtp > ? ORDER BY mtp DESC, id DESC LIMIT ?"
))
.bind(since)
.bind(limit)
.fetch_all(pool)
.await?
} else {
sqlx::query(&format!(
"{BAR_HISTORY_SELECT} ORDER BY mtp DESC, id DESC LIMIT ?"
))
.bind(limit)
.fetch_all(pool)
.await?
};
rows.iter().map(BarHistoryEntry::from_row).collect()
}
const POOL_HISTORY_SELECT: &str = "SELECT txid, blockhash, height, mtp, xmusd_balance,
exit_fee_liability, kick_time, shares_outstanding, bch_pot_sats FROM moria_v11_pool_history";
/// Pool state history, newest first. `since` (when given) restricts to rows
/// with `mtp > since` (indexed block MTP seconds).
pub async fn get_pool_history(
pool: &SqlitePool,
since: Option<i64>,
limit: i64,
) -> Result<Vec<PoolHistoryEntry>> {
let rows = if let Some(since) = since {
sqlx::query(&format!(
"{POOL_HISTORY_SELECT} WHERE mtp > ? ORDER BY mtp DESC, id DESC LIMIT ?"
))
.bind(since)
.bind(limit)
.fetch_all(pool)
.await?
} else {
sqlx::query(&format!(
"{POOL_HISTORY_SELECT} ORDER BY mtp DESC, id DESC LIMIT ?"
))
.bind(limit)
.fetch_all(pool)
.await?
};
rows.iter().map(PoolHistoryEntry::from_row).collect()
}
async fn latest_bar_rate(pool: &SqlitePool) -> Result<Option<(i64, u64, u64)>> {
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT mtp, musd_balance, xmusd_outstanding FROM moria_v11_bar_history
ORDER BY mtp DESC, id DESC LIMIT 1",
)
.fetch_optional(pool)
.await?;
Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64)))
}
async fn earliest_bar_rate_since(
pool: &SqlitePool,
threshold_mtp: i64,
) -> Result<Option<(i64, u64, u64)>> {
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT mtp, musd_balance, xmusd_outstanding FROM moria_v11_bar_history
WHERE mtp >= ? ORDER BY mtp ASC, id ASC LIMIT 1",
)
.bind(threshold_mtp)
.fetch_optional(pool)
.await?;
Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64)))
}
async fn latest_pool_rate(pool: &SqlitePool) -> Result<Option<(i64, u64, u64)>> {
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT mtp, xmusd_balance, shares_outstanding FROM moria_v11_pool_history
ORDER BY mtp DESC, id DESC LIMIT 1",
)
.fetch_optional(pool)
.await?;
Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64)))
}
async fn earliest_pool_rate_since(
pool: &SqlitePool,
threshold_mtp: i64,
) -> Result<Option<(i64, u64, u64)>> {
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT mtp, xmusd_balance, shares_outstanding FROM moria_v11_pool_history
WHERE mtp >= ? ORDER BY mtp ASC, id ASC LIMIT 1",
)
.bind(threshold_mtp)
.fetch_optional(pool)
.await?;
Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64)))
}
/// One endpoint's realized NAV-growth APR window, generic over bar
/// (MUSD/xMUSD) vs pool (xMUSD/share) rates — same math, only the meaning of
/// "balance"/"shares" differs. Converted into [`BarAprWindow`] /
/// [`PoolAprWindow`] for serialization.
#[derive(Debug, Clone, Copy, PartialEq)]
struct NavAprWindow {
apr_nav_bp: i64,
from_mtp: i64,
to_mtp: i64,
elapsed_seconds: i64,
from_numerator: u64,
from_denominator: u64,
to_numerator: u64,
to_denominator: u64,
}
/// Realized NAV-growth APR (GROSS of any exit fee) between two
/// `(mtp, balance, shares)` samples, per the pinned formula:
///
/// ```text
/// rate = balance / shares
/// apr_nav_bp = (rate_end/rate_start - 1) * (SECONDS_PER_YEAR / elapsed) * 10000
/// ```
///
/// Returns `None` when:
/// - either endpoint has zero shares (rate undefined), or the start balance
/// is zero (growth from a zero base is undefined, not "infinite");
/// - the samples don't span a positive duration, or span less than half of
/// `window_seconds` (too little history to trust the annualization).
fn nav_apr_window(
from_mtp: i64,
from_balance: u64,
from_shares: u64,
to_mtp: i64,
to_balance: u64,
to_shares: u64,
window_seconds: i64,
) -> Option<NavAprWindow> {
if from_shares == 0 || to_shares == 0 || from_balance == 0 {
return None;
}
let elapsed = to_mtp - from_mtp;
if elapsed <= 0 || elapsed * 2 < window_seconds {
return None;
}
// Exact integer cross-multiplication for the growth ratio: balances are
// < 2^63 and share counts <= 2^52 (GENESIS_RESERVE_2POW52), so these
// i128 products (up to ~2^115) stay comfortably in range. The final
// annualize-and-scale step uses f64 -- this is a reporting statistic (an
// annualized bp figure), and f64's 53-bit mantissa carries far more
// relative precision (~2^-53) than a rounded basis-point result needs,
// even for near-u64-max balances.
let to_balance_i = to_balance as i128;
let to_shares_i = to_shares as i128;
let from_balance_i = from_balance as i128;
let from_shares_i = from_shares as i128;
let growth_num = to_balance_i * from_shares_i - from_balance_i * to_shares_i;
let growth_den = from_balance_i * to_shares_i;
let apr = (growth_num as f64 / growth_den as f64)
* (SECONDS_PER_YEAR as f64 / elapsed as f64)
* 10_000.0;
Some(NavAprWindow {
apr_nav_bp: apr.round() as i64,
from_mtp,
to_mtp,
elapsed_seconds: elapsed,
from_numerator: from_balance,
from_denominator: from_shares,
to_numerator: to_balance,
to_denominator: to_shares,
})
}
impl From<NavAprWindow> for BarAprWindow {
fn from(w: NavAprWindow) -> Self {
Self {
apr_nav_bp: w.apr_nav_bp,
from_mtp: w.from_mtp,
to_mtp: w.to_mtp,
elapsed_seconds: w.elapsed_seconds,
from_rate: RateFraction {
numerator: w.from_numerator.to_string(),
denominator: w.from_denominator.to_string(),
},
to_rate: RateFraction {
numerator: w.to_numerator.to_string(),
denominator: w.to_denominator.to_string(),
},
}
}
}
impl From<NavAprWindow> for PoolAprWindow {
fn from(w: NavAprWindow) -> Self {
Self {
apr_nav_bp: w.apr_nav_bp,
from_mtp: w.from_mtp,
to_mtp: w.to_mtp,
elapsed_seconds: w.elapsed_seconds,
from_rate: RateFraction {
numerator: w.from_numerator.to_string(),
denominator: w.from_denominator.to_string(),
},
to_rate: RateFraction {
numerator: w.to_numerator.to_string(),
denominator: w.to_denominator.to_string(),
},
excludes_bch_pot: true,
}
}
}
/// Realized NAV-growth APR for the bar over the trailing `window_seconds`,
/// anchored at the newest indexed row's `mtp` (not wall-clock time). `None`
/// when there's no history yet, or [`nav_apr_window`] rejects the window.
pub async fn get_bar_apr(pool: &SqlitePool, window_seconds: i64) -> Result<Option<BarAprWindow>> {
let Some((to_mtp, to_balance, to_shares)) = latest_bar_rate(pool).await? else {
return Ok(None);
};
let Some((from_mtp, from_balance, from_shares)) =
earliest_bar_rate_since(pool, to_mtp - window_seconds).await?
else {
return Ok(None);
};
Ok(nav_apr_window(
from_mtp,
from_balance,
from_shares,
to_mtp,
to_balance,
to_shares,
window_seconds,
)
.map(BarAprWindow::from))
}
/// Realized NAV-growth APR for the pool (xMUSD terms only, excludes the BCH
/// pot) over the trailing `window_seconds`. Same anchoring/null rules as
/// [`get_bar_apr`].
pub async fn get_pool_apr(pool: &SqlitePool, window_seconds: i64) -> Result<Option<PoolAprWindow>> {
let Some((to_mtp, to_balance, to_shares)) = latest_pool_rate(pool).await? else {
return Ok(None);
};
let Some((from_mtp, from_balance, from_shares)) =
earliest_pool_rate_since(pool, to_mtp - window_seconds).await?
else {
return Ok(None);
};
Ok(nav_apr_window(
from_mtp,
from_balance,
from_shares,
to_mtp,
to_balance,
to_shares,
window_seconds,
)
.map(PoolAprWindow::from))
}
/// Combined bar + pool realized NAV-growth APR for `/moria/v11/staking/apr`.
pub async fn get_staking_apr(pool: &SqlitePool, window_seconds: i64) -> Result<StakingAprResponse> {
Ok(StakingAprResponse {
bar: get_bar_apr(pool, window_seconds).await?,
pool: get_pool_apr(pool, window_seconds).await?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin_hashes::Hash;
use bitcoincash::absolute::LockTime;
use bitcoincash::blockdata::token::{OutputData, Structure, TokenAmount};
use bitcoincash::transaction::Version;
use bitcoincash::{Amount, ScriptBuf, Sequence, TxOut};
fn tid(b: u8) -> TokenID {
TokenID::from_byte_array([b; 32])
}
fn blockhash(n: u8) -> BlockHash {
BlockHash::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[n; 32]).unwrap())
}
fn token_ids() -> MoriaV11TokenIds {
MoriaV11TokenIds {
moria: tid(0xA1),
bp_oracle: tid(0xB2),
pool: tid(0xC3),
bar: tid(0xD4),
loan_locking_bytecode: Vec::new(),
}
}
/// A mutable state-NFT output: `HasNFT | Capability::Mutable`, plus
/// `HasAmount` when `amount > 0` (a fully-subscribed state UTXO can
/// legitimately carry zero of its own token amount).
fn state_nft(id: TokenID, commitment: Vec<u8>, amount: i64) -> OutputData {
let mut bitfield = Structure::HasNFT as u8 | Capability::Mutable as u8;
if !commitment.is_empty() {
bitfield |= Structure::HasCommitmentLength as u8;
}
if amount > 0 {
bitfield |= Structure::HasAmount as u8;
}
OutputData {
id,
bitfield,
amount: TokenAmount::from_int(amount).expect("non-negative"),
commitment,
}
}
fn out(sats: u64, token: Option<OutputData>) -> TxOut {
TxOut {
value: Amount::from_sat(sats),
script_pubkey: ScriptBuf::from_bytes(vec![0xaa, 0x20]),
token,
}
}
fn make_tx(outputs: Vec<TxOut>) -> Transaction {
Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![bitcoincash::TxIn {
previous_output: bitcoincash::OutPoint {
txid: Txid::from_byte_array([0u8; 32]),
vout: 0,
},
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: bitcoincash::Witness::new(),
}],
output: outputs,
}
}
fn bar_commitment(balance: u64, liability: u64, decay_time: u64) -> Vec<u8> {
let mut buf = vec![0u8; 22];
buf[0..8].copy_from_slice(&balance.to_le_bytes());
buf[8..16].copy_from_slice(&liability.to_le_bytes());
buf[16..22].copy_from_slice(&decay_time.to_le_bytes()[..6]);
buf
}
fn pool_commitment(balance: u64, liability: u64, kick_time: u64) -> Vec<u8> {
let mut buf = vec![0u8; 28];
buf[0..8].copy_from_slice(&balance.to_le_bytes());
buf[8..16].copy_from_slice(&liability.to_le_bytes());
buf[16..22].copy_from_slice(&kick_time.to_le_bytes()[..6]);
buf
}
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
prepare_tables(&pool).await;
pool
}
#[rocket::async_test]
async fn indexes_bar_and_pool_recreations_from_a_block() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(1);
let bar_tx = make_tx(vec![out(
1_000,
Some(state_nft(
ids.bar,
bar_commitment(5_000_000, 0, 1_700_000_000),
0,
)),
)]);
let pool_tx = make_tx(vec![out(
1_500,
Some(state_nft(
ids.pool,
pool_commitment(2_000_000, 0, 1_700_000_000),
0,
)),
)]);
let inserted = index_bar_pool_history(
&pool,
&[bar_tx.clone(), pool_tx.clone()],
&bh,
10,
1_700_000_000,
&ids,
)
.await
.unwrap();
assert_eq!(inserted, 2);
let bar_rows = get_bar_history(&pool, None, 10).await.unwrap();
assert_eq!(bar_rows.len(), 1);
assert_eq!(bar_rows[0].musd_balance, 5_000_000);
assert_eq!(bar_rows[0].txid, bar_tx.compute_txid().to_string());
assert_eq!(bar_rows[0].height, 10);
let pool_rows = get_pool_history(&pool, None, 10).await.unwrap();
assert_eq!(pool_rows.len(), 1);
assert_eq!(pool_rows[0].xmusd_balance, 2_000_000);
// BCH pot = 1_500 - POOL_BASE_SATS(1000) = 500.
assert_eq!(pool_rows[0].bch_pot_sats, 500);
}
#[rocket::async_test]
async fn ignores_outputs_of_the_wrong_category_or_capability() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(2);
// Wrong category, and an immutable (non-state) NFT of the real
// category: neither should produce a history row.
let noise_tx = make_tx(vec![
out(
1_000,
Some(state_nft(tid(0xFF), bar_commitment(1, 0, 0), 0)),
),
out(
1_000,
Some(OutputData {
id: ids.bar,
bitfield: Structure::HasNFT as u8, // Capability::None
amount: TokenAmount::ZERO,
commitment: bar_commitment(1, 0, 0),
}),
),
]);
let inserted = index_bar_pool_history(&pool, &[noise_tx], &bh, 1, 1_000, &ids)
.await
.unwrap();
assert_eq!(inserted, 0);
assert!(get_bar_history(&pool, None, 10).await.unwrap().is_empty());
}
#[rocket::async_test]
async fn reorg_rollback_removes_history_rows() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(3);
let bar_tx = make_tx(vec![out(
1_000,
Some(state_nft(ids.bar, bar_commitment(1_000_000, 0, 1), 0)),
)]);
index_bar_pool_history(&pool, &[bar_tx], &bh, 1, 1_000, &ids)
.await
.unwrap();
assert_eq!(get_bar_history(&pool, None, 10).await.unwrap().len(), 1);
let deleted = delete_entries_for_block(&pool, &bh).await.unwrap();
assert_eq!(deleted, 1);
assert!(get_bar_history(&pool, None, 10).await.unwrap().is_empty());
}
/// Two bar states a day (86400s) apart: balance 1,000,000 -> 1,000,100 at
/// constant 1,000,000 shares. rate 1.0 -> 1.0001; with a 1-day window the
/// annualization factor is exactly SECONDS_PER_YEAR/86400 = 365, so
/// apr_nav_bp = 0.0001 * 365 * 10000 = 365 exactly (bp) -- hand-computed,
/// no rounding ambiguity.
#[rocket::async_test]
async fn bar_apr_matches_hand_computed_value_a_day_apart() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(4);
// state_token_amount = RESERVE - 1_000_000 -> xmusd_outstanding = 1_000_000
// (constant across both rows: fee accrual moves the balance, not shares).
let shares_1m = riftenlabs_defi::moria_v1_1::BAR_SHARE_RESERVE - 1_000_000;
let day0 = make_tx(vec![out(
1_000,
Some(state_nft(
ids.bar,
bar_commitment(1_000_000, 0, 0),
shares_1m as i64,
)),
)]);
let day1 = make_tx(vec![out(
1_000,
Some(state_nft(
ids.bar,
bar_commitment(1_000_100, 0, 86_400),
shares_1m as i64,
)),
)]);
index_bar_pool_history(&pool, &[day0], &bh, 1, 0, &ids)
.await
.unwrap();
index_bar_pool_history(&pool, &[day1], &bh, 2, 86_400, &ids)
.await
.unwrap();
let apr = get_bar_apr(&pool, 86_400).await.unwrap().expect("computed");
assert_eq!(apr.apr_nav_bp, 365);
assert_eq!(apr.from_mtp, 0);
assert_eq!(apr.to_mtp, 86_400);
assert_eq!(apr.elapsed_seconds, 86_400);
assert_eq!(apr.from_rate.numerator, "1000000");
assert_eq!(apr.from_rate.denominator, "1000000");
assert_eq!(apr.to_rate.numerator, "1000100");
assert_eq!(apr.to_rate.denominator, "1000000");
}
#[rocket::async_test]
async fn bar_apr_is_null_when_the_window_start_has_zero_shares() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(5);
// xmusd_outstanding = 0 at the start of the window (full reserve
// still sitting on the state UTXO -- nobody has deposited yet).
let empty = make_tx(vec![out(
1_000,
Some(state_nft(
ids.bar,
bar_commitment(0, 0, 0),
riftenlabs_defi::moria_v1_1::BAR_SHARE_RESERVE as i64,
)),
)]);
let seeded = make_tx(vec![out(
1_000,
Some(state_nft(ids.bar, bar_commitment(1_000_000, 0, 86_400), 0)),
)]);
index_bar_pool_history(&pool, &[empty], &bh, 1, 0, &ids)
.await
.unwrap();
index_bar_pool_history(&pool, &[seeded], &bh, 2, 86_400, &ids)
.await
.unwrap();
assert!(get_bar_apr(&pool, 86_400).await.unwrap().is_none());
}
#[rocket::async_test]
async fn bar_apr_is_null_when_history_covers_less_than_half_the_window() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(6);
// Only 1 day of history but a 7-day window requested (7d default) --
// 1 day is less than half of 7 days, so the annualization is
// rejected as too little coverage rather than extrapolated wildly.
let day0 = make_tx(vec![out(
1_000,
Some(state_nft(ids.bar, bar_commitment(1_000_000, 0, 0), 0)),
)]);
let day1 = make_tx(vec![out(
1_000,
Some(state_nft(ids.bar, bar_commitment(1_000_100, 0, 86_400), 0)),
)]);
index_bar_pool_history(&pool, &[day0], &bh, 1, 0, &ids)
.await
.unwrap();
index_bar_pool_history(&pool, &[day1], &bh, 2, 86_400, &ids)
.await
.unwrap();
assert!(get_bar_apr(&pool, DEFAULT_APR_WINDOW_SECONDS)
.await
.unwrap()
.is_none());
}
#[rocket::async_test]
async fn bar_apr_is_null_with_no_history() {
let pool = test_pool().await;
assert!(get_bar_apr(&pool, DEFAULT_APR_WINDOW_SECONDS)
.await
.unwrap()
.is_none());
}
#[rocket::async_test]
async fn pool_apr_excludes_bch_pot_flag_is_always_true() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(7);
let day0 = make_tx(vec![out(
2_000,
Some(state_nft(ids.pool, pool_commitment(2_000_000, 0, 0), 0)),
)]);
let day1 = make_tx(vec![out(
2_000,
Some(state_nft(
ids.pool,
pool_commitment(2_000_200, 0, 86_400),
0,
)),
)]);
index_bar_pool_history(&pool, &[day0], &bh, 1, 0, &ids)
.await
.unwrap();
index_bar_pool_history(&pool, &[day1], &bh, 2, 86_400, &ids)
.await
.unwrap();
let apr = get_pool_apr(&pool, 86_400)
.await
.unwrap()
.expect("computed");
assert!(apr.excludes_bch_pot);
assert_eq!(apr.apr_nav_bp, 365);
}
#[rocket::async_test]
async fn history_pagination_orders_newest_first_and_respects_since() {
let pool = test_pool().await;
let ids = token_ids();
let bh = blockhash(8);
for (i, mtp) in [0i64, 100, 200].into_iter().enumerate() {
let tx = make_tx(vec![out(
1_000,
Some(state_nft(
ids.bar,
bar_commitment(1_000_000 + i as u64, 0, mtp as u64),
0,
)),
)]);
index_bar_pool_history(&pool, &[tx], &bh, i as i64, mtp, &ids)
.await
.unwrap();
}
let all = get_bar_history(&pool, None, 10).await.unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].mtp, 200); // newest first
assert_eq!(all[2].mtp, 0);
let since_100 = get_bar_history(&pool, Some(100), 10).await.unwrap();
assert_eq!(since_100.len(), 1);
assert_eq!(since_100[0].mtp, 200);
}
}

1295
src/db/moria_v11/tests.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,9 +10,6 @@
//! deployment-agnostic. This module pins the **mainnet BCH/USD** deployment
//! constants — token category ids for the v1 (legacy) and v2 (current)
//! contracts — that the indexer needs to recognise live oracle output.
//!
//! Sourced from `~/libriften/packages/delphi-contract/src/v2/mainnet.ts`
//! (the `delphi-contract/v2: typed mainnet deployment constants` commit).
use std::sync::OnceLock;
@ -78,11 +75,9 @@ mod tests {
}
#[test]
fn v2_token_matches_libriften_constant() {
// The hex must equal `DELPHI_CONTRACT_TOKEN` in
// `~/libriften/packages/delphi-contract/src/v2/mainnet.ts`. If the
// values drift, downstream packages and the indexer will disagree
// about which on-chain UTXOs count as "the v2 oracle".
fn v2_bchusd_token_hex_is_stable() {
// Pin the published mainnet v2 BCH/USD category so a typo in the
// constant cannot silently retarget "the" oracle.
assert_eq!(
V2_BCHUSD_TOKEN_HEX,
"be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88"

View file

@ -5,11 +5,9 @@
//! Per-network ORB v0 deployment constants.
//!
//! Mirrors libriften's `packages/cauldron/src/orb/v0/{chipnet,mainnet}.ts`
//! deployment records. Values here are MONEY-SAFETY-critical: while a
//! network's value is the all-zero placeholder, the dependent indexer is
//! disabled outright (fail-closed) — nothing is indexed with assumed
//! parameters.
//! Values here are MONEY-SAFETY-critical: while a network's value is the
//! all-zero placeholder, the dependent indexer is disabled outright
//! (fail-closed) — nothing is indexed with assumed parameters.
use bitcoincash::Network;
use riftenlabs_defi::{tokenbch_delegation, tokentoken_delegation};
@ -17,30 +15,24 @@ use riftenlabs_defi::{tokenbch_delegation, tokentoken_delegation};
/// The delegation-pool platform NFTH (the platform-fee settlement destination
/// baked into every tokentoken/tokenbch pool's withdraw blob) on chipnet.
/// One value serves BOTH delegation contracts.
///
/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.poolPlatformNfth`).
const CHIPNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[
0x07, 0x2d, 0x5f, 0xbe, 0xcf, 0xa1, 0xbc, 0x37, 0xa1, 0x3f, 0x58, 0xb1, 0x88, 0xf5, 0x0a, 0x5b,
0xc1, 0xdf, 0xb9, 0xab, 0x8a, 0xe0, 0xbf, 0x5d, 0x3d, 0x9a, 0x63, 0xf6, 0xf8, 0x1e, 0xfc, 0x0b,
];
// TODO:: set the pool platform nfth for mainnet once ORB v0 deploys there
// (libriften orb/v0/mainnet.ts is still all-zero too).
// TODO:: set the pool platform nfth for mainnet once ORB v0 deploys there.
const MAINNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[0u8; 32];
/// The ORB IdoParams NFT category (display byte order, like a token id) on
/// chipnet. Every IDO preinit must include this NFT (input #1, preserved at
/// output #10); its commitment carries the economic parameters the announced
/// IDO parameters are validated against.
///
/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.idoParams.token`).
const CHIPNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[
0xc9, 0x16, 0x7d, 0x12, 0x39, 0x46, 0x27, 0xba, 0x28, 0x30, 0x70, 0x5f, 0xb2, 0xb0, 0xf0, 0x0b,
0x49, 0xeb, 0x28, 0x46, 0x9e, 0x65, 0xda, 0x53, 0x69, 0x12, 0xd0, 0x50, 0x75, 0x89, 0x08, 0x00,
];
// TODO:: set the ido params nft category for mainnet once ORB v0 deploys there
// (libriften orb/v0/mainnet.ts is still all-zero too).
// TODO:: set the ido params nft category for mainnet once ORB v0 deploys there.
const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32];
/// The ORB IdoParams NFT category for a network (all-zero = unconfigured; the
@ -63,8 +55,7 @@ pub fn pool_platform_nfth(network: Option<Network>) -> [u8; 32] {
/// True once a real (non-placeholder) value is configured. The build-time
/// `0xab…ab` contract placeholder counts as unconfigured too: pools built on
/// it must never be surfaced (libriften DESIGN.md "never fund a pool on the
/// placeholder").
/// it must never be surfaced.
pub fn is_configured(value: &[u8; 32]) -> bool {
*value != [0u8; 32] && value != tokentoken_delegation::PLATFORM_NFTH_PLACEHOLDER
}

View file

@ -41,12 +41,21 @@ pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> {
Ok((deserialize(&hex::decode(header)?)?, height as u64))
}
/// The (defi, oracle, ido, bcmr) mempool txid sets.
pub type MempoolTxSets = (HashSet<Txid>, HashSet<Txid>, HashSet<Txid>, HashSet<Txid>);
/// The (defi, oracle, ido, bcmr, moria_v11) mempool txid sets.
pub type MempoolTxSets = (
HashSet<Txid>,
HashSet<Txid>,
HashSet<Txid>,
HashSet<Txid>,
HashSet<Txid>,
);
/// Fetch defi (cauldron + delegation pools), oracle, ido and bcmr mempool
/// transactions
pub fn electrum_fetch_mempool(client: &Client, network: Option<Network>) -> Result<MempoolTxSets> {
/// Fetch defi, oracle, ido, bcmr and optional moria v1-1 mempool transactions.
pub fn electrum_fetch_mempool(
client: &Client,
network: Option<Network>,
moria_filter: Option<Value>,
) -> Result<MempoolTxSets> {
let cauldron_filter = json!({
"scriptsig": hex::encode(&V2_CONTRACT_TEMPLATE[(V2_CONTRACT_TEMPLATE.len() - 43)..]), // cauldron spends
"scriptpubkey": hex::encode([0x6a /* op_return */, 0x06 /* push */, b'S', b'U', b'M', b'M', b'O', b'N']), // new pools (potentially)
@ -141,8 +150,13 @@ pub fn electrum_fetch_mempool(client: &Client, network: Option<Network>) -> Resu
oracle_txs.extend(fetch_txs(oracle_v2_filter)?);
let ido_txs = fetch_txs(ido_filter)?;
let bcmr_txs = fetch_txs(bcmr_filter)?;
let moria_txs = if let Some(filter) = moria_filter {
fetch_txs(filter)?
} else {
HashSet::new()
};
Ok((defi_txs, oracle_txs, ido_txs, bcmr_txs))
Ok((defi_txs, oracle_txs, ido_txs, bcmr_txs, moria_txs))
}
/// Fetch blockchain tip from electrum server

View file

@ -12,13 +12,12 @@ use std::{
use bitcoin_hashes::Hash;
use bitcoincash::{
blockdata::block::Header as BlockHeader, consensus::deserialize, Block, BlockHash, Network,
TokenID, Transaction, Txid,
Transaction, Txid,
};
use electrum_client_netagnostic::{Client, ElectrumApi, Param};
use log::{debug, info, warn};
use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract};
use riftenlabs_defi::chainutil::{compute_outpoint_hash, OutPointHash};
use riftenlabs_defi::moria::MoriaTokenIds;
use crate::{
bcmr::index_bcmr,
@ -47,28 +46,6 @@ use crate::{
};
use anyhow::{bail, Context, Result};
/// Get the Moria token IDs for a given network
fn moria_token_ids(network: Option<Network>) -> MoriaTokenIds {
match network {
Some(Network::Chipnet) => MoriaTokenIds {
moria: "29566f4884539dbcedfcb55cc7f5e66b5ae14975b70c0b6eb12de7e9707775ea"
.parse::<TokenID>()
.expect("valid chipnet moria token_id"),
bp_oracle: "f3d6b85bfb0eaaf417ccabc8c8032464c5ec410e40b3b13f0c369a541bfb2a6a"
.parse::<TokenID>()
.expect("valid chipnet bp_oracle token_id"),
},
_ => MoriaTokenIds {
moria: "b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366"
.parse::<TokenID>()
.expect("valid mainnet moria token_id"),
bp_oracle: "01711e39e7bf3b8ca0d9a6fc6ea32e340caa1d64dc7d1dc51fae20fd66755558"
.parse::<TokenID>()
.expect("valid mainnet bp_oracle token_id"),
},
}
}
pub async fn update_mempool(
db: &DB,
electrum: Arc<Mutex<Client>>,
@ -78,10 +55,12 @@ pub async fn update_mempool(
db::cauldron::mempool::load_mempool(&db.cauldron_w).await?;
let electrum_clone = electrum.clone();
let (cauldron_txs, oracle_txs, ido_txs, bcmr_txs) = tokio::task::spawn_blocking(move || {
electrum_fetch_mempool(&electrum_clone.lock().unwrap(), network)
})
.await??;
let moria_filter = db::moria_v11::mempool_filter(network);
let (cauldron_txs, oracle_txs, ido_txs, bcmr_txs, moria_txs) =
tokio::task::spawn_blocking(move || {
electrum_fetch_mempool(&electrum_clone.lock().unwrap(), network, moria_filter)
})
.await??;
let txs_to_delete: Vec<Txid> = our_mempool_txs.difference(&cauldron_txs).cloned().collect();
let txs_to_add: Vec<&Txid> = cauldron_txs.difference(&our_mempool_txs).collect();
@ -318,6 +297,53 @@ pub async fn update_mempool(
let txs_to_add = ttor_sorted_kahn(txs_to_add);
index_bcmr(&db.bcmr_w, &BlockHash::all_zeros(), txs_to_add).await?;
if let Some(token_ids) = db::moria_v11::token_ids(network) {
for txid in db::moria_v11::get_unconfirmed_txids(&db.moria_w).await? {
if !moria_txs.contains(&txid) {
db::moria_v11::delete_unconfirmed_tx(&db.moria_w, &txid).await?;
}
}
let mut moria_to_add = Vec::new();
for txid in moria_txs {
if !db::moria_v11::has_entry(&db.moria_w, &txid).await? {
moria_to_add.push(txid);
}
}
let moria_electrum = electrum.clone();
let txs_to_add: Vec<Transaction> = tokio::task::spawn_blocking(move || {
moria_to_add
.into_iter()
.filter_map(
|txid| match electrum_get_tx(&moria_electrum.lock().unwrap(), &txid) {
Ok(tx) => Some(tx),
Err(e) => {
info!("Failed to get mempool moria tx {txid}: {e}");
None
}
},
)
.collect()
})
.await?;
let txs_to_add = ttor_sorted_kahn(txs_to_add);
if !txs_to_add.is_empty() {
let n = db::moria_v11::index_moria_v11(
&db.moria_w,
&txs_to_add,
&BlockHash::all_zeros(),
time_now(),
&token_ids,
)
.await?;
if n > 0 {
info!("mempool moria_v11: indexed {n} actions");
}
}
}
Ok(())
}
@ -389,10 +415,18 @@ pub async fn index_blocks(
// uses Handle::current().block_on() which panics on Tokio worker threads.
let chain_for_update = chain.clone();
let undoer_db = db.clone();
let tip_hash = tip_header.block_hash();
tokio::task::spawn_blocking(move || {
let chain_guard = chain_for_update.lock().unwrap();
let undoer = StoreBlockUndoer::new(undoer_db)?;
chain_guard.update(undoer, new_headers, None)
// Empty headers means electrum's tip is already in the chain
// (shrink / invalidate). Height comes from our map, then electrum.
let shrink_to = if new_headers.is_empty() {
chain_guard.get_block_height(&tip_hash).or(Some(tip_height))
} else {
None
};
chain_guard.update(undoer, new_headers, shrink_to)
})
.await??;
debug!("Header update done");
@ -631,15 +665,28 @@ pub async fn index_blocks(
// oracle updates
index_oracle(&db.oracle_w, &sorted_txs, &blockhash).await?;
// moria lending
let moria_actions = db::moria::index_moria(
&db.moria_w,
&sorted_txs,
&blockhash,
mtp as i64,
&moria_token_ids(network),
)
.await?;
// moria v1-1 lending (chipnet only)
let mut moria_actions = 0usize;
if let Some(token_ids) = db::moria_v11::token_ids(network) {
moria_actions = db::moria_v11::index_moria_v11(
&db.moria_w,
&sorted_txs,
&blockhash,
mtp as i64,
&token_ids,
)
.await?;
db::moria_v11::staking::index_bar_pool_history(
&db.moria_w,
&sorted_txs,
&blockhash,
block_height as i64,
mtp as i64,
&token_ids,
)
.await?;
}
db::ido::index_txs(
network,

View file

@ -232,7 +232,7 @@ async fn start_program(
)?;
db::oracle::clear_mempool(&db.oracle_w).await.unwrap();
db::moria::clear_mempool(&db.moria_w).await.unwrap();
db::moria_v11::clear_mempool(&db.moria_w).await.unwrap();
db::bcmr::clear_mempool(&db.bcmr_w).await.unwrap();
let indexing_in_progress_clone = indexing_in_progress.clone();
@ -348,8 +348,21 @@ async fn start_program(
}
});
let mut bcmrdownloader =
BCMRDownloader::new(db.bcmr_w.clone(), config.riften_ipfs_gateway.clone());
// Default the riften-ipfs gateway per-network to the local pinning node when unset, mirroring
// rostrum_addr above. BCMR content pushed to the node is then loadable immediately, without
// waiting for public-gateway DHT propagation. Bridge-networked deployments must override this
// with an address the indexer container can actually reach (e.g. host.docker.internal:<port>
// or the host's LAN IP), exactly as they already do for rostrum_addr.
let riften_ipfs_gateway = if config.riften_ipfs_gateway.is_empty() {
match network {
Network::Chipnet => "http://127.0.0.1:3001/ipfs/".to_string(),
_ => "http://127.0.0.1:3002/ipfs/".to_string(),
}
} else {
config.riften_ipfs_gateway.clone()
};
let mut bcmrdownloader = BCMRDownloader::new(db.bcmr_w.clone(), riften_ipfs_gateway);
bcmrdownloader.start()?;
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
@ -449,71 +462,84 @@ async fn launch() -> _ {
materialized_end: AtomicI64::new(initial_ohlcv_end),
});
// Synchronous post-IBD backfill: run the full ohlcv_1h materialisation before
// allowing metrics_cache and other background writers to start. We reuse the
// Post-IBD backfill: run the full ohlcv_1h materialisation before allowing
// metrics_cache and other background writers to start. We reuse the
// indexing_in_progress flag so metrics_cache backs off during this window.
//
// Skipped after a version wipe: the backfill runs before `rocket::build()` returns,
// so re-materialising all of history here would refuse connections for the whole
// rebuild rather than degrading to the (correct, slower) raw path.
// Runs in a task: it must WAIT for IBD, and main() sits before
// rocket::build(), so awaiting it inline kept the API from binding for the
// entire initial sync — on a fresh database the server refused connections
// for hours. The gate it holds preserves the old writer ordering.
//
// Skipped after a version wipe: re-materialising all of history would
// contend with the rebuild rather than degrading to the (correct, slower)
// raw path.
if !ohlcv_wiped {
const BACKFILL_BATCH_SECS: i64 = 24 * 3600;
const BACKFILL_SAFETY_SECS: i64 = 3 * 3600;
let dbpool = dbpool.clone();
let ibd_state = ibd_state.clone();
let indexing_in_progress = indexing_in_progress.clone();
let ohlcv_state = ohlcv_state.clone();
tokio::spawn(async move {
const BACKFILL_BATCH_SECS: i64 = 24 * 3600;
const BACKFILL_SAFETY_SECS: i64 = 3 * 3600;
// Wait for IBD to finish — ohlcv_1h data is only useful for confirmed blocks.
while !ibd_state.initial_sync_complete.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_secs(1)).await;
}
// Gate metrics_cache so it doesn't compete for cauldron_w during backfill.
indexing_in_progress.store(true, Ordering::Relaxed);
info!("ohlcv: starting post-IBD full backfill");
let now = crate::timeutil::time_now();
let cutoff = (now - BACKFILL_SAFETY_SECS) / 3600 * 3600;
let since_opt = match max_bucket_ts {
Some(ts) => Some(ts + 3600),
None => match db::cauldron::ohlcv::get_min_trade_bucket_ts(&dbpool.cauldron_r).await {
Ok(v) => v,
Err(e) => {
warn!("ohlcv backfill: could not read min trade ts: {e}");
None
}
},
};
if since_opt.is_none() {
info!("ohlcv backfill: no confirmed trades found, skipping");
}
if let Some(mut batch_start) = since_opt {
while batch_start < cutoff {
let batch_end = (batch_start + BACKFILL_BATCH_SECS).min(cutoff);
match db::cauldron::ohlcv::rebuild_range(
&dbpool.cauldron_r,
&dbpool.cauldron_w,
batch_start,
batch_end,
)
.await
{
Ok(n) => {
info!("ohlcv backfill: {n} buckets [{batch_start}, {batch_end})");
ohlcv_state
.materialized_end
.store(batch_end, Ordering::Relaxed);
}
Err(e) => {
warn!("ohlcv backfill failed at [{batch_start}, {batch_end}): {e}");
break;
}
}
batch_start = batch_end;
// Brief yield so new block writes are not starved.
tokio::time::sleep(Duration::from_millis(200)).await;
// Wait for IBD to finish — ohlcv_1h data is only useful for confirmed blocks.
while !ibd_state.initial_sync_complete.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
info!("ohlcv: post-IBD backfill complete");
indexing_in_progress.store(false, Ordering::Relaxed);
// Gate metrics_cache so it doesn't compete for cauldron_w during backfill.
indexing_in_progress.store(true, Ordering::Relaxed);
info!("ohlcv: starting post-IBD full backfill");
let now = crate::timeutil::time_now();
let cutoff = (now - BACKFILL_SAFETY_SECS) / 3600 * 3600;
let since_opt = match max_bucket_ts {
Some(ts) => Some(ts + 3600),
None => {
match db::cauldron::ohlcv::get_min_trade_bucket_ts(&dbpool.cauldron_r).await {
Ok(v) => v,
Err(e) => {
warn!("ohlcv backfill: could not read min trade ts: {e}");
None
}
}
}
};
if since_opt.is_none() {
info!("ohlcv backfill: no confirmed trades found, skipping");
}
if let Some(mut batch_start) = since_opt {
while batch_start < cutoff {
let batch_end = (batch_start + BACKFILL_BATCH_SECS).min(cutoff);
match db::cauldron::ohlcv::rebuild_range(
&dbpool.cauldron_r,
&dbpool.cauldron_w,
batch_start,
batch_end,
)
.await
{
Ok(n) => {
info!("ohlcv backfill: {n} buckets [{batch_start}, {batch_end})");
ohlcv_state
.materialized_end
.store(batch_end, Ordering::Relaxed);
}
Err(e) => {
warn!("ohlcv backfill failed at [{batch_start}, {batch_end}): {e}");
break;
}
}
batch_start = batch_end;
// Brief yield so new block writes are not starved.
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
info!("ohlcv: post-IBD backfill complete");
indexing_in_progress.store(false, Ordering::Relaxed);
});
}
// Background task: incrementally materialise new 1-hour OHLCV buckets as blocks arrive.
@ -635,6 +661,7 @@ async fn launch() -> _ {
rpc::price::price_at,
rpc::pool::list_active_pools,
rpc::pool::pool_history,
rpc::pool::pools_fees,
rpc::pool::pool_id_from_utxo,
rpc::apy::aggregate_apy,
rpc::contract::contract_count_token,
@ -664,12 +691,17 @@ async fn launch() -> _ {
],
)
.mount(
"/moria",
"/moria/v11",
routes![
rpc::moria::loan_history,
rpc::moria::global_history,
rpc::moria::active_loans,
rpc::moria::moria_stats,
rpc::moria_v11::loan_history,
rpc::moria_v11::global_history,
rpc::moria_v11::active_loans,
rpc::moria_v11::allowances,
rpc::moria_v11::crankable,
rpc::moria_v11::moria_stats,
rpc::moria_v11::bar_history,
rpc::moria_v11::pool_history,
rpc::moria_v11::staking_apr,
],
)
.mount(

View file

@ -18,7 +18,7 @@ pub mod contract;
pub mod err;
pub mod health;
pub mod ido;
pub mod moria;
pub mod moria_v11;
pub mod oracle;
pub mod pool;
pub mod price;

View file

@ -1,118 +0,0 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use crate::db::moria::{get_active_loans, get_global_history, get_loan_history, get_stats};
use crate::db::DB;
use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult};
use crate::rpc::response::{cached_ok, CACHE_AGGREGATE};
use rocket::{get, State};
use serde_json::Value;
fn parse_nfth(hex_str: &str) -> Result<Vec<u8>, (ApiErrorCode, String)> {
let bytes = hex::decode(hex_str).map_err(|e| {
(
ApiErrorCode::InvalidParameters,
format!("Invalid nfth hex: {e}"),
)
})?;
if bytes.len() != 32 {
return Err((
ApiErrorCode::InvalidParameters,
format!("nfth must be 32 bytes (64 hex chars), got {}", bytes.len()),
));
}
Ok(bytes)
}
/// Get full loan history for a given borrower NFT hash.
///
/// - borrower_hash: 64-character hex string (32-byte P2NFTH hash identifying the loan)
///
/// Returns an array of all actions (borrow, repay, redeem, refinance, add_collateral)
/// for this loan, sorted by timestamp.
#[get("/loan/<borrower_hash>/history")]
pub async fn loan_history(borrower_hash: &str, db: &State<DB>) -> CachedApiResult<Value> {
let hash_bytes = hex::decode(borrower_hash).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid borrower hash: {e}"),
)
})?;
if hash_bytes.len() != 32 {
return Err(bad_request(
ApiErrorCode::InvalidParameters,
"Borrower hash must be 32 bytes (64 hex characters)",
));
}
let entries = get_loan_history(&db.moria_r, &hash_bytes)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Get global moria action history with pagination and optional nfth filter.
///
/// - offset: Number of entries to skip (default: 0)
/// - limit: Maximum entries to return (default: 50, max: 200)
/// - nfth: Comma-separated list of borrower NFT hashes (64-char hex each) to filter by
#[get("/history?<offset>&<limit>&<nfth>")]
pub async fn global_history(
offset: Option<i64>,
limit: Option<i64>,
nfth: Option<&str>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let offset = offset.unwrap_or(0).max(0);
let limit = limit.unwrap_or(50).clamp(1, 200);
let nfth_filter: Vec<Vec<u8>> = match nfth {
Some(s) if !s.is_empty() => {
let mut filters = Vec::new();
for hash_hex in s.split(',') {
let hash_hex = hash_hex.trim();
if hash_hex.is_empty() {
continue;
}
filters.push(parse_nfth(hash_hex).map_err(|(code, msg)| bad_request(code, &msg))?);
}
filters
}
_ => Vec::new(),
};
let entries = get_global_history(&db.moria_r, &nfth_filter, offset, limit)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// List all active (not yet repaid/redeemed) loans.
#[get("/loans/active")]
pub async fn active_loans(db: &State<DB>) -> CachedApiResult<Value> {
let entries = get_active_loans(&db.moria_r).await.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Get Moria protocol statistics.
#[get("/stats")]
pub async fn moria_stats(db: &State<DB>) -> CachedApiResult<Value> {
let stats = get_stats(&db.moria_r).await.map_err(db_error)?;
Ok(cached_ok(stats, CACHE_AGGREGATE))
}

224
src/rpc/moria_v11.rs Normal file
View file

@ -0,0 +1,224 @@
// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use crate::db::moria_v11::{
get_active_loans, get_allowances_by_owner, get_crankable, get_global_history, get_loan_history,
get_stats,
staking::{get_bar_history, get_pool_history, get_staking_apr, DEFAULT_APR_WINDOW_SECONDS},
};
use crate::db::DB;
use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult};
use crate::rpc::response::{cached_ok, CACHE_AGGREGATE};
use crate::timeutil::time_now;
use rocket::{get, State};
use serde_json::Value;
fn parse_nfth(hex_str: &str) -> Result<Vec<u8>, (ApiErrorCode, String)> {
let bytes = hex::decode(hex_str).map_err(|e| {
(
ApiErrorCode::InvalidParameters,
format!("Invalid nfth hex: {e}"),
)
})?;
if bytes.len() != 32 {
return Err((
ApiErrorCode::InvalidParameters,
format!("nfth must be 32 bytes (64 hex chars), got {}", bytes.len()),
));
}
Ok(bytes)
}
/// Get full loan history for a given borrower NFT hash (v1-1).
///
/// - borrower_hash: 64-character hex string (32-byte P2NFTH hash)
///
/// Returns an array of all actions for this loan, sorted by timestamp ascending.
#[get("/loan/<borrower_hash>/history")]
pub async fn loan_history(borrower_hash: &str, db: &State<DB>) -> CachedApiResult<Value> {
let hash_bytes = hex::decode(borrower_hash).map_err(|e| {
bad_request(
ApiErrorCode::InvalidParameters,
&format!("Invalid borrower hash: {e}"),
)
})?;
if hash_bytes.len() != 32 {
return Err(bad_request(
ApiErrorCode::InvalidParameters,
"Borrower hash must be 32 bytes (64 hex characters)",
));
}
let entries = get_loan_history(&db.moria_r, &hash_bytes)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Get global moria v1-1 action history with pagination and optional nfth filter.
///
/// - offset: Number of entries to skip (default: 0)
/// - limit: Maximum entries to return (default: 50, max: 200)
/// - nfth: Comma-separated list of borrower NFT hashes (64-char hex each)
#[get("/history?<offset>&<limit>&<nfth>")]
pub async fn global_history(
offset: Option<i64>,
limit: Option<i64>,
nfth: Option<&str>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let offset = offset.unwrap_or(0).max(0);
let limit = limit.unwrap_or(50).clamp(1, 200);
let nfth_filter: Vec<Vec<u8>> = match nfth {
Some(s) if !s.is_empty() => {
let mut filters = Vec::new();
for hash_hex in s.split(',') {
let hash_hex = hash_hex.trim();
if hash_hex.is_empty() {
continue;
}
filters.push(parse_nfth(hash_hex).map_err(|(code, msg)| bad_request(code, &msg))?);
}
filters
}
_ => Vec::new(),
};
let entries = get_global_history(&db.moria_r, &nfth_filter, offset, limit)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// List all active (live loan UTXO) v1-1 loans, including delegate hash.
#[get("/loans/active")]
pub async fn active_loans(db: &State<DB>) -> CachedApiResult<Value> {
let entries = get_active_loans(&db.moria_r).await.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Live allowance UTXOs for an owner NFT hash (salvage feed — dead-loan deposits included).
///
/// - owner_hash: 64-character hex string (borrower / owner P2NFTH)
#[get("/allowances/<owner_hash>")]
pub async fn allowances(owner_hash: &str, db: &State<DB>) -> CachedApiResult<Value> {
let hash_bytes = parse_nfth(owner_hash).map_err(|(code, msg)| bad_request(code, &msg))?;
let entries = get_allowances_by_owner(&db.moria_r, &hash_bytes)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Loans that are currently legal and funded to crank.
///
/// Optional `now` query overrides the clock used for cadence checks (unix seconds).
/// Defaults to wall-clock time.
#[get("/crankable?<now>")]
pub async fn crankable(now: Option<i64>, db: &State<DB>) -> CachedApiResult<Value> {
let now_ts = now.unwrap_or_else(time_now);
let entries = get_crankable(&db.moria_r, now_ts).await.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Get Moria v1-1 protocol statistics.
#[get("/stats")]
pub async fn moria_stats(db: &State<DB>) -> CachedApiResult<Value> {
let stats = get_stats(&db.moria_r).await.map_err(db_error)?;
Ok(cached_ok(stats, CACHE_AGGREGATE))
}
/// Bar (single-asset MUSD staking) state history, newest first.
///
/// - since: only rows with mtp > since (indexed block MTP seconds)
/// - limit: max rows to return (default 100, max 1000)
#[get("/bar/history?<since>&<limit>")]
pub async fn bar_history(
since: Option<i64>,
limit: Option<i64>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let limit = limit.unwrap_or(100).clamp(1, 1000);
let entries = get_bar_history(&db.moria_r, since, limit)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Liquidation-pool state history, newest first.
///
/// - since: only rows with mtp > since (indexed block MTP seconds)
/// - limit: max rows to return (default 100, max 1000)
#[get("/pool/history?<since>&<limit>")]
pub async fn pool_history(
since: Option<i64>,
limit: Option<i64>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let limit = limit.unwrap_or(100).clamp(1, 1000);
let entries = get_pool_history(&db.moria_r, since, limit)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(entries).unwrap(),
CACHE_AGGREGATE,
))
}
/// Realized bar + pool NAV-growth APR over a trailing window, GROSS of any
/// exit fee (a continuing-holder figure, never a redemption/exit rate). The
/// pool figure is xMUSD-terms only (excludes the BCH pot — see
/// `excludes_bch_pot` on the response). `bar`/`pool` are independently
/// `null` when there's no history yet or the window has insufficient
/// coverage (see `db::moria_v11::staking` module docs for the exact rule).
///
/// - window: window length in seconds (default 604800 = 7 days)
#[get("/staking/apr?<window>")]
pub async fn staking_apr(window: Option<i64>, db: &State<DB>) -> CachedApiResult<Value> {
let window = window.unwrap_or(DEFAULT_APR_WINDOW_SECONDS);
if window <= 0 {
return Err(bad_request(
ApiErrorCode::InvalidParameters,
"window must be a positive number of seconds",
));
}
let resp = get_staking_apr(&db.moria_r, window)
.await
.map_err(db_error)?;
Ok(cached_ok(
serde_json::to_value(resp).unwrap(),
CACHE_AGGREGATE,
))
}

View file

@ -7,7 +7,10 @@ use crate::{
cashaddr::utiladdr::p2pkh_hex_to_addr,
db::{
cauldron::{
pool::{db_pool_get_details, db_pool_history, db_pool_id_from_utxo},
pool::{
db_pool_fees, db_pool_get_details, db_pool_history, db_pool_id_from_utxo,
HistoryCursor, PoolHistoryEntry,
},
poolvisitor::{
db_visit_pool_entries, OptionalFields, OptionalPoolFields, PoolFilters, PoolVisitor,
},
@ -161,13 +164,111 @@ pub async fn list_active_pools(
/// "owner_pkh": "36c0020dd39e7cd66c21f237dc53d384661a557f"
/// }
/// ```
#[get("/pool/history/<pool_id>?<start>")]
/// Parse a `next_cursor` back into its parts.
fn parse_history_cursor(
raw: &str,
) -> Result<HistoryCursor, rocket::response::status::Custom<rocket::serde::json::Json<Value>>> {
let (seq, utxo_hex) = raw.split_once('_').ok_or_else(|| {
bad_request(
ApiErrorCode::InvalidPoolId,
"Cursor must be <sequence>_<utxo hex>",
)
})?;
let sequence = seq.parse::<i64>().map_err(|_| {
bad_request(
ApiErrorCode::InvalidPoolId,
"Cursor sequence is not a number",
)
})?;
let utxo = hex::decode(utxo_hex)
.map_err(|_| bad_request(ApiErrorCode::InvalidPoolId, "Cursor utxo is not hex"))?;
Ok(HistoryCursor { sequence, utxo })
}
/// Ids accepted in one request. Each is a 64-char hex, so this keeps the query
/// string well under any proxy's URL limit while covering a large wallet in a
/// single call.
const FEES_MAX_IDS: usize = 200;
/// `/cauldron/pools/fees?ids=<a,b,c>&start=<unix>`
///
/// Fees earned per pool since `start`. Exists so a wallet showing its total does
/// not have to download every pool's entire history and re-derive the figure in
/// the browser — which cost one large uncached request per position.
///
/// Pools absent from the response had no history in the window; a caller
/// distinguishing "earned nothing" from "no such pool" should treat absence as
/// the latter.
#[get("/pools/fees?<ids>&<start>")]
pub async fn pools_fees(ids: &str, start: Option<u64>, conn: &State<DB>) -> CachedApiResult<Value> {
let start = start.unwrap_or(0);
let raw: Vec<&str> = ids
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if raw.is_empty() {
return Ok(cached_ok(json!([]), CACHE_AGGREGATE));
}
if raw.len() > FEES_MAX_IDS {
return Err(bad_request(
ApiErrorCode::InvalidPoolId,
&format!(
"At most {FEES_MAX_IDS} pool ids per request, got {}",
raw.len()
),
));
}
let mut pool_ids = Vec::with_capacity(raw.len());
for id in raw {
pool_ids.push(id.parse::<PoolID>().map_err(|e| {
bad_request(
ApiErrorCode::InvalidPoolId,
&format!("Invalid pool ID: {e}"),
)
})?);
}
let fees = db_pool_fees(&conn.cauldron_r, &pool_ids, start)
.await
.map_err(db_error)?;
Ok(cached_ok(json!(fees), CACHE_AGGREGATE))
}
/// Rows returned when the caller does not ask for a different bound.
const HISTORY_DEFAULT_LIMIT: i64 = 5_000;
/// Hard ceiling. The busiest pool has ~32k lifetime entries and every row is
/// shipped as JSON, so an unbounded response is the most expensive thing this
/// service can be asked for.
const HISTORY_MAX_LIMIT: i64 = 20_000;
#[get("/pool/history/<pool_id>?<start>&<limit>&<fields>&<after>")]
pub async fn pool_history(
pool_id: &str,
start: Option<u64>,
limit: Option<i64>,
fields: Option<&str>,
// `next_cursor` from the previous page, as `<sequence>_<utxo hex>`.
after: Option<&str>,
conn: &State<DB>,
) -> CachedApiResult<Value> {
let start = start.unwrap_or(time_now() as u64 - (30 * 3600 * 24) /* 30 days ago */);
let limit = limit
.unwrap_or(HISTORY_DEFAULT_LIMIT)
.clamp(1, HISTORY_MAX_LIMIT);
// `fields=reserves` drops `txid` and `k`, which together are roughly 40% of
// the payload and which no known consumer reads. Opt-in because this
// endpoint is public: existing callers keep the full shape.
let reserves_only = fields == Some("reserves");
let after = match after {
None => None,
Some(raw) => Some(parse_history_cursor(raw)?),
};
let pool_id = pool_id.parse::<PoolID>().map_err(|e| {
bad_request(
@ -187,17 +288,48 @@ pub async fn pool_history(
}
})?;
let history = db_pool_history(&conn.cauldron_r, &pool_id, start)
// One more than asked for, so a full page can be distinguished from a
// truncated one without a second count query.
let mut history = db_pool_history(&conn.cauldron_r, &pool_id, start, limit + 1, after.as_ref())
.await
.map_err(db_error)?;
let has_more = history.len() as i64 > limit;
if has_more {
history.truncate(limit as usize);
}
// Only when there is a further page: a caller that follows this until it is
// null reads the whole history without ever asking for it unbounded.
let next_cursor = if has_more {
history
.last()
.map(|e| format!("{}_{}", e.cursor.sequence, hex::encode(&e.cursor.utxo)))
} else {
None
};
let history = if reserves_only {
json!(history
.iter()
.map(PoolHistoryEntry::reserves_only)
.collect::<Vec<_>>())
} else {
json!(history)
};
// Append-only data: a minute of staleness costs a just-executed trade a
// moment before it appears, which is the right trade against re-running
// this for every caller. Note the client must send a coarse `start` for any
// of this to help — a second-granularity value makes every URL unique.
Ok(cached_ok(
json!({
"history": history,
"token_id": token_id,
"owner_pkh": owner_pkh,
"next_cursor": next_cursor,
}),
CACHE_NONE,
CACHE_AGGREGATE,
))
}