Phase 4+5: genuine dual Postgres/SQLite + end-to-end scanner proof

Building an end-to-end integration test (mock grin node → real tick() →
SQLite store) surfaced the real dual-DB blockers in sqlx::Any — NOT the
placeholder issue the review flagged:
- Any truncates i64 -> i32 for SQLite (12345678900 -> -539222988),
  which would corrupt grin amounts. Store outputs.value as a decimal
  string, sum in Rust. (Probe-documented; other int columns are < 2^31
  for decades.)
- Any can't decode SQLite BOOLEAN; is_coinbase/spent are now INTEGER 0/1.
- DEFAULT now() -> DEFAULT CURRENT_TIMESTAMP (portable).

The integration test now proves the whole pipeline end-to-end on SQLite:
node UTXO listing → view-only rewind (golden output) → store with
value + key_id → correct balance → spend-by-absence reconcile → zero.
One schema + one query set now serve both backends.
This commit is contained in:
jwinterm
2026-07-09 13:24:01 -04:00
parent 0dc1804e67
commit d839b75a76
3 changed files with 214 additions and 35 deletions
+7 -3
View File
@@ -32,17 +32,21 @@ CREATE TABLE IF NOT EXISTS accounts (
CREATE TABLE IF NOT EXISTS outputs (
"commit" TEXT PRIMARY KEY,
rewind_hash TEXT NOT NULL REFERENCES accounts(rewind_hash) ON DELETE CASCADE,
value BIGINT NOT NULL,
-- nanogrin as a DECIMAL STRING. sqlx's `Any` driver truncates i64 -> i32
-- for SQLite, which would corrupt amounts > ~2.1e9 (grin values reach
-- ~1e16), so the value is stored as text and summed in Rust. Other integer
-- columns stay well under 2^31 for decades.
value TEXT NOT NULL,
height BIGINT NOT NULL,
mmr_index BIGINT NOT NULL,
is_coinbase BOOLEAN NOT NULL DEFAULT false,
is_coinbase INTEGER NOT NULL DEFAULT 0, -- 0/1 (Any can't decode SQLite BOOLEAN)
lock_height BIGINT NOT NULL DEFAULT 0,
-- Recovered from the proof message during rewind (grin_recover_output
-- equivalent, server-side). Enables direct spend by the client.
key_id TEXT,
n_child INTEGER,
-- Spend tracking: set when this output's commitment appears as a tx input.
spent BOOLEAN NOT NULL DEFAULT false,
spent INTEGER NOT NULL DEFAULT 0, -- 0/1 (Any can't decode SQLite BOOLEAN)
spent_height BIGINT
);
+43 -32
View File
@@ -4,12 +4,17 @@
//! Uses `sqlx::Any` so a single build serves both Postgres (scale) and SQLite
//! (single operator) — the driver is chosen at runtime from `DATABASE_URL`.
//!
//! Dialect: the SQL below uses Postgres `$N` placeholders and runs unchanged on
//! BOTH Postgres and SQLite through sqlx's `Any` driver (SQLite accepts `$N`
//! numbered parameters and binds them positionally). Verified end-to-end by the
//! `sqlite_probe` test, which applies the real migration + reused-placeholder
//! upserts against SQLite. `DEFAULT CURRENT_TIMESTAMP` (not `now()`) keeps the
//! schema portable.
//! Dialect: one schema + one set of queries serve BOTH Postgres and SQLite via
//! sqlx's `Any` driver, verified end-to-end by the `sqlite_probe` test (real
//! migration + reused-placeholder upserts) and the scanner integration test.
//! Portability required working around `Any`'s quirks, NOT `$N` placeholders
//! (SQLite accepts those and binds positionally):
//! - `Any` truncates i64 -> i32 for SQLite, so `outputs.value` (nanogrin, up
//! to ~1e16) is stored as a DECIMAL STRING and summed in Rust. Other integer
//! columns stay well under 2^31 for decades.
//! - `Any` can't decode SQLite `BOOLEAN`, so boolean flags (`is_coinbase`,
//! `spent`) are `INTEGER` 0/1.
//! - `DEFAULT CURRENT_TIMESTAMP` (not `now()`) is portable.
//!
//! SECURITY: only view-only data is ever stored. No spend key, no private key.
@@ -181,10 +186,10 @@ pub async fn insert_output(pool: &AnyPool, out: &StoredOutput, rewind_hash: &str
)
.bind(&out.commit)
.bind(rewind_hash)
.bind(out.value as i64)
.bind(out.value.to_string())
.bind(out.height as i64)
.bind(out.mmr_index as i64)
.bind(out.is_coinbase)
.bind(out.is_coinbase as i64)
.bind(out.lock_height as i64)
.bind(&out.key_id)
.bind(out.n_child.map(|n| n as i32))
@@ -195,7 +200,7 @@ pub async fn insert_output(pool: &AnyPool, out: &StoredOutput, rewind_hash: &str
/// Mark an output spent (its commitment left the node's unspent set).
pub async fn mark_spent(pool: &AnyPool, commit: &str, spent_height: i64) -> Result<()> {
sqlx::query("UPDATE outputs SET spent = true, spent_height = $2 WHERE \"commit\" = $1")
sqlx::query("UPDATE outputs SET spent = 1, spent_height = $2 WHERE \"commit\" = $1")
.bind(commit)
.bind(spent_height)
.execute(pool)
@@ -206,7 +211,7 @@ pub async fn mark_spent(pool: &AnyPool, commit: &str, spent_height: i64) -> Resu
/// Un-mark a spend (a reorg brought a previously-spent output back into the
/// unspent set). Self-heals an over-eager spend reconcile.
pub async fn mark_unspent(pool: &AnyPool, commit: &str) -> Result<()> {
sqlx::query("UPDATE outputs SET spent = false, spent_height = NULL WHERE \"commit\" = $1")
sqlx::query("UPDATE outputs SET spent = 0, spent_height = NULL WHERE \"commit\" = $1")
.bind(commit)
.execute(pool)
.await?;
@@ -216,7 +221,7 @@ pub async fn mark_unspent(pool: &AnyPool, commit: &str) -> Result<()> {
/// Every currently-unspent commitment across all accounts — the set the scanner
/// re-checks against the node to detect spends by absence.
pub async fn all_unspent_commits(pool: &AnyPool) -> Result<Vec<String>> {
let rows = sqlx::query("SELECT \"commit\" FROM outputs WHERE spent = false")
let rows = sqlx::query("SELECT \"commit\" FROM outputs WHERE spent = 0")
.fetch_all(pool)
.await?;
Ok(rows.iter().map(|r| r.get::<String, _>("commit")).collect())
@@ -226,7 +231,7 @@ pub async fn all_unspent_commits(pool: &AnyPool) -> Result<Vec<String>> {
pub async fn unspent_outputs(pool: &AnyPool, rewind_hash: &str) -> Result<Vec<StoredOutput>> {
let rows = sqlx::query(
"SELECT \"commit\", value, height, mmr_index, is_coinbase, lock_height, key_id, n_child \
FROM outputs WHERE rewind_hash = $1 AND spent = false ORDER BY height ASC",
FROM outputs WHERE rewind_hash = $1 AND spent = 0 ORDER BY height ASC",
)
.bind(rewind_hash)
.fetch_all(pool)
@@ -235,18 +240,25 @@ pub async fn unspent_outputs(pool: &AnyPool, rewind_hash: &str) -> Result<Vec<St
Ok(rows.iter().map(row_to_output).collect())
}
/// Sum a set of decimal-string `value` rows into `(total, count)`. Summed in
/// Rust because `value` is stored as text (see schema) and `Any` truncates
/// large integers for SQLite. `u128` accumulator avoids overflow across many
/// outputs; the total is clamped to `u64` (grin's supply fits comfortably).
fn sum_value_rows(rows: &[sqlx::any::AnyRow]) -> (u64, u64) {
let total: u128 = rows
.iter()
.map(|r| r.get::<String, _>("value").parse::<u64>().unwrap_or(0) as u128)
.sum();
(total.min(u64::MAX as u128) as u64, rows.len() as u64)
}
/// (total unspent, count) for an account — the balance read.
pub async fn account_totals(pool: &AnyPool, rewind_hash: &str) -> Result<(u64, u64)> {
let row = sqlx::query(
"SELECT COALESCE(SUM(value),0) AS total, COUNT(*) AS n \
FROM outputs WHERE rewind_hash = $1 AND spent = false",
)
.bind(rewind_hash)
.fetch_one(pool)
.await?;
let total: i64 = row.try_get("total").unwrap_or(0);
let n: i64 = row.try_get("n").unwrap_or(0);
Ok((total.max(0) as u64, n.max(0) as u64))
let rows = sqlx::query("SELECT value FROM outputs WHERE rewind_hash = $1 AND spent = 0")
.bind(rewind_hash)
.fetch_all(pool)
.await?;
Ok(sum_value_rows(&rows))
}
/// (unlocked total, count) for an account: unspent AND spendable at `tip_height`
@@ -258,17 +270,15 @@ pub async fn account_unlocked_totals(
rewind_hash: &str,
tip_height: i64,
) -> Result<(u64, u64)> {
let row = sqlx::query(
"SELECT COALESCE(SUM(value),0) AS total, COUNT(*) AS n \
FROM outputs WHERE rewind_hash = $1 AND spent = false AND lock_height <= $2",
let rows = sqlx::query(
"SELECT value FROM outputs \
WHERE rewind_hash = $1 AND spent = 0 AND lock_height <= $2",
)
.bind(rewind_hash)
.bind(tip_height)
.fetch_one(pool)
.fetch_all(pool)
.await?;
let total: i64 = row.try_get("total").unwrap_or(0);
let n: i64 = row.try_get("n").unwrap_or(0);
Ok((total.max(0) as u64, n.max(0) as u64))
Ok(sum_value_rows(&rows))
}
/// The height at which the scanner last processed this account.
@@ -283,10 +293,11 @@ pub async fn account_scan_height(pool: &AnyPool, rewind_hash: &str) -> Result<Op
fn row_to_output(r: &sqlx::any::AnyRow) -> StoredOutput {
StoredOutput {
commit: r.get::<String, _>("commit"),
value: r.get::<i64, _>("value").max(0) as u64,
// value is stored as a decimal string (see schema note on Any/i64).
value: r.get::<String, _>("value").parse::<u64>().unwrap_or(0),
height: r.get::<i64, _>("height").max(0) as u64,
mmr_index: r.get::<i64, _>("mmr_index").max(0) as u64,
is_coinbase: r.get::<bool, _>("is_coinbase"),
is_coinbase: r.get::<i64, _>("is_coinbase") != 0,
lock_height: r.get::<i64, _>("lock_height").max(0) as u64,
key_id: r.try_get::<Option<String>, _>("key_id").ok().flatten(),
n_child: r
@@ -417,7 +428,7 @@ pub async fn rollback_to(pool: &AnyPool, fork_height: i64) -> Result<()> {
.execute(&mut *tx)
.await?;
// Spends recorded above the fork are undone (the input may be unspent again).
sqlx::query("UPDATE outputs SET spent = false, spent_height = NULL WHERE spent_height > $1")
sqlx::query("UPDATE outputs SET spent = 0, spent_height = NULL WHERE spent_height > $1")
.bind(fork_height)
.execute(&mut *tx)
.await?;
+164
View File
@@ -237,3 +237,167 @@ async fn handle_reorg(
db::set_cursor(pool, fork_index.saturating_sub(1) as i64, fork as i64, &fork_hash).await?;
Ok(())
}
#[cfg(test)]
mod integration {
//! End-to-end: drive the real `tick()` against a MOCK grin node serving the
//! golden-vector output, over a real SQLite store. Proves the whole pipeline
//! (node listing → view-only rewind → store → balance → spend reconcile).
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use axum::{extract::State, routing::post, Json, Router};
use serde_json::{json, Value};
use tokio::net::TcpListener;
use super::*;
use crate::config::Config;
use crate::secret::Secret;
// Golden depth-3 (Grim) output — its rewind_hash/commit/proof are the ones
// grin's own ProofBuilder produced (see src/rewind.rs golden vectors).
const RH: &str = "c2f675f2b05b5bed7d7f01480c6f2aaf368fe4da5e169c3b6d18bb354ba784a2";
const COMMIT: &str = "099c6701a2c0cf2c02ada5a051a87e554524bcfa7d77339caf35b607fc1a1b2226";
const PROOF: &str = "69d7d34b41a9cff366101cd1473fd28dde406de95fa29d403a30684f93a6c857409eb08742b6fd8704c73ea65e982f3f332dbb6f2c61458b6acb901b8d98be9b01cf44cbcf9a8e087638ceabd7666c2bc9728d46ce21a5560da9710e69d4ae6099ad49666851f4181b2fea9f10ff400a5fc789ef132b96668bedcc8697349798747fc68dcb32c081f39810d7d05846f955cf398abf358ec4c013c6cf35e7abb4552a1bf8a2eaff0a2c4bd5a3f232fd323f1cd59588623dfdbbd3276e1e4dbc5c00038cb18ba9ee83d70a803bcbb5f32972153454a093bbee48b14c2653ed34c5bc94b0220d10f3b97f4914d62c0288e9146ad34527634736ff28db7dc6171cee1bed2bbe3c85788417778556ceb259fe8fd7571ff884b6dacc375d56d7a528cc037993c0402c6f7b2ca13940501e798b6476ae24c4b65cca0805e98de96819b246997de4f3680bf81b3c7dbf4c456c29677e3a3be281993e06b8c8aae14559672f5103b80f5aaba187ea0c74903af9896433aec723063b695efd2254b831143c7335058496aa4a8d8a90c0bfb4260da8790f32073f4f809a59f93ef33c0379d20a48212c1ac36181eeef59ababfb1b6f9a0ce4c9baaa83299c21ccad09a0493ad79f35083b00a149d72745feb8e6776fb16f06128a37f1ffd406476deeb5e7f35208b3cc7544197be7def4509fdcbd92d8bd72e3fd0437b3f249c247981ba70bc2ebf985c3efa546ceaf953e7061b33b4f1a5da78a32406b6258483efb1c64e49b77e3ca960e1f9f1be3779d657fd879ad8d282a3bf0a19d2c6f3a0b161e4fb0e2c2dff2efbb7fbf8be5b355b22f0fe2a2166ebe0627b9006cb740a190a50dcde3c068734f9554efaa9a196c7a985239ddce0b246c34c8627297fa3b3050cac82d7af9ab7063e8873f000f533f9a250cdc777c4980cf569f6c4011fcb3dfb5b1eb995c";
const VALUE: u64 = 12_345_678_900;
const MMR: u64 = 3;
const HEIGHT: u64 = 100;
struct MockNode {
highest_index: u64,
tip_height: u64,
tip_hash: String,
/// Currently-unspent outputs (mmr_index, commit, proof).
utxos: Vec<(u64, String, String)>,
/// Commits the node still lists as unspent (for get_outputs).
present: HashSet<String>,
}
async fn rpc(State(m): State<Arc<Mutex<MockNode>>>, Json(req): Json<Value>) -> Json<Value> {
let m = m.lock().unwrap();
let method = req["method"].as_str().unwrap_or("");
let params = &req["params"];
let ok = |v: Value| Json(json!({ "result": { "Ok": v } }));
match method {
"get_tip" => ok(json!({
"height": m.tip_height, "last_block_pushed": m.tip_hash,
"prev_block_to_last": "", "total_difficulty": 0
})),
"get_header" => ok(json!({ "hash": m.tip_hash, "height": m.tip_height })),
"get_unspent_outputs" => {
let start = params[0].as_u64().unwrap_or(0);
let end = params[1].as_u64().unwrap_or(u64::MAX);
let outs: Vec<Value> = m
.utxos
.iter()
.filter(|(idx, _, _)| *idx >= start && *idx <= end)
.map(|(idx, commit, proof)| {
json!({
"output_type": "Transaction", "commit": commit, "spent": false,
"proof": proof, "block_height": HEIGHT, "mmr_index": idx
})
})
.collect();
let last = outs
.iter()
.filter_map(|o| o["mmr_index"].as_u64())
.max()
.unwrap_or(m.highest_index);
ok(json!({
"highest_index": m.highest_index,
"last_retrieved_index": last,
"outputs": outs
}))
}
"get_outputs" => {
let commits = params[0].as_array().cloned().unwrap_or_default();
let present: Vec<Value> = commits
.iter()
.filter_map(|c| c.as_str())
.filter(|c| m.present.contains(*c))
.map(|c| json!({ "commit": c }))
.collect();
ok(Value::Array(present))
}
"get_pmmr_indices" => {
ok(json!({ "highest_index": m.highest_index, "last_retrieved_index": 1, "outputs": [] }))
}
_ => Json(json!({ "result": { "Err": "unknown method" } })),
}
}
fn cfg_for(url: String) -> Config {
Config {
bind_addr: "127.0.0.1:0".parse().unwrap(),
database_url: Secret::new(String::new()),
node_foreign_api_url: url,
node_foreign_api_secret: Secret::new(String::new()),
scan_poll_secs: 1,
scan_batch_blocks: 100,
restore_max_depth_days: 0,
admin_key: Secret::new(String::new()),
}
}
#[tokio::test]
async fn scan_discovers_then_spends_the_golden_output() {
// ── mock node serving the golden output as an unspent UTXO ──
let state = Arc::new(Mutex::new(MockNode {
highest_index: MMR,
tip_height: 500,
tip_hash: "tiphash".into(),
utxos: vec![(MMR, COMMIT.into(), PROOF.into())],
present: HashSet::from([COMMIT.to_string()]),
}));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = Router::new().route("/v2/foreign", post(rpc)).with_state(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let node = GrinNode::new(&cfg_for(format!("http://{addr}/v2/foreign"))).unwrap();
// ── real SQLite store + migration ──
sqlx::any::install_default_drivers();
let path = std::env::temp_dir().join("grinlws_scan_it.db");
let _ = std::fs::remove_file(&path);
let pool = sqlx::any::AnyPoolOptions::new()
.max_connections(1)
.connect(&format!("sqlite://{}?mode=rwc", path.display()))
.await
.unwrap();
let schema = include_str!("../migrations/0001_init.sql");
let no_comments: String = schema
.lines()
.map(|l| l.find("--").map(|i| &l[..i]).unwrap_or(l))
.collect::<Vec<_>>()
.join("\n");
for s in no_comments.split(';') {
let s = s.trim();
if !s.is_empty() {
sqlx::query(s).execute(&pool).await.unwrap();
}
}
db::register_account(&pool, RH, HEIGHT as i64).await.unwrap();
// ── tick 1: discover ──
tick(&pool, &node).await.expect("tick 1");
let (total, count) = db::account_totals(&pool, RH).await.unwrap();
assert_eq!((total, count), (VALUE, 1), "golden output discovered + stored");
let (unlocked, _) = db::account_unlocked_totals(&pool, RH, 500).await.unwrap();
assert_eq!(unlocked, VALUE, "regular output is spendable now");
let outs = db::unspent_outputs(&pool, RH).await.unwrap();
assert_eq!(outs[0].key_id.as_deref(), Some("0300000000000000000000000700000000"));
// ── spend it: remove from the node's unspent set ──
{
let mut m = state.lock().unwrap();
m.utxos.clear();
m.present.clear();
}
// ── tick 2: reconcile by absence ──
tick(&pool, &node).await.expect("tick 2");
assert_eq!(db::account_totals(&pool, RH).await.unwrap().0, 0, "spent → zero balance");
let _ = std::fs::remove_file(&path);
}
}