Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion chain-runtimes/monero/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion chain-runtimes/monero/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ members = ["crates/monero-runtime"]
exclude = ["vendor/sqlite-wasm-vfs-0.2.0"]

[workspace.package]
version = "0.1.0-alpha.12"
version = "0.1.0-alpha.14"
edition = "2021"
license = "Apache-2.0"
rust-version = "1.85"
Expand Down
40 changes: 39 additions & 1 deletion chain-runtimes/monero/crates/monero-runtime/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! Paging and aggregation run as indexed SQLite queries.

use monero_wallet::address::Network;
use monero_wallet::WalletOutput;
use monero_wallet::{OutputWithDecoys, WalletOutput};
use rusqlite::{params, OptionalExtension};
use serde_json::{json, Value};

Expand Down Expand Up @@ -297,6 +297,44 @@ pub fn load_output(db: &Db, output_id: i64) -> Result<WalletOutput> {
})
}

/// The ring saved for the identity's output at `global_index`, if any. A blob that no longer
/// parses counts as none, so a newly picked ring replaces it.
pub fn load_ring(db: &Db, identity_id: i64, global_index: u64) -> Result<Option<OutputWithDecoys>> {
let Ok(index) = i64::try_from(global_index) else {
return Ok(None);
};
let blob: Option<Vec<u8>> = db
.conn()
.query_row(
"SELECT blob FROM rings WHERE identity_id = ?1 AND global_index = ?2",
params![identity_id, index],
|r| r.get(0),
)
.optional()
.map_err(|e| db_err("loadRing", e))?;
Ok(blob.and_then(|blob| OutputWithDecoys::read(&mut blob.as_slice()).ok()))
}

/// Saves newly picked rings in one commit; a first max send can pick one per spendable output.
pub fn save_rings(db: &Db, identity_id: i64, rings: &[(u64, OutputWithDecoys)]) -> Result<()> {
if rings.is_empty() {
return Ok(());
}
let tx = db
.conn()
.unchecked_transaction()
.map_err(|e| db_err("beginSaveRings", e))?;
for (global_index, ring) in rings {
let index = i64::try_from(*global_index).map_err(|e| db_err("saveRing", e))?;
tx.execute(
"INSERT OR REPLACE INTO rings (identity_id, global_index, blob) VALUES (?1, ?2, ?3)",
params![identity_id, index, ring.serialize()],
)
.map_err(|e| db_err("saveRing", e))?;
}
tx.commit().map_err(|e| db_err("commitSaveRings", e))
}

/// Transaction history, paged, newest height first.
pub fn history(db: &Db, identity_id: i64, limit: u32, offset: u32) -> Result<Value> {
let limit = limit.clamp(1, 500);
Expand Down
44 changes: 43 additions & 1 deletion chain-runtimes/monero/crates/monero-runtime/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use rusqlite::Connection;
use crate::error::{db_err, ErrorCode, Result, RuntimeError};

/// Current schema version. Once released, any schema change must add a migration
/// and bump this; before that, V1 is edited in place.
/// and bump this; before that, V1 is edited in place. A new table that older
/// runtimes can ignore is the exception: it is created on every open without a
/// bump, so a downgrade still opens the database.
pub const SCHEMA_VERSION: i64 = 1;

/// Reorg safety window: `blocks` keeps hashes only for this many blocks below the tip (about a day).
Expand All @@ -35,12 +37,30 @@ pub fn migrate(conn: &Connection) -> Result<()> {
if version < 1 {
tx.execute_batch(V1).map_err(|e| db_err("migrateV1", e))?;
}
// Additive and idempotent, so it runs on every open without a version bump: an older runtime
// still opens the database and simply ignores the table.
tx.execute_batch(RINGS)
.map_err(|e| db_err("migrateRings", e))?;

tx.execute(&format!("PRAGMA user_version = {SCHEMA_VERSION}"), [])
.map_err(|e| db_err("setUserVersion", e))?;
tx.commit().map_err(|e| db_err("commitMigrate", e))
}

/// The ring picked when a send first used an output, reused by every later attempt as wallet2's
/// ringdb does: fresh decoys each time would let a node intersect them down to the real spend.
/// Keyed by the output's global index, so a rescan that re-creates the output row keeps its ring;
/// removing the account removes its rings, which hold the output's keys.
const RINGS: &str = r#"
CREATE TABLE IF NOT EXISTS rings (
identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
global_index INTEGER NOT NULL,
-- monero-oxide `OutputWithDecoys` serialization
blob BLOB NOT NULL,
PRIMARY KEY (identity_id, global_index)
);
"#;

/// Version 1.
///
/// Amounts are `*_atomic` in piconero. Total supply (1.8e19) exceeds i64, but a
Expand Down Expand Up @@ -327,6 +347,27 @@ mod tests {
assert_eq!(v, SCHEMA_VERSION);
}

// A database written before the rings table existed keeps its version and gains the table.
#[test]
fn an_existing_database_gains_the_rings_table() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(V1).unwrap();
conn.execute_batch("PRAGMA user_version = 1").unwrap();
migrate(&conn).unwrap();
let rings: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='rings'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(rings, 1);
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 1);
}

#[test]
fn refuses_to_open_a_newer_database() {
let conn = Connection::open_in_memory().unwrap();
Expand All @@ -347,6 +388,7 @@ mod tests {
"outputs",
"transactions",
"possible_spends",
"rings",
] {
let n: i64 = conn
.query_row(
Expand Down
Loading
Loading