2024-04-03 11:39:49 +02:00
|
|
|
// Copyright (C) 2024 Riften Labs AS
|
|
|
|
|
//
|
|
|
|
|
// 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::{bail, Context, Result};
|
2024-10-14 11:18:50 +00:00
|
|
|
use bitcoin_hashes::hex::{FromHex, ToHex};
|
|
|
|
|
use bitcoincash::TokenID;
|
2024-04-03 11:39:49 +02:00
|
|
|
use rocket::{get, http::Status, response::status::Custom, serde::json::Json, State};
|
|
|
|
|
use rusqlite::{params, Connection};
|
|
|
|
|
use rust_decimal::prelude::*;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
2024-05-09 11:25:27 +02:00
|
|
|
use crate::{db::DB, timeutil::time_now};
|
2024-04-03 11:39:49 +02:00
|
|
|
|
|
|
|
|
struct PriceInterval {
|
|
|
|
|
start: i64,
|
|
|
|
|
step: i64,
|
|
|
|
|
sats: i64,
|
|
|
|
|
tokens: i64,
|
|
|
|
|
min: f64,
|
|
|
|
|
max: f64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PriceInterval {
|
|
|
|
|
pub fn new(start: i64, step: i64) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
start,
|
|
|
|
|
step,
|
|
|
|
|
sats: 0,
|
|
|
|
|
tokens: 0,
|
|
|
|
|
min: f64::MAX,
|
|
|
|
|
max: f64::MIN,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn next(&self) -> Self {
|
|
|
|
|
let new_start = self.start + self.step;
|
|
|
|
|
Self::new(new_start, self.step)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn end(&self) -> i64 {
|
|
|
|
|
self.start + self.step
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn avg_price(&self) -> Option<f64> {
|
|
|
|
|
if self.tokens == 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(self.sats as f64 / self.tokens as f64)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_pool(&mut self, sats: i64, tokens: i64) {
|
|
|
|
|
if tokens != 0 {
|
|
|
|
|
let price = sats as f64 / tokens as f64;
|
|
|
|
|
if price > self.max {
|
|
|
|
|
self.max = price
|
|
|
|
|
}
|
|
|
|
|
if price < self.min {
|
|
|
|
|
self.min = price
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.sats += sats;
|
|
|
|
|
self.tokens += tokens;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn to_result(&self) -> Option<(i64, f64, f64, f64)> {
|
|
|
|
|
if self.tokens == 0 {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some((
|
|
|
|
|
self.start,
|
|
|
|
|
self.avg_price().expect("avg price not calculated"),
|
|
|
|
|
self.max,
|
|
|
|
|
self.min,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the current price of a given token
|
|
|
|
|
fn current_price(db: &Connection, token_id: &str) -> Result<f64> {
|
|
|
|
|
let sql = "SELECT uf.sats, uf.token_amount
|
|
|
|
|
FROM utxo_funding uf
|
|
|
|
|
LEFT JOIN utxo_spending us ON uf.new_utxo_hash = us.spent_utxo_hash
|
|
|
|
|
WHERE us.spent_utxo_hash IS NULL AND uf.token_id = ?";
|
|
|
|
|
|
|
|
|
|
let mut statement = db.prepare(sql)?;
|
|
|
|
|
|
|
|
|
|
let mut rows = statement.query(params![token_id])?;
|
|
|
|
|
|
|
|
|
|
let mut sum_sats: u64 = 0;
|
|
|
|
|
let mut sum_tokens: u64 = 0;
|
|
|
|
|
|
|
|
|
|
while let Some(row) = rows.next()? {
|
|
|
|
|
let sats: i64 = row.get(0)?;
|
|
|
|
|
let tokens: i64 = row.get(1)?;
|
|
|
|
|
|
|
|
|
|
sum_sats += sats as u64;
|
|
|
|
|
sum_tokens += tokens as u64;
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-06 08:03:12 +02:00
|
|
|
let sum_sats = Decimal::from_u64(sum_sats).context("overflow")?;
|
|
|
|
|
let sum_tokens = Decimal::from_u64(sum_tokens).context("overflow")?;
|
|
|
|
|
|
|
|
|
|
let price = sum_sats.checked_div(sum_tokens).unwrap_or_default();
|
2024-04-03 11:39:49 +02:00
|
|
|
|
|
|
|
|
price.to_f64().context("overflow")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(clippy::type_complexity)]
|
|
|
|
|
fn historic_price(
|
|
|
|
|
connection: &Connection,
|
|
|
|
|
timestamp_start: i64, // Start timestamp in posix
|
|
|
|
|
timestamp_end: i64, // End timestamp in posix
|
|
|
|
|
step_size: i64, // Interval in seconds (e.g., 600 for 10 minutes)
|
|
|
|
|
token_id: &str,
|
|
|
|
|
) -> Result<Vec<(i64, f64, f64, f64)>> {
|
|
|
|
|
if timestamp_start > timestamp_end {
|
|
|
|
|
bail!("Start cannot be higher than end");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let total_intervals = (timestamp_end - timestamp_start) / step_size;
|
|
|
|
|
|
|
|
|
|
const MAX_INTERVALS: i64 = 10000;
|
|
|
|
|
if total_intervals > MAX_INTERVALS {
|
|
|
|
|
bail!(
|
|
|
|
|
"Too many intervals ({} > {})",
|
|
|
|
|
total_intervals,
|
|
|
|
|
MAX_INTERVALS
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prepare and execute the SQL query for the current interval
|
|
|
|
|
let sql = "
|
|
|
|
|
SELECT
|
|
|
|
|
COALESCE(tx.first_seen_timestamp, tx.mtp_timestamp) AS effective_timestamp,
|
|
|
|
|
utxo_funding.sats,
|
|
|
|
|
utxo_funding.token_amount
|
|
|
|
|
FROM
|
|
|
|
|
utxo_funding
|
|
|
|
|
LEFT JOIN
|
|
|
|
|
tx ON utxo_funding.txid = tx.txid
|
|
|
|
|
WHERE
|
|
|
|
|
utxo_funding.token_id = ? AND
|
|
|
|
|
effective_timestamp >= ? AND
|
|
|
|
|
effective_timestamp < ?
|
|
|
|
|
ORDER BY
|
|
|
|
|
effective_timestamp ASC
|
|
|
|
|
";
|
|
|
|
|
let mut statement = connection.prepare(sql)?;
|
|
|
|
|
|
|
|
|
|
let mut rows = statement.query(params![token_id, timestamp_start, timestamp_end])?;
|
|
|
|
|
|
|
|
|
|
let mut result: Vec<(i64, f64, f64, f64)> = Vec::with_capacity(total_intervals as usize);
|
|
|
|
|
|
|
|
|
|
let mut current_interval = PriceInterval::new(timestamp_start, step_size);
|
|
|
|
|
|
|
|
|
|
while let Some(row) = rows.next()? {
|
|
|
|
|
let timestamp: i64 = row.get(0)?;
|
|
|
|
|
let sats: i64 = row.get(1)?;
|
|
|
|
|
let tokens: i64 = row.get(2)?;
|
|
|
|
|
|
|
|
|
|
if timestamp >= current_interval.end() {
|
|
|
|
|
if let Some(r) = current_interval.to_result() {
|
|
|
|
|
result.push(r);
|
|
|
|
|
}
|
|
|
|
|
loop {
|
|
|
|
|
current_interval = current_interval.next();
|
|
|
|
|
if timestamp < current_interval.end() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current_interval.add_pool(sats, tokens);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// final trade window
|
|
|
|
|
if let Some(r) = current_interval.to_result() {
|
|
|
|
|
result.push(r);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(result)
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-14 11:18:50 +00:00
|
|
|
fn price_at_or_before(
|
|
|
|
|
connection: &Connection,
|
|
|
|
|
timestamp: i64,
|
|
|
|
|
token_id: &TokenID,
|
|
|
|
|
) -> Result<(i64, f64)> {
|
|
|
|
|
let sql = "
|
|
|
|
|
WITH max_timestamps AS (
|
2024-10-21 12:48:47 +02:00
|
|
|
SELECT
|
2024-10-14 11:18:50 +00:00
|
|
|
phe.pool,
|
|
|
|
|
MAX(COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp)) AS max_effective_timestamp
|
2024-10-21 12:48:47 +02:00
|
|
|
FROM
|
2024-10-14 11:18:50 +00:00
|
|
|
pool_history_entry phe
|
2024-10-21 12:48:47 +02:00
|
|
|
JOIN
|
2024-10-14 11:18:50 +00:00
|
|
|
utxo_funding uf ON phe.utxo = uf.new_utxo_hash
|
2024-10-21 12:48:47 +02:00
|
|
|
WHERE
|
2024-10-14 11:18:50 +00:00
|
|
|
COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp) <= ?
|
|
|
|
|
AND uf.token_id = ?
|
2024-10-21 12:48:47 +02:00
|
|
|
GROUP BY
|
2024-10-14 11:18:50 +00:00
|
|
|
phe.pool
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
uf.token_amount,
|
|
|
|
|
uf.sats,
|
|
|
|
|
COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp) AS effective_timestamp
|
2024-10-21 12:48:47 +02:00
|
|
|
FROM
|
2024-10-14 11:18:50 +00:00
|
|
|
pool_history_entry phe
|
2024-10-21 12:48:47 +02:00
|
|
|
JOIN
|
2024-10-14 11:18:50 +00:00
|
|
|
utxo_funding uf ON phe.utxo = uf.new_utxo_hash
|
2024-10-21 12:48:47 +02:00
|
|
|
JOIN
|
2024-10-14 11:18:50 +00:00
|
|
|
pool p ON p.creation_utxo = phe.pool
|
2024-10-21 12:48:47 +02:00
|
|
|
JOIN
|
2024-10-14 11:18:50 +00:00
|
|
|
max_timestamps mt ON phe.pool = mt.pool
|
|
|
|
|
AND COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp) = mt.max_effective_timestamp
|
2024-10-21 12:48:47 +02:00
|
|
|
WHERE
|
2024-10-14 11:18:50 +00:00
|
|
|
p.withdrawn_in_utxo IS NULL
|
|
|
|
|
";
|
|
|
|
|
|
|
|
|
|
let mut statement = connection.prepare(sql)?;
|
|
|
|
|
let mut rows = statement.query(params![timestamp, token_id.to_hex()])?;
|
|
|
|
|
|
|
|
|
|
let mut sum_sats: u64 = 0;
|
|
|
|
|
let mut sum_tokens: u64 = 0;
|
|
|
|
|
let mut latest_timestamp = 0;
|
|
|
|
|
|
|
|
|
|
while let Some(row) = rows.next()? {
|
|
|
|
|
let token_amount: i64 = row.get(0)?;
|
|
|
|
|
let sats: i64 = row.get(1)?;
|
|
|
|
|
let row_timestamp: i64 = row.get(2)?;
|
|
|
|
|
|
|
|
|
|
if sats >= 0 && token_amount >= 0 {
|
|
|
|
|
sum_sats += sats as u64;
|
|
|
|
|
sum_tokens += token_amount as u64;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if row_timestamp > latest_timestamp {
|
|
|
|
|
latest_timestamp = row_timestamp;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let sum_sats_decimal = Decimal::from_u64(sum_sats).unwrap_or_default();
|
|
|
|
|
let sum_tokens_decimal = Decimal::from_u64(sum_tokens).unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let overall_price = sum_sats_decimal
|
|
|
|
|
.checked_div(sum_tokens_decimal)
|
|
|
|
|
.context("Division failed: sum_tokens is zero or invalid")?;
|
|
|
|
|
|
|
|
|
|
let overall_price_f64 = overall_price.to_f64().context("Conversion to f64 failed")?;
|
|
|
|
|
|
|
|
|
|
Ok((latest_timestamp, overall_price_f64))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[get("/price/<token>/at/<timestamp>")]
|
|
|
|
|
pub fn price_at(
|
|
|
|
|
token: &str,
|
|
|
|
|
timestamp: &str,
|
|
|
|
|
conn: &State<DB>,
|
|
|
|
|
) -> Result<Json<Value>, Custom<String>> {
|
|
|
|
|
let timestamp: i64 = timestamp.parse().map_err(|_| {
|
|
|
|
|
Custom(
|
|
|
|
|
Status::BadRequest,
|
|
|
|
|
"Invalid timestamp format. Must be a valid number.".to_string(),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let db = conn
|
|
|
|
|
.cauldron_r
|
|
|
|
|
.get()
|
|
|
|
|
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
let current_time = time_now();
|
|
|
|
|
if timestamp > current_time {
|
|
|
|
|
return Err(Custom(
|
|
|
|
|
Status::BadRequest,
|
|
|
|
|
"Timestamp is in the future".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let token = TokenID::from_hex(token)
|
|
|
|
|
.map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
match price_at_or_before(&db, timestamp, &token) {
|
|
|
|
|
Ok((latest_timestamp, price)) => Ok(Json(json!({
|
|
|
|
|
"timestamp": latest_timestamp,
|
|
|
|
|
"price": price
|
|
|
|
|
}))),
|
|
|
|
|
Err(e) => Err(Custom(
|
|
|
|
|
Status::InternalServerError,
|
|
|
|
|
format!("Error fetching price: {}", e),
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-03 11:39:49 +02:00
|
|
|
#[get("/price/<token>/current")]
|
2024-05-09 11:25:27 +02:00
|
|
|
pub fn price_current(token: &str, conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
|
2024-04-03 11:39:49 +02:00
|
|
|
let db = conn
|
2024-05-09 11:25:27 +02:00
|
|
|
.cauldron_r
|
2024-04-03 11:39:49 +02:00
|
|
|
.get()
|
|
|
|
|
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
let price = current_price(&db, token)
|
|
|
|
|
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({
|
|
|
|
|
"price": price,
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[get("/price/<token>/history?<start>&<end>&<stepsize>")]
|
|
|
|
|
pub fn price_history(
|
|
|
|
|
token: &str,
|
|
|
|
|
start: Option<i64>,
|
|
|
|
|
end: Option<i64>,
|
|
|
|
|
stepsize: Option<i64>,
|
2024-05-09 11:25:27 +02:00
|
|
|
conn: &State<DB>,
|
2024-04-03 11:39:49 +02:00
|
|
|
) -> Result<Json<Value>, Custom<String>> {
|
|
|
|
|
let current_timestamp = time_now();
|
|
|
|
|
|
|
|
|
|
let db = conn
|
2024-05-09 11:25:27 +02:00
|
|
|
.cauldron_r
|
2024-04-03 11:39:49 +02:00
|
|
|
.get()
|
|
|
|
|
.map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
let history = historic_price(
|
|
|
|
|
&db,
|
|
|
|
|
start.unwrap_or(current_timestamp - 30 * 24 * 3600 /* 30 days */),
|
|
|
|
|
end.unwrap_or(current_timestamp),
|
|
|
|
|
stepsize.unwrap_or(3600 /* 1 hour */),
|
|
|
|
|
token,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
let history_json: Vec<Value> = history
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(time, avg, max, min)| {
|
|
|
|
|
json!({
|
|
|
|
|
"time": time,
|
|
|
|
|
"avg": avg,
|
|
|
|
|
"max": max,
|
|
|
|
|
"min": min,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({
|
|
|
|
|
"history": json!(history_json)
|
|
|
|
|
})))
|
|
|
|
|
}
|
2024-10-14 11:18:50 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use crate::db::cauldron::{pool, tx, utxo_funding};
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
use bitcoin_hashes::Hash;
|
|
|
|
|
use r2d2::Pool;
|
|
|
|
|
use r2d2_sqlite::SqliteConnectionManager;
|
|
|
|
|
use rocket::http::Status;
|
|
|
|
|
use rocket::local::blocking::Client;
|
|
|
|
|
use rocket::routes;
|
|
|
|
|
use rusqlite::{params, Connection};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
fn setup_mock_db(connection: &Connection) {
|
|
|
|
|
utxo_funding::create_table(connection);
|
|
|
|
|
tx::create_table(connection);
|
|
|
|
|
pool::create_table(connection);
|
|
|
|
|
|
|
|
|
|
// Insert mock data into utxo_funding
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
params!["mock_utxo_hash1", "test_txid1", 50000, 1000, "0000000000000000000000000000000000000000000000000000000000000000"],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 1 into utxo_funding");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
params!["mock_utxo_hash2", "test_txid2", 60000, 2000, "0000000000000000000000000000000000000000000000000000000000000000"],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 2 into utxo_funding");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
params!["mock_utxo_hash3", "test_txid3", 90000, 3000, "0000000000000000000000000000000000000000000000000000000000000000"],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 3 into utxo_funding");
|
|
|
|
|
|
|
|
|
|
// Insert mock data into tx table
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO tx (txid, first_seen_timestamp, mtp_timestamp) VALUES (?, ?, ?)",
|
|
|
|
|
params!["test_txid1", 1727963300, 1727963300],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 1 into tx");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO tx (txid, first_seen_timestamp, mtp_timestamp) VALUES (?, ?, ?)",
|
|
|
|
|
params!["test_txid2", 1727963350, 1727963350],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 2 into tx");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO tx (txid, first_seen_timestamp, mtp_timestamp) VALUES (?, ?, ?)",
|
|
|
|
|
params!["test_txid3", 1727963400, 1727963400],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 3 into tx");
|
|
|
|
|
|
|
|
|
|
// Insert mock data into `pool_history_entry`
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
params!["pool1", "mock_utxo_hash1", "test_txid1", "tx_pos1", 1727963300, 1727963300],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 1 into pool_history_entry");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
params!["pool2", "mock_utxo_hash2", "test_txid2", "tx_pos2", 1727963350, 1727963350],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 2 into pool_history_entry");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
params!["pool3", "mock_utxo_hash3", "test_txid3", "tx_pos3", 1727963400, 1727963400],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 3 into pool_history_entry");
|
|
|
|
|
|
|
|
|
|
// Insert newer entry for pool1
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
params!["pool1", "mock_utxo_hash1_newer", "test_txid1_newer", "tx_pos1_newer", 1727963500, 1727963500],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert newer test data into pool_history_entry");
|
|
|
|
|
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
params!["mock_utxo_hash1_newer", "test_txid1_newer", 70000, 1500, "0000000000000000000000000000000000000000000000000000000000000000"],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert newer test data into utxo_funding");
|
|
|
|
|
|
|
|
|
|
// Insert active pools with required columns (owner_pkh and token_id)
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)",
|
|
|
|
|
params!["pool1", "dummy_owner_pkh1", "dummy_token_id1", Option::<String>::None], // Active pool
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 1 into pool");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)",
|
|
|
|
|
params!["pool2", "dummy_owner_pkh2", "dummy_token_id2", Option::<String>::None], // Active pool
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 2 into pool");
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)",
|
|
|
|
|
params!["pool3", "dummy_owner_pkh3", "dummy_token_id3", Option::<String>::None], // Active pool
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert test data 3 into pool");
|
|
|
|
|
|
|
|
|
|
// Insert an inactive pool
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)",
|
|
|
|
|
params!["inactive_pool", "dummy_owner_pkh_inactive", "dummy_token_id_inactive", "inactive_utxo"], // Inactive pool
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert inactive pool");
|
|
|
|
|
|
|
|
|
|
// Insert corresponding utxo_funding for the inactive pool
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
params!["inactive_utxo", "inactive_txid", 80000, 2500, "0000000000000000000000000000000000000000000000000000000000000000"],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert inactive utxo_funding");
|
|
|
|
|
|
|
|
|
|
// Insert pool_history_entry for the inactive pool
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
params!["inactive_pool", "inactive_utxo", "inactive_txid", "tx_pos_inactive", 1727963350, 1727963350],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert inactive pool_history_entry");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mock function to create a DB pool
|
|
|
|
|
fn mock_db_pool() -> DB {
|
|
|
|
|
let manager = SqliteConnectionManager::memory();
|
|
|
|
|
let pool = Pool::new(manager).expect("Failed to create pool.");
|
|
|
|
|
|
|
|
|
|
let write_conn = pool.get().expect("Failed to get connection.");
|
|
|
|
|
setup_mock_db(&write_conn);
|
|
|
|
|
|
|
|
|
|
DB {
|
|
|
|
|
cauldron_w: Arc::new(pool.clone()),
|
|
|
|
|
cauldron_r: Arc::new(pool.clone()),
|
|
|
|
|
bcmr_w: Arc::new(pool.clone()),
|
|
|
|
|
bcmr_r: Arc::new(pool.clone()),
|
2024-10-21 12:48:47 +02:00
|
|
|
crc20_w: Arc::new(pool.clone()),
|
|
|
|
|
crc20_r: Arc::new(pool.clone()),
|
2024-10-14 11:18:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_price_at_specific_time() {
|
|
|
|
|
let mock_db = mock_db_pool();
|
|
|
|
|
|
|
|
|
|
let rocket = rocket::build()
|
|
|
|
|
.manage(mock_db)
|
|
|
|
|
.mount("/cauldron", routes![price_at]);
|
|
|
|
|
|
|
|
|
|
let client = Client::tracked(rocket).expect("valid rocket instance");
|
|
|
|
|
|
|
|
|
|
let token_id = TokenID::all_zeros().to_hex();
|
|
|
|
|
|
|
|
|
|
// Test 1: Querying at a timestamp that exactly matches test_txid1
|
|
|
|
|
let timestamp = 1727963300;
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!("/cauldron/price/{}/at/{}", token_id, timestamp))
|
|
|
|
|
.dispatch();
|
|
|
|
|
assert_eq!(response.status(), Status::Ok);
|
|
|
|
|
|
|
|
|
|
let json_value: serde_json::Value =
|
|
|
|
|
serde_json::from_str(response.into_string().unwrap().as_str()).unwrap();
|
|
|
|
|
let actual_price = json_value["price"]
|
|
|
|
|
.as_f64()
|
|
|
|
|
.expect("Price field is not a valid f64");
|
|
|
|
|
|
|
|
|
|
// Expected price based on 50,000 sats and 1,000 tokens
|
|
|
|
|
let expected_price = 50.0;
|
|
|
|
|
assert!((actual_price - expected_price).abs() < 0.01);
|
|
|
|
|
|
|
|
|
|
// Test 2: Querying at a timestamp that includes Pool 1 and Pool 2
|
|
|
|
|
let timestamp = 1727963350;
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!("/cauldron/price/{}/at/{}", token_id, timestamp))
|
|
|
|
|
.dispatch();
|
|
|
|
|
assert_eq!(response.status(), Status::Ok);
|
|
|
|
|
let json_value: serde_json::Value =
|
|
|
|
|
serde_json::from_str(response.into_string().unwrap().as_str()).unwrap();
|
|
|
|
|
let actual_price = json_value["price"]
|
|
|
|
|
.as_f64()
|
|
|
|
|
.expect("Price field is not a valid f64");
|
|
|
|
|
|
|
|
|
|
// The combined price should be 36.67 (rounded)
|
|
|
|
|
let expected_price = 36.67;
|
|
|
|
|
assert!((actual_price - expected_price).abs() < 0.01);
|
|
|
|
|
|
|
|
|
|
// Test 3: Querying a timestamp that includes Pool 1, Pool 2, and Pool 3
|
|
|
|
|
let timestamp = 1727963400;
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!("/cauldron/price/{}/at/{}", token_id, timestamp))
|
|
|
|
|
.dispatch();
|
|
|
|
|
assert_eq!(response.status(), Status::Ok);
|
|
|
|
|
let json_value: serde_json::Value =
|
|
|
|
|
serde_json::from_str(response.into_string().unwrap().as_str()).unwrap();
|
|
|
|
|
let actual_price = json_value["price"]
|
|
|
|
|
.as_f64()
|
|
|
|
|
.expect("Price field is not a valid f64");
|
|
|
|
|
|
|
|
|
|
// The combined price should be 33.33 (rounded)
|
|
|
|
|
let expected_price = 33.33;
|
|
|
|
|
assert!((actual_price - expected_price).abs() < 0.01);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_price_at_newer_timestamp_for_pool1() {
|
|
|
|
|
let mock_db = mock_db_pool();
|
|
|
|
|
|
|
|
|
|
let rocket = rocket::build()
|
|
|
|
|
.manage(mock_db.clone())
|
|
|
|
|
.mount("/cauldron", routes![price_at]);
|
|
|
|
|
|
|
|
|
|
let client = Client::tracked(rocket).expect("valid rocket instance");
|
|
|
|
|
|
|
|
|
|
let token_id = TokenID::all_zeros().to_hex();
|
|
|
|
|
|
|
|
|
|
// Test: Query at a newer timestamp (1727963500) for pool1 and ensure it only accounts for the newer entry
|
|
|
|
|
let timestamp = 1727963500; // Newer timestamp
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!("/cauldron/price/{}/at/{}", token_id, timestamp))
|
|
|
|
|
.dispatch();
|
|
|
|
|
assert_eq!(response.status(), Status::Ok);
|
|
|
|
|
|
|
|
|
|
let json_value: serde_json::Value =
|
|
|
|
|
serde_json::from_str(response.into_string().unwrap().as_str()).unwrap();
|
|
|
|
|
let actual_price = json_value["price"]
|
|
|
|
|
.as_f64()
|
|
|
|
|
.expect("Price field is not a valid f64");
|
|
|
|
|
|
|
|
|
|
// Expected price based on the total from pool1, pool2, and pool3
|
|
|
|
|
let expected_price = 33.85; // Rounded to 2 decimal places
|
|
|
|
|
assert!((actual_price - expected_price).abs() < 0.01);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_price_with_multiple_entries_at_same_timestamp() {
|
|
|
|
|
let mock_db = mock_db_pool();
|
|
|
|
|
|
|
|
|
|
let rocket = rocket::build()
|
|
|
|
|
.manage(mock_db.clone())
|
|
|
|
|
.mount("/cauldron", routes![price_at]);
|
|
|
|
|
|
|
|
|
|
let client = Client::tracked(rocket).expect("valid rocket instance");
|
|
|
|
|
|
|
|
|
|
let token_id = TokenID::all_zeros().to_hex();
|
|
|
|
|
|
|
|
|
|
// Insert additional entries with the same timestamp for an existing pool (e.g., pool1)
|
|
|
|
|
let connection = mock_db.cauldron_r.get().expect("Failed to get connection.");
|
|
|
|
|
|
|
|
|
|
// These entries should have the same timestamp as previous mockdata and be counted in the price calculation
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
params!["pool1", "mock_utxo_hash1_extra", "test_txid1_extra", "0", 1727963300, 1727963300],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert extra test data into pool_history_entry");
|
|
|
|
|
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
params!["mock_utxo_hash1_extra", "test_txid1_extra", 40000, 500, "0000000000000000000000000000000000000000000000000000000000000000"],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert extra test data into utxo_funding");
|
|
|
|
|
|
|
|
|
|
// Test: Query at the timestamp matching test_txid1 (1727963300) and check the price
|
|
|
|
|
let timestamp = 1727963300;
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!("/cauldron/price/{}/at/{}", token_id, timestamp))
|
|
|
|
|
.dispatch();
|
|
|
|
|
assert_eq!(response.status(), Status::Ok);
|
|
|
|
|
|
|
|
|
|
let json_value: serde_json::Value =
|
|
|
|
|
serde_json::from_str(response.into_string().unwrap().as_str()).unwrap();
|
|
|
|
|
let actual_price = json_value["price"]
|
|
|
|
|
.as_f64()
|
|
|
|
|
.expect("Price field is not a valid f64");
|
|
|
|
|
|
|
|
|
|
// Expected price based on (50,000 + 40,000) sats and (1,000 + 500) tokens = 90,000 / 1,500 = 60.0
|
|
|
|
|
let expected_price = 60.0;
|
|
|
|
|
assert!((actual_price - expected_price).abs() < 0.01);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_bad_token_hash() {
|
|
|
|
|
let mock_db = mock_db_pool();
|
|
|
|
|
|
|
|
|
|
let rocket = rocket::build()
|
|
|
|
|
.manage(mock_db)
|
|
|
|
|
.mount("/cauldron", routes![price_current, price_history, price_at]);
|
|
|
|
|
|
|
|
|
|
let client = Client::tracked(rocket).expect("valid rocket instance");
|
|
|
|
|
|
|
|
|
|
// Test the `/price/<token>/at/<timestamp>` endpoint with a bad token hash
|
|
|
|
|
let bad_token_id = "bad_token_id";
|
|
|
|
|
let timestamp = 1727963432; // Arbitrary timestamp
|
|
|
|
|
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!("/cauldron/price/{}/at/{}", bad_token_id, timestamp))
|
|
|
|
|
.dispatch();
|
|
|
|
|
assert_eq!(response.status(), Status::BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_timestamp_in_future() {
|
|
|
|
|
let mock_db = mock_db_pool();
|
|
|
|
|
|
|
|
|
|
let rocket = rocket::build()
|
|
|
|
|
.manage(mock_db)
|
|
|
|
|
.mount("/cauldron", routes![price_at]);
|
|
|
|
|
|
|
|
|
|
let client = Client::tracked(rocket).expect("valid rocket instance");
|
|
|
|
|
|
|
|
|
|
let token_id = TokenID::all_zeros().to_hex();
|
|
|
|
|
let future_timestamp = (time_now() + 100000).to_string();
|
|
|
|
|
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!(
|
|
|
|
|
"/cauldron/price/{}/at/{}",
|
|
|
|
|
token_id, future_timestamp
|
|
|
|
|
))
|
|
|
|
|
.dispatch();
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), Status::BadRequest);
|
|
|
|
|
let body = response.into_string().unwrap();
|
|
|
|
|
assert!(body.contains("Timestamp is in the future"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_timestamp_format() {
|
|
|
|
|
let mock_db = mock_db_pool();
|
|
|
|
|
|
|
|
|
|
let rocket = rocket::build()
|
|
|
|
|
.manage(mock_db)
|
|
|
|
|
.mount("/cauldron", routes![price_at]);
|
|
|
|
|
|
|
|
|
|
let client = Client::tracked(rocket).expect("valid rocket instance");
|
|
|
|
|
|
|
|
|
|
// Test the `/price/<token>/at/<timestamp>` endpoint with an invalid timestamp
|
|
|
|
|
let token_id = TokenID::all_zeros().to_hex();
|
|
|
|
|
let invalid_timestamp = "ASDASD:";
|
|
|
|
|
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!(
|
|
|
|
|
"/cauldron/price/{}/at/{}",
|
|
|
|
|
token_id, invalid_timestamp
|
|
|
|
|
))
|
|
|
|
|
.dispatch();
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), Status::BadRequest);
|
|
|
|
|
let body = response.into_string().unwrap();
|
|
|
|
|
assert!(body.contains("Invalid timestamp format"));
|
|
|
|
|
}
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_price_with_high_tokens_and_low_sats() {
|
|
|
|
|
let mock_db = mock_db_pool();
|
|
|
|
|
|
|
|
|
|
let rocket = rocket::build()
|
|
|
|
|
.manage(mock_db.clone())
|
|
|
|
|
.mount("/cauldron", routes![price_at]);
|
|
|
|
|
|
|
|
|
|
let client = Client::tracked(rocket).expect("valid rocket instance");
|
|
|
|
|
|
|
|
|
|
let connection = mock_db.cauldron_r.get().expect("Failed to get connection.");
|
|
|
|
|
|
|
|
|
|
// Setup mock data for this specific test
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"CREATE TABLE IF NOT EXISTS utxo_funding (
|
|
|
|
|
new_utxo_hash TEXT PRIMARY KEY,
|
|
|
|
|
txid TEXT,
|
|
|
|
|
sats BIGINT,
|
|
|
|
|
token_amount BIGINT,
|
|
|
|
|
token_id TEXT
|
|
|
|
|
)",
|
|
|
|
|
[],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to create utxo_funding table");
|
|
|
|
|
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"CREATE TABLE IF NOT EXISTS pool_history_entry (
|
|
|
|
|
pool TEXT,
|
|
|
|
|
utxo TEXT PRIMARY KEY,
|
|
|
|
|
txid TEXT,
|
|
|
|
|
tx_pos TEXT,
|
|
|
|
|
mtp_timestamp BIGINT,
|
|
|
|
|
first_seen_timestamp BIGINT
|
|
|
|
|
)",
|
|
|
|
|
[],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to create pool_history_entry table");
|
|
|
|
|
|
|
|
|
|
// Insert high tokens and low sats
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
params![
|
2024-10-21 12:48:47 +02:00
|
|
|
"mock_utxo_hash_high_tokens",
|
|
|
|
|
"test_txid_high_tokens",
|
2024-10-14 11:18:50 +00:00
|
|
|
1_i64, // Low sats
|
|
|
|
|
9999999999999999_i64, // Very high token amount
|
|
|
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert high-token data into utxo_funding");
|
|
|
|
|
|
|
|
|
|
connection
|
|
|
|
|
.execute(
|
|
|
|
|
"INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
params![
|
2024-10-21 12:48:47 +02:00
|
|
|
"pool_high_tokens",
|
|
|
|
|
"mock_utxo_hash_high_tokens",
|
|
|
|
|
"test_txid_high_tokens",
|
|
|
|
|
"tx_pos_high_tokens",
|
2024-10-14 11:18:50 +00:00
|
|
|
1727963300,
|
|
|
|
|
1727963300
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
.expect("Failed to insert high-token data into pool_history_entry");
|
|
|
|
|
|
|
|
|
|
// Test: Querying the price with high tokens and low sats
|
|
|
|
|
let token_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
|
|
|
let timestamp = 1727963300;
|
|
|
|
|
let response = client
|
|
|
|
|
.get(format!("/cauldron/price/{}/at/{}", token_id, timestamp))
|
|
|
|
|
.dispatch();
|
|
|
|
|
|
|
|
|
|
// Expect that we could not handle the calculation/conversion.
|
|
|
|
|
assert_eq!(response.status(), Status::InternalServerError);
|
|
|
|
|
}
|
|
|
|
|
}
|