Compare commits
10 commits
0efac510fb
...
bb4e489904
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb4e489904 | ||
|
|
5861c412c8 | ||
|
|
cc0ab0b16f | ||
|
|
b5e8c45487 | ||
|
|
7f424c79d0 | ||
|
|
bafaad5965 | ||
|
|
bbcbe0354c | ||
|
|
499275eaef | ||
|
|
90ad5def9a | ||
|
|
a5e91afea8 |
27 changed files with 5276 additions and 1120 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -2283,7 +2283,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "riftenlabs-defi"
|
name = "riftenlabs-defi"
|
||||||
version = "0.4.5"
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7541d5d2101a03925b9de89846c1e9a66bcdcdf6a4435a7bcf30bf294bb236f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitcoin_hashes",
|
"bitcoin_hashes",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ serde_json = "1.0.133"
|
||||||
rocket = { version = "0.5.1", features = ["json"] }
|
rocket = { version = "0.5.1", features = ["json"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||||
rayon = "1.10.0"
|
rayon = "1.10.0"
|
||||||
riftenlabs-defi = "0.4.5"
|
riftenlabs-defi = "0.5.1"
|
||||||
rocket_cors = "0.6.0"
|
rocket_cors = "0.6.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
stderrlog = "0.6.0"
|
stderrlog = "0.6.0"
|
||||||
|
|
|
||||||
|
|
@ -67,5 +67,5 @@ default = "false"
|
||||||
[[param]]
|
[[param]]
|
||||||
name = "riften_ipfs_gateway"
|
name = "riften_ipfs_gateway"
|
||||||
type = "String"
|
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()"
|
default = "\"\".to_string()"
|
||||||
|
|
|
||||||
|
|
@ -422,7 +422,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ipfs_path_validation_accepts_real_cids_and_rejects_injection() {
|
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.
|
// base64url CID with a filename extension appended directly.
|
||||||
assert!(is_safe_ipfs_path("QmAbc123"));
|
assert!(is_safe_ipfs_path("QmAbc123"));
|
||||||
assert!(is_safe_ipfs_path("bafybeig4n5ut/icon.png"));
|
assert!(is_safe_ipfs_path("bafybeig4n5ut/icon.png"));
|
||||||
|
|
|
||||||
51
src/chain.rs
51
src/chain.rs
|
|
@ -90,7 +90,7 @@ impl BlockUndoer for StoreBlockUndoer {
|
||||||
{
|
{
|
||||||
was_indexed = true;
|
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?
|
.await?
|
||||||
> 0
|
> 0
|
||||||
{
|
{
|
||||||
|
|
@ -277,7 +277,12 @@ impl Chain {
|
||||||
|
|
||||||
let rewind = match new_headers.first() {
|
let rewind = match new_headers.first() {
|
||||||
Some(first) => first.height,
|
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..) {
|
for (hash, header) in headers.drain(rewind as usize..) {
|
||||||
|
|
@ -575,4 +580,46 @@ mod tests {
|
||||||
"0e16637fe0700a7c52e9a6eaa58bd6ac7202652103be8f778680c66f51ad2e9b"
|
"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
297
src/db/cauldron/fees.rs
Normal 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.2999–0.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ use crate::db::cauldron::tokenlist::db_utils::{
|
||||||
|
|
||||||
pub mod candlestick;
|
pub mod candlestick;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod fees;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod mempool;
|
pub mod mempool;
|
||||||
pub mod ohlcv;
|
pub mod ohlcv;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::db::blob::{blob_to_display_hex, display_hex_to_blob, FromBlob, ToBlob};
|
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 crate::def::PoolID;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
|
@ -656,14 +657,41 @@ pub async fn get_pool_period_snapshot_by_pool_ids(
|
||||||
Ok(pools)
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct PoolHistoryEntry {
|
pub struct PoolHistoryEntry {
|
||||||
txid: String,
|
pub txid: String,
|
||||||
sats: u64,
|
pub sats: u64,
|
||||||
token_amount: u64,
|
pub token_amount: u64,
|
||||||
timestamp: u64,
|
pub timestamp: u64,
|
||||||
#[serde(serialize_with = "serialize_integer_as_string")]
|
#[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(
|
async fn get_pool_history_entry(
|
||||||
|
|
@ -691,31 +719,162 @@ async fn get_pool_history_entry(
|
||||||
token_amount,
|
token_amount,
|
||||||
timestamp: timestamp as u64,
|
timestamp: timestamp as u64,
|
||||||
k: Integer::from(sats) * Integer::from(token_amount),
|
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(
|
pub async fn db_pool_history(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
pool_id: &PoolID,
|
pool_id: &PoolID,
|
||||||
start_time: u64,
|
start_time: u64,
|
||||||
|
limit: i64,
|
||||||
|
after: Option<&HistoryCursor>,
|
||||||
) -> Result<Vec<PoolHistoryEntry>> {
|
) -> Result<Vec<PoolHistoryEntry>> {
|
||||||
let query = "SELECT
|
let query = "SELECT
|
||||||
phe.txid,
|
phe.txid,
|
||||||
phe.sats,
|
phe.sats,
|
||||||
phe.token_amount,
|
phe.token_amount,
|
||||||
phe.effective_timestamp as timestamp
|
phe.effective_timestamp as timestamp,
|
||||||
|
phe.utxo,
|
||||||
|
phe.sequence
|
||||||
FROM
|
FROM
|
||||||
pool_history_entry phe
|
pool_history_entry phe
|
||||||
WHERE
|
WHERE
|
||||||
phe.pool = ?1
|
phe.pool = ?1
|
||||||
AND timestamp >= ?2
|
AND timestamp >= ?2
|
||||||
|
AND (?4 IS NULL OR (phe.sequence, phe.utxo) > (?4, ?5))
|
||||||
ORDER BY
|
ORDER BY
|
||||||
phe.sequence ASC;
|
phe.sequence ASC, phe.utxo ASC
|
||||||
|
LIMIT ?3;
|
||||||
";
|
";
|
||||||
|
|
||||||
let rows = sqlx::query(query)
|
let rows = sqlx::query(query)
|
||||||
.bind(pool_id.to_blob())
|
.bind(pool_id.to_blob())
|
||||||
.bind(start_time as i64)
|
.bind(start_time as i64)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(after.map(|c| c.sequence))
|
||||||
|
.bind(after.map(|c| c.utxo.clone()))
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -727,6 +886,8 @@ pub async fn db_pool_history(
|
||||||
let sats: i64 = row.get(1);
|
let sats: i64 = row.get(1);
|
||||||
let token_amount: i64 = row.get(2);
|
let token_amount: i64 = row.get(2);
|
||||||
let timestamp: i64 = row.get(3);
|
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 sats = sats as u64;
|
||||||
let token_amount = token_amount as u64;
|
let token_amount = token_amount as u64;
|
||||||
|
|
||||||
|
|
@ -738,6 +899,7 @@ pub async fn db_pool_history(
|
||||||
token_amount,
|
token_amount,
|
||||||
timestamp: timestamp as u64,
|
timestamp: timestamp as u64,
|
||||||
k,
|
k,
|
||||||
|
cursor: HistoryCursor { sequence, utxo },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,6 +1118,170 @@ mod tests {
|
||||||
pool_utxo_0
|
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]
|
#[tokio::test]
|
||||||
async fn test_volume_no_trades_in_period() {
|
async fn test_volume_no_trades_in_period() {
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
|
|
|
||||||
|
|
@ -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];
|
pub const IDO_SIGNATURE: &[u8] = &[0x08, 0x43, 0x6c, 0x64, 0x49, 0x64, 0x6f, 0x30, 0x30, 0x75];
|
||||||
|
|
||||||
static OFFERING_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
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(|| {
|
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(|| {
|
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(|| {
|
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(|| {
|
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(|| {
|
static EXECUTION_FEE_PAYOUT_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||||
hex::decode("c0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7501207c7e7c7e52cd8852ccc0c6a2").unwrap()
|
hex::decode("c0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7501207c7e7c7e52cd8852ccc0c6a2").unwrap()
|
||||||
});
|
});
|
||||||
static OFFERING_INITIATOR_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
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(|| {
|
static OFFERING_INTEGRATED_TIMELOCKED_P2NFTH_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||||
hex::decode("c0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978cf7bce7eaa88c0cf517f77517f7581c0c85279c8887cc99c").unwrap()
|
hex::decode("c0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978cf7bce7eaa88c0cf517f77517f7581c0c85279c8887cc99c").unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
static IDO_INITIATOR_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
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(|| {
|
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(|| {
|
static IDO_PREINIT_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||||
hex::decode("c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d100880302010054797ec1014e7f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a06902aa20012060797e5d797e5c797eaa7e01877e5c798277009c6302a91401200111797e5c797ea97e01877e776800cd788800d1c0c88800d352799d00d2008859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e7768686802aa20c15a7f7552797eaa7e01877ec0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012302aa2001205f797e5d797e5b797eaa7e01877e7e5b798277009c63011702a914012060797e5b797ea97e01877e7e776800cd02aa20c15a7f7553797e54797eaa7e01877e8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f7702aa20030200005d797e52797eaa7e01877e51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f7702aa20030200000111797e707c5a937f757e01207e01ff0119797eaa7e707c012b937f777eaa7e01877e52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f7702aa20030200000115797e52797eaa7e01877e53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f7702aa20030200000119797e52797eaa7e01877e54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008803020000011c797e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cd02aa20c15a7f7501207e01000128797eaa7e53797eaa7e01877e8857cc59c6a26957d159ce8857d359d09d57d259cf88525752807e011f797e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551").unwrap()
|
hex::decode("0902aa207caa7e01877e00890902a9147ca97e01877e5189c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d100880302010054797ec1014e7f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a26901205f797e5c797e5b797e008a5c798277009c63012060797e5b797e518a776800cd788800d1c0c88800d352799d78009c6300d20442434d52886700d200886859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e77686868c15a7f75787e008ac0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012301205e797e5c797e5a797e008a7e5b798277009c63011701205f797e5a797e518a7e776800cdc15a7f7552797e53797e008a8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f77030200005c797e787e008a51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f770302000060797e7853795a937f757e01207e01ff0118797eaa7e785379012b937f777e008a52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f77030200000114797e787e008a53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f77030200000118797e787e008a54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008803020000011c797e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cdc15a7f7501207e01000127797eaa7e52797e008a8857cc59c6a26957d159ce8857d359d09d57d259cf88525752807e011f797e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551").unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
static STORAGE_CONTRACT: LazyLock<Vec<u8>> =
|
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()
|
hex::decode("0902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800cf00d28800d000d38800c600cca26952d10088c45387").unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The 127-byte commitment of the ORB IdoParams NFT (libriften
|
/// The 127-byte commitment of the ORB IdoParams NFT. Byte layout:
|
||||||
/// packages/cauldron/src/orb/v0). Byte layout:
|
|
||||||
/// \[0\] version (must equal IDO_PARAMS_VERSION)
|
/// \[0\] version (must equal IDO_PARAMS_VERSION)
|
||||||
/// [1..33] permanentPoolPlatformNfth
|
/// [1..33] permanentPoolPlatformNfth
|
||||||
/// [33..65] delphiCategory
|
/// [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.
|
/// nfths are hashes and are kept in their on-chain order.
|
||||||
const IDO_PARAMS_COMMITMENT_SIZE: usize = 127;
|
const IDO_PARAMS_COMMITMENT_SIZE: usize = 127;
|
||||||
|
|
||||||
/// The only ORB IdoParams NFT commitment version this indexer understands
|
/// The only ORB IdoParams NFT commitment version this indexer understands.
|
||||||
/// (libriften `ORB_IDO_PARAMS_VERSION`). Any other value means the params NFT
|
/// Any other value means the params NFT was minted for a newer/incompatible
|
||||||
/// was minted for a newer/incompatible layout and the IDO is not indexed.
|
/// layout and the IDO is not indexed.
|
||||||
const IDO_PARAMS_VERSION: u8 = 0x00;
|
const IDO_PARAMS_VERSION: u8 = 0x00;
|
||||||
|
|
||||||
pub struct IdoParamsNftCommitment {
|
pub struct IdoParamsNftCommitment {
|
||||||
|
|
@ -597,7 +596,7 @@ fn build_offering_bytecode(
|
||||||
minOffer: &Integer,
|
minOffer: &Integer,
|
||||||
// display byte order; baked into the bytecode in VM (reversed) order. None
|
// 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
|
// 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]>,
|
xTokenCategory: Option<&[u8]>,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let rev_xtoken_cat: Vec<u8> = xTokenCategory
|
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
|
// The partial postlaunch bytecode stored in preinit output #7 (via the partial
|
||||||
// ido initiator bytecode). Composition per libriften templates/ido.json
|
// ido initiator bytecode). Composition:
|
||||||
// `postLaunchPartialBytecode`:
|
|
||||||
// <xTokenCategory rev> <permanentLiquidityShare> <price> <platformFeeNFTH>
|
// <xTokenCategory rev> <permanentLiquidityShare> <price> <platformFeeNFTH>
|
||||||
// <platformFee> <permanentLiquidityMinFee 2B> <permanentPoolPlatformNfth 32B>
|
// <platformFee> <permanentLiquidityMinFee 2B> <permanentPoolPlatformNfth 32B>
|
||||||
// <executionFee 4B> contracts.IDOPostLaunch
|
// <executionFee 4B> contracts.IDOPostLaunch
|
||||||
|
|
@ -1521,8 +1519,7 @@ fn parse_ido_preinit_tx_params(
|
||||||
minOffer: vm_number_to_bigint(&data[122..130]),
|
minOffer: vm_number_to_bigint(&data[122..130]),
|
||||||
// On-chain in VM order; kept in display/UI order. An
|
// On-chain in VM order; kept in display/UI order. An
|
||||||
// all-zero category is the native-BCH sentinel and
|
// all-zero category is the native-BCH sentinel and
|
||||||
// normalizes to None (libriften
|
// normalizes to None.
|
||||||
// isNativeXTokenCategory).
|
|
||||||
xTokenCategory: if data[130..162].iter().all(|&b| b == 0) {
|
xTokenCategory: if data[130..162].iter().all(|&b| b == 0) {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1550,15 +1547,23 @@ fn parse_ido_preinit_tx_params(
|
||||||
// all-zero on-chain sentinel): a native IDO takes payment via
|
// all-zero on-chain sentinel): a native IDO takes payment via
|
||||||
// the offering's XWNT path and its permanent pool is a
|
// the offering's XWNT path and its permanent pool is a
|
||||||
// single-UTXO tokenbch pool. No rejection here.
|
// 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 {
|
if !offered_token_is_in_supply {
|
||||||
let permanentLiquidityOTokenReserve: Integer = &preinit_parameters
|
let permanentLiquidityOTokenReserve: Integer = &preinit_parameters
|
||||||
.offeredTokenAmount
|
.offeredTokenAmount
|
||||||
* &preinit_parameters.permanentLiquidityShareNumerator
|
* &preinit_parameters.permanentLiquidityShareNumerator
|
||||||
/ &PERMANENT_LIQUIDITY_SHARE_DENOMINATOR.clone();
|
/ &PERMANENT_LIQUIDITY_SHARE_DENOMINATOR.clone();
|
||||||
if preinit_parameters.offeredTokenTotalSupply
|
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;
|
is_valid_ido = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1870,7 +1875,7 @@ fn parse_ido_preinit_tx(
|
||||||
// In an IDO the offering layer never charges a platform fee;
|
// In an IDO the offering layer never charges a platform fee;
|
||||||
// the fee is collected by the postlaunch covenant instead, so
|
// the fee is collected by the postlaunch covenant instead, so
|
||||||
// every offering-layer contract is instantiated with a zero
|
// every offering-layer contract is instantiated with a zero
|
||||||
// platformFee (libriften idoOfferingLayerParameters).
|
// platformFee.
|
||||||
&Integer::from(0i64),
|
&Integer::from(0i64),
|
||||||
¶ms.offering.delphiCategory,
|
¶ms.offering.delphiCategory,
|
||||||
¶ms.offering.launchConditions.expiresAt,
|
¶ms.offering.launchConditions.expiresAt,
|
||||||
|
|
@ -3526,7 +3531,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
// A native-BCH xToken bakes an EMPTY push (the contract branches on
|
// A native-BCH xToken bakes an EMPTY push (the contract branches on
|
||||||
// xTokenCategory == 0x), matching libriften encodeIdoXTokenCategory.
|
// xTokenCategory == 0x).
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_postlaunch_bakes_empty_push_for_native_x_token() {
|
fn partial_postlaunch_bakes_empty_push_for_native_x_token() {
|
||||||
let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {
|
let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {
|
||||||
|
|
|
||||||
|
|
@ -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::cauldron::tokentoken::create_table as tokentoken_prepare_tables;
|
||||||
use crate::db::crc20::prepare_tables as crc20_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::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;
|
use crate::db::oracle::prepare_tables as oracle_prepare_tables;
|
||||||
|
|
||||||
/// Create read and write database pools for a given database path
|
/// 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;
|
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) =
|
let (db_exists, moria_db_write, moria_db_read) =
|
||||||
create_db_pool(&db_path(db_dir, "moria.db"), read_slots.moria).await;
|
create_db_pool(&db_path(db_dir, "moria.db"), read_slots.moria).await;
|
||||||
if !db_exists {
|
let _ = db_exists; // schema is additive via IF NOT EXISTS
|
||||||
moria_prepare_tables(&moria_db_write).await;
|
moria_prepare_tables(&moria_db_write).await;
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize ido database
|
// Initialize ido database
|
||||||
let (db_exists, ido_db_write, ido_db_read) =
|
let (db_exists, ido_db_write, ido_db_read) =
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ pub mod cauldron;
|
||||||
pub mod crc20;
|
pub mod crc20;
|
||||||
pub mod ido;
|
pub mod ido;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
pub mod moria;
|
pub mod moria_v11;
|
||||||
pub mod oracle;
|
pub mod oracle;
|
||||||
pub mod orbconstants;
|
pub mod orbconstants;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
src/db/moria_v11/deploy.rs
Normal file
54
src/db/moria_v11/deploy.rs
Normal 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
869
src/db/moria_v11/ingest.rs
Normal 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
457
src/db/moria_v11/mod.rs
Normal 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
308
src/db/moria_v11/query.rs
Normal 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
999
src/db/moria_v11/staking.rs
Normal 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
1295
src/db/moria_v11/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,9 +10,6 @@
|
||||||
//! deployment-agnostic. This module pins the **mainnet BCH/USD** deployment
|
//! deployment-agnostic. This module pins the **mainnet BCH/USD** deployment
|
||||||
//! constants — token category ids for the v1 (legacy) and v2 (current)
|
//! constants — token category ids for the v1 (legacy) and v2 (current)
|
||||||
//! contracts — that the indexer needs to recognise live oracle output.
|
//! 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;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
|
@ -78,11 +75,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v2_token_matches_libriften_constant() {
|
fn v2_bchusd_token_hex_is_stable() {
|
||||||
// The hex must equal `DELPHI_CONTRACT_TOKEN` in
|
// Pin the published mainnet v2 BCH/USD category so a typo in the
|
||||||
// `~/libriften/packages/delphi-contract/src/v2/mainnet.ts`. If the
|
// constant cannot silently retarget "the" oracle.
|
||||||
// values drift, downstream packages and the indexer will disagree
|
|
||||||
// about which on-chain UTXOs count as "the v2 oracle".
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
V2_BCHUSD_TOKEN_HEX,
|
V2_BCHUSD_TOKEN_HEX,
|
||||||
"be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88"
|
"be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88"
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,9 @@
|
||||||
|
|
||||||
//! Per-network ORB v0 deployment constants.
|
//! Per-network ORB v0 deployment constants.
|
||||||
//!
|
//!
|
||||||
//! Mirrors libriften's `packages/cauldron/src/orb/v0/{chipnet,mainnet}.ts`
|
//! Values here are MONEY-SAFETY-critical: while a network's value is the
|
||||||
//! deployment records. Values here are MONEY-SAFETY-critical: while a
|
//! all-zero placeholder, the dependent indexer is disabled outright
|
||||||
//! network's value is the all-zero placeholder, the dependent indexer is
|
//! (fail-closed) — nothing is indexed with assumed parameters.
|
||||||
//! disabled outright (fail-closed) — nothing is indexed with assumed
|
|
||||||
//! parameters.
|
|
||||||
|
|
||||||
use bitcoincash::Network;
|
use bitcoincash::Network;
|
||||||
use riftenlabs_defi::{tokenbch_delegation, tokentoken_delegation};
|
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
|
/// The delegation-pool platform NFTH (the platform-fee settlement destination
|
||||||
/// baked into every tokentoken/tokenbch pool's withdraw blob) on chipnet.
|
/// baked into every tokentoken/tokenbch pool's withdraw blob) on chipnet.
|
||||||
/// One value serves BOTH delegation contracts.
|
/// One value serves BOTH delegation contracts.
|
||||||
///
|
|
||||||
/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.poolPlatformNfth`).
|
|
||||||
const CHIPNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[
|
const CHIPNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[
|
||||||
0x07, 0x2d, 0x5f, 0xbe, 0xcf, 0xa1, 0xbc, 0x37, 0xa1, 0x3f, 0x58, 0xb1, 0x88, 0xf5, 0x0a, 0x5b,
|
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,
|
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
|
// TODO:: set the pool platform nfth for mainnet once ORB v0 deploys there.
|
||||||
// (libriften orb/v0/mainnet.ts is still all-zero too).
|
|
||||||
const MAINNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[0u8; 32];
|
const MAINNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[0u8; 32];
|
||||||
|
|
||||||
/// The ORB IdoParams NFT category (display byte order, like a token id) on
|
/// 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
|
/// chipnet. Every IDO preinit must include this NFT (input #1, preserved at
|
||||||
/// output #10); its commitment carries the economic parameters the announced
|
/// output #10); its commitment carries the economic parameters the announced
|
||||||
/// IDO parameters are validated against.
|
/// IDO parameters are validated against.
|
||||||
///
|
|
||||||
/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.idoParams.token`).
|
|
||||||
const CHIPNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[
|
const CHIPNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[
|
||||||
0xc9, 0x16, 0x7d, 0x12, 0x39, 0x46, 0x27, 0xba, 0x28, 0x30, 0x70, 0x5f, 0xb2, 0xb0, 0xf0, 0x0b,
|
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,
|
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
|
// TODO:: set the ido params nft category for mainnet once ORB v0 deploys there.
|
||||||
// (libriften orb/v0/mainnet.ts is still all-zero too).
|
|
||||||
const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32];
|
const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32];
|
||||||
|
|
||||||
/// The ORB IdoParams NFT category for a network (all-zero = unconfigured; the
|
/// 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
|
/// True once a real (non-placeholder) value is configured. The build-time
|
||||||
/// `0xab…ab` contract placeholder counts as unconfigured too: pools built on
|
/// `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
|
/// it must never be surfaced.
|
||||||
/// placeholder").
|
|
||||||
pub fn is_configured(value: &[u8; 32]) -> bool {
|
pub fn is_configured(value: &[u8; 32]) -> bool {
|
||||||
*value != [0u8; 32] && value != tokentoken_delegation::PLATFORM_NFTH_PLACEHOLDER
|
*value != [0u8; 32] && value != tokentoken_delegation::PLATFORM_NFTH_PLACEHOLDER
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,21 @@ pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> {
|
||||||
Ok((deserialize(&hex::decode(header)?)?, height as u64))
|
Ok((deserialize(&hex::decode(header)?)?, height as u64))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The (defi, oracle, ido, bcmr) mempool txid sets.
|
/// The (defi, oracle, ido, bcmr, moria_v11) mempool txid sets.
|
||||||
pub type MempoolTxSets = (HashSet<Txid>, HashSet<Txid>, HashSet<Txid>, HashSet<Txid>);
|
pub type MempoolTxSets = (
|
||||||
|
HashSet<Txid>,
|
||||||
|
HashSet<Txid>,
|
||||||
|
HashSet<Txid>,
|
||||||
|
HashSet<Txid>,
|
||||||
|
HashSet<Txid>,
|
||||||
|
);
|
||||||
|
|
||||||
/// Fetch defi (cauldron + delegation pools), oracle, ido and bcmr mempool
|
/// Fetch defi, oracle, ido, bcmr and optional moria v1-1 mempool transactions.
|
||||||
/// transactions
|
pub fn electrum_fetch_mempool(
|
||||||
pub fn electrum_fetch_mempool(client: &Client, network: Option<Network>) -> Result<MempoolTxSets> {
|
client: &Client,
|
||||||
|
network: Option<Network>,
|
||||||
|
moria_filter: Option<Value>,
|
||||||
|
) -> Result<MempoolTxSets> {
|
||||||
let cauldron_filter = json!({
|
let cauldron_filter = json!({
|
||||||
"scriptsig": hex::encode(&V2_CONTRACT_TEMPLATE[(V2_CONTRACT_TEMPLATE.len() - 43)..]), // cauldron spends
|
"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)
|
"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)?);
|
oracle_txs.extend(fetch_txs(oracle_v2_filter)?);
|
||||||
let ido_txs = fetch_txs(ido_filter)?;
|
let ido_txs = fetch_txs(ido_filter)?;
|
||||||
let bcmr_txs = fetch_txs(bcmr_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
|
/// Fetch blockchain tip from electrum server
|
||||||
|
|
|
||||||
123
src/index.rs
123
src/index.rs
|
|
@ -12,13 +12,12 @@ use std::{
|
||||||
use bitcoin_hashes::Hash;
|
use bitcoin_hashes::Hash;
|
||||||
use bitcoincash::{
|
use bitcoincash::{
|
||||||
blockdata::block::Header as BlockHeader, consensus::deserialize, Block, BlockHash, Network,
|
blockdata::block::Header as BlockHeader, consensus::deserialize, Block, BlockHash, Network,
|
||||||
TokenID, Transaction, Txid,
|
Transaction, Txid,
|
||||||
};
|
};
|
||||||
use electrum_client_netagnostic::{Client, ElectrumApi, Param};
|
use electrum_client_netagnostic::{Client, ElectrumApi, Param};
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract};
|
use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract};
|
||||||
use riftenlabs_defi::chainutil::{compute_outpoint_hash, OutPointHash};
|
use riftenlabs_defi::chainutil::{compute_outpoint_hash, OutPointHash};
|
||||||
use riftenlabs_defi::moria::MoriaTokenIds;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bcmr::index_bcmr,
|
bcmr::index_bcmr,
|
||||||
|
|
@ -47,28 +46,6 @@ use crate::{
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
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(
|
pub async fn update_mempool(
|
||||||
db: &DB,
|
db: &DB,
|
||||||
electrum: Arc<Mutex<Client>>,
|
electrum: Arc<Mutex<Client>>,
|
||||||
|
|
@ -78,10 +55,12 @@ pub async fn update_mempool(
|
||||||
db::cauldron::mempool::load_mempool(&db.cauldron_w).await?;
|
db::cauldron::mempool::load_mempool(&db.cauldron_w).await?;
|
||||||
|
|
||||||
let electrum_clone = electrum.clone();
|
let electrum_clone = electrum.clone();
|
||||||
let (cauldron_txs, oracle_txs, ido_txs, bcmr_txs) = tokio::task::spawn_blocking(move || {
|
let moria_filter = db::moria_v11::mempool_filter(network);
|
||||||
electrum_fetch_mempool(&electrum_clone.lock().unwrap(), network)
|
let (cauldron_txs, oracle_txs, ido_txs, bcmr_txs, moria_txs) =
|
||||||
})
|
tokio::task::spawn_blocking(move || {
|
||||||
.await??;
|
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_delete: Vec<Txid> = our_mempool_txs.difference(&cauldron_txs).cloned().collect();
|
||||||
let txs_to_add: Vec<&Txid> = cauldron_txs.difference(&our_mempool_txs).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);
|
let txs_to_add = ttor_sorted_kahn(txs_to_add);
|
||||||
index_bcmr(&db.bcmr_w, &BlockHash::all_zeros(), txs_to_add).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,10 +415,18 @@ pub async fn index_blocks(
|
||||||
// uses Handle::current().block_on() which panics on Tokio worker threads.
|
// uses Handle::current().block_on() which panics on Tokio worker threads.
|
||||||
let chain_for_update = chain.clone();
|
let chain_for_update = chain.clone();
|
||||||
let undoer_db = db.clone();
|
let undoer_db = db.clone();
|
||||||
|
let tip_hash = tip_header.block_hash();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let chain_guard = chain_for_update.lock().unwrap();
|
let chain_guard = chain_for_update.lock().unwrap();
|
||||||
let undoer = StoreBlockUndoer::new(undoer_db)?;
|
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??;
|
.await??;
|
||||||
debug!("Header update done");
|
debug!("Header update done");
|
||||||
|
|
@ -631,15 +665,28 @@ pub async fn index_blocks(
|
||||||
// oracle updates
|
// oracle updates
|
||||||
index_oracle(&db.oracle_w, &sorted_txs, &blockhash).await?;
|
index_oracle(&db.oracle_w, &sorted_txs, &blockhash).await?;
|
||||||
|
|
||||||
// moria lending
|
// moria v1-1 lending (chipnet only)
|
||||||
let moria_actions = db::moria::index_moria(
|
let mut moria_actions = 0usize;
|
||||||
&db.moria_w,
|
if let Some(token_ids) = db::moria_v11::token_ids(network) {
|
||||||
&sorted_txs,
|
moria_actions = db::moria_v11::index_moria_v11(
|
||||||
&blockhash,
|
&db.moria_w,
|
||||||
mtp as i64,
|
&sorted_txs,
|
||||||
&moria_token_ids(network),
|
&blockhash,
|
||||||
)
|
mtp as i64,
|
||||||
.await?;
|
&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(
|
db::ido::index_txs(
|
||||||
network,
|
network,
|
||||||
|
|
|
||||||
166
src/main.rs
166
src/main.rs
|
|
@ -232,7 +232,7 @@ async fn start_program(
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
db::oracle::clear_mempool(&db.oracle_w).await.unwrap();
|
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();
|
db::bcmr::clear_mempool(&db.bcmr_w).await.unwrap();
|
||||||
|
|
||||||
let indexing_in_progress_clone = indexing_in_progress.clone();
|
let indexing_in_progress_clone = indexing_in_progress.clone();
|
||||||
|
|
@ -348,8 +348,21 @@ async fn start_program(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut bcmrdownloader =
|
// Default the riften-ipfs gateway per-network to the local pinning node when unset, mirroring
|
||||||
BCMRDownloader::new(db.bcmr_w.clone(), config.riften_ipfs_gateway.clone());
|
// 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()?;
|
bcmrdownloader.start()?;
|
||||||
|
|
||||||
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
|
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
|
||||||
|
|
@ -449,71 +462,84 @@ async fn launch() -> _ {
|
||||||
materialized_end: AtomicI64::new(initial_ohlcv_end),
|
materialized_end: AtomicI64::new(initial_ohlcv_end),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Synchronous post-IBD backfill: run the full ohlcv_1h materialisation before
|
// Post-IBD backfill: run the full ohlcv_1h materialisation before allowing
|
||||||
// allowing metrics_cache and other background writers to start. We reuse the
|
// metrics_cache and other background writers to start. We reuse the
|
||||||
// indexing_in_progress flag so metrics_cache backs off during this window.
|
// indexing_in_progress flag so metrics_cache backs off during this window.
|
||||||
//
|
//
|
||||||
// Skipped after a version wipe: the backfill runs before `rocket::build()` returns,
|
// Runs in a task: it must WAIT for IBD, and main() sits before
|
||||||
// so re-materialising all of history here would refuse connections for the whole
|
// rocket::build(), so awaiting it inline kept the API from binding for the
|
||||||
// rebuild rather than degrading to the (correct, slower) raw path.
|
// 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 {
|
if !ohlcv_wiped {
|
||||||
const BACKFILL_BATCH_SECS: i64 = 24 * 3600;
|
let dbpool = dbpool.clone();
|
||||||
const BACKFILL_SAFETY_SECS: i64 = 3 * 3600;
|
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.
|
// Wait for IBD to finish — ohlcv_1h data is only useful for confirmed blocks.
|
||||||
while !ibd_state.initial_sync_complete.load(Ordering::Relaxed) {
|
while !ibd_state.initial_sync_complete.load(Ordering::Relaxed) {
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
info!("ohlcv: post-IBD backfill complete");
|
// Gate metrics_cache so it doesn't compete for cauldron_w during backfill.
|
||||||
indexing_in_progress.store(false, Ordering::Relaxed);
|
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.
|
// Background task: incrementally materialise new 1-hour OHLCV buckets as blocks arrive.
|
||||||
|
|
@ -635,6 +661,7 @@ async fn launch() -> _ {
|
||||||
rpc::price::price_at,
|
rpc::price::price_at,
|
||||||
rpc::pool::list_active_pools,
|
rpc::pool::list_active_pools,
|
||||||
rpc::pool::pool_history,
|
rpc::pool::pool_history,
|
||||||
|
rpc::pool::pools_fees,
|
||||||
rpc::pool::pool_id_from_utxo,
|
rpc::pool::pool_id_from_utxo,
|
||||||
rpc::apy::aggregate_apy,
|
rpc::apy::aggregate_apy,
|
||||||
rpc::contract::contract_count_token,
|
rpc::contract::contract_count_token,
|
||||||
|
|
@ -664,12 +691,17 @@ async fn launch() -> _ {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.mount(
|
.mount(
|
||||||
"/moria",
|
"/moria/v11",
|
||||||
routes![
|
routes![
|
||||||
rpc::moria::loan_history,
|
rpc::moria_v11::loan_history,
|
||||||
rpc::moria::global_history,
|
rpc::moria_v11::global_history,
|
||||||
rpc::moria::active_loans,
|
rpc::moria_v11::active_loans,
|
||||||
rpc::moria::moria_stats,
|
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(
|
.mount(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ pub mod contract;
|
||||||
pub mod err;
|
pub mod err;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod ido;
|
pub mod ido;
|
||||||
pub mod moria;
|
pub mod moria_v11;
|
||||||
pub mod oracle;
|
pub mod oracle;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod price;
|
pub mod price;
|
||||||
|
|
|
||||||
118
src/rpc/moria.rs
118
src/rpc/moria.rs
|
|
@ -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
224
src/rpc/moria_v11.rs
Normal 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,
|
||||||
|
))
|
||||||
|
}
|
||||||
140
src/rpc/pool.rs
140
src/rpc/pool.rs
|
|
@ -7,7 +7,10 @@ use crate::{
|
||||||
cashaddr::utiladdr::p2pkh_hex_to_addr,
|
cashaddr::utiladdr::p2pkh_hex_to_addr,
|
||||||
db::{
|
db::{
|
||||||
cauldron::{
|
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::{
|
poolvisitor::{
|
||||||
db_visit_pool_entries, OptionalFields, OptionalPoolFields, PoolFilters, PoolVisitor,
|
db_visit_pool_entries, OptionalFields, OptionalPoolFields, PoolFilters, PoolVisitor,
|
||||||
},
|
},
|
||||||
|
|
@ -161,13 +164,111 @@ pub async fn list_active_pools(
|
||||||
/// "owner_pkh": "36c0020dd39e7cd66c21f237dc53d384661a557f"
|
/// "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(
|
pub async fn pool_history(
|
||||||
pool_id: &str,
|
pool_id: &str,
|
||||||
start: Option<u64>,
|
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>,
|
conn: &State<DB>,
|
||||||
) -> CachedApiResult<Value> {
|
) -> CachedApiResult<Value> {
|
||||||
let start = start.unwrap_or(time_now() as u64 - (30 * 3600 * 24) /* 30 days ago */);
|
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| {
|
let pool_id = pool_id.parse::<PoolID>().map_err(|e| {
|
||||||
bad_request(
|
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
|
.await
|
||||||
.map_err(db_error)?;
|
.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(
|
Ok(cached_ok(
|
||||||
json!({
|
json!({
|
||||||
"history": history,
|
"history": history,
|
||||||
"token_id": token_id,
|
"token_id": token_id,
|
||||||
"owner_pkh": owner_pkh,
|
"owner_pkh": owner_pkh,
|
||||||
|
"next_cursor": next_cursor,
|
||||||
}),
|
}),
|
||||||
CACHE_NONE,
|
CACHE_AGGREGATE,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue