diff --git a/Cargo.lock b/Cargo.lock index dd94494..35f8b1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,7 @@ dependencies = [ "ergotree-interpreter", "ergotree-ir", "fjall", + "fs2", "generic-array", "hex", "num-bigint", @@ -416,6 +417,7 @@ dependencies = [ "secp256k1", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "tokio", @@ -1332,6 +1334,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -4108,6 +4120,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4117,6 +4145,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.1" diff --git a/config/basis.toml.example b/config/basis.toml.example index 4e51103..92ce804 100644 --- a/config/basis.toml.example +++ b/config/basis.toml.example @@ -8,14 +8,32 @@ data_dir = "data" database_url = "sqlite:data/basis.db" [ergo] -basis_reserve_contract_p2s = "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT" +# The historical reserve identity is the temporary compatibility default. The +# `/reserves/create`, `/config/reserve-contract-p2s`, and +# `/redemption/build` are disabled; this is not a claim about legacy library +# prototypes and not a legacy-safety endorsement. +# The pinned Basis v2 candidate identity is embedded, but runtime activation +# fails closed until the v2 scanner and BNS2/BRS2 stores are installed. # Tracker NFT ID (hex-encoded) - required for reserve creation and redemption # This NFT identifies the tracker server and must be set in reserve contract R6 register tracker_nft_id = "your_tracker_nft_token_id_here" +# Set true only once when intentionally creating a new, empty tracker generation. +# Keep false for every restart and for every existing on-chain tracker NFT. +allow_fresh_tracker_generation = false +# Confirmation policy, expressed as successor depth (the inclusion tip is 0). +confirmed_chain_min_successor_depth = 6 +confirmed_chain_max_evidence_age_ms = 60000 +# Required explicit application horizon. Maintainers must ratify and uncomment +# a value; the publisher stays disabled while it is absent, below the +# acceptance depth, or above the implementation bound of 4095. +# confirmed_chain_reorg_monitor_depth = 720 +# Set true only together with a genuinely new, history-free BNS1 generation. +# Keep false for every restart; a missing/misdirected journal then fails closed. +allow_fresh_reconciliation_journal = false # Tracker public key - can be hex-encoded public key or P2PK address tracker_public_key = "your_tracker_public_key_or_p2pk_address_here" # Tracker secret key for local signing (hex-encoded, 32 bytes) -# If provided, the server will sign redemption transactions locally instead of using the Ergo node API +# The tracker-box publisher signs in process and never sends this key to the node. # WARNING: never commit this value to version control tracker_secret_key = "your_tracker_secret_key_here" diff --git a/crates/basis_app/src/app.rs b/crates/basis_app/src/app.rs index cc74cea..035e3ac 100644 --- a/crates/basis_app/src/app.rs +++ b/crates/basis_app/src/app.rs @@ -91,13 +91,10 @@ pub enum Screen { Accounts, Notes, Reserves, - Transactions, AddressBook, Settings, CreateNote, - RedeemNote, CreateReserve, - GenerateTransaction, AcceptancePolicy, } diff --git a/crates/basis_app/src/ui.rs b/crates/basis_app/src/ui.rs index 7961838..b192918 100644 --- a/crates/basis_app/src/ui.rs +++ b/crates/basis_app/src/ui.rs @@ -2,15 +2,8 @@ use crate::acceptance_policy::{ create_policy, get_blacklist_entries, get_policy_summary, get_whitelist_entries, get_whitelist_entries_with_limit, remove_from_blacklist, remove_from_whitelist, }; -use crate::app::{App, NoteInfo, Screen, WalletStats}; +use crate::app::{App, Screen, WalletStats}; use anyhow::Result; -use basis_offchain::signing::{add_input_proof, redemption_signing_message}; -use ergo_lib::chain::transaction::unsigned::UnsignedTransaction; -use ergo_lib::chain::transaction::Transaction; -use ergo_lib::ergo_chain_types::{Header, PreHeader}; -use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; -use ergo_lib::ergotree_ir::serialization::SigmaSerializable; -use ergo_lib::wallet::secret_key::SecretKey; use std::io::{self, Write}; // ANSI Color codes @@ -44,12 +37,9 @@ pub async fn run(app: &mut App) -> Result<()> { Screen::AddressBook => draw_address_book(app).await?, Screen::Notes => draw_notes(app).await?, Screen::Reserves => draw_reserves(app).await?, - Screen::Transactions => draw_transactions(app).await?, Screen::Settings => draw_settings(app).await?, Screen::CreateNote => draw_create_note(app).await?, - Screen::RedeemNote => draw_redeem_note(app).await?, Screen::CreateReserve => draw_create_reserve(app).await?, - Screen::GenerateTransaction => draw_generate_transaction(app).await?, Screen::AcceptancePolicy => draw_acceptance_policy(app).await?, } } @@ -610,7 +600,6 @@ async fn draw_notes(app: &mut App) -> Result<()> { ); println!(" {}[c]{} Create Note", CYAN, RESET); - println!(" {}[r]{} Redeem Note", CYAN, RESET); println!(); println!(" {}[b]{} Back to Menu\n", YELLOW, RESET); @@ -654,7 +643,6 @@ async fn draw_notes(app: &mut App) -> Result<()> { wait_for_enter("Press Enter to continue..."); } "c" => app.navigate_to(Screen::CreateNote), - "r" => app.navigate_to(Screen::RedeemNote), "b" | "B" => app.navigate_to(Screen::MainMenu), _ => { app.set_notification("Invalid option".to_string(), true); @@ -744,25 +732,6 @@ async fn draw_reserves(app: &mut App) -> Result<()> { Ok(()) } -async fn draw_transactions(app: &mut App) -> Result<()> { - println!("{} TRANSACTIONS & REDEMPTIONS{}", BOLD, RESET); - println!("{} ───────────────────────────{}\n", CYAN, RESET); - - println!(" {}[1]{} Generate Redemption Transaction", CYAN, RESET); - println!(); - println!(" {}[b]{} Back to Menu\n", YELLOW, RESET); - - match read_choice("Select option: ").as_str() { - "1" => app.navigate_to(Screen::GenerateTransaction), - "b" | "B" => app.navigate_to(Screen::MainMenu), - _ => { - app.set_notification("Invalid option".to_string(), true); - } - } - - Ok(()) -} - async fn draw_settings(app: &mut App) -> Result<()> { println!("{} SETTINGS{}", BOLD, RESET); println!("{} ─────────{}\n", CYAN, RESET); @@ -905,286 +874,6 @@ async fn draw_create_note(app: &mut App) -> Result<()> { Ok(()) } -async fn draw_redeem_note(app: &mut App) -> Result<()> { - println!("{} REDEEM NOTE{}", BOLD, RESET); - println!("{} ───────────{}\n", CYAN, RESET); - - if app.current_account.is_none() { - app.set_notification("No account selected".to_string(), true); - app.navigate_to(Screen::Notes); - return Ok(()); - } - - // Refresh notes to ensure we have latest data - if let Some(ref acc) = app.current_account { - match app.client.get_recipient_notes(&acc.pubkey).await { - Ok(notes) => { - app.received_notes = notes - .into_iter() - .map(|n| NoteInfo { - issuer: n.issuer_pubkey, - recipient: n.recipient_pubkey, - amount: n.amount_collected, - redeemed: n.amount_redeemed, - _timestamp: n.timestamp, - }) - .collect(); - } - Err(e) => { - println!("{} Error loading notes: {}{}", RED, e, RESET); - } - } - } - - if app.received_notes.is_empty() { - println!("{} No notes received.{}", GRAY, RESET); - println!( - " {}Tip:{} Create a note from another account first.\n", - YELLOW, RESET - ); - println!(" Press Enter to go back...\n"); - read_input(""); - app.navigate_to(Screen::Notes); - return Ok(()); - } - - // Display received notes list - println!(" {}Your Received Notes:{}", BOLD, RESET); - for (i, note) in app.received_notes.iter().enumerate() { - let outstanding = note.amount.saturating_sub(note.redeemed); - println!( - " [{}] From: {}... | {} ERG outstanding", - i + 1, - ¬e.issuer[..16], - outstanding as f64 / 1_000_000_000.0 - ); - } - println!(); - println!(" {}[0]{} Cancel\n", RED, RESET); - - let selection = read_input("Select note to redeem: "); - if selection == "0" || selection.is_empty() { - app.navigate_to(Screen::Notes); - return Ok(()); - } - - let idx = match selection.parse::() { - Ok(n) if n > 0 && n <= app.received_notes.len() => n - 1, - _ => { - app.set_notification("Invalid selection".to_string(), true); - app.navigate_to(Screen::Notes); - return Ok(()); - } - }; - - let selected_note = &app.received_notes[idx]; - let issuer = selected_note.issuer.clone(); - let recipient = app.current_account.as_ref().unwrap().pubkey.clone(); - let outstanding = selected_note.amount.saturating_sub(selected_note.redeemed); - - if outstanding == 0 { - app.set_notification("Note is fully redeemed".to_string(), true); - app.navigate_to(Screen::Notes); - return Ok(()); - } - - // Show selected note details - println!("\n {}Selected Note:{}", BOLD, RESET); - println!(" From: {}...", &issuer[..16]); - println!(" Amount: {} nanoERG", selected_note.amount); - println!(" Redeemed: {} nanoERG", selected_note.redeemed); - println!(" Outstanding: {} nanoERG", outstanding); - println!(); - - // Ask for redemption amount - let amount_str = read_input(&format!( - "Amount to redeem (default: {} nanoERG, Press Enter for full): ", - outstanding - )); - - let amount = if amount_str.is_empty() { - outstanding - } else { - match amount_str.parse::() { - Ok(a) if a <= outstanding => a, - Ok(_) => { - app.set_notification( - format!("Amount exceeds outstanding liability: {}", outstanding), - true, - ); - app.navigate_to(Screen::Notes); - return Ok(()); - } - Err(_) => { - app.set_notification("Invalid amount".to_string(), true); - app.navigate_to(Screen::Notes); - return Ok(()); - } - } - }; - - // Fetch the full note (payment timestamp) from the server. - let note = match app.client.get_note(&issuer, &recipient).await { - Ok(Some(n)) => n, - Ok(None) => { - app.set_notification("Note not found".to_string(), true); - app.navigate_to(Screen::Notes); - return Ok(()); - } - Err(e) => { - app.set_notification(format!("Error fetching note: {}", e), true); - app.navigate_to(Screen::Notes); - return Ok(()); - } - }; - - println!("\n {}Building redemption via tracker...{}", CYAN, RESET); - match tracker_assisted_redeem(app, &issuer, &recipient, amount, note.timestamp).await { - Ok(tx_id) => { - let short = &tx_id[..16.min(tx_id.len())]; - app.set_notification(format!("Redeemed {} nanoERG, tx {}", amount, short), false); - let _ = app.refresh_data().await; - } - Err(e) => { - app.set_notification(format!("Redemption failed: {}", e), true); - } - } - - app.navigate_to(Screen::Notes); - Ok(()) -} - -/// Tracker-assisted redemption. The tracker builds the unsigned transaction and signs the fee -/// input(s) (`POST /redemption/build`); the TUI signs the issuer message with the local issuer -/// account, adds the reserve input's `proveDlog(recipient)` proof over the same `bytes_to_sign`, -/// and submits the fully-signed transaction (`POST /redemption/submit`). -/// -/// Returns the broadcast transaction id on success. -async fn tracker_assisted_redeem( - app: &App, - issuer: &str, - recipient: &str, - amount: u64, - timestamp: u64, -) -> Result { - let issuer_pk: [u8; 33] = hex::decode(issuer) - .map_err(|e| format!("issuer hex: {}", e))? - .try_into() - .map_err(|_| "issuer pubkey must be 33 bytes".to_string())?; - let recipient_pk: [u8; 33] = hex::decode(recipient) - .map_err(|e| format!("recipient hex: {}", e))? - .try_into() - .map_err(|_| "recipient pubkey must be 33 bytes".to_string())?; - - // Authoritative total debt from the tracker (must match context var #3 exactly). - let total_debt = app - .client - .get_tracker_proof(issuer, recipient) - .await - .map_err(|e| format!("tracker proof failed: {}", e))? - .total_debt; - - // Issuer (reserve owner) signs the redemption message. - let message = redemption_signing_message(&issuer_pk, &recipient_pk, total_debt, timestamp); - let issuer_account = app - .account_manager - .accounts - .values() - .find(|a| a.get_pubkey_hex() == issuer) - .ok_or_else(|| { - format!( - "no local account for issuer {}... (the issuer must co-sign the redemption)", - &issuer[..16.min(issuer.len())] - ) - })?; - let issuer_sig = issuer_account - .sign_message(&message) - .map_err(|e| format!("issuer signing failed: {}", e))?; - - // Recipient (receiver) secret for the reserve input's proveDlog(recipient). - let cur = app.current_account.as_ref().ok_or("no current account")?; - let receiver_account = app - .account_manager - .get_account(&cur.name) - .ok_or("current account not found")?; - let receiver_secret: [u8; 32] = hex::decode(receiver_account.get_private_key_hex()) - .map_err(|e| format!("receiver secret hex: {}", e))? - .try_into() - .map_err(|_| "receiver secret must be 32 bytes".to_string())?; - let receiver_sk = SecretKey::dlog_from_bytes(&receiver_secret) - .ok_or("invalid receiver dlog secret".to_string())?; - - // Tracker builds the unsigned tx and signs the fee input(s). - let build = app - .client - .redemption_build(basis_cli_lib::api::RedemptionBuildRequest { - issuer_pubkey: issuer.to_string(), - recipient_pubkey: recipient.to_string(), - amount, - timestamp, - issuer_signature: hex::encode(issuer_sig), - emergency: false, - tracker_box_id: None, - change_address: None, - }) - .await - .map_err(|e| format!("tracker build failed: {}", e))?; - - // Reconstruct the signing material the tracker produced. - let unsigned: UnsignedTransaction = serde_json::from_value(build.unsigned_tx) - .map_err(|e| format!("parse unsigned tx: {}", e))?; - let partial: Transaction = - serde_json::from_value(build.partial_tx).map_err(|e| format!("parse partial tx: {}", e))?; - let parse_box = |h: &str| -> Result { - let bytes = hex::decode(h).map_err(|e| format!("box hex: {}", e))?; - ErgoBox::sigma_parse_bytes(&bytes).map_err(|e| format!("box parse: {:?}", e)) - }; - let mut input_boxes = Vec::with_capacity(build.input_box_binaries.len()); - for h in &build.input_box_binaries { - input_boxes.push(parse_box(h)?); - } - let mut data_boxes = Vec::with_capacity(build.data_box_binaries.len()); - for h in &build.data_box_binaries { - data_boxes.push(parse_box(h)?); - } - if build.headers.len() < 10 { - return Err(format!( - "tracker returned {} headers (need 10)", - build.headers.len() - )); - } - let pre_header = PreHeader::from(build.headers[0].clone()); - let headers: [Header; 10] = build.headers[..10] - .to_vec() - .try_into() - .map_err(|_| "headers array".to_string())?; - - // Add the reserve input (index 0) proveDlog(recipient) proof over the same bytes_to_sign. - let signed = add_input_proof( - &unsigned, - Some(&partial), - &input_boxes, - &data_boxes, - &pre_header, - &headers, - 0, - &receiver_sk, - ) - .map_err(|e| format!("reserve proof failed: {:?}", e))?; - - let tx_json = serde_json::to_value(&signed).map_err(|e| format!("serialize tx: {}", e))?; - app.client - .redemption_submit( - tx_json, - issuer, - recipient, - amount, - build.new_already_redeemed, - ) - .await - .map_err(|e| format!("submit failed: {}", e)) -} - async fn draw_create_reserve(app: &mut App) -> Result<()> { println!("{} CREATE RESERVE{}", BOLD, RESET); println!("{} ──────────────{}\n", CYAN, RESET); @@ -1239,31 +928,12 @@ async fn draw_create_reserve(app: &mut App) -> Result<()> { } println!(); - let submit = read_input("Submit to tracker node for broadcast? (y/n): "); - if submit == "y" || submit == "Y" { - match app.client.submit_reserve(response).await { - Ok(submission) => { - app.set_notification( - format!( - "Reserve submitted, tx {}", - &submission.tx_id[..16.min(submission.tx_id.len())] - ), - false, - ); - } - Err(e) => { - app.set_notification( - format!("Failed to submit reserve: {}", e), - true, - ); - } - } - } else { - app.set_notification( - "Reserve payload generated (not submitted)".to_string(), - false, - ); - } + // The tracker is not a wallet proxy. Reserve owners review and sign the + // generated payload with their own wallet. + app.set_notification( + "Reserve payload generated for owner-wallet review and signing".to_string(), + false, + ); } Err(e) => { app.set_notification(format!("Failed to create reserve: {}", e), true); @@ -1283,245 +953,6 @@ async fn draw_create_reserve(app: &mut App) -> Result<()> { Ok(()) } -async fn draw_generate_transaction(app: &mut App) -> Result<()> { - println!("{} GENERATE REDEMPTION TRANSACTION{}", BOLD, RESET); - println!("{} ───────────────────────────────{}\n", CYAN, RESET); - println!( - " {}[Press Enter with empty input to cancel]{}\n", - GRAY, RESET - ); - - if app.current_account.is_none() { - app.set_notification("No account selected".to_string(), true); - app.navigate_to(Screen::Transactions); - return Ok(()); - } - - let issuer = match select_pubkey_from_address_book(app, "Issuer pubkey (66 hex chars)") { - Some(pk) => pk, - None => { - app.set_notification("Transaction generation cancelled".to_string(), false); - app.navigate_to(Screen::Transactions); - return Ok(()); - } - }; - - let recipient = match select_pubkey_from_address_book(app, "Recipient pubkey (66 hex chars)") { - Some(pk) => pk, - None => { - app.set_notification("Transaction generation cancelled".to_string(), false); - app.navigate_to(Screen::Transactions); - return Ok(()); - } - }; - - let amount_str = read_input("Amount (nanoERG): "); - if amount_str.is_empty() { - app.set_notification("Transaction generation cancelled".to_string(), false); - app.navigate_to(Screen::Transactions); - return Ok(()); - } - - let emergency_str = read_input("Emergency redemption? (y/n): "); - let emergency = emergency_str == "y" || emergency_str == "Y"; - - if issuer.len() == 66 && recipient.len() == 66 { - if let Ok(amount) = amount_str.parse::() { - // Get note info - match app.client.get_note(&issuer, &recipient).await { - Ok(Some(note)) => { - if note.outstanding_debt() < amount { - app.set_notification( - "Insufficient outstanding liability".to_string(), - true, - ); - app.navigate_to(Screen::Transactions); - return Ok(()); - } - - // Get reserve box - match app.client.get_reserves_by_issuer(&issuer).await { - Ok(reserves) => { - if let Some(reserve) = reserves.first() { - let reserve_box_id = reserve.box_id.clone(); - let _tracker_nft_id = reserve.base_info.tracker_nft_id.clone(); - - // Get tracker box - match app.client.get_latest_tracker_box_id().await { - Ok(tracker_box) => { - let tracker_box_id = tracker_box.tracker_box_id; - - // Get proofs - match app - .client - .get_tracker_proof(&issuer, &recipient) - .await - { - Ok(tracker_proof) => { - let total_debt = tracker_proof.total_debt; - let _tracker_lookup_proof = tracker_proof.proof; - let _tracker_state_digest = - tracker_proof.tracker_state_digest; - - // Get issuer signature - let issuer_bytes = hex::decode(&issuer)?; - let recipient_bytes = hex::decode(&recipient)?; - - let mut key_hash_input = Vec::new(); - key_hash_input.extend_from_slice(&issuer_bytes); - key_hash_input.extend_from_slice(&recipient_bytes); - - use blake2::{Blake2b, Digest}; - use generic_array::typenum::U32; - let key_hash = Blake2b::::new() - .chain_update(&key_hash_input) - .finalize() - .to_vec(); - - let mut message = Vec::with_capacity(48); - message.extend_from_slice(&key_hash); - message - .extend_from_slice(&total_debt.to_be_bytes()); - message.extend_from_slice( - ¬e.timestamp.to_be_bytes(), - ); - - if let Some(ref acc) = app.current_account { - if let Some(account) = - app.account_manager.get_account(&acc.name) - { - match account.sign_message(&message) { - Ok(issuer_signature) => { - // Get tracker signature - match app - .client - .request_tracker_signature( - &issuer, - &recipient, - total_debt, - note.timestamp, - emergency, - ) - .await - { - Ok(tracker_sig_response) => { - println!("\n{}Transaction generated successfully!{}", GREEN, RESET); - println!( - "\n{}Details:{}", - BOLD, RESET - ); - println!( - " Issuer: {}", - issuer - ); - println!( - " Recipient: {}", - recipient - ); - println!( - " Amount: {} nanoERG", - amount - ); - println!(" Total Debt: {} nanoERG", total_debt); - println!( - " Reserve Box: {}", - reserve_box_id - ); - println!( - " Tracker Box: {}", - tracker_box_id - ); - println!( - " Emergency: {}", - emergency - ); - println!( - "\n{}Signature:{}", - BOLD, RESET - ); - println!( - " Issuer: {}...", - hex::encode( - &issuer_signature - )[..32] - .to_string() - ); - println!( - " Tracker: {}...", - tracker_sig_response - .tracker_signature - [..32] - .to_string() - ); - println!(); - wait_for_enter("Press Enter to continue..."); - app.set_notification( - "Transaction generated" - .to_string(), - false, - ); - } - Err(e) => { - app.set_notification(format!("Tracker signature error: {}", e), true); - } - } - } - Err(e) => { - app.set_notification( - format!("Signing error: {}", e), - true, - ); - } - } - } - } - } - Err(e) => { - app.set_notification( - format!("Tracker proof error: {}", e), - true, - ); - } - } - } - Err(e) => { - app.set_notification( - format!("Tracker box error: {}", e), - true, - ); - } - } - } else { - app.set_notification("No reserve found".to_string(), true); - } - } - Err(e) => { - app.set_notification(format!("Reserve error: {}", e), true); - } - } - } - Ok(None) => { - app.set_notification("Note not found".to_string(), true); - } - Err(e) => { - app.set_notification(format!("Note error: {}", e), true); - } - } - } else { - app.set_notification("Invalid amount".to_string(), true); - } - } else { - app.set_notification( - "Invalid pubkey length (must be 66 hex chars)".to_string(), - true, - ); - } - - app.navigate_to(Screen::Transactions); - Ok(()) -} - -// Address book helper fn select_pubkey_from_address_book(app: &App, prompt_prefix: &str) -> Option { // Collect address book contacts let mut all_contacts: Vec<(String, String)> = Vec::new(); diff --git a/crates/basis_app/tests/legacy_navigation_surface_guard.rs b/crates/basis_app/tests/legacy_navigation_surface_guard.rs new file mode 100644 index 0000000..9f54b1a --- /dev/null +++ b/crates/basis_app/tests/legacy_navigation_surface_guard.rs @@ -0,0 +1,23 @@ +#[test] +fn tui_has_no_legacy_redemption_or_transaction_navigation() { + let app = include_str!("../src/app.rs"); + let ui = include_str!("../src/ui.rs"); + + for needle in ["Transactions", "RedeemNote", "GenerateTransaction"] { + assert!( + !app.contains(needle), + "legacy Screen variant remains: {needle}" + ); + assert!( + !ui.contains(needle), + "legacy TUI navigation remains: {needle}" + ); + } + + for label in ["Redeem Note", "Generate Redemption Transaction"] { + assert!( + !ui.contains(label), + "legacy TUI menu label remains: {label}" + ); + } +} diff --git a/crates/basis_cli/src/api.rs b/crates/basis_cli/src/api.rs index 75163ea..ec05361 100644 --- a/crates/basis_cli/src/api.rs +++ b/crates/basis_cli/src/api.rs @@ -2,6 +2,13 @@ use anyhow::Result; use basis_store; use serde::{Deserialize, Serialize}; +const V1_REDEMPTION_RETIRED: &str = + "Basis v1 redemption is retired; a fully validated v2 manifest and confirmed-chain authority are required before proof generation or signing"; + +fn reject_retired_v1_redemption() -> Result { + Err(anyhow::anyhow!(V1_REDEMPTION_RETIRED)) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateNoteRequest { pub issuer_pubkey: String, @@ -44,60 +51,6 @@ pub struct KeyStatusResponse { pub has_pending_refund: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedeemRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub amount: u64, - pub timestamp: u64, - /// Reserve box ID (optional - will be looked up if not provided) - #[serde(default)] - pub reserve_box_id: String, - /// Tracker box ID (optional - fetched by server) - #[serde(default)] - pub tracker_box_id: String, - /// Tracker NFT ID from reserve box R6 register (optional - fetched by server) - #[serde(default)] - pub tracker_nft_id: String, - /// Current blockchain height (optional - fetched by server) - #[serde(default)] - pub current_height: u64, - /// Recipient address for redemption output (optional - derived from recipient_pubkey if not provided) - #[serde(default)] - pub recipient_address: String, - /// Change address for transaction outputs (optional - server will derive from tracker pubkey if not provided) - #[serde(default)] - pub change_address: String, - /// Issuer's Schnorr signature (65 bytes, hex encoded = 130 chars) - pub issuer_signature: String, - /// Whether this is an emergency redemption - #[serde(default)] - pub emergency: bool, - /// Tracker's Schnorr signature (optional - server will generate if not provided) - #[serde(default)] - pub tracker_signature: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedeemResponse { - pub redemption_id: String, - pub amount: u64, - pub timestamp: u64, - pub proof_available: bool, - pub transaction_pending: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompleteRedemptionRequest { - pub redemption_id: String, - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub redeemed_amount: u64, - /// Cumulative reserve-tree already_redeemed after this redemption. - #[serde(default)] - pub new_already_redeemed: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateReserveRequest { pub nft_id: String, @@ -126,24 +79,7 @@ pub struct Asset { pub amount: u64, } -/// Response from submitting a reserve creation payload to the tracker's Ergo node. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReserveSubmissionResponse { - pub tx_id: String, -} - -// Tracker signature request/response for redemption -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrackerSignatureRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub total_debt: u64, - /// Payment timestamp in milliseconds since Unix epoch - pub timestamp: u64, - #[serde(default)] - pub emergency: bool, -} - +/// Legacy response shape retained by the unconditional v1 tombstone. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrackerSignatureResponse { pub success: bool, @@ -328,117 +264,20 @@ impl TrackerClient { } } - // Redemption - pub async fn initiate_redemption(&self, request: RedeemRequest) -> Result { - let url = format!("{}/redeem", self.base_url); - let response = match ureq::post(&url).send_json(serde_json::to_value(request)?) { - Ok(resp) => resp, - Err(ureq::Error::Status(code, resp)) => { - let error_text = resp - .into_string() - .unwrap_or_else(|_| format!("HTTP {}", code)); - return Err(anyhow::anyhow!( - "Failed to initiate redemption: {}", - error_text - )); - } - Err(e) => { - return Err(anyhow::anyhow!("Request failed: {}", e)); - } - }; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap()) - } else { - Err(anyhow::anyhow!("API error: {:?}", api_response.error)) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!( - "Failed to initiate redemption: {}", - error_text - )) - } - } - - pub async fn complete_redemption(&self, request: CompleteRedemptionRequest) -> Result<()> { - let url = format!("{}/redeem/complete", self.base_url); - let response = match ureq::post(&url).send_json(serde_json::to_value(request)?) { - Ok(resp) => resp, - Err(ureq::Error::Status(code, resp)) => { - let error_text = resp - .into_string() - .unwrap_or_else(|_| format!("HTTP {}", code)); - return Err(anyhow::anyhow!( - "Failed to complete redemption: {}", - error_text - )); - } - Err(e) => { - return Err(anyhow::anyhow!("Request failed: {}", e)); - } - }; - - if response.status() == 200 { - Ok(()) - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!( - "Failed to complete redemption: {}", - error_text - )) - } - } - - /// Request tracker signature for redemption - /// Following the Basis protocol specification - POST /tracker/signature + /// Retired v1 tracker-signature call. pub async fn request_tracker_signature( &self, - issuer_pubkey: &str, - recipient_pubkey: &str, - total_debt: u64, - timestamp: u64, - emergency: bool, + _issuer_pubkey: &str, + _recipient_pubkey: &str, + _total_debt: u64, + _timestamp: u64, + _emergency: bool, ) -> Result { - let request = TrackerSignatureRequest { - issuer_pubkey: issuer_pubkey.to_string(), - recipient_pubkey: recipient_pubkey.to_string(), - total_debt, - timestamp, - emergency, - }; - - let url = format!("{}/tracker/signature", self.base_url); - let response = ureq::post(&url).send_json(serde_json::to_value(request)?)?; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap()) - } else { - Err(anyhow::anyhow!("API error: {:?}", api_response.error)) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!( - "Failed to request tracker signature: {}", - error_text - )) - } + reject_retired_v1_redemption() } } -// Define structs outside of the impl block -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedemptionPreparationRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub amount: u64, - pub timestamp: u64, -} - +/// Legacy response shape retained by the unconditional v1 tombstone. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RedemptionPreparationResponse { pub redemption_id: String, @@ -450,8 +289,9 @@ pub struct RedemptionPreparationResponse { pub tracker_box_id: String, // ID of the tracker box used for the proof } -// Tracker-assisted 2-phase redemption build/submit (POST /redemption/build, /redemption/submit). +/// Legacy request shape retained by the unconditional v1 tombstone. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RedemptionBuildRequest { pub issuer_pubkey: String, pub recipient_pubkey: String, @@ -463,8 +303,6 @@ pub struct RedemptionBuildRequest { pub emergency: bool, #[serde(default)] pub tracker_box_id: Option, - #[serde(default)] - pub change_address: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -484,26 +322,12 @@ pub struct RedemptionBuildResponse { pub recipient_address: String, pub is_first_redemption: bool, pub fee: u64, - /// Cumulative reserve-tree `already_redeemed` proven by the build; round-trip to submit. + /// Legacy response field; no successful v1 build response is produced. #[serde(default)] pub new_already_redeemed: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedemptionSubmitRequest { - pub signed_tx: serde_json::Value, - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub redeemed_amount: u64, - pub new_already_redeemed: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedemptionSubmitResponse { - pub tx_id: String, -} - -// Tracker proof response for context var #8 +/// Legacy response shape retained by the unconditional v1 tombstone. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrackerProofResponse { pub key: String, @@ -513,8 +337,7 @@ pub struct TrackerProofResponse { pub tracker_state_digest: String, } -// Reserve proof response for context var #5 (insert) and #7 (lookup) -// GET /reserve/proof endpoint response +/// Legacy response shape retained by the unconditional v1 tombstone. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReserveProofResponse { /// Hex-encoded AVL tree key: hash(ownerKey || receiverKey) @@ -535,188 +358,47 @@ pub struct ReserveProofResponse { } impl TrackerClient { - /// Get tracker lookup proof for context var #8 + /// Retired v1 tracker-proof call. pub async fn get_tracker_proof( &self, - issuer_pubkey: &str, - recipient_pubkey: &str, + _issuer_pubkey: &str, + _recipient_pubkey: &str, ) -> Result { - let url = format!( - "{}/tracker/proof?issuer_pubkey={}&recipient_pubkey={}", - self.base_url, issuer_pubkey, recipient_pubkey - ); - let response = ureq::get(&url).call()?; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap()) - } else { - Err(anyhow::anyhow!("API error: {:?}", api_response.error)) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!( - "Failed to get tracker proof: {}", - error_text - )) - } + reject_retired_v1_redemption() } - /// Get reserve proof for context var #5 (insert) and #7 (lookup) + /// Retired v1 reserve-proof call. pub async fn get_reserve_proof( &self, - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - timestamp: u64, + _issuer_pubkey: &str, + _recipient_pubkey: &str, + _amount: u64, + _timestamp: u64, ) -> Result { - let url = format!( - "{}/reserve/proof?issuer_pubkey={}&recipient_pubkey={}&amount={}×tamp={}", - self.base_url, issuer_pubkey, recipient_pubkey, amount, timestamp - ); - let response = ureq::get(&url).call()?; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap()) - } else { - // If reserve not found, this might be first redemption - Err(anyhow::anyhow!( - "Reserve record not found: {:?}", - api_response.error - )) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!( - "Failed to get reserve proof: {}", - error_text - )) - } + reject_retired_v1_redemption() } + /// Retired v1 redemption-preparation call. pub async fn prepare_redemption( &self, - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, + _issuer_pubkey: &str, + _recipient_pubkey: &str, + _amount: u64, ) -> Result { - let request = RedemptionPreparationRequest { - issuer_pubkey: issuer_pubkey.to_string(), - recipient_pubkey: recipient_pubkey.to_string(), - amount, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - }; - - let url = format!("{}/redemption/prepare", self.base_url); - let response = ureq::post(&url).send_json(serde_json::to_value(request)?)?; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap()) - } else { - Err(anyhow::anyhow!( - "API error during redemption preparation: {:?}", - api_response.error - )) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!( - "Failed to prepare redemption: {}", - error_text - )) - } + reject_retired_v1_redemption() } - /// Build the unsigned redemption transaction on the tracker and have it sign the fee input(s) - /// locally. Returns the unsigned tx, the fee-signed partial tx, the sigma-serialized input/data - /// boxes, and the block headers the client needs to add the reserve-input proof. - /// POST /redemption/build + /// Retired v1 transaction-build call. pub async fn redemption_build( &self, - request: RedemptionBuildRequest, + _request: RedemptionBuildRequest, ) -> Result { - let url = format!("{}/redemption/build", self.base_url); - let response = match ureq::post(&url).send_json(serde_json::to_value(request)?) { - Ok(resp) => resp, - Err(ureq::Error::Status(code, resp)) => { - let error_text = resp - .into_string() - .unwrap_or_else(|_| format!("HTTP {}", code)); - return Err(anyhow::anyhow!("redemption build failed: {}", error_text)); - } - Err(e) => return Err(anyhow::anyhow!("redemption build request failed: {}", e)), - }; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap()) - } else { - Err(anyhow::anyhow!( - "redemption build API error: {:?}", - api_response.error - )) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!("redemption build failed: {}", error_text)) - } + reject_retired_v1_redemption() } - /// Broadcast a fully-signed redemption transaction via the tracker. POST /redemption/submit. - /// Returns the node-accepted transaction id. - /// - /// `redeemed_amount` and `new_already_redeemed` let the tracker sync its local note and - /// reserve-tree state after a successful broadcast. - pub async fn redemption_submit( - &self, - signed_tx: serde_json::Value, - issuer_pubkey: &str, - recipient_pubkey: &str, - redeemed_amount: u64, - new_already_redeemed: u64, - ) -> Result { - let url = format!("{}/redemption/submit", self.base_url); - let request = RedemptionSubmitRequest { - signed_tx, - issuer_pubkey: issuer_pubkey.to_string(), - recipient_pubkey: recipient_pubkey.to_string(), - redeemed_amount, - new_already_redeemed, - }; - let response = match ureq::post(&url).send_json(serde_json::to_value(request)?) { - Ok(resp) => resp, - Err(ureq::Error::Status(code, resp)) => { - let error_text = resp - .into_string() - .unwrap_or_else(|_| format!("HTTP {}", code)); - return Err(anyhow::anyhow!("redemption submit failed: {}", error_text)); - } - Err(e) => return Err(anyhow::anyhow!("redemption submit request failed: {}", e)), - }; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap().tx_id) - } else { - Err(anyhow::anyhow!( - "redemption submit API error: {:?}", - api_response.error - )) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!("redemption submit failed: {}", error_text)) - } + /// Retired v1 transaction-submit call. + pub async fn redemption_submit(&self, _signed_tx: serde_json::Value) -> Result { + reject_retired_v1_redemption() } // Events & Status @@ -782,27 +464,6 @@ impl TrackerClient { } } - /// Submit a reserve creation payload to the tracker's configured Ergo node for broadcast. - pub async fn submit_reserve( - &self, - payload: ReserveCreationResponse, - ) -> Result { - let url = format!("{}/reserves/submit", self.base_url); - let response = ureq::post(&url).send_json(serde_json::to_value(payload)?)?; - - if response.status() == 200 { - let api_response: ApiResponse = response.into_json()?; - if api_response.success { - Ok(api_response.data.unwrap()) - } else { - Err(anyhow::anyhow!("API error: {:?}", api_response.error)) - } - } else { - let error_text = response.into_string()?; - Err(anyhow::anyhow!("Failed to submit reserve: {}", error_text)) - } - } - /// Upload acceptance policy to server #[allow(dead_code)] pub async fn upload_policy( @@ -938,7 +599,7 @@ impl From for basis_store::ExtendedReserveInfo { } impl TrackerClient { - // New methods for the redemption transaction generation + // Reserve discovery remains active; v1 redemption construction methods above are tombstones. /// Get reserves for a specific issuer pub async fn get_reserves_by_issuer( @@ -1165,44 +826,6 @@ impl TrackerClient { } } - /// Get the private key for a wallet address from the Ergo node. - /// This is used to satisfy `proveDlog(receiver)` conditions in the Basis reserve contract. - pub async fn get_private_key( - &self, - node_url: &str, - api_key: Option<&str>, - address: &str, - ) -> Result { - let url = format!("{}/wallet/getPrivateKey", node_url.trim_end_matches('/')); - let mut request = ureq::post(&url); - - if let Some(key) = api_key { - request = request.set("api_key", key); - } - - let request_body = serde_json::json!({ "address": address }); - let response = request.send_json(request_body)?; - - if response.status() == 200 { - let secret: String = response.into_json()?; - Ok(secret) - } else if response.status() == 404 { - let error_text = response.into_string().unwrap_or_default(); - Err(anyhow::anyhow!( - "Address {} not found in node wallet: {}", - address, - error_text - )) - } else { - let error_text = response.into_string().unwrap_or_default(); - Err(anyhow::anyhow!( - "Failed to get private key for {}: {}", - address, - error_text - )) - } - } - pub async fn get_all_notes(&self) -> Result> { let url = format!("{}/notes", self.base_url); let response = ureq::get(&url).call()?; @@ -1265,3 +888,43 @@ impl SerializableIouNoteWithAge { self.amount_collected.saturating_sub(self.amount_redeemed) } } + +#[cfg(test)] +mod v1_redemption_tombstone_tests { + use super::*; + + fn assert_retired(result: Result) { + assert_eq!( + result.err().expect("v1 call must fail").to_string(), + V1_REDEMPTION_RETIRED + ); + } + + #[tokio::test] + async fn every_v1_client_proof_build_sign_and_submit_call_fails_before_network_io() { + let client = TrackerClient::new("http://127.0.0.1:1".to_string()); + + assert_retired( + client + .request_tracker_signature("issuer", "receiver", 1, 1, false) + .await, + ); + assert_retired(client.get_tracker_proof("issuer", "receiver").await); + assert_retired(client.get_reserve_proof("issuer", "receiver", 1, 1).await); + assert_retired(client.prepare_redemption("issuer", "receiver", 1).await); + assert_retired( + client + .redemption_build(RedemptionBuildRequest { + issuer_pubkey: "issuer".to_string(), + recipient_pubkey: "receiver".to_string(), + amount: 1, + timestamp: 1, + issuer_signature: "signature".to_string(), + emergency: false, + tracker_box_id: None, + }) + .await, + ); + assert_retired(client.redemption_submit(serde_json::json!({})).await); + } +} diff --git a/crates/basis_cli/src/commands/mod.rs b/crates/basis_cli/src/commands/mod.rs index 952b146..452d323 100644 --- a/crates/basis_cli/src/commands/mod.rs +++ b/crates/basis_cli/src/commands/mod.rs @@ -4,5 +4,3 @@ pub mod keypair; pub mod note; pub mod reserve; pub mod status; -pub mod test_redemption; -pub mod transaction; diff --git a/crates/basis_cli/src/commands/note.rs b/crates/basis_cli/src/commands/note.rs index 62fe6a5..d3920dc 100644 --- a/crates/basis_cli/src/commands/note.rs +++ b/crates/basis_cli/src/commands/note.rs @@ -1,9 +1,5 @@ use crate::account::AccountManager; -use crate::api::{ - CompleteRedemptionRequest, CreateNoteRequest, KeyStatusResponse, RedeemRequest, - SerializableIouNote, TrackerClient, -}; -use crate::commands::transaction::execute_local_redemption; +use crate::api::{CreateNoteRequest, KeyStatusResponse, SerializableIouNote, TrackerClient}; use crate::demo_keys; use crate::output::progress; use anyhow::Result; @@ -72,19 +68,6 @@ pub struct NoteListResult { pub notes: Vec, } -/// Result of `note redeem` (either the server-signed or the locally-signed path). -#[derive(Debug, Serialize)] -pub struct NoteRedeemResult { - pub amount: u64, - pub server_sign: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub redemption_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub proof_available: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tx_id: Option, -} - #[derive(Subcommand)] pub enum NoteCommands { /// Create a new debt note @@ -120,7 +103,7 @@ pub enum NoteCommands { #[arg(long)] recipient: String, }, - /// Redeem a note + /// Retired: v2 admission has no active proof, signer, or submit path Redeem { /// Issuer public key (hex) #[arg(long)] @@ -128,9 +111,6 @@ pub enum NoteCommands { /// Amount to redeem in nanoERG #[arg(long)] amount: u64, - /// Use server-side signing and broadcasting instead of local signing (default is local). - #[arg(long, default_value = "false")] - server_sign: bool, }, } @@ -236,17 +216,9 @@ pub async fn handle_note_command( println!("Note not found"); } } - NoteCommands::Redeem { - issuer, - amount, - server_sign, - } => { - let result = redeem_note(account_manager, client, &issuer, amount, server_sign).await?; - if json { - println!("{}", serde_json::to_string_pretty(&result)?); - } else if let Some(tx_id) = result.tx_id { - println!("✅ Redemption broadcast. Transaction ID: {}", tx_id); - } + NoteCommands::Redeem { issuer, amount } => { + let _ = (issuer, amount); + redeem_note()?; } } @@ -352,8 +324,7 @@ pub async fn create_demo_note(amount: u64, output: Option) -> Result Result { - let current_account = account_manager - .get_current() - .ok_or_else(|| anyhow::anyhow!("No current account selected"))?; - - let recipient_pubkey = current_account.get_pubkey_hex(); - let recipient_secret = current_account.get_private_key_hex(); - - // First, get the note to retrieve its original timestamp and validate debt - let note = client - .get_note(issuer, &recipient_pubkey) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "Note not found for issuer {} and recipient {}", - issuer, - recipient_pubkey - ) - })?; - - // Verify that the note has sufficient outstanding debt - if note.outstanding_debt() < amount { - return Err(anyhow::anyhow!( - "Insufficient outstanding debt: {} nanoERG available, {} nanoERG requested", - note.outstanding_debt(), - amount - )); - } - - let new_already_redeemed = note.amount_redeemed.saturating_add(amount); +const NOTE_REDEMPTION_RETIRED: &str = + "note redeem is retired; v2 remains disabled until confirmed-chain authority and a validated-manifest-only prover, signer, and submit path are integrated"; - if server_sign { - // Server-side signing path (legacy). - let timestamp = note.timestamp; +fn reject_retired_note_redemption() -> Result<()> { + anyhow::bail!(NOTE_REDEMPTION_RETIRED) +} - let redeem_request = RedeemRequest { - issuer_pubkey: issuer.to_string(), - recipient_pubkey: recipient_pubkey.clone(), - amount, - timestamp, - reserve_box_id: String::new(), // Will be looked up by server - tracker_box_id: String::new(), // Will be fetched by server - tracker_nft_id: String::new(), // Will be fetched by server - current_height: 0, // Will be fetched by server - recipient_address: String::new(), // Will be derived from recipient_pubkey by server - change_address: String::new(), // Will be derived from tracker pubkey by server - issuer_signature: note.signature.clone(), - emergency: false, - tracker_signature: None, // Server will generate tracker signature - }; - - let response = client.initiate_redemption(redeem_request).await?; - progress!("✅ Redemption initiated"); - progress!(" Redemption ID: {}", response.redemption_id); - progress!(" Amount: {} nanoERG", response.amount); - progress!(" Proof available: {}", response.proof_available); - - // Complete redemption with the proper payload including redemption_id. - let complete_request = CompleteRedemptionRequest { - redemption_id: response.redemption_id.clone(), - issuer_pubkey: issuer.to_string(), - recipient_pubkey, - redeemed_amount: amount, - new_already_redeemed: Some(new_already_redeemed), - }; - - client.complete_redemption(complete_request).await?; - progress!("✅ Redemption completed"); - - Ok(NoteRedeemResult { - amount, - server_sign: true, - redemption_id: Some(response.redemption_id), - proof_available: Some(response.proof_available), - tx_id: None, - }) - } else { - // Local signing path (default): the CLI wallet signs the reserve and fee inputs - // and broadcasts directly to the Ergo node; the server only provides the tracker - // Schnorr signature. `execute_local_redemption` also syncs tracker state after - // broadcast so the reserve tree is ready for the next redemption. - let tx_id = execute_local_redemption( - client, - account_manager, - issuer, - &recipient_pubkey, - amount, - false, // emergency - None, // tracker_box_id - None, // change_address - Some(recipient_secret), - None, // fee_secret - ) - .await?; - - Ok(NoteRedeemResult { - amount, - server_sign: false, - redemption_id: None, - proof_available: None, - tx_id: Some(tx_id), - }) - } +/// Compatibility tombstone for CLI and MCP callers. +/// +/// This function deliberately rejects before account lookup, network access, +/// transaction construction, signing, or broadcast. +pub fn redeem_note() -> Result<()> { + reject_retired_note_redemption() } fn print_reserve_status(status: &KeyStatusResponse) { @@ -629,3 +503,14 @@ fn blake2b256_hash(data: &[u8]) -> [u8; 32] { .try_into() .expect("Blake2b should produce at least 32 bytes") } + +#[cfg(test)] +mod security_boundary_tests { + use super::reject_retired_note_redemption; + + #[test] + fn note_redemption_is_an_unconditional_pre_effect_tombstone() { + let error = reject_retired_note_redemption().unwrap_err().to_string(); + assert!(error.contains("retired")); + } +} diff --git a/crates/basis_cli/src/commands/reserve.rs b/crates/basis_cli/src/commands/reserve.rs index 2a5cc37..cee0da1 100644 --- a/crates/basis_cli/src/commands/reserve.rs +++ b/crates/basis_cli/src/commands/reserve.rs @@ -21,7 +21,7 @@ pub enum ReserveCommands { #[arg(long)] amount: u64, - /// Submit the generated payload to the tracker's Ergo node for broadcast + /// Retired compatibility flag; tracker-side wallet submission is rejected #[arg(long)] submit: bool, }, @@ -84,6 +84,12 @@ pub async fn create_reserve( amount: u64, submit: bool, ) -> Result { + if submit { + anyhow::bail!( + "Tracker-side reserve submission is retired; generate the payload and sign it with the reserve owner's wallet" + ); + } + // Get the owner public key from either the command line argument or current account let owner_pubkey = resolve_pubkey(account_manager, owner, "owner")?; @@ -110,11 +116,7 @@ pub async fn create_reserve( // Call the API to create the reserve payload let payload = client.create_reserve(request).await?; - let tx_id = if submit { - Some(client.submit_reserve(payload.clone()).await?.tx_id) - } else { - None - }; + let tx_id = None; Ok(ReserveCreateResult { nft_id, @@ -209,8 +211,6 @@ pub async fn handle_reserve_command( println!(" -H \"Content-Type: application/json\" \\"); println!(" -H \"api_key: your-api-key\" \\"); println!(" -d '...' # (replace with the full payload above)"); - println!(); - println!(" Or re-run with --submit to broadcast via the tracker node."); } } } @@ -268,3 +268,40 @@ fn get_collateralization_status(ratio: f64) -> &'static str { _ => "EXCELLENT", } } + +#[cfg(test)] +mod security_boundary_tests { + use super::*; + use crate::config::ConfigManager; + + #[tokio::test] + async fn submit_flag_fails_before_account_resolution_or_network_io() { + let config_path = std::env::temp_dir().join(format!( + "basis-cli-retired-submit-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock must follow Unix epoch") + .as_nanos() + )); + let config = + ConfigManager::new(Some(config_path)).expect("empty isolated config must load"); + let accounts = AccountManager::new(config).expect("empty account manager must initialize"); + let client = TrackerClient::new("http://127.0.0.1:1".to_string()); + + let error = create_reserve( + &accounts, + &client, + "not-a-token-id".to_string(), + None, + 0, + true, + ) + .await + .expect_err("retired submit flag must be rejected before any other work"); + + assert!(error + .to_string() + .contains("Tracker-side reserve submission is retired")); + } +} diff --git a/crates/basis_cli/src/commands/test_redemption.rs b/crates/basis_cli/src/commands/test_redemption.rs deleted file mode 100644 index a9b0d08..0000000 --- a/crates/basis_cli/src/commands/test_redemption.rs +++ /dev/null @@ -1,334 +0,0 @@ -use crate::api::TrackerClient; -use crate::output::progress; -use anyhow::Result; -use basis_store; -use clap::Subcommand; -use serde::Serialize; -use serde_json::json; -use std::fs; -use std::thread; -use std::time::Duration; - -#[derive(Subcommand)] -pub enum TestCommands { - /// Test redemption transaction by polling notes and generating unsigned transaction - TestRedemption { - /// Output file for the transaction JSON (optional, defaults to redemption_transaction_{timestamp}.json) - #[arg(long)] - output_file: Option, - - /// Amount to redeem in nanoERG (optional, defaults to 50% of available debt) - #[arg(long)] - amount: Option, - - /// Polling interval in seconds (optional, defaults to 30 seconds) - #[arg(long, default_value_t = 30)] - poll_interval: u64, - }, -} - -/// Result of a completed `test test-redemption` run. -#[derive(Debug, Serialize)] -pub struct TestRedemptionResult { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub redemption_amount: u64, - pub output_file: String, - /// The unsigned redemption transaction that was written to `output_file`. - pub transaction: serde_json::Value, -} - -pub async fn handle_test_command( - cmd: TestCommands, - client: &TrackerClient, - json: bool, -) -> Result<()> { - match cmd { - TestCommands::TestRedemption { - output_file, - amount, - poll_interval, - } => { - let result = run_test_redemption(client, output_file, amount, poll_interval).await?; - if json { - println!("{}", serde_json::to_string_pretty(&result)?); - } - Ok(()) - } - } -} - -/// Poll the tracker for a note with sufficient collateral and generate an -/// unsigned redemption transaction for it, written to a JSON file. -pub async fn run_test_redemption( - client: &TrackerClient, - output_file: Option, - amount: Option, - poll_interval: u64, -) -> Result { - progress!("🚀 Starting redemption transaction test..."); - progress!("📡 Connecting to server: {}", "configured server URL"); - - // Verify server health - match client.health_check().await { - Ok(healthy) => { - if healthy { - progress!("✅ Server connection verified"); - } else { - return Err(anyhow::anyhow!("❌ Server health check failed")); - } - } - Err(e) => { - return Err(anyhow::anyhow!("❌ Server health check failed: {}", e)); - } - } - - progress!( - "🔄 Starting note polling loop (checking every {} seconds)...", - poll_interval - ); - - loop { - progress!("🔍 Polling for notes..."); - - // Get all notes from the server - let notes = match client.get_all_notes().await { - Ok(notes) => notes, - Err(e) => { - eprintln!("⚠️ Failed to retrieve notes: {}", e); - progress!("⏳ Retrying in {} seconds...", poll_interval); - thread::sleep(Duration::from_secs(poll_interval)); - continue; - } - }; - - progress!("📊 Retrieved {} notes", notes.len()); - - // Find a note with sufficient collateral - if let Some((note, reserve_info)) = - find_note_with_sufficient_collateral(client, ¬es, amount).await - { - progress!("✅ Found suitable note with sufficient collateral!"); - - // Determine redemption amount - let redemption_amount = amount.unwrap_or_else(|| { - let available_debt = note.outstanding_debt(); - std::cmp::min(available_debt, reserve_info.base_info.collateral_amount / 2) - // Use up to 50% of available debt - }); - - if redemption_amount == 0 { - progress!("⚠️ Redemption amount is 0, skipping this note"); - progress!("⏳ Continuing to poll for notes..."); - thread::sleep(Duration::from_secs(poll_interval)); - continue; - } - - progress!("💰 Redemption amount: {} nanoERG", redemption_amount); - - // Prepare redemption data - progress!("🔧 Preparing redemption data..."); - let redemption_data = match client - .prepare_redemption( - ¬e.issuer_pubkey, - ¬e.recipient_pubkey, - redemption_amount, - ) - .await - { - Ok(data) => data, - Err(e) => { - eprintln!("⚠️ Failed to prepare redemption: {}", e); - progress!("⏳ Continuing to poll for notes..."); - thread::sleep(Duration::from_secs(poll_interval)); - continue; - } - }; - - progress!("✅ Redemption data prepared successfully"); - progress!(" - AVL proof: {} bytes", redemption_data.avl_proof.len()); - progress!( - " - Tracker signature: {} bytes", - redemption_data.tracker_signature.len() - ); - progress!( - " - Tracker state digest: {}", - redemption_data.tracker_state_digest - ); - progress!(" - Block height: {}", redemption_data.block_height); - - // Generate unsigned transaction JSON - progress!("📝 Generating unsigned transaction..."); - let transaction_json = generate_unsigned_transaction( - ¬e.issuer_pubkey, - ¬e.recipient_pubkey, - redemption_amount, - &redemption_data, - &reserve_info, - ); - - // Determine output file name - let filename = match output_file.as_ref() { - Some(name) => name.clone(), - None => format!( - "redemption_transaction_{}.json", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - ), - }; - - // Write transaction to file - progress!("💾 Writing transaction to file: {}", filename); - fs::write(&filename, serde_json::to_string_pretty(&transaction_json)?)?; - - progress!("🎉 Redemption transaction test completed successfully!"); - progress!("📋 Transaction details:"); - progress!(" - Issuer: {}", note.issuer_pubkey); - progress!(" - Recipient: {}", note.recipient_pubkey); - progress!(" - Redemption amount: {} nanoERG", redemption_amount); - progress!(" - Transaction saved to: {}", filename); - progress!(" - Source Ergo node: 159.89.116.15:11088"); - - return Ok(TestRedemptionResult { - issuer_pubkey: note.issuer_pubkey.clone(), - recipient_pubkey: note.recipient_pubkey.clone(), - redemption_amount, - output_file: filename, - transaction: transaction_json, - }); - } else { - progress!("⚠️ No suitable notes found with sufficient collateral"); - progress!("⏳ Continuing to poll for notes..."); - thread::sleep(Duration::from_secs(poll_interval)); - } - } -} - -async fn find_note_with_sufficient_collateral( - client: &TrackerClient, - notes: &[crate::api::SerializableIouNoteWithAge], - requested_amount: Option, -) -> Option<( - crate::api::SerializableIouNoteWithAge, - basis_store::ExtendedReserveInfo, -)> { - for note in notes { - // Get the issuer's reserve information - let reserves = match client.get_reserves_by_issuer(¬e.issuer_pubkey).await { - Ok(reserves) => reserves, - Err(_) => continue, // Skip if we can't get reserve info - }; - - if let Some(reserve_info) = reserves.first() { - let outstanding_debt = note.amount_collected.saturating_sub(note.amount_redeemed); - let available_collateral = reserve_info.base_info.collateral_amount; - - // Determine the redemption amount to check - let check_amount = requested_amount.unwrap_or(outstanding_debt); - - // Check if the note has sufficient collateral - if outstanding_debt > 0 && available_collateral >= check_amount { - progress!("🎯 Found suitable note:"); - progress!(" - Issuer: {}", note.issuer_pubkey); - progress!(" - Recipient: {}", note.recipient_pubkey); - progress!(" - Outstanding debt: {} nanoERG", outstanding_debt); - progress!( - " - Available collateral: {} nanoERG", - available_collateral - ); - - return Some((note.clone(), reserve_info.clone())); - } - } - } - - None -} - -fn generate_unsigned_transaction( - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - redemption_data: &crate::api::RedemptionPreparationResponse, - reserve_info: &basis_store::ExtendedReserveInfo, -) -> serde_json::Value { - // Convert public keys to proper P2PK addresses - let recipient_address = pubkey_to_address(recipient_pubkey) - .unwrap_or_else(|_| format!("invalid_recipient_{}", &recipient_pubkey[..16])); - - // Calculate remaining collateral after redemption - let remaining_collateral = reserve_info.base_info.collateral_amount - amount; - let transaction_fee = 1_000_000; // 0.001 ERG - - // Create the transaction structure following the Ergo node's /wallet/transaction/send format - json!({ - "requests": [ - { - "address": recipient_address, - "value": amount, - "assets": [], - "registers": {} - }, - { - "address": &reserve_info.base_info.contract_address, - "value": remaining_collateral - transaction_fee, - "assets": [ - { - "tokenId": &reserve_info.base_info.tracker_nft_id, - "amount": 1 - } - ], - "registers": { - "R4": format!("07{}", issuer_pubkey), - "R5": format!("64{}", redemption_data.tracker_state_digest), - "R6": format!("0e20{}", &reserve_info.base_info.tracker_nft_id) - } - } - ], - "fee": transaction_fee, - "inputsRaw": [ - &reserve_info.box_id - ], - "dataInputsRaw": [ - &redemption_data.tracker_box_id - ], - "metadata": { - "source": "159.89.116.15:11088", - "issuer_pubkey": issuer_pubkey, - "recipient_pubkey": recipient_pubkey, - "redemption_amount": amount, - "timestamp": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - "block_height": redemption_data.block_height - } - }) -} - -// Helper function to convert public key to a P2PK address using ergo-lib -fn pubkey_to_address(pubkey_hex: &str) -> Result { - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - let pubkey_bytes = - hex::decode(pubkey_hex).map_err(|e| anyhow::anyhow!("Invalid public key hex: {}", e))?; - - if pubkey_bytes.len() != 33 { - return Err(anyhow::anyhow!("Public key must be 33 bytes")); - } - - let ec_point = EcPoint::sigma_parse_bytes(&pubkey_bytes) - .map_err(|e| anyhow::anyhow!("Invalid public key format: {}", e))?; - - let prove_dlog = ProveDlog::new(ec_point); - let address = Address::P2Pk(prove_dlog); - - let encoder = - ergo_lib::ergotree_ir::chain::address::AddressEncoder::new(NetworkPrefix::Mainnet); - Ok(encoder.address_to_str(&address)) -} diff --git a/crates/basis_cli/src/commands/transaction.rs b/crates/basis_cli/src/commands/transaction.rs deleted file mode 100644 index 1b994a0..0000000 --- a/crates/basis_cli/src/commands/transaction.rs +++ /dev/null @@ -1,1904 +0,0 @@ -use crate::api::{CompleteRedemptionRequest, TrackerClient}; -use crate::output::progress; -use anyhow::Result; -use clap::Subcommand; -use serde::Serialize; -use serde_json::json; -use std::collections::HashMap; -use std::fs; - -use ergo_lib::chain::ergo_state_context::ErgoStateContext; -use ergo_lib::chain::parameters::Parameters; -use ergo_lib::chain::transaction::unsigned::UnsignedTransaction; -use ergo_lib::ergo_chain_types::{Header, PreHeader}; -use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; -use ergo_lib::ergotree_ir::serialization::SigmaSerializable; -use ergo_lib::wallet::secret_key::SecretKey; - -use basis_offchain::signing::{add_input_proof, redemption_signing_message}; - -const NODE_URL: &str = "http://127.0.0.1:9053"; -const API_KEY: &str = "hello"; -const TRANSACTION_FEE: u64 = 1_000_000; - -/// Encode an unsigned integer using Ergo's VLQ (Variable-Length Quantity) encoding. -/// Bytes are emitted least-significant group first (little-endian VLQ). -fn vlq_encode(mut value: usize) -> Vec { - if value == 0 { - return vec![0]; - } - let mut bytes = Vec::new(); - while value > 0 { - let mut byte = (value & 0x7f) as u8; - value >>= 7; - if value != 0 { - byte |= 0x80; - } - bytes.push(byte); - } - bytes -} - -/// Serialize bytes as an Ergo `Coll[Byte]` constant: type prefix `0x0e` + VLQ(length) + data. -/// The length is VLQ-encoded (not a single byte), so collections of 128 bytes or more — e.g. larger -/// AVL lookup/insert proofs — are encoded correctly. -fn serialize_coll_bytes(data: &[u8]) -> String { - let mut bytes = vec![0x0e]; - bytes.extend_from_slice(&vlq_encode(data.len())); - bytes.extend_from_slice(data); - hex::encode(bytes) -} - -/// Serialize a long value as an Ergo `Long` constant: type prefix `0x05` + zigzag(VLQ), -/// using Ergo's little-endian VLQ byte order. -fn serialize_ergo_long(value: i64) -> String { - let zigzag = ((value << 1) ^ (value >> 63)) as u64; - format!("05{}", hex::encode(vlq_encode(zigzag as usize))) -} - -/// Serialize a byte value as Ergo constant (prefix 02). -fn serialize_ergo_byte(value: u8) -> String { - format!("02{:02x}", value) -} - -/// Decode a server-returned box id that may be double hex-encoded. -fn decode_box_id(raw: &str) -> String { - if raw.len() == 96 { - if let Ok(bytes) = hex::decode(raw) { - if bytes.len() == 48 && bytes.iter().all(|b| b.is_ascii_hexdigit()) { - return String::from_utf8(bytes).unwrap_or_else(|_| raw.to_string()); - } - } - } - raw.to_string() -} - -#[derive(Subcommand)] -pub enum TransactionCommands { - /// Generate unsigned redemption transaction - GenerateRedemption { - /// Issuer public key (hex) - #[arg(long)] - issuer_pubkey: String, - /// Recipient public key (hex) - #[arg(long)] - recipient_pubkey: String, - /// Redemption amount in nanoERG - #[arg(long)] - amount: u64, - /// Output file for the transaction JSON (optional, defaults to stdout) - #[arg(long)] - output_file: Option, - /// Emergency redemption flag (after 3 days tracker unavailability) - #[arg(long, default_value = "false")] - emergency: bool, - /// Tracker box ID to use as data input (optional; fetched from server if omitted) - #[arg(long)] - tracker_box_id: Option, - /// Wallet change address for fee-input change output (optional; defaults to recipient address) - #[arg(long)] - change_address: Option, - /// Sign the redemption locally with ergo-lib (client-side proveDlog for both inputs) - /// and broadcast it, instead of emitting an unsigned transaction for the node wallet. - #[arg(long, default_value = "false")] - local_sign: bool, - /// Recipient (receiver) dlog secret as 32-byte hex. Required for local signing of the - /// reserve input's proveDlog(receiver). If omitted, fetched from the node wallet. - #[arg(long)] - recipient_secret: Option, - /// Fee-payer dlog secret as 32-byte hex. Used to sign the fee input locally. If omitted, - /// fetched from the node wallet for the change/fee address. - #[arg(long)] - fee_secret: Option, - }, - /// Tracker-assisted redemption: the tracker builds the unsigned transaction and signs the fee - /// input(s) (POST /redemption/build); the CLI signs the issuer message with the current account - /// and adds the reserve input's proveDlog(recipient) proof, then submits (POST - /// /redemption/submit). This exercises the new 2-phase server endpoints end-to-end. - RedeemAssisted { - /// Issuer public key (hex). The current account should be this issuer. - #[arg(long)] - issuer_pubkey: String, - /// Recipient (receiver) public key (hex). - #[arg(long)] - recipient_pubkey: String, - /// Redemption amount in nanoERG - #[arg(long)] - amount: u64, - /// Recipient (receiver) dlog secret as 32-byte hex for the reserve input's - /// proveDlog(receiver). If omitted, fetched from the node wallet. - #[arg(long)] - recipient_secret: Option, - }, -} - -/// Result of a redemption that was signed locally and broadcast to the network. -#[derive(Debug, Serialize)] -pub struct RedemptionBroadcastResult { - pub tx_id: String, -} - -/// Result of generating an unsigned redemption transaction for the node wallet. -#[derive(Debug, Serialize)] -pub struct UnsignedRedemptionResult { - /// The unsigned transaction payload (node wallet format). - pub transaction: serde_json::Value, - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub amount: u64, - pub recipient_output_value: u64, - pub reserve_output_value: u64, - pub total_debt: u64, - pub already_redeemed: u64, - pub reserve_box_id: String, - pub tracker_box_id: String, - pub fee: u64, - pub fee_input_count: usize, - pub fee_input_total: u64, - pub change_amount: u64, - pub change_address: String, - pub emergency: bool, - pub first_redemption: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_file: Option, -} - -/// Typed result of `transaction generate-redemption`: either a broadcast tx id -/// (`--local-sign`) or an unsigned transaction payload for the node wallet. -#[derive(Debug, Serialize)] -#[serde(untagged)] -pub enum GenerateRedemptionResult { - Broadcast(RedemptionBroadcastResult), - Unsigned(Box), -} - -pub async fn handle_transaction_command( - cmd: TransactionCommands, - client: &TrackerClient, - account_manager: &crate::account::AccountManager, - json: bool, -) -> Result<()> { - match cmd { - TransactionCommands::GenerateRedemption { - issuer_pubkey, - recipient_pubkey, - amount, - output_file, - emergency, - tracker_box_id, - change_address, - local_sign, - recipient_secret, - fee_secret, - } => { - let result = generate_redemption_transaction( - client, - account_manager, - &issuer_pubkey, - &recipient_pubkey, - amount, - output_file, - emergency, - tracker_box_id, - change_address, - local_sign, - recipient_secret, - fee_secret, - ) - .await?; - if json { - println!("{}", serde_json::to_string_pretty(&result)?); - } - Ok(()) - } - TransactionCommands::RedeemAssisted { - issuer_pubkey, - recipient_pubkey, - amount, - recipient_secret, - } => { - let result = redeem_tracker_assisted( - client, - account_manager, - &issuer_pubkey, - &recipient_pubkey, - amount, - recipient_secret, - ) - .await?; - if json { - println!("{}", serde_json::to_string_pretty(&result)?); - } - Ok(()) - } - } -} - -/// Intermediate results from building an unsigned redemption transaction. -struct RedemptionBuildResult { - transaction_json: serde_json::Value, - reserve_box_binary: String, - fee_input_binaries: Vec, - tracker_box_binary: String, - reserve_box_id: String, - tracker_box_id: String, - reserve_output_value: u64, - recipient_output_value: u64, - change_amount: u64, - total_debt: u64, - note_timestamp: u64, - is_first_redemption: bool, - fee_input_count: usize, - fee_input_total: u64, - change_address: String, - recipient_address: String, - issuer_signature_len: usize, - tracker_signature_len: usize, - insert_proof_len: usize, - reserve_lookup_proof_len: Option, - tracker_lookup_proof_len: usize, - already_redeemed: u64, -} - -/// Build the unsigned redemption transaction JSON and collect all metadata needed to either -/// sign+broadcast locally or emit JSON for the node wallet. -#[allow(clippy::too_many_arguments)] -async fn build_redemption_tx( - client: &TrackerClient, - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - emergency: bool, - tracker_box_id: Option, - change_address: Option, - issuer_signature_hex: &str, - local_sign: bool, -) -> Result { - // Validate public keys - if hex::decode(issuer_pubkey) - .map_err(|e| anyhow::anyhow!("Invalid issuer public key: {}", e))? - .len() - != 33 - { - return Err(anyhow::anyhow!( - "Issuer public key must be 33 bytes (66 hex characters)" - )); - } - - if hex::decode(recipient_pubkey) - .map_err(|e| anyhow::anyhow!("Invalid recipient public key: {}", e))? - .len() - != 33 - { - return Err(anyhow::anyhow!( - "Recipient public key must be 33 bytes (66 hex characters)" - )); - } - - progress!("🔍 Retrieving note information..."); - let note = client - .get_note(issuer_pubkey, recipient_pubkey) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "Note not found for issuer {} and recipient {}", - issuer_pubkey, - recipient_pubkey - ) - })?; - - // Verify that the redemption amount does not exceed the note's outstanding debt - if note.outstanding_debt() < amount { - return Err(anyhow::anyhow!( - "Insufficient outstanding debt: {} nanoERG available, {} nanoERG requested", - note.outstanding_debt(), - amount - )); - } - - // Resolve the current tracker box early so we can match reserves by tracker NFT ID. - let tracker_box_id = if let Some(id) = tracker_box_id { - progress!("✅ Using provided tracker box: {}", &id[..16]); - id - } else { - progress!("🔍 Retrieving latest tracker box..."); - let tracker_box_response = client.get_latest_tracker_box_id().await; - match tracker_box_response { - Ok(response) => { - progress!("✅ Found tracker box: {}", &response.tracker_box_id[..16]); - response.tracker_box_id - } - Err(e) => { - return Err(anyhow::anyhow!( - "No tracker box found: {}. Cannot generate redemption transaction without a tracker box.", - e - )); - } - } - }; - - // The tracker NFT ID is the first asset in the tracker box. Reserves whose R6 - // tracker NFT ID does not match this cannot be redeemed against the current - // tracker box (the contract checks tracker.tokens(0)._1 == SELF.R6). - progress!("🔍 Retrieving tracker NFT ID from tracker box..."); - let tracker_box_details = client - .get_box_from_node(&tracker_box_id, NODE_URL, Some(API_KEY)) - .await - .map_err(|e| { - anyhow::anyhow!( - "Failed to retrieve tracker box {} from Ergo node: {}", - tracker_box_id, - e - ) - })?; - let expected_tracker_nft_id = tracker_box_details - .assets - .first() - .map(|a| a.token_id.clone()) - .ok_or_else(|| anyhow::anyhow!("Tracker box {} contains no assets", tracker_box_id))?; - progress!("✅ Tracker NFT ID: {}", &expected_tracker_nft_id); - - progress!("🔍 Retrieving issuer's reserve box..."); - let reserves_response = client.get_reserves_by_issuer(issuer_pubkey).await?; - const MIN_RESERVE_REMAINDER: u64 = 1_000_000; // 0.001 ERG min box value - let required = amount.saturating_add(MIN_RESERVE_REMAINDER); - let reserve_box = reserves_response - .iter() - .filter(|r| r.base_info.collateral_amount >= required) - .filter(|r| r.base_info.tracker_nft_id == expected_tracker_nft_id) - .min_by(|a, b| { - a.base_info - .collateral_amount - .cmp(&b.base_info.collateral_amount) - .then_with(|| b.base_info.last_updated_height.cmp(&a.base_info.last_updated_height)) - }) - .ok_or_else(|| { - anyhow::anyhow!( - "No reserve box with sufficient collateral and matching tracker NFT {} found for issuer {} (need >= {} nanoERG)", - expected_tracker_nft_id, - issuer_pubkey, - required - ) - })?; - - progress!( - "✅ Selected reserve box: {} (collateral: {} nanoERG, height: {}, tracker NFT: {})", - decode_box_id(&reserve_box.box_id), - reserve_box.base_info.collateral_amount, - reserve_box.base_info.last_updated_height, - &reserve_box.base_info.tracker_nft_id - ); - - let reserve_box_id = decode_box_id(&reserve_box.box_id); - let tracker_nft_id = &reserve_box.base_info.tracker_nft_id; - - progress!("🔗 Converting public keys to addresses..."); - let recipient_address = pubkey_to_address(recipient_pubkey)?; - - // Fetch the recipient's private key for the node-wallet JSON path. - progress!("🔍 Resolving recipient private key..."); - let recipient_private_key = client - .get_private_key(NODE_URL, Some(API_KEY), &recipient_address) - .await - .map_err(|e| anyhow::anyhow!("Failed to fetch recipient private key from node wallet. Ensure the recipient address {} is in the wallet: {}", recipient_address, e))?; - progress!("✅ Fetched recipient private key"); - - // Get tracker lookup proof for context var #8 from server - progress!("🔍 Retrieving tracker lookup proof from server..."); - let tracker_proof = client - .get_tracker_proof(issuer_pubkey, recipient_pubkey) - .await?; - let total_debt = tracker_proof.total_debt; - let tracker_lookup_proof = hex::decode(&tracker_proof.proof) - .map_err(|e| anyhow::anyhow!("Invalid tracker proof hex: {}", e))?; - let note_timestamp = note.timestamp; - - // Get the reserve proof from the server. - progress!("🔍 Retrieving reserve insert proof from server..."); - let reserve_proof = client - .get_reserve_proof(issuer_pubkey, recipient_pubkey, amount, note_timestamp) - .await - .map_err(|e| anyhow::anyhow!("Failed to get reserve proof: {}", e))?; - - // Build serialized SAvlTree for R5 register. - let r5_bytes = build_savl_tree_from_digest(&reserve_proof.new_reserve_state_digest); - let r5_hex = hex::encode(&r5_bytes); - - // Get the reserve contract P2S address from the server configuration - progress!("🔍 Retrieving reserve contract P2S address from server configuration..."); - let reserve_contract_p2s = client.get_basis_reserve_contract_p2s().await.map_err(|e| { - anyhow::anyhow!( - "Failed to retrieve reserve contract P2S address from server: {}", - e - ) - })?; - - let reserve_output_value = reserve_box - .base_info - .collateral_amount - .saturating_sub(amount); - if reserve_output_value == 0 { - return Err(anyhow::anyhow!( - "Reserve output value would be zero after redemption" - )); - } - let recipient_output_value = amount; - - let is_first_redemption = note.amount_redeemed == 0; - let (reserve_lookup_proof, reserve_insert_proof) = if is_first_redemption { - progress!("🔍 First redemption - using reserve insert proof..."); - let insert_proof = hex::decode(&reserve_proof.insert_proof) - .map_err(|e| anyhow::anyhow!("Invalid reserve insert proof hex: {}", e))?; - (None, insert_proof) - } else { - progress!("🔍 Using reserve lookup and insert proofs..."); - progress!( - "✅ Got reserve proof: already_redeemed={} nanoERG, is_first={}", - reserve_proof.already_redeemed, - reserve_proof.is_first_redemption - ); - let lookup_proof = if let Some(proof_hex) = &reserve_proof.proof { - Some( - hex::decode(proof_hex) - .map_err(|e| anyhow::anyhow!("Invalid reserve lookup proof hex: {}", e))?, - ) - } else { - return Err(anyhow::anyhow!( - "Reserve lookup proof is required for subsequent redemption" - )); - }; - let insert_proof = hex::decode(&reserve_proof.insert_proof) - .map_err(|e| anyhow::anyhow!("Invalid reserve insert proof hex: {}", e))?; - (lookup_proof, insert_proof) - }; - - // Verify tracker box exists on Ergo node - progress!("🔍 Verifying tracker box on Ergo node..."); - client.get_box_from_node(&tracker_box_id, NODE_URL, Some(API_KEY)).await - .map_err(|e| anyhow::anyhow!("Failed to retrieve tracker box {} from Ergo node: {}. Cannot generate redemption transaction.", tracker_box_id, e))?; - - // Retrieve the actual reserve box from the Ergo node - progress!("🔍 Retrieving reserve box from Ergo node..."); - let reserve_box_details = client - .get_box_from_node(&reserve_box_id, NODE_URL, Some(API_KEY)) - .await - .map_err(|e| anyhow::anyhow!("Failed to retrieve reserve box from Ergo node: {}", e))?; - - let reserve_nft_id = reserve_box_details - .assets - .first() - .map(|asset| asset.token_id.clone()) - .unwrap_or_else(|| tracker_nft_id.clone()); - - let refund_initiation_height = basis_store::ergo_scanner::decode_ergo_long_register( - reserve_box_details.additional_registers.get("R7"), - ); - - // Fetch wallet-owned fee input boxes from the node. - progress!("🔍 Retrieving wallet fee inputs from Ergo node..."); - let wallet_boxes = client - .get_wallet_boxes(NODE_URL, Some(API_KEY)) - .await - .map_err(|e| anyhow::anyhow!("Failed to retrieve wallet boxes from Ergo node: {}", e))?; - - // For local signing every fee input must be a plain P2PK box and all fee inputs must share - // the same ergoTree. - let target_tree = if local_sign { - if let Some(ref addr) = change_address { - Some(address_to_ergo_tree(addr)?) - } else { - wallet_boxes - .iter() - .find(|b| b.assets.is_empty() && ergo_tree_to_p2pk_address(&b.ergo_tree).is_ok()) - .map(|b| b.ergo_tree.clone()) - } - } else { - None - }; - - let (fee_inputs, fee_input_total) = select_fee_inputs( - &wallet_boxes, - TRANSACTION_FEE, - &reserve_box_id, - &tracker_box_id, - target_tree.as_deref(), - ) - .ok_or_else(|| anyhow::anyhow!( - "No wallet boxes covering {} nanoERG fee found. Ensure the wallet is synced and has at least {} nanoERG available.", - TRANSACTION_FEE, TRANSACTION_FEE - ))?; - - progress!( - "✅ Selected {} fee input box(es) totaling {} nanoERG", - fee_inputs.len(), - fee_input_total - ); - - let change_address = if let Some(addr) = change_address { - addr - } else if let Ok(addr) = ergo_tree_to_p2pk_address(&fee_inputs[0].ergo_tree) { - addr - } else { - recipient_address.clone() - }; - - progress!("📦 Preparing box IDs for transaction..."); - - let issuer_signature = hex::decode(issuer_signature_hex) - .map_err(|e| anyhow::anyhow!("Invalid issuer signature hex: {}", e))?; - - // Get tracker signature from server - let tracker_signature_response = client - .request_tracker_signature( - issuer_pubkey, - recipient_pubkey, - total_debt, - note_timestamp, - emergency, - ) - .await?; - let tracker_signature = hex::decode(&tracker_signature_response.tracker_signature) - .map_err(|e| anyhow::anyhow!("Invalid tracker signature hex: {}", e))?; - - let insert_proof = reserve_insert_proof.clone(); - - // Build context extension map - let mut context_extension: HashMap = HashMap::new(); - context_extension.insert("0".to_string(), serialize_ergo_byte(0)); - context_extension.insert("1".to_string(), format!("07{}", recipient_pubkey)); - context_extension.insert("2".to_string(), serialize_coll_bytes(&issuer_signature)); - context_extension.insert("3".to_string(), serialize_ergo_long(total_debt as i64)); - context_extension.insert("4".to_string(), serialize_ergo_long(note_timestamp as i64)); - context_extension.insert("5".to_string(), serialize_coll_bytes(&insert_proof)); - context_extension.insert("6".to_string(), serialize_coll_bytes(&tracker_signature)); - if let Some(ref proof) = reserve_lookup_proof { - context_extension.insert("7".to_string(), serialize_coll_bytes(proof)); - } - context_extension.insert("8".to_string(), serialize_coll_bytes(&tracker_lookup_proof)); - - let reserve_ergo_tree = address_to_ergo_tree(&reserve_contract_p2s)?; - let recipient_ergo_tree = address_to_ergo_tree(&recipient_address)?; - let change_ergo_tree = address_to_ergo_tree(&change_address)?; - - let current_height = client.get_node_height(NODE_URL, Some(API_KEY)).await?; - - let reserve_box_binary = client - .get_box_binary(&reserve_box_id, NODE_URL, Some(API_KEY)) - .await?; - let tracker_box_binary = client - .get_box_binary(&tracker_box_id, NODE_URL, Some(API_KEY)) - .await?; - - let mut fee_input_json = Vec::new(); - let mut fee_input_binaries = Vec::new(); - for fee_box in &fee_inputs { - fee_input_json.push(json!({ - "boxId": fee_box.box_id, - "extension": serde_json::json!({}) - })); - let binary = client - .get_box_binary(&fee_box.box_id, NODE_URL, Some(API_KEY)) - .await - .map_err(|e| { - anyhow::anyhow!( - "Failed to get binary for fee input {}: {}", - fee_box.box_id, - e - ) - })?; - fee_input_binaries.push(binary); - } - - let mut inputs = vec![json!({ - "boxId": reserve_box_id, - "extension": context_extension - })]; - inputs.extend(fee_input_json); - - let change_amount = fee_input_total.saturating_sub(TRANSACTION_FEE); - let mut outputs = vec![ - json!({ - "value": reserve_output_value, - "ergoTree": reserve_ergo_tree, - "creationHeight": current_height, - "assets": [ - { - "tokenId": reserve_nft_id, - "amount": 1 - } - ], - "additionalRegisters": { - "R4": format!("07{}", issuer_pubkey), - "R5": r5_hex, - "R6": format!("0e{:02x}{}", tracker_nft_id.len() / 2, tracker_nft_id), - "R7": serialize_ergo_long(refund_initiation_height as i64) - } - }), - json!({ - "value": recipient_output_value, - "ergoTree": recipient_ergo_tree, - "creationHeight": current_height, - "assets": [], - "additionalRegisters": {} - }), - json!({ - "value": TRANSACTION_FEE, - "ergoTree": "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a701730073011001020402d19683030193a38cc7b2a57300000193c2b2a57301007473027303830108cdeeac93b1a57304", - "creationHeight": current_height, - "assets": [], - "additionalRegisters": {} - }), - ]; - - if change_amount > 0 { - let change_assets: Vec = fee_inputs - .iter() - .flat_map(|b| { - b.assets.iter().map(|a| { - json!({ - "tokenId": a.token_id, - "amount": a.amount - }) - }) - }) - .collect(); - outputs.push(json!({ - "value": change_amount, - "ergoTree": change_ergo_tree, - "creationHeight": current_height, - "assets": change_assets, - "additionalRegisters": {} - })); - } - - let mut inputs_raw = vec![reserve_box_binary.clone()]; - inputs_raw.extend(fee_input_binaries.clone()); - - let transaction_json = json!({ - "tx": { - "inputs": inputs, - "dataInputs": [ - { - "boxId": tracker_box_id - } - ], - "outputs": outputs - }, - "inputsRaw": inputs_raw, - "dataInputsRaw": [ - tracker_box_binary - ], - "secrets": { - "dlog": [recipient_private_key] - } - }); - - Ok(RedemptionBuildResult { - transaction_json, - reserve_box_binary, - fee_input_binaries, - tracker_box_binary, - reserve_box_id, - tracker_box_id, - reserve_output_value, - recipient_output_value, - change_amount, - total_debt, - note_timestamp, - is_first_redemption, - fee_input_count: fee_inputs.len(), - fee_input_total, - change_address, - recipient_address, - issuer_signature_len: issuer_signature.len(), - tracker_signature_len: tracker_signature.len(), - insert_proof_len: insert_proof.len(), - reserve_lookup_proof_len: reserve_lookup_proof.as_ref().map(|p| p.len()), - tracker_lookup_proof_len: tracker_lookup_proof.len(), - already_redeemed: reserve_proof.already_redeemed, - }) -} - -/// Execute a full local-sign redemption: fetch the note and on-chain data, build the unsigned -/// transaction, request the tracker signature, sign the reserve and fee inputs locally, and -/// broadcast the transaction to the local Ergo node. -#[allow(clippy::too_many_arguments)] -pub async fn execute_local_redemption( - client: &TrackerClient, - account_manager: &crate::account::AccountManager, - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - emergency: bool, - tracker_box_id: Option, - change_address: Option, - recipient_secret: Option, - fee_secret: Option, -) -> Result { - progress!("🔍 Retrieving note information..."); - let note = client - .get_note(issuer_pubkey, recipient_pubkey) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "Note not found for issuer {} and recipient {}", - issuer_pubkey, - recipient_pubkey - ) - })?; - - // The reserve signature must be freshly generated for the current redemption because the - // contract requires `timestamp > storedTimestamp`. After a prior redemption the note's - // timestamp is refreshed, so the signature stored on the note is stale. - progress!("🔍 Fetching tracker proof for total debt..."); - let tracker_proof = client - .get_tracker_proof(issuer_pubkey, recipient_pubkey) - .await?; - let total_debt = tracker_proof.total_debt; - - progress!("🔑 Signing redemption message with the current issuer account..."); - let current = account_manager - .get_current() - .ok_or_else(|| anyhow::anyhow!("No current account selected; issuer account is required to sign the redemption message"))?; - let issuer_pk: [u8; 33] = hex::decode(issuer_pubkey) - .map_err(|e| anyhow::anyhow!("Invalid issuer public key: {}", e))? - .try_into() - .map_err(|_| anyhow::anyhow!("Issuer public key must be 33 bytes"))?; - let recipient_pk: [u8; 33] = hex::decode(recipient_pubkey) - .map_err(|e| anyhow::anyhow!("Invalid recipient public key: {}", e))? - .try_into() - .map_err(|_| anyhow::anyhow!("Recipient public key must be 33 bytes"))?; - if current.get_pubkey_hex() != issuer_pubkey { - return Err(anyhow::anyhow!( - "Current account {} is not the issuer {}; only the issuer can sign the redemption message", - current.get_pubkey_hex(), - issuer_pubkey - )); - } - let message = redemption_signing_message(&issuer_pk, &recipient_pk, total_debt, note.timestamp); - let issuer_signature = current.sign_message(&message)?; - let issuer_signature_hex = hex::encode(&issuer_signature); - - let build = build_redemption_tx( - client, - issuer_pubkey, - recipient_pubkey, - amount, - emergency, - tracker_box_id, - change_address, - &issuer_signature_hex, - true, - ) - .await?; - - let tx_id = sign_and_broadcast_local(SignLocalParams { - client, - issuer_pubkey, - recipient_pubkey, - amount, - total_debt: build.total_debt, - note_timestamp: build.note_timestamp, - is_first_redemption: build.is_first_redemption, - emergency, - reserve_box_id: &build.reserve_box_id, - tracker_box_id: &build.tracker_box_id, - reserve_output_value: build.reserve_output_value, - recipient_output_value: build.recipient_output_value, - change_amount: build.change_amount, - unsigned_tx: build.transaction_json["tx"].clone(), - reserve_box_binary: &build.reserve_box_binary, - fee_input_binaries: &build.fee_input_binaries, - tracker_box_binary: &build.tracker_box_binary, - issuer_signature_len: build.issuer_signature_len, - tracker_signature_len: build.tracker_signature_len, - insert_proof_len: build.insert_proof_len, - reserve_lookup_proof_len: build.reserve_lookup_proof_len, - tracker_lookup_proof_len: build.tracker_lookup_proof_len, - fee_input_count: build.fee_input_count, - fee_input_total: build.fee_input_total, - change_address: &build.change_address, - recipient_address: &build.recipient_address, - recipient_secret, - fee_secret, - }) - .await?; - - // Sync the tracker's local state so subsequent redemptions can generate a reserve lookup - // proof against the updated reserve tree. - let new_already_redeemed = build.already_redeemed.saturating_add(amount); - if let Err(e) = client - .complete_redemption(CompleteRedemptionRequest { - redemption_id: tx_id.clone(), - issuer_pubkey: issuer_pubkey.to_string(), - recipient_pubkey: recipient_pubkey.to_string(), - redeemed_amount: amount, - new_already_redeemed: Some(new_already_redeemed), - }) - .await - { - eprintln!( - "⚠️ Redemption broadcast succeeded, but tracker state sync failed: {}. Subsequent redemptions may fail until the tracker state is repaired.", - e - ); - } else { - progress!("✅ Tracker state synced for next redemption."); - } - - progress!("✅ Redemption broadcast with LOCAL proveDlog signatures."); - progress!("📋 Transaction ID: {}", tx_id); - - Ok(tx_id) -} - -pub async fn generate_redemption_transaction( - client: &TrackerClient, - account_manager: &crate::account::AccountManager, - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - output_file: Option, - emergency: bool, - tracker_box_id: Option, - change_address: Option, - local_sign: bool, - recipient_secret: Option, - fee_secret: Option, -) -> Result { - if local_sign { - let tx_id = execute_local_redemption( - client, - account_manager, - issuer_pubkey, - recipient_pubkey, - amount, - emergency, - tracker_box_id, - change_address, - recipient_secret, - fee_secret, - ) - .await?; - progress!("✅ Redemption broadcast. Transaction ID: {}", tx_id); - return Ok(GenerateRedemptionResult::Broadcast( - RedemptionBroadcastResult { tx_id }, - )); - } - - // Fetch the note so we can compute the issuer signature with the current account. - progress!("🔍 Retrieving note information..."); - let note = client - .get_note(issuer_pubkey, recipient_pubkey) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "Note not found for issuer {} and recipient {}", - issuer_pubkey, - recipient_pubkey - ) - })?; - - // Verify that the redemption amount does not exceed the note's outstanding debt - if note.outstanding_debt() < amount { - return Err(anyhow::anyhow!( - "Insufficient outstanding debt: {} nanoERG available, {} nanoERG requested", - note.outstanding_debt(), - amount - )); - } - - // Get issuer signature from CLI wallet - progress!("🔑 Signing redemption with issuer key..."); - let current_account = account_manager - .get_current() - .ok_or_else(|| anyhow::anyhow!("No current account selected"))?; - - let issuer_pk: [u8; 33] = hex::decode(issuer_pubkey) - .map_err(|e| anyhow::anyhow!("Invalid issuer public key: {}", e))? - .try_into() - .map_err(|_| anyhow::anyhow!("Issuer public key must be 33 bytes"))?; - let recipient_pk: [u8; 33] = hex::decode(recipient_pubkey) - .map_err(|e| anyhow::anyhow!("Invalid recipient public key: {}", e))? - .try_into() - .map_err(|_| anyhow::anyhow!("Recipient public key must be 33 bytes"))?; - - let message = redemption_signing_message( - &issuer_pk, - &recipient_pk, - note.amount_collected, - note.timestamp, - ); - let issuer_signature = current_account.sign_message(&message)?; - let issuer_signature_hex = hex::encode(issuer_signature); - - let build = build_redemption_tx( - client, - issuer_pubkey, - recipient_pubkey, - amount, - emergency, - tracker_box_id, - change_address, - &issuer_signature_hex, - false, - ) - .await?; - - let transaction_json = build.transaction_json.clone(); - - let json_string = serde_json::to_string_pretty(&transaction_json)?; - - match &output_file { - Some(file_path) => { - fs::write(file_path, &json_string)?; - progress!("✅ Transaction JSON written to: {}", file_path); - } - None => { - progress!("{}", json_string); - } - } - - progress!("✅ Redemption transaction generated successfully!"); - progress!("📋 Transaction details:"); - progress!(" Issuer: {}", issuer_pubkey); - progress!(" Recipient: {}", recipient_pubkey); - progress!(" Redemption amount: {} nanoERG", amount); - progress!( - " Recipient receives: {} nanoERG", - build.recipient_output_value - ); - progress!( - " Reserve output value: {} nanoERG", - build.reserve_output_value - ); - progress!(" Total debt: {} nanoERG", build.total_debt); - progress!(" Already redeemed: {} nanoERG", note.amount_redeemed); - progress!(" Reserve box ID: {}", build.reserve_box_id); - progress!(" Tracker box ID: {}", build.tracker_box_id); - progress!(" Transaction fee: {} nanoERG", TRANSACTION_FEE); - progress!( - " Fee inputs: {} box(es), {} nanoERG total", - build.fee_input_count, - build.fee_input_total - ); - if build.change_amount > 0 { - progress!( - " Change output: {} nanoERG to {}", - build.change_amount, - build.change_address - ); - } - progress!(" Emergency redemption: {}", emergency); - progress!(" First redemption: {}", build.is_first_redemption); - progress!("📝 Context Extension Variables:"); - progress!(" #0 (action): 0x00 (redemption, reserve output index 0)"); - progress!(" #1 (receiver): {}", recipient_pubkey); - progress!(" #2 (reserveSig): {} bytes", build.issuer_signature_len); - progress!(" #3 (totalDebt): {}", build.total_debt); - progress!(" #5 (insertProof): {} bytes", build.insert_proof_len); - progress!(" #6 (trackerSig): {} bytes", build.tracker_signature_len); - if let Some(len) = build.reserve_lookup_proof_len { - progress!(" #7 (reserveLookupProof): {} bytes", len); - } else { - progress!(" #7 (reserveLookupProof): omitted (first redemption)"); - } - progress!( - " #8 (trackerLookupProof): {} bytes", - build.tracker_lookup_proof_len - ); - - Ok(GenerateRedemptionResult::Unsigned(Box::new( - UnsignedRedemptionResult { - transaction: transaction_json, - issuer_pubkey: issuer_pubkey.to_string(), - recipient_pubkey: recipient_pubkey.to_string(), - amount, - recipient_output_value: build.recipient_output_value, - reserve_output_value: build.reserve_output_value, - total_debt: build.total_debt, - already_redeemed: note.amount_redeemed, - reserve_box_id: build.reserve_box_id, - tracker_box_id: build.tracker_box_id, - fee: TRANSACTION_FEE, - fee_input_count: build.fee_input_count, - fee_input_total: build.fee_input_total, - change_amount: build.change_amount, - change_address: build.change_address, - emergency, - first_redemption: build.is_first_redemption, - output_file, - }, - ))) -} - -/// Tracker-assisted redemption driver (mirrors the TUI flow). The tracker builds the unsigned -/// transaction and signs the fee input(s); the CLI signs the issuer message with the current -/// account and adds the reserve input's proveDlog(recipient) proof, then submits via the tracker. -pub async fn redeem_tracker_assisted( - client: &TrackerClient, - account_manager: &crate::account::AccountManager, - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - recipient_secret: Option, -) -> Result { - use ergo_lib::chain::transaction::unsigned::UnsignedTransaction; - use ergo_lib::chain::transaction::Transaction; - use ergo_lib::ergo_chain_types::{Header, PreHeader}; - - let issuer_pk: [u8; 33] = hex::decode(issuer_pubkey) - .map_err(|e| anyhow::anyhow!("issuer hex: {}", e))? - .try_into() - .map_err(|_| anyhow::anyhow!("issuer pubkey must be 33 bytes"))?; - let recipient_pk: [u8; 33] = hex::decode(recipient_pubkey) - .map_err(|e| anyhow::anyhow!("recipient hex: {}", e))? - .try_into() - .map_err(|_| anyhow::anyhow!("recipient pubkey must be 33 bytes"))?; - - progress!("🔍 Fetching note and tracker proof..."); - let note = client - .get_note(issuer_pubkey, recipient_pubkey) - .await? - .ok_or_else(|| anyhow::anyhow!("note not found for issuer/recipient"))?; - let total_debt = client - .get_tracker_proof(issuer_pubkey, recipient_pubkey) - .await? - .total_debt; - let timestamp = note.timestamp; - progress!("✅ total_debt={} timestamp={}", total_debt, timestamp); - - // Issuer signs the redemption message with the current account. - let current = account_manager - .get_current() - .ok_or_else(|| anyhow::anyhow!("no current account (should be the issuer)"))?; - if current.get_pubkey_hex() != issuer_pubkey { - progress!( - "⚠️ current account {}... != issuer {}...; signature may be rejected", - ¤t.get_pubkey_hex()[..16], - &issuer_pubkey[..16] - ); - } - let message = redemption_signing_message(&issuer_pk, &recipient_pk, total_debt, timestamp); - let issuer_sig = current.sign_message(&message)?; - progress!("✅ issuer signature ({} bytes)", issuer_sig.len()); - - // Tracker builds the unsigned tx and signs the fee input(s). - progress!("🔍 Requesting tracker build (POST /redemption/build)..."); - let build = client - .redemption_build(crate::api::RedemptionBuildRequest { - issuer_pubkey: issuer_pubkey.to_string(), - recipient_pubkey: recipient_pubkey.to_string(), - amount, - timestamp, - issuer_signature: hex::encode(issuer_sig), - emergency: false, - tracker_box_id: None, - change_address: None, - }) - .await - .map_err(|e| anyhow::anyhow!("tracker build failed: {}", e))?; - progress!( - "✅ build ok: reserve {} (out {} nanoERG), fee {} nanoERG, change {} to {}", - &build.reserve_box_id[..16.min(build.reserve_box_id.len())], - build.reserve_output_value, - build.fee, - build.change_amount, - build.change_address - ); - - // Reconstruct signing material. - let unsigned: UnsignedTransaction = serde_json::from_value(build.unsigned_tx) - .map_err(|e| anyhow::anyhow!("parse unsigned tx: {}", e))?; - let partial: Transaction = serde_json::from_value(build.partial_tx) - .map_err(|e| anyhow::anyhow!("parse partial tx: {}", e))?; - let parse_box = |h: &str| -> Result { - let bytes = hex::decode(h).map_err(|e| anyhow::anyhow!("box hex: {}", e))?; - ErgoBox::sigma_parse_bytes(&bytes).map_err(|e| anyhow::anyhow!("box parse: {:?}", e)) - }; - let mut input_boxes = Vec::with_capacity(build.input_box_binaries.len()); - for h in &build.input_box_binaries { - input_boxes.push(parse_box(h)?); - } - let mut data_boxes = Vec::with_capacity(build.data_box_binaries.len()); - for h in &build.data_box_binaries { - data_boxes.push(parse_box(h)?); - } - if build.headers.len() < 10 { - return Err(anyhow::anyhow!( - "tracker returned {} headers (need 10)", - build.headers.len() - )); - } - let pre_header = PreHeader::from(build.headers[0].clone()); - let headers: [Header; 10] = build.headers[..10] - .to_vec() - .try_into() - .map_err(|_| anyhow::anyhow!("headers array"))?; - - // Recipient (receiver) secret for the reserve input's proveDlog(recipient). - let recipient_address = pubkey_to_address(recipient_pubkey)?; - let receiver_secret = - resolve_dlog_secret(&recipient_secret, client, &recipient_address, "recipient").await?; - let receiver_sk = SecretKey::dlog_from_bytes(&receiver_secret) - .ok_or_else(|| anyhow::anyhow!("invalid recipient dlog secret"))?; - - // Add the reserve input (index 0) proof over the same bytes_to_sign. - progress!("🖊️ Adding reserve proveDlog(recipient) proof..."); - let signed = add_input_proof( - &unsigned, - Some(&partial), - &input_boxes, - &data_boxes, - &pre_header, - &headers, - 0, - &receiver_sk, - ) - .map_err(|e| anyhow::anyhow!("reserve proof failed: {:?}", e))?; - - let tx_json = serde_json::to_value(&signed)?; - let local_id = tx_json - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - progress!("✅ signed tx id: {}", local_id); - - progress!("📡 Submitting via tracker (POST /redemption/submit)..."); - let tx_id = client - .redemption_submit( - tx_json, - issuer_pubkey, - recipient_pubkey, - amount, - build.new_already_redeemed, - ) - .await - .map_err(|e| anyhow::anyhow!("submit failed: {}", e))?; - progress!("✅ Redemption broadcast. Transaction ID: {}", tx_id); - Ok(RedemptionBroadcastResult { tx_id }) -} - -/// Select wallet boxes covering the required fee amount. -/// -/// Prefer boxes without tokens and exclude the reserve box being spent and the -/// tracker box used as a data input (the wallet may report both if they have -/// been added to scans). If `target_tree` is provided, only boxes whose -/// `ergoTree` exactly matches it are considered; this is required for local -/// signing where every fee input must be provable with the same dlog secret. -/// If the wallet only has token-bearing boxes, fall back to them and preserve -/// their tokens in the change output. -fn select_fee_inputs( - wallet_boxes: &[crate::api::ErgoBoxDetails], - required: u64, - reserve_box_id: &str, - tracker_box_id: &str, - target_tree: Option<&str>, -) -> Option<(Vec, u64)> { - // Work with owned clones so we can return them without borrow issues. - let candidates: Vec = wallet_boxes - .iter() - .filter(|b| b.box_id != reserve_box_id && b.box_id != tracker_box_id) - .filter(|b| target_tree.map_or(true, |t| b.ergo_tree == t)) - .cloned() - .collect(); - - // First try to use only boxes without tokens. - let mut token_free: Vec<_> = candidates - .iter() - .filter(|b| b.assets.is_empty()) - .cloned() - .collect(); - token_free.sort_by_key(|b| b.value); - - // Try a single box. - if let Some(box_) = token_free.iter().find(|b| b.value >= required) { - return Some((vec![box_.clone()], box_.value)); - } - - // Try accumulating token-free boxes. - let mut selected = Vec::new(); - let mut total = 0u64; - for box_ in token_free { - total += box_.value; - selected.push(box_); - if total >= required { - return Some((selected, total)); - } - } - - // Fall back to token-bearing boxes if no token-free combination works. - let mut token_boxes: Vec<_> = candidates - .iter() - .filter(|b| !b.assets.is_empty()) - .cloned() - .collect(); - token_boxes.sort_by_key(|b| b.value); - - // Try a single token-bearing box. - if let Some(box_) = token_boxes.iter().find(|b| b.value >= required) { - return Some((vec![box_.clone()], box_.value)); - } - - // Try accumulating token-bearing boxes. - let mut selected = Vec::new(); - let mut total = 0u64; - for box_ in token_boxes { - total += box_.value; - selected.push(box_); - if total >= required { - return Some((selected, total)); - } - } - - None -} - -/// Build serialized SAvlTree constant matching Scala's -/// `ValueSerializer.serialize(AvlTreeConstant(tree))`. -/// Format: 0x64 || 33-byte digest || flags || VLQ key_length || VLQ value_length -fn build_savl_tree_from_digest(digest_hex: &str) -> Vec { - let digest_bytes = hex::decode(digest_hex).unwrap_or_else(|_| vec![0u8; 33]); - - // The server returns a 33-byte root digest: 32-byte AVL digest + 1-byte flags. - // Scala's SAvlTree serialization is: type byte 0x64 || 32-byte digest || flags - // || VLQ key_length || VLQ value_length. - let root_digest: Vec = if digest_bytes.len() >= 33 { - digest_bytes[..33].to_vec() - } else { - // Pad short digests (should not happen in practice) - let mut padded = vec![0u8; 33]; - padded[..digest_bytes.len()].copy_from_slice(&digest_bytes); - padded - }; - - let mut r5_bytes = Vec::with_capacity(38); - r5_bytes.push(0x64u8); // SAvlTree type byte - r5_bytes.extend_from_slice(&root_digest); // 33-byte digest from the AVL prover - r5_bytes.push(0x03u8); // flags: insertions and updates allowed (insertOrUpdate contract) - r5_bytes.extend_from_slice(&vlq_encode(32)); // key length - r5_bytes.extend_from_slice(&vlq_encode(0)); // value length: variable (0) - - r5_bytes -} - -// Helper function to convert public key to a P2PK address using ergo-lib -fn pubkey_to_address(pubkey_hex: &str) -> Result { - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - let pubkey_bytes = - hex::decode(pubkey_hex).map_err(|e| anyhow::anyhow!("Invalid public key hex: {}", e))?; - - if pubkey_bytes.len() != 33 { - return Err(anyhow::anyhow!("Public key must be 33 bytes")); - } - - // Parse public key as EcPoint (compressed secp256k1 point) - let ec_point = EcPoint::sigma_parse_bytes(&pubkey_bytes) - .map_err(|e| anyhow::anyhow!("Invalid public key format: {}", e))?; - - // Create P2PK address from EcPoint - let prove_dlog = ProveDlog::new(ec_point); - let address = Address::P2Pk(prove_dlog); - - // Encode address as base58 string (using mainnet prefix by default) - let encoder = - ergo_lib::ergotree_ir::chain::address::AddressEncoder::new(NetworkPrefix::Mainnet); - Ok(encoder.address_to_str(&address)) -} - -// Helper function to convert a P2S or P2PK address string to its hex-encoded ergoTree. -fn address_to_ergo_tree(address_str: &str) -> Result { - use ergo_lib::ergotree_ir::chain::address::{AddressEncoder, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); - let address = encoder - .parse_address_from_str(address_str) - .map_err(|e| anyhow::anyhow!("Invalid address '{}': {}", address_str, e))?; - let tree = address.script().map_err(|e| { - anyhow::anyhow!("Failed to get script for address '{}': {}", address_str, e) - })?; - Ok(hex::encode(tree.sigma_serialize_bytes().map_err(|e| { - anyhow::anyhow!("Failed to serialize ergoTree: {:?}", e) - })?)) -} - -/// Derive the P2PK address from a P2PK (proveDlog) ergoTree (hex). Used to find the address that -/// owns a fee-input box so the correct local secret is used to sign it. A P2PK ergoTree serializes -/// as `00 08 cd <33-byte compressed point>`, so the public key is bytes `[3..36]`. -fn ergo_tree_to_p2pk_address(ergo_tree_hex: &str) -> Result { - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, AddressEncoder, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - let bytes = hex::decode(ergo_tree_hex)?; - if bytes.len() != 36 || bytes[0] != 0x00 || bytes[1] != 0x08 || bytes[2] != 0xcd { - return Err(anyhow::anyhow!("ergoTree is not a P2PK (proveDlog) script")); - } - let point = EcPoint::sigma_parse_bytes(&bytes[3..]) - .map_err(|e| anyhow::anyhow!("failed to parse P2PK point: {:?}", e))?; - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); - Ok(encoder.address_to_str(&Address::P2Pk(ProveDlog::from(point)))) -} - -/// Parameters for the local (client-side) redemption signing path. -struct SignLocalParams<'a> { - client: &'a TrackerClient, - issuer_pubkey: &'a str, - recipient_pubkey: &'a str, - amount: u64, - total_debt: u64, - note_timestamp: u64, - is_first_redemption: bool, - emergency: bool, - reserve_box_id: &'a str, - tracker_box_id: &'a str, - reserve_output_value: u64, - recipient_output_value: u64, - change_amount: u64, - /// Node-canonical unsigned transaction JSON (the `tx` object from the proven builder). - /// Parsed into an ergo-lib `UnsignedTransaction` so `bytes_to_sign` matches the node exactly. - unsigned_tx: serde_json::Value, - reserve_box_binary: &'a str, - fee_input_binaries: &'a [String], - tracker_box_binary: &'a str, - issuer_signature_len: usize, - tracker_signature_len: usize, - insert_proof_len: usize, - reserve_lookup_proof_len: Option, - tracker_lookup_proof_len: usize, - fee_input_count: usize, - fee_input_total: u64, - change_address: &'a str, - recipient_address: &'a str, - recipient_secret: Option, - fee_secret: Option, -} - -async fn resolve_dlog_secret( - provided: &Option, - client: &TrackerClient, - address: &str, - label: &str, -) -> Result<[u8; 32]> { - let hexstr = match provided { - Some(h) => h.clone(), - None => { - progress!( - "🔑 Fetching {} private key from node wallet ({})...", - label, - address - ); - client - .get_private_key(NODE_URL, Some(API_KEY), address) - .await - .map_err(|e| { - anyhow::anyhow!( - "failed to fetch {} private key for {} (provide --{}-secret to override): {}", - label, - address, - label.replace(' ', "-"), - e - ) - })? - } - }; - let bytes = hex::decode(hexstr.trim()) - .map_err(|e| anyhow::anyhow!("{} secret is not valid hex: {}", label, e))?; - if bytes.len() != 32 { - return Err(anyhow::anyhow!( - "{} secret must be 32 bytes, got {}", - label, - bytes.len() - )); - } - let mut out = [0u8; 32]; - out.copy_from_slice(&bytes); - Ok(out) -} - -fn parse_broadcast_tx_id(body: &str) -> String { - if let Ok(value) = serde_json::from_str::(body) { - match value { - serde_json::Value::String(s) => return s, - serde_json::Value::Object(map) => { - if let Some(serde_json::Value::String(id)) = map.get("id") { - return id.clone(); - } - } - _ => {} - } - } - body.trim().trim_matches('"').to_string() -} - -/// Build an [`ErgoStateContext`] for local signing from the node's current view of the chain. -/// -/// ergo-lib 0.28 requires the last 10 block headers plus chain parameters to construct a state -/// context. The Basis reserve contract does not read `CONTEXT.headers` or chain parameters, so the -/// parameters are left empty and the headers are only used to derive a valid `PreHeader` (height). -async fn fetch_state_context() -> Result<(ErgoStateContext, PreHeader, [Header; 10])> { - let headers_url = format!("{}/blocks/lastHeaders/10", NODE_URL); - let headers_resp = ureq::get(&headers_url) - .set("api_key", API_KEY) - .call() - .map_err(|e| anyhow::anyhow!("failed to fetch last headers: {}", e))?; - let headers_json: serde_json::Value = headers_resp - .into_json() - .map_err(|e| anyhow::anyhow!("failed to read headers response: {}", e))?; - let headers: Vec
= serde_json::from_value(headers_json) - .map_err(|e| anyhow::anyhow!("failed to parse headers from node: {}", e))?; - if headers.len() < 10 { - return Err(anyhow::anyhow!( - "need at least 10 headers for state context, got {}", - headers.len() - )); - } - - let pre_header = PreHeader::from(headers[0].clone()); - let headers_array: [Header; 10] = headers - .into_iter() - .take(10) - .collect::>() - .try_into() - .map_err(|_| anyhow::anyhow!("failed to collect headers into [Header; 10]"))?; - let parameters = Parameters::default(); - - let ctx = ErgoStateContext::new(pre_header.clone(), headers_array.clone(), parameters); - Ok((ctx, pre_header, headers_array)) -} - -/// Scala `immutable.HashMap` (HashTrieMap, Scala 2.12) iteration order for the given byte keys. -/// This is exactly the order `sigma.interpreter.ContextExtension` (a `Map[Byte, Constant]`) is -/// serialized in (`obj.values.foreach`). `UnsignedInput` is `boxId ++ extension`, so the extension -/// byte order is part of `bytes_to_sign`; ergo-lib serializes its insertion-ordered map as-is, hence -/// the keys must be inserted in this order or the local `bytes_to_sign` diverges from the node and -/// `proveDlog(receiver)` verification fails. Validated against a node-signed redemption for the -/// first-redemption set `{0,1,2,3,4,5,6,8}` (order `0,5,1,6,2,3,8,4`); the same model yields the -/// order for the full set `{0..8}` used by subsequent redemptions (which add `#7`). -fn scala_context_extension_order(keys: &[u8]) -> Vec { - // `Byte.hashCode` widens the byte to Int; `improve` is Scala HashMap's hash mixing (Murmur3-style). - fn improve(h: u32) -> u32 { - let h = h.wrapping_add(!(h.wrapping_shl(9))); - let h = h ^ (h >> 14); - let h = h.wrapping_add(h.wrapping_shl(4)); - h ^ (h >> 10) - } - fn build(level: u32, keys: &[u8], out: &mut Vec) { - use std::collections::BTreeMap; - // Group by the 5-bit trie index at this level; BTreeMap yields ascending bucket order, - // matching the bitmap/elems array iteration of HashTrieMap. - let mut groups: BTreeMap> = BTreeMap::new(); - for &k in keys { - let idx = (improve(k as u32) >> level) & 0x1f; - groups.entry(idx).or_default().push(k); - } - for (_idx, group) in groups { - if group.len() == 1 { - out.push(group[0]); - } else if level >= 30 { - // HashMapCollision1: iterates its list in insertion order. - out.extend(group); - } else { - build(level + 5, &group, out); - } - } - } - let mut out = Vec::new(); - build(0, keys, &mut out); - out -} - -/// Reorder the reserve input's (`inputs[0]`) context-extension keys in the JSON so that ergo-lib -/// parses them into its insertion-ordered map in Scala's `ContextExtension` serialization order, -/// for whichever variable indices are present (first-redemption set without `#7`, or the full set -/// `{0..8}` for subsequent redemptions). -fn reorder_reserve_extension_scala(tx: &mut serde_json::Value) { - use serde_json::Map; - let ext = match tx - .get_mut("inputs") - .and_then(|i| i.get_mut(0)) - .and_then(|inp| inp.get_mut("extension")) - .and_then(|e| e.as_object_mut()) - { - Some(e) => e, - None => return, - }; - let present: Vec = ext.keys().filter_map(|k| k.parse::().ok()).collect(); - let order = scala_context_extension_order(&present); - let mut reordered: Map = Map::new(); - for k in &order { - let ks = k.to_string(); - if let Some(v) = ext.remove(&ks) { - reordered.insert(ks, v); - } - } - let remaining: Vec = ext.keys().cloned().collect(); - for k in remaining { - if let Some(v) = ext.remove(&k) { - reordered.insert(k, v); - } - } - *ext = reordered; -} - -/// Build the redemption transaction with ergo-lib and sign it locally (client-side), producing -/// the `proveDlog` proofs for BOTH the reserve input (receiver) and the fee input (fee payer) -/// in-process via a `Wallet`/`TestProver`, then broadcast the fully-signed transaction to the -/// node. This mirrors what a TUI/client does without delegating signing to the node wallet. -#[allow(clippy::too_many_arguments)] -async fn sign_and_broadcast_local(p: SignLocalParams<'_>) -> Result { - progress!("🖊️ Signing redemption locally (client-side proveDlog for both inputs)..."); - - // Parse the proven builder's unsigned transaction. Before parsing, reorder the reserve input's - // context-extension keys into Scala's `ContextExtension` serialization order: the extension is - // part of `bytes_to_sign` and Scala serializes its map in index (not insertion) order, so signing - // requires the exact same order or `proveDlog(receiver)` verification fails on the node. - let mut unsigned_tx = p.unsigned_tx; - reorder_reserve_extension_scala(&mut unsigned_tx); - let unsigned: UnsignedTransaction = serde_json::from_value(unsigned_tx) - .map_err(|e| anyhow::anyhow!("failed to parse unsigned transaction: {}", e))?; - - // Parse input boxes from their sigma-serialized bytes (fetched via /utxo/byIdBinary). - let reserve_box = - ErgoBox::sigma_parse_bytes(&hex::decode(p.reserve_box_binary)?).map_err(|e| { - anyhow::anyhow!("failed to parse reserve box {}: {:?}", p.reserve_box_id, e) - })?; - let tracker_box = - ErgoBox::sigma_parse_bytes(&hex::decode(p.tracker_box_binary)?).map_err(|e| { - anyhow::anyhow!("failed to parse tracker box {}: {:?}", p.tracker_box_id, e) - })?; - let mut fee_boxes = Vec::with_capacity(p.fee_input_binaries.len()); - for (i, fb) in p.fee_input_binaries.iter().enumerate() { - let b = ErgoBox::sigma_parse_bytes(&hex::decode(fb)?) - .map_err(|e| anyhow::anyhow!("failed to parse fee input box {}: {:?}", i, e))?; - fee_boxes.push(b); - } - - let mut boxes_to_spend = vec![reserve_box]; - boxes_to_spend.extend(fee_boxes); - let data_boxes = vec![tracker_box]; - - // Resolve the two dlog secrets (receiver + fee payer) and sign locally. - let recipient_secret = resolve_dlog_secret( - &p.recipient_secret, - p.client, - p.recipient_address, - "recipient", - ) - .await?; - let fee_secret = - resolve_dlog_secret(&p.fee_secret, p.client, p.change_address, "fee-payer").await?; - let recipient_sk = SecretKey::dlog_from_bytes(&recipient_secret) - .ok_or_else(|| anyhow::anyhow!("invalid recipient dlog secret"))?; - let fee_sk = SecretKey::dlog_from_bytes(&fee_secret) - .ok_or_else(|| anyhow::anyhow!("invalid fee-payer dlog secret"))?; - - let (_state_context, pre_header, headers) = fetch_state_context().await?; - - // Two-phase single-input signing (tracker-assisted pattern): sign every fee input first - // (indices 1..N), then the reserve input (index 0), chaining `base_signed` so each previously - // produced proof is preserved. This mirrors the TUI flow where the tracker (fee) and the - // receiver (reserve) sign independently over the same `bytes_to_sign`. - let n_inputs = unsigned.inputs.len(); - let mut partial: Option = None; - for fee_idx in 1..n_inputs { - partial = Some( - add_input_proof( - &unsigned, - partial.as_ref(), - &boxes_to_spend, - &data_boxes, - &pre_header, - &headers, - fee_idx, - &fee_sk, - ) - .map_err(|e| anyhow::anyhow!("fee input {} signing failed: {:?}", fee_idx, e))?, - ); - } - let signed = add_input_proof( - &unsigned, - partial.as_ref(), - &boxes_to_spend, - &data_boxes, - &pre_header, - &headers, - 0, - &recipient_sk, - ) - .map_err(|e| anyhow::anyhow!("reserve input signing failed: {:?}", e))?; - - // Broadcast the fully-signed transaction to the node. - let signed_json = serde_json::to_value(&signed)?; - let local_id = signed_json - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - progress!("✅ Signed locally. tx id: {}", local_id); - let _ = fs::write( - "/tmp/last_signed_tx.json", - serde_json::to_string_pretty(&signed_json)?, - ); - let url = format!("{}/transactions", NODE_URL); - let result = ureq::post(&url) - .set("api_key", API_KEY) - .send_json(signed_json); - let body = match result { - Ok(resp) => resp - .into_string() - .map_err(|e| anyhow::anyhow!("failed to read broadcast response: {}", e))?, - Err(ureq::Error::Status(code, resp)) => { - let err_body = resp.into_string().unwrap_or_default(); - return Err(anyhow::anyhow!( - "broadcast rejected by node (HTTP {}): {}", - code, - err_body - )); - } - Err(e) => return Err(anyhow::anyhow!("broadcast to {} failed: {}", url, e)), - }; - let tx_id = parse_broadcast_tx_id(&body); - - progress!("✅ Redemption broadcast with LOCAL proveDlog signatures."); - progress!("📋 Transaction details:"); - progress!(" Transaction ID: {}", tx_id); - progress!(" Issuer: {}", p.issuer_pubkey); - progress!(" Recipient: {}", p.recipient_pubkey); - progress!(" Redemption amount: {} nanoERG", p.amount); - progress!( - " Recipient receives: {} nanoERG", - p.recipient_output_value - ); - progress!( - " Reserve output value: {} nanoERG", - p.reserve_output_value - ); - progress!(" Total debt: {} nanoERG", p.total_debt); - progress!(" First redemption: {}", p.is_first_redemption); - progress!(" Emergency redemption: {}", p.emergency); - progress!(" Reserve box ID: {}", p.reserve_box_id); - progress!(" Tracker box ID: {}", p.tracker_box_id); - progress!(" Transaction fee: {} nanoERG", TRANSACTION_FEE); - progress!( - " Fee inputs: {} box(es), {} nanoERG total", - p.fee_input_count, - p.fee_input_total - ); - if p.change_amount > 0 { - progress!( - " Change output: {} nanoERG to {}", - p.change_amount, - p.change_address - ); - } - progress!("📝 Context Extension Variables:"); - progress!(" #0 (action): 0x00 (redemption, reserve output index 0)"); - progress!(" #1 (receiver): {}", p.recipient_pubkey); - progress!(" #2 (reserveSig): {} bytes", p.issuer_signature_len); - progress!(" #3 (totalDebt): {}", p.total_debt); - progress!(" #4 (timestamp): {}", p.note_timestamp); - progress!(" #5 (insertProof): {} bytes", p.insert_proof_len); - progress!(" #6 (trackerSig): {} bytes", p.tracker_signature_len); - match p.reserve_lookup_proof_len { - Some(len) => progress!(" #7 (reserveLookupProof): {} bytes", len), - None => progress!(" #7 (reserveLookupProof): omitted (first redemption)"), - } - progress!( - " #8 (trackerLookupProof): {} bytes", - p.tracker_lookup_proof_len - ); - - Ok(tx_id) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::{json, Map, Value}; - - /// A valid P2PK (proveDlog) ergoTree as returned by the node for a wallet address. - const P2PK_TREE: &str = - "0008cd02725e8878d5198ca7f5853dddf35560ddab05ab0a26adae7e664b84162c9962e5"; - const GENERATOR_GE: &str = - "070279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - - /// Expected Scala `ContextExtension` order for the first-redemption set `{0,1,2,3,4,5,6,8}`, - /// confirmed on-chain. Kept as a hardcoded regression guard for `scala_context_extension_order`. - const SCALA_EXT_ORDER_NO_R7: &[&str] = &["0", "5", "1", "6", "2", "3", "8", "4"]; - - /// Build a minimal, ergo-lib-parseable unsigned redemption tx whose reserve-input extension - /// keys are inserted in the given order (with valid, type-correct constant values). - fn tx_json_with_ext_keys(keys_in_order: &[&str]) -> Value { - let mut ext: Map = Map::new(); - for k in keys_in_order { - let v = match *k { - "0" => serialize_ergo_byte(0), - "1" => GENERATOR_GE.to_string(), - "2" => serialize_coll_bytes(&[0u8; 65]), - "3" => serialize_ergo_long(400_000_000), - "4" => serialize_ergo_long(1_783_612_740_170), - "5" => serialize_coll_bytes(&[]), - "6" => serialize_coll_bytes(&[0u8; 65]), - "7" => serialize_coll_bytes(&[0u8; 32]), - "8" => serialize_coll_bytes(&[]), - _ => serialize_coll_bytes(&[]), - }; - ext.insert((*k).to_string(), json!(v)); - } - json!({ - "inputs": [ - { "boxId": "01".repeat(32), "extension": Value::Object(ext) } - ], - "dataInputs": [], - "outputs": [ - { - "value": 1_000_000, - "ergoTree": P2PK_TREE, - "creationHeight": 1, - "assets": [], - "additionalRegisters": {} - } - ] - }) - } - - fn ext_json_keys(tx: &Value) -> Vec { - tx["inputs"][0]["extension"] - .as_object() - .unwrap() - .keys() - .cloned() - .collect() - } - - #[test] - fn reorder_produces_scala_order_for_first_redemption_set() { - let mut tx = tx_json_with_ext_keys(&["6", "1", "3", "4", "8", "2", "0", "5"]); - reorder_reserve_extension_scala(&mut tx); - assert_eq!(ext_json_keys(&tx), SCALA_EXT_ORDER_NO_R7); - } - - #[test] - fn reorder_is_idempotent_when_already_in_scala_order() { - let mut tx = tx_json_with_ext_keys(SCALA_EXT_ORDER_NO_R7); - reorder_reserve_extension_scala(&mut tx); - assert_eq!(ext_json_keys(&tx), SCALA_EXT_ORDER_NO_R7); - } - - #[test] - fn reorder_produces_scala_order_for_subsequent_redemption_set() { - // Subsequent redemptions add #7 (reserveLookupProof): the full set {0..8} orders as below. - let mut tx = tx_json_with_ext_keys(&["6", "1", "3", "4", "7", "8", "2", "0", "5"]); - reorder_reserve_extension_scala(&mut tx); - assert_eq!( - ext_json_keys(&tx), - &["0", "5", "1", "6", "2", "7", "3", "8", "4"] - ); - } - - #[test] - fn reorder_orders_arbitrary_key_set_in_scala_order() { - // Full {0..9} set: keys are ordered by Scala HashTrieMap iteration, not by insertion. - let mut tx = tx_json_with_ext_keys(&["9", "6", "1", "7", "3", "4", "8", "2", "0", "5"]); - reorder_reserve_extension_scala(&mut tx); - assert_eq!( - ext_json_keys(&tx), - &["0", "5", "1", "6", "9", "2", "7", "3", "8", "4"] - ); - } - - #[test] - fn parsed_unsigned_tx_extension_iterates_in_scala_order() { - let mut tx = tx_json_with_ext_keys(&["6", "1", "3", "4", "8", "2", "0", "5"]); - reorder_reserve_extension_scala(&mut tx); - let unsigned: UnsignedTransaction = serde_json::from_value(tx).unwrap(); - let order: Vec = unsigned.inputs.as_slice()[0] - .extension - .values - .keys() - .copied() - .collect(); - let expected: Vec = SCALA_EXT_ORDER_NO_R7 - .iter() - .map(|s| s.parse::().unwrap()) - .collect(); - assert_eq!(order, expected); - } - - #[test] - fn scala_context_extension_order_matches_known_sets() { - assert_eq!( - scala_context_extension_order(&[0, 1, 2, 3, 4, 5, 6, 8]), - vec![0, 5, 1, 6, 2, 3, 8, 4] - ); - assert_eq!( - scala_context_extension_order(&[0, 1, 2, 3, 4, 5, 6, 7, 8]), - vec![0, 5, 1, 6, 2, 7, 3, 8, 4] - ); - // Order is independent of the input slice order. - assert_eq!( - scala_context_extension_order(&[8, 6, 5, 4, 3, 2, 1, 0]), - vec![0, 5, 1, 6, 2, 3, 8, 4] - ); - } - - #[test] - fn vlq_encode_matches_ergo_encoding() { - assert_eq!(vlq_encode(0), vec![0x00]); - assert_eq!(vlq_encode(127), vec![0x7f]); - assert_eq!(vlq_encode(128), vec![0x80, 0x01]); - assert_eq!(vlq_encode(200), vec![0xc8, 0x01]); - assert_eq!(vlq_encode(16384), vec![0x80, 0x80, 0x01]); - } - - #[test] - fn serialize_ergo_byte_encodes_type_and_value() { - assert_eq!(serialize_ergo_byte(0), "0200"); - assert_eq!(serialize_ergo_byte(255), "02ff"); - } - - #[test] - fn serialize_ergo_long_encodes_zigzag_vlq() { - assert_eq!(serialize_ergo_long(0), "0500"); - // -1 zigzags to 1. - assert_eq!(serialize_ergo_long(-1), "0501"); - // 1 zigzags to 2. - assert_eq!(serialize_ergo_long(1), "0502"); - } - - #[test] - fn serialize_coll_bytes_uses_vlq_length() { - // Empty: type 0x0e + length 0. - assert_eq!(serialize_coll_bytes(&[]), "0e00"); - // 65 bytes (< 128): single-byte length 0x41. - let sig = serialize_coll_bytes(&[0u8; 65]); - assert!(sig.starts_with("0e41")); - assert_eq!(sig.len(), 2 + 2 + 65 * 2); - // 200 bytes (>= 128): length is VLQ 200 -> [0xc8, 0x01]. - let long = serialize_coll_bytes(&[0u8; 200]); - assert!(long.starts_with("0ec801")); - assert_eq!(long.len(), 2 + 4 + 200 * 2); - } - - #[test] - fn parse_broadcast_tx_id_handles_node_shapes() { - assert_eq!(parse_broadcast_tx_id("\"abc123\""), "abc123"); - assert_eq!(parse_broadcast_tx_id("{\"id\":\"def456\"}"), "def456"); - assert_eq!(parse_broadcast_tx_id("plainid"), "plainid"); - } - - #[test] - fn address_round_trip_yields_p2pk_ergo_tree() { - let pubkey = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; - let address = pubkey_to_address(pubkey).unwrap(); - let tree = address_to_ergo_tree(&address).unwrap(); - assert_eq!(tree, format!("0008cd{}", pubkey)); - } -} diff --git a/crates/basis_cli/src/interactive.rs b/crates/basis_cli/src/interactive.rs index af148eb..bea8deb 100644 --- a/crates/basis_cli/src/interactive.rs +++ b/crates/basis_cli/src/interactive.rs @@ -69,7 +69,7 @@ impl InteractiveMode { println!(" note list --issuer - List notes where you are issuer"); println!(" note list --recipient - List notes where you are recipient"); println!(" note get --issuer --recipient "); - println!(" note redeem --issuer --amount "); + println!(" note redeem - Retired compatibility tombstone"); println!(" reserve status [--issuer ]"); println!(" reserve collateralization [--issuer ]"); println!(" status - Show server status and recent events"); diff --git a/crates/basis_cli/src/lib.rs b/crates/basis_cli/src/lib.rs index 2eb35a8..17af9aa 100644 --- a/crates/basis_cli/src/lib.rs +++ b/crates/basis_cli/src/lib.rs @@ -6,3 +6,4 @@ pub mod crypto; pub mod demo_keys; pub mod interactive; pub mod output; +pub mod v2_manifest; diff --git a/crates/basis_cli/src/main.rs b/crates/basis_cli/src/main.rs index 69ef9ac..0844368 100644 --- a/crates/basis_cli/src/main.rs +++ b/crates/basis_cli/src/main.rs @@ -54,16 +54,6 @@ enum Commands { #[command(subcommand)] cmd: commands::reserve::ReserveCommands, }, - /// Transaction operations - Transaction { - #[command(subcommand)] - cmd: commands::transaction::TransactionCommands, - }, - /// Test operations - Test { - #[command(subcommand)] - cmd: commands::test_redemption::TestCommands, - }, /// Interactive mode Interactive, /// Server status @@ -115,13 +105,6 @@ async fn run(cli: Cli) -> Result<()> { Commands::Reserve { cmd } => { commands::reserve::handle_reserve_command(cmd, &account_manager, &client, json).await } - Commands::Transaction { cmd } => { - commands::transaction::handle_transaction_command(cmd, &client, &account_manager, json) - .await - } - Commands::Test { cmd } => { - commands::test_redemption::handle_test_command(cmd, &client, json).await - } Commands::Interactive => { let mut interactive = interactive::InteractiveMode::new(account_manager, client); interactive.run().await diff --git a/crates/basis_cli/src/v2_manifest.rs b/crates/basis_cli/src/v2_manifest.rs new file mode 100644 index 0000000..4f9d33a --- /dev/null +++ b/crates/basis_cli/src/v2_manifest.rs @@ -0,0 +1,50 @@ +//! Dormant v2 client admission boundary. +//! +//! No proof generation or signing callback can be entered through this module +//! with a raw manifest. The callback receives only the opaque validation token +//! produced after the exact signer-side manifest checks have succeeded. +//! This is admission only: there is no v2 prover, signer, wallet integration, +//! submission, or broadcast implementation here. A future private integration +//! must consume only the validated token and bind reserve and funding proofs to +//! the same exact boxes carried by its manifest. +//! +//! ```compile_fail +//! use basis_store::basis_v2_builder::{ +//! V2RedemptionManifest, ValidatedV2RedemptionManifest, +//! }; +//! +//! fn bypass(manifest: &V2RedemptionManifest) { +//! let _ = ValidatedV2RedemptionManifest { manifest }; +//! } +//! ``` + +use anyhow::Result; +use basis_store::basis_v2_builder::{ + with_validated_v2_redemption_manifest, with_validated_v2_redemption_manifest_bytes, + V2RedemptionManifest, V2SigningIntent, ValidatedV2RedemptionManifest, +}; + +/// Validate the complete v2 manifest before entering a fallible proof/signing +/// callback seam. This does not implement the callback's proof or signing +/// operation. Production remains dormant because a `V2SigningIntent` cannot be +/// assembled until the independent header-ancestry authority supplies its +/// opaque verified tip. +pub fn with_validated_v2_manifest( + manifest: &V2RedemptionManifest, + intent: &V2SigningIntent, + callback: impl FnOnce(ValidatedV2RedemptionManifest<'_>) -> Result, +) -> Result { + with_validated_v2_redemption_manifest(manifest, intent, callback).map_err(anyhow::Error::new)? +} + +/// Bounded raw-byte admission seam for a future CLI transport. Raw JSON is +/// size-checked and parsed before the same opaque validation token can reach a +/// proof or signing callback. +pub fn with_validated_v2_manifest_bytes( + encoded: &[u8], + intent: &V2SigningIntent, + callback: impl FnOnce(ValidatedV2RedemptionManifest<'_>) -> Result, +) -> Result { + with_validated_v2_redemption_manifest_bytes(encoded, intent, callback) + .map_err(anyhow::Error::new)? +} diff --git a/crates/basis_cli/tests/cli_integration_tests.rs b/crates/basis_cli/tests/cli_integration_tests.rs index 901108a..245c455 100644 --- a/crates/basis_cli/tests/cli_integration_tests.rs +++ b/crates/basis_cli/tests/cli_integration_tests.rs @@ -2,7 +2,7 @@ #[cfg(test)] mod cli_tests { - use basis_store::{IouNote, RedemptionRequest}; + use basis_store::IouNote; #[test] fn test_note_validation() { @@ -14,46 +14,6 @@ mod cli_tests { assert_eq!(note.timestamp, 1234567890); } - #[test] - fn test_redemption_request_validation() { - let request = RedemptionRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 500, - timestamp: 1234567890, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), - current_height: 1000, - recipient_address: "test_recipient_address".to_string(), - change_address: "test_change_address".to_string(), - issuer_signature: "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101".to_string(), - emergency: false, - tracker_signature: Some("020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202".to_string()), - reserve_box_value: 1000000000, - fee_input_box_ids: vec![], - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - // Test field validation - assert!(request.amount > 0, "Amount should be positive"); - assert!( - !request.issuer_pubkey.is_empty(), - "Issuer pubkey should not be empty" - ); - assert!( - !request.recipient_pubkey.is_empty(), - "Recipient pubkey should not be empty" - ); - assert!( - !request.reserve_box_id.is_empty(), - "Reserve box ID should not be empty" - ); - } - #[test] fn test_pubkey_format_validation() { // Test that pubkey format validation works diff --git a/crates/basis_cli/tests/local_sign_v3.rs b/crates/basis_cli/tests/local_sign_v3.rs deleted file mode 100644 index 6be6889..0000000 --- a/crates/basis_cli/tests/local_sign_v3.rs +++ /dev/null @@ -1,44 +0,0 @@ -// Regression test for the local-sign v3 ErgoTree parsing failure. -// -// `basis_cli transaction generate-redemption --local-sign` fails on reserve boxes whose -// ErgoTree uses header version 3 (the current `insertOrUpdate` Basis reserve contract): -// -// Error: failed to parse reserve box 8dc21481...: ErgoTreeHeaderError(VersionError(InvalidVersion(3))) -// -// The failure happens in `sign_and_broadcast_local` -// (`crates/basis_cli/src/commands/transaction.rs`) when it parses the sigma-serialized -// input boxes with `ErgoBox::sigma_parse_bytes`, because ergo-lib 0.28 does not support -// ErgoTree header version 3. -// -// EXPECTED BEHAVIOR: parsing the real on-chain reserve box binary (below) must succeed so -// that client-side `proveDlog` signing works for v3 reserves. -// -// This test FAILS with ergo-lib 0.28 and should start passing once the v3 parsing issue -// is fixed (e.g. by an ergo-lib upgrade or a custom box-deserialization workaround). -// Until then, node-wallet signing (`/wallet/transaction/sign`) is the working path. - -use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; -use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - -/// Sigma-serialized bytes of the real mainnet reserve box -/// `8dc21481ed3f084f99d021124c9923e418c1ece60f2d44f8885d48923b29dcd0` -/// (v3 ErgoTree, `insertOrUpdate` Basis reserve contract), as returned by the Ergo node -/// `/utxo/byIdBinary` endpoint during the ninth redemption test. -const RESERVE_BOX_BINARY: &str = "8084af5f1bac052004140414050004000400041004200500040004420400040004000410050004420500040004420442010104e021050004020500058084af5f04040500040605000480a3050100d806d6017ee4e3000204d6029d72017300d603b2a59e7201730100d604e4c6a70407d605ededed93c27203c2a793db63087203db6308a793e4c672030407720493e4c67203060ee4c6a7060ed606e5c6a707057302959372027303d813d607b2db6501fe730400d608db07027204d609e4e30107d60acbb37208db07027209d60be4e30305d60ce4e30405d60de3070ed60ee6720dd60f7a720cd61095720e7cb4e4dc640ae4c6a7056402720ae4720d730573067307d61199c1a7c17203d612db6a01ddd613e4e3020ed614b4721373087309d615b3b3720a7a720b720fd616e4e3060ed617b17216d618917217730ad619e4c672070407ea02d1ededededededed7205938cb2db63087207730b0001e4c6a7060e937ce4dc640ae4c67207056402720ae4e3080e720b91720c95720e7cb4e4dc640ae4c6a7056402720ae4720d730c730d730e93e4dc6410e4c6a705640283013c0e0e8602720ab3720f7a9a72107211e4e3050ee4c672030564939f72127bb47213730fb17213a0ee72149f72047bcbb3b3721472157208eded917211731090721199720b7210957218957218d801d61ab4721673117312939f72127bb4721673137217a0ee721a9f72197bcbb3b3721a7215db0702721973149199a38cc7720701731593e5c67203070573167206cd7209959372027317d1ededed720593e4c672030564e4c6a7056493e5c672030705731872069299c17203c1a7731995937202731aea02d1edededed720592c17203c1a793e4c672030564e4c6a7056492e4c6720307057ea305937206731bcd720495937202731cea02d1ed917206731d927ea3059a72067e731e05cd7204d1731f80ee6f01018d29f4da1ea43f9d752b927200c54d9230637cc677c8a66d477f1684bd3098010407022880fde8cace85c2c810fb32c5441a32198b0f7a122b9a672cfb7e50eb898cdc64f91e7e764bd5cd630eb53d582a72a2f2bdaa1ca1459b2f6d79680bc8b1a32ebd010320000e20000b0695159e5f5c32c606385bd5f276d80133149c84c8b1325366381bf6f17f05004b65c7cdf46ba7fdb741803e5d6534de70911f07a409c285532821054cc2959b00"; - -// This test is ignored because it documents a known upstream limitation: -// ergo-lib 0.28.0 does not support ErgoTree header version 3. Re-enable once -// an ergo-lib release adds v3 support or the project switches to a custom -// box-deserialization workaround. -#[ignore = "Blocked on ergo-lib ErgoTree v3 support (ergo-lib 0.28.0)"] -#[test] -fn local_sign_reserve_box_v3_parses() { - let bytes = hex::decode(RESERVE_BOX_BINARY).expect("valid hex"); - let parsed = ErgoBox::sigma_parse_bytes(&bytes); - assert!( - parsed.is_ok(), - "local-sign must be able to parse v3 ErgoTree reserve boxes; \ - got error: {:?}", - parsed.err() - ); -} diff --git a/crates/basis_core/src/basis_v2.rs b/crates/basis_core/src/basis_v2.rs new file mode 100644 index 0000000..c209518 --- /dev/null +++ b/crates/basis_core/src/basis_v2.rs @@ -0,0 +1,766 @@ +//! Exact message and state domain for the Basis reserve ABI generation v2. +//! +//! V2 claims are deliberately reserve-bound. A signature created for one +//! reserve singleton, tracker singleton, asset family, owner, or receiver must +//! not be valid in any other domain. + +use crate::impls::{schnorr_sign, validate_public_key, SchnorrVerifier}; +use crate::traits::{CryptoError, SignatureVerifier}; +use crate::types::{PubKey, Signature}; +use blake2::{Blake2b, Digest}; +use generic_array::typenum::U32; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use thiserror::Error; + +/// ABI generation authenticated by both Basis v2 reserve contracts. +pub const BASIS_V2_ABI_GENERATION: u8 = 2; +/// Network byte compiled into the current Basis v2 contract family. +pub const BASIS_V2_ERGO_MAINNET_DOMAIN: u8 = 0; +/// Asset discriminator compiled into the ERG reserve contract. +pub const BASIS_V2_ERG_ASSET_KIND: u8 = 0; +/// Asset discriminator compiled into the token reserve contract. +pub const BASIS_V2_TOKEN_ASSET_KIND: u8 = 1; + +/// `"BASIS" || ABI 2 || Ergo mainnet || ERG`. +pub const BASIS_V2_ERG_DOMAIN_TAG: [u8; 8] = *b"BASIS\x02\x00\x00"; +/// `"BASIS" || ABI 2 || Ergo mainnet || token`. +pub const BASIS_V2_TOKEN_DOMAIN_TAG: [u8; 8] = *b"BASIS\x02\x00\x01"; + +/// Maximum non-negative value representable by ErgoScript `Long`. +pub const BASIS_V2_MAX_LONG: u64 = i64::MAX as u64; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum BasisV2Error { + #[error("invalid compressed public key")] + InvalidPublicKey, + #[error("reserve NFT and reserve token ids must differ")] + DuplicateReserveAssetId, + #[error("total debt must be in 1..=Long.MaxValue")] + InvalidTotalDebt, + #[error("timestamp must be in 1..=Long.MaxValue")] + InvalidTimestamp, + #[error("redeemed amount exceeds total debt")] + RedeemedExceedsDebt, + #[error("redemption amount must be positive")] + InvalidRedemptionAmount, + #[error("redemption amount exceeds the remaining claim")] + RedemptionExceedsClaim, + #[error("claim update regresses timestamp or cumulative debt")] + ClaimRegression, + #[error("state value must be exactly 24 bytes")] + InvalidStateLength, + #[error("state value contains a negative ErgoScript Long")] + NegativeStateValue, + #[error("invalid Schnorr signature")] + InvalidSignature, + #[error("owner secret key does not derive the claim owner public key")] + OwnerSecretMismatch, + #[error("signature is outside the canonical ErgoScript-compatible 65-byte profile")] + NonCanonicalSignature, +} + +impl From for BasisV2Error { + fn from(_: CryptoError) -> Self { + Self::InvalidSignature + } +} + +/// Collateral family authenticated by a v2 claim key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReserveAssetV2 { + Erg, + Token { token_id: [u8; 32] }, +} + +/// All inputs that define the unique settlement domain of a v2 claim. +/// +/// The fields are intentionally private: constructing the struct directly +/// would bypass public-key parsing and the token/singleton separation check. +/// +/// ```compile_fail +/// use basis_core::basis_v2::{ClaimDomainV2, ReserveAssetV2}; +/// let _ = ClaimDomainV2 { +/// reserve_nft_id: [1; 32], +/// tracker_nft_id: [2; 32], +/// owner_pubkey: [0; 33], +/// receiver_pubkey: [0; 33], +/// asset: ReserveAssetV2::Erg, +/// }; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClaimDomainV2 { + reserve_nft_id: [u8; 32], + tracker_nft_id: [u8; 32], + owner_pubkey: PubKey, + receiver_pubkey: PubKey, + asset: ReserveAssetV2, +} + +impl ClaimDomainV2 { + pub fn erg( + reserve_nft_id: [u8; 32], + tracker_nft_id: [u8; 32], + owner_pubkey: PubKey, + receiver_pubkey: PubKey, + ) -> Result { + Self::new( + reserve_nft_id, + tracker_nft_id, + owner_pubkey, + receiver_pubkey, + ReserveAssetV2::Erg, + ) + } + + pub fn token( + reserve_nft_id: [u8; 32], + reserve_token_id: [u8; 32], + tracker_nft_id: [u8; 32], + owner_pubkey: PubKey, + receiver_pubkey: PubKey, + ) -> Result { + Self::new( + reserve_nft_id, + tracker_nft_id, + owner_pubkey, + receiver_pubkey, + ReserveAssetV2::Token { + token_id: reserve_token_id, + }, + ) + } + + fn new( + reserve_nft_id: [u8; 32], + tracker_nft_id: [u8; 32], + owner_pubkey: PubKey, + receiver_pubkey: PubKey, + asset: ReserveAssetV2, + ) -> Result { + validate_public_key(&owner_pubkey).map_err(|_| BasisV2Error::InvalidPublicKey)?; + validate_public_key(&receiver_pubkey).map_err(|_| BasisV2Error::InvalidPublicKey)?; + if let ReserveAssetV2::Token { token_id } = asset { + if token_id == reserve_nft_id { + return Err(BasisV2Error::DuplicateReserveAssetId); + } + } + Ok(Self { + reserve_nft_id, + tracker_nft_id, + owner_pubkey, + receiver_pubkey, + asset, + }) + } + + pub fn reserve_nft_id(&self) -> [u8; 32] { + self.reserve_nft_id + } + + pub fn tracker_nft_id(&self) -> [u8; 32] { + self.tracker_nft_id + } + + pub fn owner_pubkey(&self) -> PubKey { + self.owner_pubkey + } + + pub fn receiver_pubkey(&self) -> PubKey { + self.receiver_pubkey + } + + pub fn asset(&self) -> ReserveAssetV2 { + self.asset + } + + /// Exact `blake2b256(...)` key consumed by both v2 ErgoScripts. + pub fn claim_key(&self) -> [u8; 32] { + let mut hasher = Blake2b::::new(); + match self.asset { + ReserveAssetV2::Erg => { + hasher.update(BASIS_V2_ERG_DOMAIN_TAG); + hasher.update(self.reserve_nft_id); + } + ReserveAssetV2::Token { token_id } => { + hasher.update(BASIS_V2_TOKEN_DOMAIN_TAG); + hasher.update(self.reserve_nft_id); + hasher.update(token_id); + } + } + hasher.update(self.tracker_nft_id); + hasher.update(self.owner_pubkey); + hasher.update(self.receiver_pubkey); + hasher.finalize().into() + } + + /// Exact 48-byte message consumed by the debtor and tracker signature checks. + pub fn signing_message( + &self, + total_debt: u64, + timestamp: u64, + ) -> Result<[u8; 48], BasisV2Error> { + validate_claim_values(total_debt, timestamp)?; + let mut message = [0u8; 48]; + message[..32].copy_from_slice(&self.claim_key()); + message[32..40].copy_from_slice(&total_debt.to_be_bytes()); + message[40..48].copy_from_slice(×tamp.to_be_bytes()); + Ok(message) + } +} + +/// A debtor-signed cumulative claim in one exact v2 reserve domain. +/// +/// All fields are private so a caller cannot replace the authenticated domain, +/// cumulative values, or signature after validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimV2 { + domain: ClaimDomainV2, + total_debt: u64, + timestamp: u64, + signature: Signature, +} + +impl ClaimV2 { + pub fn sign( + domain: ClaimDomainV2, + total_debt: u64, + timestamp: u64, + owner_secret: &[u8; 32], + ) -> Result { + let owner_secret = + SecretKey::from_slice(owner_secret).map_err(|_| BasisV2Error::OwnerSecretMismatch)?; + let derived_owner = + PublicKey::from_secret_key(&Secp256k1::new(), &owner_secret).serialize(); + if derived_owner != domain.owner_pubkey { + return Err(BasisV2Error::OwnerSecretMismatch); + } + let message = domain.signing_message(total_debt, timestamp)?; + let signature = schnorr_sign(&message, &owner_secret.secret_bytes(), &domain.owner_pubkey)?; + Ok(Self { + domain, + total_debt, + timestamp, + signature, + }) + } + + /// Parse a wire claim and require the owner signature before constructing + /// an invariant-bearing value. + pub fn from_signed( + domain: ClaimDomainV2, + total_debt: u64, + timestamp: u64, + signature: Signature, + ) -> Result { + validate_claim_values(total_debt, timestamp)?; + let claim = Self { + domain, + total_debt, + timestamp, + signature, + }; + claim.verify()?; + Ok(claim) + } + + pub fn domain(&self) -> ClaimDomainV2 { + self.domain + } + + pub fn total_debt(&self) -> u64 { + self.total_debt + } + + pub fn timestamp(&self) -> u64 { + self.timestamp + } + + pub fn signature(&self) -> &Signature { + &self.signature + } + + pub fn signing_message(&self) -> Result<[u8; 48], BasisV2Error> { + self.domain.signing_message(self.total_debt, self.timestamp) + } + + pub fn verify(&self) -> Result<(), BasisV2Error> { + let message = self.signing_message()?; + verify_basis_v2_signature(&self.signature, &message, &self.domain.owner_pubkey) + } +} + +/// Verify the canonical 65-byte Schnorr profile emitted by the Basis v2 +/// runtime for either the reserve owner or tracker key. +/// +/// The contracts accept a wider 64..=66 byte surface and interpret `e` and +/// `z` as signed big-endian integers. Runtime manifests deliberately use the +/// single 33-byte commitment plus non-negative 32-byte response profile so a +/// locally accepted signature cannot be rejected by the ErgoScript signed +/// integer interpretation. +pub fn verify_basis_v2_signature( + signature: &Signature, + message: &[u8], + public_key: &PubKey, +) -> Result<(), BasisV2Error> { + validate_public_key(public_key).map_err(|_| BasisV2Error::InvalidPublicKey)?; + // The contracts accept a wider 64..=66 byte surface and interpret + // `e` and `z` as signed big-endian integers. The Rust wire type is the + // deliberately narrower 65-byte canonical profile emitted by + // `schnorr_sign`: both 32-byte integers must be non-negative under the + // ErgoScript interpretation. Without these guards the generic Rust + // verifier can accept an unsigned-scalar signature rejected on-chain. + if signature[33] & 0x80 != 0 { + return Err(BasisV2Error::NonCanonicalSignature); + } + let mut challenge = Blake2b::::new(); + challenge.update(&signature[..33]); + challenge.update(message); + challenge.update(public_key); + if challenge.finalize()[0] & 0x80 != 0 { + return Err(BasisV2Error::NonCanonicalSignature); + } + SchnorrVerifier + .verify_signature(signature, message, public_key) + .map_err(BasisV2Error::from) +} + +/// Fixed 24-byte value committed by reserve R5 in ABI v2. +/// +/// ```compile_fail +/// use basis_core::basis_v2::RedeemedStateV2; +/// let _ = RedeemedStateV2 { timestamp: 0, total_debt: 0, redeemed: 1 }; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RedeemedStateV2 { + timestamp: u64, + total_debt: u64, + redeemed: u64, +} + +impl RedeemedStateV2 { + pub const ENCODED_LEN: usize = 24; + + pub fn new(timestamp: u64, total_debt: u64, redeemed: u64) -> Result { + validate_claim_values(total_debt, timestamp)?; + if redeemed > BASIS_V2_MAX_LONG || redeemed > total_debt { + return Err(BasisV2Error::RedeemedExceedsDebt); + } + Ok(Self { + timestamp, + total_debt, + redeemed, + }) + } + + pub fn encode(self) -> [u8; Self::ENCODED_LEN] { + let mut encoded = [0u8; Self::ENCODED_LEN]; + encoded[..8].copy_from_slice(&self.timestamp.to_be_bytes()); + encoded[8..16].copy_from_slice(&self.total_debt.to_be_bytes()); + encoded[16..24].copy_from_slice(&self.redeemed.to_be_bytes()); + encoded + } + + pub fn timestamp(&self) -> u64 { + self.timestamp + } + + pub fn total_debt(&self) -> u64 { + self.total_debt + } + + pub fn redeemed(&self) -> u64 { + self.redeemed + } + + pub fn decode(encoded: &[u8]) -> Result { + if encoded.len() != Self::ENCODED_LEN { + return Err(BasisV2Error::InvalidStateLength); + } + let timestamp = decode_non_negative_long(&encoded[..8])?; + let total_debt = decode_non_negative_long(&encoded[8..16])?; + let redeemed = decode_non_negative_long(&encoded[16..24])?; + Self::new(timestamp, total_debt, redeemed) + } + + /// Apply the same cumulative-claim and redemption rules as the v2 scripts. + pub fn advance( + self, + claim_timestamp: u64, + claim_total_debt: u64, + amount: u64, + ) -> Result { + validate_claim_values(claim_total_debt, claim_timestamp)?; + let same_claim = claim_timestamp == self.timestamp && claim_total_debt == self.total_debt; + let monotone_successor = + claim_timestamp > self.timestamp && claim_total_debt >= self.total_debt; + if !same_claim && !monotone_successor { + return Err(BasisV2Error::ClaimRegression); + } + if amount == 0 { + return Err(BasisV2Error::InvalidRedemptionAmount); + } + let available = claim_total_debt + .checked_sub(self.redeemed) + .ok_or(BasisV2Error::RedemptionExceedsClaim)?; + if amount > available { + return Err(BasisV2Error::RedemptionExceedsClaim); + } + let redeemed = self + .redeemed + .checked_add(amount) + .ok_or(BasisV2Error::RedemptionExceedsClaim)?; + Self::new(claim_timestamp, claim_total_debt, redeemed) + } +} + +fn validate_claim_values(total_debt: u64, timestamp: u64) -> Result<(), BasisV2Error> { + if total_debt == 0 || total_debt > BASIS_V2_MAX_LONG { + return Err(BasisV2Error::InvalidTotalDebt); + } + if timestamp == 0 || timestamp > BASIS_V2_MAX_LONG { + return Err(BasisV2Error::InvalidTimestamp); + } + Ok(()) +} + +fn decode_non_negative_long(bytes: &[u8]) -> Result { + let raw: [u8; 8] = bytes + .try_into() + .map_err(|_| BasisV2Error::InvalidStateLength)?; + let value = i64::from_be_bytes(raw); + if value < 0 { + return Err(BasisV2Error::NegativeStateValue); + } + Ok(value as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + fn key(secret: u8) -> ([u8; 32], PubKey) { + let mut bytes = [0u8; 32]; + bytes[31] = secret; + let secret_key = SecretKey::from_slice(&bytes).unwrap(); + let public_key = PublicKey::from_secret_key(&Secp256k1::new(), &secret_key).serialize(); + (bytes, public_key) + } + + fn generic_valid_signature_with_sign_bits( + domain: ClaimDomainV2, + total_debt: u64, + timestamp: u64, + high_challenge: bool, + high_z: bool, + ) -> Signature { + use crate::traits::SignatureVerifier; + use num_bigint::BigUint; + + let message = domain.signing_message(total_debt, timestamp).unwrap(); + let owner_secret = key(1).0; + let owner_scalar = BigUint::from_bytes_be(&owner_secret); + let order = BigUint::from_bytes_be(&[ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFE, 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, + 0xD0, 0x36, 0x41, 0x41, + ]); + let secp = Secp256k1::new(); + + for counter in 1u32..10_000 { + let mut nonce_hash = Blake2b::::new(); + nonce_hash.update(counter.to_be_bytes()); + let nonce_bytes: [u8; 32] = nonce_hash.finalize().into(); + let Ok(nonce) = SecretKey::from_slice(&nonce_bytes) else { + continue; + }; + let a = PublicKey::from_secret_key(&secp, &nonce).serialize(); + let mut challenge = Blake2b::::new(); + challenge.update(a); + challenge.update(message); + challenge.update(domain.owner_pubkey()); + let challenge_bytes: [u8; 32] = challenge.finalize().into(); + if (challenge_bytes[0] & 0x80 != 0) != high_challenge { + continue; + } + if secp256k1::Scalar::from_be_bytes(challenge_bytes).is_err() { + continue; + } + let z = (BigUint::from_bytes_be(&nonce_bytes) + + BigUint::from_bytes_be(&challenge_bytes) * &owner_scalar) + % ℴ + if z == BigUint::from(0u8) { + continue; + } + let z_raw = z.to_bytes_be(); + let mut z_bytes = [0u8; 32]; + z_bytes[32 - z_raw.len()..].copy_from_slice(&z_raw); + if (z_bytes[0] & 0x80 != 0) != high_z { + continue; + } + let mut signature = [0u8; 65]; + signature[..33].copy_from_slice(&a); + signature[33..].copy_from_slice(&z_bytes); + SchnorrVerifier + .verify_signature(&signature, &message, &domain.owner_pubkey()) + .unwrap(); + return signature; + } + panic!("failed to find the requested valid Schnorr sign-bit profile"); + } + + fn erg_domain() -> ClaimDomainV2 { + ClaimDomainV2::erg([1u8; 32], [2u8; 32], key(1).1, key(2).1).unwrap() + } + + fn challenge_is_non_negative( + signature: &Signature, + domain: ClaimDomainV2, + total_debt: u64, + timestamp: u64, + ) -> bool { + let message = domain.signing_message(total_debt, timestamp).unwrap(); + let mut challenge = Blake2b::::new(); + challenge.update(&signature[..33]); + challenge.update(message); + challenge.update(domain.owner_pubkey()); + let bytes: [u8; 32] = challenge.finalize().into(); + bytes[0] & 0x80 == 0 && secp256k1::Scalar::from_be_bytes(bytes).is_ok() + } + + #[test] + fn domain_tags_match_the_contract_bytes() { + assert_eq!(hex::encode(BASIS_V2_ERG_DOMAIN_TAG), "4241534953020000"); + assert_eq!(hex::encode(BASIS_V2_TOKEN_DOMAIN_TAG), "4241534953020001"); + let base = erg_domain(); + assert_eq!( + hex::encode(base.claim_key()), + "656c938601a973fe7dd8b5984b70430bf2c885b69a0d91268b0f5c4383a02d73" + ); + let token = ClaimDomainV2::token( + base.reserve_nft_id(), + [5u8; 32], + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), + ) + .unwrap(); + assert_eq!( + hex::encode(token.claim_key()), + "db33c7176e8d11041a971258458e6be92b59b56865e06ebfbe8e44e9e007f4a1" + ); + } + + #[test] + fn every_domain_coordinate_changes_the_claim_key() { + let base = erg_domain(); + let base_key = base.claim_key(); + let mutations = [ + ClaimDomainV2::erg( + [3u8; 32], + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), + ) + .unwrap(), + ClaimDomainV2::erg( + base.reserve_nft_id(), + [4u8; 32], + base.owner_pubkey(), + base.receiver_pubkey(), + ) + .unwrap(), + ClaimDomainV2::erg( + base.reserve_nft_id(), + base.tracker_nft_id(), + key(3).1, + base.receiver_pubkey(), + ) + .unwrap(), + ClaimDomainV2::erg( + base.reserve_nft_id(), + base.tracker_nft_id(), + base.owner_pubkey(), + key(4).1, + ) + .unwrap(), + ClaimDomainV2::token( + base.reserve_nft_id(), + [5u8; 32], + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), + ) + .unwrap(), + ]; + for mutation in mutations { + assert_ne!(mutation.claim_key(), base_key); + } + + let token_a = ClaimDomainV2::token( + base.reserve_nft_id(), + [5u8; 32], + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), + ) + .unwrap(); + let token_b = ClaimDomainV2::token( + base.reserve_nft_id(), + [6u8; 32], + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), + ) + .unwrap(); + assert_ne!(token_a.claim_key(), token_b.claim_key()); + } + + #[test] + fn domain_constructors_reject_each_invalid_coordinate() { + let base = erg_domain(); + assert_eq!( + ClaimDomainV2::erg( + base.reserve_nft_id(), + base.tracker_nft_id(), + [0u8; 33], + base.receiver_pubkey(), + ), + Err(BasisV2Error::InvalidPublicKey) + ); + assert_eq!( + ClaimDomainV2::erg( + base.reserve_nft_id(), + base.tracker_nft_id(), + base.owner_pubkey(), + [0u8; 33], + ), + Err(BasisV2Error::InvalidPublicKey) + ); + assert_eq!( + ClaimDomainV2::token( + base.reserve_nft_id(), + base.reserve_nft_id(), + base.tracker_nft_id(), + base.owner_pubkey(), + base.receiver_pubkey(), + ), + Err(BasisV2Error::DuplicateReserveAssetId) + ); + } + + #[test] + fn signed_claim_is_bound_to_its_exact_domain() { + let domain = erg_domain(); + let signature = generic_valid_signature_with_sign_bits(domain, 100, 10, false, false); + ClaimV2::from_signed(domain, 100, 10, signature).unwrap(); + + // Select deterministically a different reserve domain whose challenge + // is also in the canonical non-negative profile. The rejection below + // must therefore come from the Schnorr equation, not the profile gate. + let wrong_domain = (3u8..=u8::MAX) + .map(|marker| { + ClaimDomainV2::erg( + [marker; 32], + domain.tracker_nft_id(), + domain.owner_pubkey(), + domain.receiver_pubkey(), + ) + .unwrap() + }) + .find(|candidate| challenge_is_non_negative(&signature, *candidate, 100, 10)) + .expect("a deterministic canonical wrong-domain challenge"); + assert_eq!( + ClaimV2::from_signed(wrong_domain, 100, 10, signature,), + Err(BasisV2Error::InvalidSignature) + ); + } + + #[test] + fn signing_rejects_a_secret_for_another_owner() { + let domain = ClaimDomainV2::erg([1u8; 32], [2u8; 32], key(1).1, key(2).1).unwrap(); + assert_eq!( + ClaimV2::sign(domain, 100, 10, &key(3).0), + Err(BasisV2Error::OwnerSecretMismatch) + ); + } + + #[test] + fn wire_claim_rejects_a_generic_signature_with_negative_ergo_challenge() { + let domain = erg_domain(); + let signature = generic_valid_signature_with_sign_bits(domain, 100, 10, true, false); + assert_eq!( + ClaimV2::from_signed(domain, 100, 10, signature), + Err(BasisV2Error::NonCanonicalSignature) + ); + } + + #[test] + fn wire_claim_rejects_a_generic_signature_with_negative_ergo_response() { + let domain = erg_domain(); + let signature = generic_valid_signature_with_sign_bits(domain, 100, 10, false, true); + assert_eq!( + ClaimV2::from_signed(domain, 100, 10, signature), + Err(BasisV2Error::NonCanonicalSignature) + ); + } + + #[test] + fn contract_long_boundaries_fail_closed() { + let domain = erg_domain(); + assert_eq!( + domain.signing_message(0, 1), + Err(BasisV2Error::InvalidTotalDebt) + ); + assert_eq!( + domain.signing_message(BASIS_V2_MAX_LONG + 1, 1), + Err(BasisV2Error::InvalidTotalDebt) + ); + assert_eq!( + domain.signing_message(1, BASIS_V2_MAX_LONG + 1), + Err(BasisV2Error::InvalidTimestamp) + ); + assert_eq!( + domain.signing_message(1, 0), + Err(BasisV2Error::InvalidTimestamp) + ); + assert_eq!( + RedeemedStateV2::new(1, BASIS_V2_MAX_LONG + 1, 0), + Err(BasisV2Error::InvalidTotalDebt) + ); + assert_eq!( + RedeemedStateV2::new(BASIS_V2_MAX_LONG + 1, 1, 0), + Err(BasisV2Error::InvalidTimestamp) + ); + assert_eq!( + RedeemedStateV2::new(1, 1, BASIS_V2_MAX_LONG + 1), + Err(BasisV2Error::RedeemedExceedsDebt) + ); + } + + #[test] + fn redeemed_state_roundtrips_and_advances_monotonically() { + let state = RedeemedStateV2::new(10, 100, 20).unwrap(); + assert_eq!(RedeemedStateV2::decode(&state.encode()).unwrap(), state); + assert_eq!(state.advance(10, 100, 30).unwrap().redeemed(), 50); + assert_eq!(state.advance(11, 120, 30).unwrap().redeemed(), 50); + assert_eq!(state.advance(9, 100, 1), Err(BasisV2Error::ClaimRegression)); + assert_eq!(state.advance(11, 99, 1), Err(BasisV2Error::ClaimRegression)); + assert_eq!( + state.advance(10, 100, 81), + Err(BasisV2Error::RedemptionExceedsClaim) + ); + } + + #[test] + fn redeemed_state_rejects_wrong_shape_and_negative_longs() { + assert_eq!( + RedeemedStateV2::decode(&[0u8; 23]), + Err(BasisV2Error::InvalidStateLength) + ); + let mut negative = RedeemedStateV2::new(1, 1, 0).unwrap().encode(); + negative[0] = 0x80; + assert_eq!( + RedeemedStateV2::decode(&negative), + Err(BasisV2Error::NegativeStateValue) + ); + } +} diff --git a/crates/basis_core/src/lib.rs b/crates/basis_core/src/lib.rs index ede9b09..98082fb 100644 --- a/crates/basis_core/src/lib.rs +++ b/crates/basis_core/src/lib.rs @@ -2,6 +2,7 @@ //! Contains shared types, traits, and implementations for cryptography and AVL trees pub mod acceptance; +pub mod basis_v2; pub mod impls; pub mod traits; pub mod types; diff --git a/crates/basis_mcp/src/server.rs b/crates/basis_mcp/src/server.rs index a4ab641..1468114 100644 --- a/crates/basis_mcp/src/server.rs +++ b/crates/basis_mcp/src/server.rs @@ -386,23 +386,14 @@ impl BasisMcp { ) } - /// Redeem a note (local-signing path; the current account is the recipient). + /// Retired compatibility tombstone; returns before network or signing effects. #[tool(annotations(read_only_hint = false, destructive_hint = true))] async fn note_redeem( &self, Parameters(params): Parameters, ) -> Result { - let state = self.state.lock().await; - json_result( - commands::note::redeem_note( - &state.account_manager, - &state.client, - ¶ms.issuer, - params.amount, - false, - ) - .await, - ) + let _ = params; + json_result(commands::note::redeem_note()) } /// Build a reserve-creation payload (owner = current account) via the tracker. @@ -419,6 +410,7 @@ impl BasisMcp { params.nft_id, None, params.amount, + false, ) .await, ) diff --git a/crates/basis_offchain/src/lib.rs b/crates/basis_offchain/src/lib.rs index b3dd5b8..1291680 100644 --- a/crates/basis_offchain/src/lib.rs +++ b/crates/basis_offchain/src/lib.rs @@ -1,9 +1,14 @@ -//! Offchain logic for Basis tracker +//! Offchain logic for Basis tracker. +//! +//! The v1 transaction-builder and signing surfaces are removed. Production +//! construction resumes only through the exact versioned v2 manifest boundary. +//! +//! ```compile_fail +//! use basis_offchain::transaction_builder::RedemptionTransactionBuilder; +//! ``` pub mod ergo_tx; pub mod schnorr; -pub mod signing; -pub mod transaction_builder; #[cfg(test)] pub mod test_helpers; diff --git a/crates/basis_offchain/src/signing.rs b/crates/basis_offchain/src/signing.rs deleted file mode 100644 index 8fb6f45..0000000 --- a/crates/basis_offchain/src/signing.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Single-input transaction proving and redemption signing-message helpers. -//! -//! `Wallet::sign_transaction` proves *every* input and errors on any input it cannot prove, so it -//! cannot be used for the tracker-assisted 2-phase redemption where the tracker signs only the fee -//! input and the TUI signs only the reserve input. This module proves a *single* input over the -//! transaction's `bytes_to_sign` and splices the proof in, preserving every other input's proof. -//! -//! Neither caller reorders the reserve-input context extension: the tracker emits it in Scala -//! `ContextExtension` order, and both ends sign the identical `bytes_to_sign`. - -use blake2::{Blake2b, Digest}; -use generic_array::typenum::U32; -use thiserror::Error; - -use ergo_lib::chain::transaction::unsigned::UnsignedTransaction; -use ergo_lib::chain::transaction::{Input, Transaction}; -use ergo_lib::ergo_chain_types::{Header, PreHeader}; -use ergo_lib::ergotree_interpreter::sigma_protocol::private_input::PrivateInput; -use ergo_lib::ergotree_interpreter::sigma_protocol::prover::hint::HintsBag; -use ergo_lib::ergotree_interpreter::sigma_protocol::prover::{ProofBytes, Prover, ProverResult}; -use ergo_lib::ergotree_ir::chain::context::{Context, ContextExtensionProvider, TxIoVec}; -use ergo_lib::ergotree_ir::chain::context_extension::ContextExtension; -use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; -use ergo_lib::wallet::secret_key::SecretKey; - -#[derive(Error, Debug)] -pub enum SigningError { - #[error("input index {0} out of range")] - InputIndex(usize), - #[error("input box for index {0} not provided")] - MissingBox(usize), - #[error("bytes_to_sign failed: {0}")] - BytesToSign(String), - #[error("bounded vec: {0}")] - BoundedVec(String), - #[error("transaction build: {0}")] - TxBuild(String), - #[error("proving input {0} failed: {1}")] - Prove(usize, String), - #[error("not a dlog secret")] - NotDlogSecret, -} - -/// Redemption signing message: `blake2b256(issuer || receiver) || totalDebt(8 BE) || timestamp(8 BE)`. -pub fn redemption_signing_message( - issuer_pk: &[u8; 33], - receiver_pk: &[u8; 33], - total_debt: u64, - timestamp: u64, -) -> [u8; 48] { - let mut h = Blake2b::::new(); - h.update(issuer_pk); - h.update(receiver_pk); - let key = h.finalize(); - let mut msg = [0u8; 48]; - msg[..32].copy_from_slice(&key); - msg[32..40].copy_from_slice(&total_debt.to_be_bytes()); - msg[40..48].copy_from_slice(×tamp.to_be_bytes()); - msg -} - -/// Prove a single input of `unsigned_tx` with `secret` and return a signed `Transaction` whose -/// `input_idx` carries the produced proof while every other input keeps the proof it had in -/// `base_signed` (or `ProofBytes::Empty` when `base_signed` is `None`). -/// -/// `input_boxes` are the spent boxes in transaction-input order; `data_boxes` are the data-input -/// boxes. `pre_header`/`headers` come from the same `ErgoStateContext` the caller uses for the tx. -#[allow(clippy::too_many_arguments)] -pub fn add_input_proof( - unsigned_tx: &UnsignedTransaction, - base_signed: Option<&Transaction>, - input_boxes: &[ErgoBox], - data_boxes: &[ErgoBox], - pre_header: &PreHeader, - headers: &[Header; 10], - input_idx: usize, - secret: &SecretKey, -) -> Result { - let n = unsigned_tx.inputs.len(); - if input_idx >= n { - return Err(SigningError::InputIndex(input_idx)); - } - let target_box = input_boxes - .get(input_idx) - .ok_or(SigningError::MissingBox(input_idx))?; - if input_boxes.len() != n { - return Err(SigningError::MissingBox(n)); - } - - let bytes_to_sign = unsigned_tx - .bytes_to_sign() - .map_err(|e| SigningError::BytesToSign(format!("{:?}", e)))?; - - // Real tx id (proofs do not affect it) via a throwaway empty-proof transaction, used to - // materialize the output boxes the (reserve) contract reads from `OUTPUTS`. - let empty_inputs = build_inputs(unsigned_tx, base_signed, None, input_boxes.len())?; - let empty_tx = Transaction::new( - TxIoVec::try_from(empty_inputs) - .map_err(|e| SigningError::BoundedVec(format!("{:?}", e)))?, - unsigned_tx.data_inputs.clone(), - unsigned_tx.output_candidates.clone(), - ) - .map_err(|e| SigningError::TxBuild(format!("{:?}", e)))?; - let tx_id = empty_tx.id(); - - let output_boxes: Vec = unsigned_tx - .output_candidates - .iter() - .enumerate() - .map(|(i, c)| { - ErgoBox::from_box_candidate(c, tx_id, i as u16) - .map_err(|e| SigningError::TxBuild(format!("output {}: {:?}", i, e))) - }) - .collect::, _>>()?; - - let private_input: PrivateInput = match secret { - SecretKey::DlogSecretKey(d) => PrivateInput::DlogProverInput(d.clone()), - _ => return Err(SigningError::NotDlogSecret), - }; - let prover = ergo_lib::ergotree_interpreter::sigma_protocol::prover::TestProver { - secrets: vec![private_input], - }; - - // Build the Context for the target input (self_box = target box, full inputs/outputs/data). - let ctx = build_context( - input_idx, - target_box, - input_boxes, - &output_boxes, - data_boxes, - pre_header, - headers, - &unsigned_tx.inputs.get(input_idx).unwrap().extension, - )?; - - let proof = prover - .prove( - &target_box.ergo_tree, - &ctx, - &bytes_to_sign, - &HintsBag::empty(), - ) - .map_err(|e| SigningError::Prove(input_idx, format!("{:?}", e)))?; - - let signed_inputs = build_inputs( - unsigned_tx, - base_signed, - Some((input_idx, proof)), - input_boxes.len(), - )?; - Transaction::new( - TxIoVec::try_from(signed_inputs) - .map_err(|e| SigningError::BoundedVec(format!("{:?}", e)))?, - unsigned_tx.data_inputs.clone(), - unsigned_tx.output_candidates.clone(), - ) - .map_err(|e| SigningError::TxBuild(format!("{:?}", e))) -} - -fn build_inputs( - unsigned_tx: &UnsignedTransaction, - base_signed: Option<&Transaction>, - new_proof: Option<(usize, ProverResult)>, - count: usize, -) -> Result, SigningError> { - let mut inputs = Vec::with_capacity(count); - for i in 0..count { - let box_id = unsigned_tx.inputs.get(i).unwrap().box_id; - let ext = unsigned_tx.inputs.get(i).unwrap().extension.clone(); - let input = if let Some((idx, ref p)) = new_proof { - if i == idx { - // interpreter ProverResult -> transaction ProverResult (private type) via Into - Input::new(box_id, p.clone().into()) - } else if let Some(base) = base_signed.and_then(|b| b.inputs.get(i)) { - Input::new(box_id, base.spending_proof.clone()) - } else { - Input::new( - box_id, - ProverResult { - proof: ProofBytes::Empty, - extension: ext, - } - .into(), - ) - } - } else if let Some(base) = base_signed.and_then(|b| b.inputs.get(i)) { - Input::new(box_id, base.spending_proof.clone()) - } else { - Input::new( - box_id, - ProverResult { - proof: ProofBytes::Empty, - extension: ext, - } - .into(), - ) - }; - inputs.push(input); - } - Ok(inputs) -} - -/// No-op `ContextExtensionProvider`: the Basis reserve contract never reads other inputs' -/// context extensions (`getVarFromInput`), so returning `None` for every index is safe. -struct NoopExtensionProvider; - -impl ContextExtensionProvider for NoopExtensionProvider { - fn context_extension(&self, _input_index: usize) -> Option<&ContextExtension> { - None - } -} - -static NOOP_EXTENSION_PROVIDER: NoopExtensionProvider = NoopExtensionProvider; - -#[allow(clippy::too_many_arguments)] -fn build_context<'ctx>( - _input_idx: usize, - self_box: &'ctx ErgoBox, - input_boxes: &'ctx [ErgoBox], - output_boxes: &'ctx [ErgoBox], - data_boxes: &'ctx [ErgoBox], - pre_header: &PreHeader, - headers: &[Header; 10], - extension: &'ctx ContextExtension, -) -> Result, SigningError> { - let inputs: TxIoVec<&'ctx ErgoBox> = TxIoVec::try_from(input_boxes.iter().collect::>()) - .map_err(|e| SigningError::BoundedVec(format!("{:?}", e)))?; - let data_inputs: Option> = if data_boxes.is_empty() { - None - } else { - Some( - TxIoVec::try_from(data_boxes.iter().collect::>()) - .map_err(|e| SigningError::BoundedVec(format!("{:?}", e)))?, - ) - }; - Ok(Context { - height: pre_header.height, - self_box, - outputs: output_boxes, - data_inputs, - inputs, - pre_header: pre_header.clone(), - headers: headers.clone(), - extension, - tree_version: Default::default(), - extension_provider: &NOOP_EXTENSION_PROVIDER, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn redemption_signing_message_is_48_bytes() { - let issuer = [0x02u8; 33]; - let receiver = [0x03u8; 33]; - let msg = redemption_signing_message(&issuer, &receiver, 123, 456); - assert_eq!(msg.len(), 48); - assert_eq!(&msg[32..40], &123u64.to_be_bytes()); - assert_eq!(&msg[40..48], &456u64.to_be_bytes()); - } - - /// Cross-validation against the Scala reference implementation: test vector TV001 from - /// specs/SCHNORR_SIGNATURE_SPEC.md (message = blake2b256(owner||receiver) || totalDebt BE - /// || timestamp BE). - #[test] - fn redemption_signing_message_matches_scala_vector_tv001() { - let issuer: [u8; 33] = - hex::decode("0284bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0") - .unwrap() - .try_into() - .unwrap(); - let receiver: [u8; 33] = - hex::decode("02207bba70bc66309baa582a6ac120fd52d68026c51f6326f8ccedcbd2c1b7eb82") - .unwrap() - .try_into() - .unwrap(); - let msg = redemption_signing_message(&issuer, &receiver, 1_000_000_000, 1_743_379_200_000); - assert_eq!( - hex::encode(&msg), - "07b67390866bedf6c19b3fab1e29993ea6878e0d0dd0577ac6b6368c96a1220b000000003b9aca0000000195e97f7800" - ); - } -} diff --git a/crates/basis_offchain/src/transaction_builder.rs b/crates/basis_offchain/src/transaction_builder.rs deleted file mode 100644 index 6be3ef5..0000000 --- a/crates/basis_offchain/src/transaction_builder.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Transaction building for Basis redemption -//! -//! This module provides the foundation for building redemption transactions that interact with -//! the Basis reserve contract on the Ergo blockchain. The transaction builder prepares all -//! necessary components for redemption including: -//! -//! - Reserve box spending (input) -//! - Tracker box as data input (for AVL proof verification) -//! - Updated reserve box (output) -//! - Redemption output box (funds sent to recipient) -//! - Context extension with contract parameters -//! - Schnorr signatures (issuer and tracker) -//! - AVL tree proofs for debt verification -//! -//! When blockchain integration is complete, this will use ergo-lib to build actual transactions -//! that can be submitted to the Ergo network. - -use thiserror::Error; - -/// Public key type (Secp256k1) -pub type PubKey = [u8; 33]; - -#[derive(Error, Debug)] -pub enum TransactionBuilderError { - #[error("Transaction building error: {0}")] - TransactionBuilding(String), - #[error("Insufficient funds: {0}")] - InsufficientFunds(String), - #[error("Configuration error: {0}")] - Configuration(String), -} - -/// Context for transaction building containing blockchain and fee parameters -/// -/// This structure holds all the contextual information needed to build a valid -/// redemption transaction that can be accepted by the Ergo network. -#[derive(Debug, Clone)] -pub struct TxContext { - /// Current blockchain height (required for transaction validity) - pub current_height: u32, - /// Transaction fee in nanoERG (0.001 ERG = 1,000,000 nanoERG) - pub fee: u64, - /// Change address for any leftover funds after redemption - pub change_address: String, - /// Network prefix for Ergo address encoding - pub network_prefix: u8, -} - -impl Default for TxContext { - fn default() -> Self { - Self { - current_height: 0, - fee: 1000000, // 0.001 ERG - change_address: "".to_string(), - network_prefix: 0, // mainnet - } - } -} - -/// Complete redemption transaction data structure -/// -/// This structure contains all the components needed to build a redemption transaction -/// that follows the Basis contract specification. The transaction structure is: -/// -/// - Inputs: [Reserve box] (spent) -/// - Data Inputs: [Tracker box] (for AVL proof verification) -/// - Outputs: [Updated reserve box, Redemption output box, Change box (optional)] -/// - Context Extension: Contract parameters (action, signatures, proofs, amounts) -#[derive(Debug, Clone)] -pub struct RedemptionTransactionData { - /// Reserve box ID being spent (contains collateral backing the debt) - pub reserve_box_id: String, - /// Tracker box ID used as data input (contains AVL tree commitment) - pub tracker_box_id: String, - /// Amount being redeemed from the reserve (debt amount) - pub redemption_amount: u64, - /// Recipient address where redeemed funds are sent - pub recipient_address: String, - /// AVL proof bytes proving the debt exists in the tracker's state - pub avl_proof: Vec, - /// Issuer's 65-byte Schnorr signature authorizing the redemption - pub issuer_signature: Vec, - /// Tracker's 65-byte Schnorr signature validating the debt - pub tracker_signature: Vec, - /// Transaction fee in nanoERG - pub fee: u64, - /// Tracker NFT ID from R6 register (hex-encoded serialized SColl(SByte) format following byte_array_register_serialization.md spec) - pub tracker_nft_id: String, -} - -/// Builder for redemption transactions following the Basis contract specification -/// -/// This builder assembles all components needed for a redemption transaction: -/// - Validates redemption parameters (sufficient funds, time locks) -/// - Prepares transaction structure with proper inputs/outputs -/// - Ensures Schnorr signature compatibility (65-byte format) -/// - Estimates transaction size for fee calculation -pub struct RedemptionTransactionBuilder; - -impl RedemptionTransactionBuilder {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_transaction_context() { - let context = TxContext { - current_height: 1000, - fee: 2000000, // 0.002 ERG - change_address: "test_change_address".to_string(), - network_prefix: 16, // testnet - }; - - assert_eq!(context.current_height, 1000); - assert_eq!(context.fee, 2000000); - assert_eq!(context.network_prefix, 16); - - let default_context = TxContext::default(); - assert_eq!(default_context.fee, 1000000); - assert_eq!(default_context.network_prefix, 0); - } -} diff --git a/crates/basis_server/src/acceptance/builder.rs b/crates/basis_server/src/acceptance/builder.rs index 9c3cb7d..1f7ba51 100644 --- a/crates/basis_server/src/acceptance/builder.rs +++ b/crates/basis_server/src/acceptance/builder.rs @@ -546,6 +546,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 4000000000, + projected_issuer_gross_debt: None, reserve_tracker: None, }; assert!(pred.acceptable(&ctx)); @@ -560,6 +561,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: u64::MAX, + projected_issuer_gross_debt: None, reserve_tracker: None, }; assert!(pred.acceptable(&ctx2)); @@ -574,6 +576,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: None, reserve_tracker: None, }; assert!(!pred.acceptable(&ctx3)); @@ -624,6 +627,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker), }; @@ -675,6 +679,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker), }; @@ -727,6 +732,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker), }; @@ -779,6 +785,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker), }; @@ -831,6 +838,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 0, + projected_issuer_gross_debt: Some(0), reserve_tracker: Some(tracker), }; @@ -865,6 +873,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker), }; @@ -919,6 +928,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker.clone()), }; assert!(pred.acceptable(&ctx1)); @@ -933,6 +943,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker), }; assert!(!pred.acceptable(&ctx2)); @@ -983,6 +994,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: Some(100), reserve_tracker: Some(tracker), }; @@ -1050,6 +1062,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: None, reserve_tracker: None, }; assert!(pred.acceptable(&ctx)); diff --git a/crates/basis_server/src/acceptance/mod.rs b/crates/basis_server/src/acceptance/mod.rs index 9dca79b..e5ea52e 100644 --- a/crates/basis_server/src/acceptance/mod.rs +++ b/crates/basis_server/src/acceptance/mod.rs @@ -19,6 +19,9 @@ pub struct PredicateContext { pub recipient_pubkey: PubKey, /// Total cumulative debt amount in the note pub total_debt: u64, + /// Conservative issuer-wide gross debt after applying this candidate note. + /// `None` means the tracker snapshot was unavailable; collateral checks reject. + pub projected_issuer_gross_debt: Option, /// Optional cloned reserve tracker for collateralization checks pub reserve_tracker: Option, } @@ -29,16 +32,40 @@ impl std::fmt::Debug for PredicateContext { .field("issuer_pubkey", &hex::encode(&self.issuer_pubkey)) .field("recipient_pubkey", &hex::encode(&self.recipient_pubkey)) .field("total_debt", &self.total_debt) + .field( + "projected_issuer_gross_debt", + &self.projected_issuer_gross_debt, + ) .field("reserve_tracker", &self.reserve_tracker.is_some()) .finish() } } +fn collateral_inputs_available(ctx: &PredicateContext) -> bool { + if ctx.projected_issuer_gross_debt.is_none() { + return false; + } + + ctx.reserve_tracker + .as_ref() + .and_then(|tracker| { + tracker + .get_reserve_by_owner(&hex::encode(ctx.issuer_pubkey)) + .ok() + }) + .is_some() +} + /// Trait for note acceptance predicates pub trait NotePredicate: Send + Sync + std::fmt::Debug { /// Evaluate whether a note is acceptable given the context fn acceptable(&self, ctx: &PredicateContext) -> bool; + /// Whether this predicate tree needs the issuer-wide liability snapshot. + fn requires_liability_snapshot(&self) -> bool { + false + } + /// Get the predicate name fn name(&self) -> &str; } @@ -152,7 +179,10 @@ impl NotePredicate for CollateralizationPredicate { }; let assets = reserve.base_info.collateral_amount; - let liabilities = reserve.total_debt; + let liabilities = match ctx.projected_issuer_gross_debt { + Some(liabilities) => liabilities, + None => return false, + }; if liabilities == 0 { // No debt means fully collateralized (or no reserve needed) @@ -166,6 +196,10 @@ impl NotePredicate for CollateralizationPredicate { fn name(&self) -> &str { &self.name } + + fn requires_liability_snapshot(&self) -> bool { + true + } } /// No-pending-refund predicate - rejects if the issuer's reserve has a pending refund @@ -229,6 +263,12 @@ impl NotePredicate for AllOfPredicate { fn name(&self) -> &str { &self.name } + + fn requires_liability_snapshot(&self) -> bool { + self.predicates + .iter() + .any(|predicate| predicate.requires_liability_snapshot()) + } } /// Any-of (OR) composite predicate @@ -259,6 +299,12 @@ impl NotePredicate for AnyOfPredicate { fn name(&self) -> &str { &self.name } + + fn requires_liability_snapshot(&self) -> bool { + self.predicates + .iter() + .any(|predicate| predicate.requires_liability_snapshot()) + } } /// Not (negation) predicate @@ -280,12 +326,21 @@ impl NotPredicate { impl NotePredicate for NotPredicate { fn acceptable(&self, ctx: &PredicateContext) -> bool { + // Missing liability data is an evaluation failure, not a negative + // collateral result that negation may turn into acceptance. + if self.predicate.requires_liability_snapshot() && !collateral_inputs_available(ctx) { + return false; + } !self.predicate.acceptable(ctx) } fn name(&self) -> &str { &self.name } + + fn requires_liability_snapshot(&self) -> bool { + self.predicate.requires_liability_snapshot() + } } /// Default policy when no predicate matches @@ -321,6 +376,7 @@ mod tests { issuer_pubkey: test_pubkey(issuer_n), recipient_pubkey: test_pubkey(255), total_debt, + projected_issuer_gross_debt: None, reserve_tracker: None, } } @@ -443,6 +499,75 @@ mod tests { assert!(!pred.acceptable(&ctx)); } + #[test] + fn test_collateralization_rejects_candidate_debt_above_scanned_reserve() { + use basis_store::{ReserveInfo, ReserveTracker}; + + let issuer_pubkey = test_pubkey(1); + let tracker = ReserveTracker::new(); + tracker + .update_reserve(basis_store::reserve_tracker::ExtendedReserveInfo { + base_info: ReserveInfo { + collateral_amount: 100, + last_updated_height: 0, + contract_address: "test".to_string(), + tracker_nft_id: "test".to_string(), + refund_initiation_height: 0, + }, + // This is the value assigned by ExtendedReserveInfo::new and the scanner. + total_debt: 0, + box_id: "box1".to_string(), + owner_pubkey: hex::encode(issuer_pubkey), + last_updated_timestamp: 0, + }) + .unwrap(); + + let pred = CollateralizationPredicate::new("test", 1.0); + let ctx = PredicateContext { + issuer_pubkey, + recipient_pubkey: test_pubkey(2), + total_debt: 101, + projected_issuer_gross_debt: Some(101), + reserve_tracker: Some(tracker), + }; + + assert!(!pred.acceptable(&ctx)); + } + + #[test] + fn test_collateralization_rejects_unavailable_aggregate_snapshot() { + use basis_store::{ReserveInfo, ReserveTracker}; + + let issuer_pubkey = test_pubkey(1); + let tracker = ReserveTracker::new(); + tracker + .update_reserve(basis_store::reserve_tracker::ExtendedReserveInfo { + base_info: ReserveInfo { + collateral_amount: 100, + last_updated_height: 0, + contract_address: "test".to_string(), + tracker_nft_id: "test".to_string(), + refund_initiation_height: 0, + }, + total_debt: 0, + box_id: "box1".to_string(), + owner_pubkey: hex::encode(issuer_pubkey), + last_updated_timestamp: 0, + }) + .unwrap(); + + let pred = CollateralizationPredicate::new("test", 1.0); + let ctx = PredicateContext { + issuer_pubkey, + recipient_pubkey: test_pubkey(2), + total_debt: 50, + projected_issuer_gross_debt: None, + reserve_tracker: Some(tracker), + }; + + assert!(!pred.acceptable(&ctx)); + } + #[test] fn test_allof_empty_returns_true() { let pred = AllOfPredicate::new("test", vec![]); @@ -556,6 +681,63 @@ mod tests { assert!(pred.acceptable(&ctx)); } + #[test] + fn test_not_does_not_turn_missing_collateral_state_into_acceptance() { + let pred = NotPredicate::new( + "test", + Box::new(CollateralizationPredicate::new("collateral", 1.0)), + ); + let ctx = test_context(1, 100); + + assert!(!pred.acceptable(&ctx)); + } + + #[test] + fn test_not_does_not_turn_missing_reserve_into_acceptance() { + let pred = NotPredicate::new( + "test", + Box::new(CollateralizationPredicate::new("collateral", 1.0)), + ); + let mut ctx = test_context(1, 100); + ctx.projected_issuer_gross_debt = Some(100); + ctx.reserve_tracker = Some(basis_store::ReserveTracker::new()); + + assert!(!pred.acceptable(&ctx)); + } + + #[test] + fn test_liability_snapshot_requirement_propagates_through_composites() { + let whitelist = WhitelistPredicate::new("whitelist", HashSet::new()); + assert!(!whitelist.requires_liability_snapshot()); + + let collateral = CollateralizationPredicate::new("collateral", 1.0); + assert!(collateral.requires_liability_snapshot()); + + let all = AllOfPredicate::new( + "all", + vec![ + Box::new(WhitelistPredicate::new("whitelist", HashSet::new())), + Box::new(CollateralizationPredicate::new("collateral", 1.0)), + ], + ); + assert!(all.requires_liability_snapshot()); + + let any = AnyOfPredicate::new( + "any", + vec![ + Box::new(WhitelistPredicate::new("whitelist", HashSet::new())), + Box::new(CollateralizationPredicate::new("collateral", 1.0)), + ], + ); + assert!(any.requires_liability_snapshot()); + + let not = NotPredicate::new( + "not", + Box::new(CollateralizationPredicate::new("collateral", 1.0)), + ); + assert!(not.requires_liability_snapshot()); + } + #[test] fn test_default_policy_accept() { assert!(DefaultPolicy::Accept.acceptable()); @@ -618,6 +800,7 @@ mod tests { issuer_pubkey: test_pubkey(1), recipient_pubkey: test_pubkey(2), total_debt: 100, + projected_issuer_gross_debt: None, reserve_tracker: None, }; let cloned = ctx.clone(); @@ -695,6 +878,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: None, reserve_tracker: Some(tracker), }; @@ -736,6 +920,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: None, reserve_tracker: Some(tracker), }; @@ -754,6 +939,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: None, reserve_tracker: None, }; @@ -775,6 +961,7 @@ mod tests { }, recipient_pubkey: [0u8; 33], total_debt: 100, + projected_issuer_gross_debt: None, reserve_tracker: Some(tracker), }; diff --git a/crates/basis_server/src/api.rs b/crates/basis_server/src/api.rs index 135f3b1..8c79ddb 100644 --- a/crates/basis_server/src/api.rs +++ b/crates/basis_server/src/api.rs @@ -1,150 +1,17 @@ use axum::{extract::State, http::StatusCode, Json}; -use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; +use serde::Deserialize; use crate::{ models::{ - ApiResponse, Asset, CheckAcceptanceRequest, CheckAcceptanceResponse, - CompleteRedemptionRequest, CreateNoteRequest, CreateReserveRequest, KeyStatusResponse, - NoteConfirmationSummary, NoteStateRequest, NoteStateResponse, PendingTxResponse, - ProofResponse, RedeemRequest, RedeemResponse, RedemptionPreparationRequest, - RedemptionPreparationResponse, ReserveCreationResponse, ReservePaymentRequest, - SerializableIouNote, TrackerEvent, TrackerSignatureRequest, TrackerSignatureResponse, - TrackerStateResponse, UploadPolicyRequest, UploadPolicyResponse, + ApiResponse, Asset, CheckAcceptanceRequest, CheckAcceptanceResponse, CreateNoteRequest, + CreateReserveRequest, KeyStatusResponse, NoteConfirmationSummary, NoteStateRequest, + NoteStateResponse, PendingTxResponse, ReserveCreationResponse, ReservePaymentRequest, + SerializableIouNote, TrackerEvent, TrackerStateResponse, UploadPolicyRequest, + UploadPolicyResponse, }, AppState, TrackerCommand, }; -use basis_store::reqwest; use basis_store::{IouNote, NoteError, PubKey, Signature}; -use ergo_lib::ergotree_ir::chain::address::AddressEncoder; -use serde::{Deserialize, Serialize}; - -// Structs for the Schnorr signing API -#[derive(Serialize, Deserialize)] -struct SchnorrSignRequest { - address: String, - message: String, -} - -#[derive(Deserialize)] -struct SchnorrSignResponse { - #[serde(rename = "signedMessage")] - _signed_message: String, - signature: String, - #[serde(rename = "publicKey")] - _public_key: String, -} - -#[derive(Deserialize)] -struct ErrorResponse { - error: Option, -} - -#[derive(Deserialize)] -struct ErrorMessage { - code: String, - message: String, -} - -/// Call the Ergo node's schnorrSign API to generate a tracker signature -async fn call_schnorr_sign_api( - node_url: &str, - api_key: Option<&str>, - address: &str, - message: &str, -) -> Result { - let client = reqwest::Client::new(); - - let request_body = SchnorrSignRequest { - address: address.to_string(), - message: message.to_string(), - }; - - let url = format!("{}/utils/schnorrSign", node_url.trim_end_matches('/')); - let mut request_builder = client.post(&url); - - // Add API key if provided - if let Some(key) = api_key { - request_builder = request_builder.header("api_key", key); - } - - let response = request_builder - .json(&request_body) - .send() - .await - .map_err(|e| format!("Failed to send request: {}", e))?; - - let status = response.status(); - let response_text = response - .text() - .await - .map_err(|e| format!("Failed to read response: {}", e))?; - - if status.is_success() { - let sign_response: SchnorrSignResponse = serde_json::from_str(&response_text) - .map_err(|e| format!("Failed to parse response: {}", e))?; - - Ok(sign_response.signature) - } else { - // Try to parse error response - let error_response: Result = serde_json::from_str(&response_text); - let error_msg = match error_response { - Ok(err_resp) => { - if let Some(err) = err_resp.error { - format!("{}: {}", err.code, err.message) - } else { - response_text - } - } - Err(_) => response_text, - }; - Err(format!("API error {}: {}", status.as_u16(), error_msg)) - } -} - -/// Verify that a signature from the Ergo node is compatible with the Basis server's verification algorithm -/// This is needed because the Ergo node's Schnorr implementation has been found to be incompatible -/// with the Basis server's verification algorithm -async fn verify_ergo_node_signature_compatibility( - signature_hex: &str, - message_hex: &str, - public_key_bytes: &[u8; 33], -) -> Result<(), String> { - // Decode the signature and message - let signature_bytes = - hex::decode(signature_hex).map_err(|e| format!("Failed to decode signature: {}", e))?; - let message_bytes = - hex::decode(message_hex).map_err(|e| format!("Failed to decode message: {}", e))?; - - // Check signature length - if signature_bytes.len() != 65 { - return Err(format!( - "Signature is not 65 bytes: {}", - signature_bytes.len() - )); - } - - // Convert to fixed-size arrays - let mut signature_array = [0u8; 65]; - signature_array.copy_from_slice(&signature_bytes); - - // Try to verify using the same algorithm as basis_offchain - // This will help detect compatibility issues - match basis_offchain::schnorr::schnorr_verify( - &signature_array, - &message_bytes, - public_key_bytes, - ) { - Ok(()) => { - // Verification succeeded - Ok(()) - } - Err(_) => { - // Verification failed - this indicates the signature is not compatible - Err("Signature verification failed with Basis server algorithm".to_string()) - } - } -} // Basic handler that responds with a static string pub async fn root() -> &'static str { @@ -241,29 +108,15 @@ pub async fn create_note( signature, ); - // Send command to tracker thread - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - if let Err(e) = state - .tx - .send(crate::TrackerCommand::AddNote { + let tracker_response = + crate::tracker_request(&state.tx, |response_tx| crate::TrackerCommand::AddNote { issuer_pubkey, note, response_tx, }) - .await - { - tracing::error!("Failed to send to tracker thread: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } + .await; - // Wait for response from tracker thread - match response_rx.await { + match tracker_response { Ok(Ok(())) => { tracing::info!( "Successfully created note from {} to {}", @@ -306,9 +159,28 @@ pub async fn create_note( NoteError::AmountOverflow => "Amount overflow".to_string(), NoteError::FutureTimestamp => "Future timestamp".to_string(), NoteError::PastTimestamp => "Past timestamp".to_string(), + NoteError::DebtRegression => "Cumulative debt cannot decrease".to_string(), NoteError::RedemptionTooEarly => "Redemption too early".to_string(), NoteError::InsufficientCollateral => "Insufficient collateral".to_string(), + NoteError::MigrationRequired(msg) => format!("Migration required: {}", msg), + NoteError::GenerationMismatch(msg) => format!("Generation mismatch: {}", msg), + NoteError::GenerationBindingRequired(msg) => { + format!("Generation binding required: {}", msg) + } + NoteError::CapacityExceeded { limit } => { + format!("Tracker note capacity exceeded ({})", limit) + } NoteError::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } + NoteError::PublicationInProgress => { + "Tracker publication in progress; retry".to_string() + } + NoteError::PublicationLeaseMismatch => { + "Tracker publication lease mismatch".to_string() + } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -362,32 +234,17 @@ pub async fn get_notes_by_issuer( } }; - // Send command to tracker thread - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - tracing::debug!("Sending GetNotesByIssuer command to tracker thread"); - - if let Err(e) = state - .tx - .send(crate::TrackerCommand::GetNotesByIssuer { + let tracker_response = crate::tracker_request(&state.tx, |response_tx| { + crate::TrackerCommand::GetNotesByIssuer { issuer_pubkey, response_tx, - }) - .await - { - tracing::error!("Failed to send to tracker thread: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } + } + }) + .await; tracing::debug!("GetNotesByIssuer command sent successfully"); - - // Wait for response from tracker thread - match response_rx.await { + match tracker_response { Ok(Ok(notes)) => { tracing::info!( "Successfully retrieved {} notes for issuer {}", @@ -432,9 +289,28 @@ pub async fn get_notes_by_issuer( NoteError::AmountOverflow => "Amount overflow".to_string(), NoteError::FutureTimestamp => "Future timestamp".to_string(), NoteError::PastTimestamp => "Past timestamp".to_string(), + NoteError::DebtRegression => "Cumulative debt cannot decrease".to_string(), NoteError::RedemptionTooEarly => "Redemption too early".to_string(), NoteError::InsufficientCollateral => "Insufficient collateral".to_string(), + NoteError::MigrationRequired(msg) => format!("Migration required: {}", msg), + NoteError::GenerationMismatch(msg) => format!("Generation mismatch: {}", msg), + NoteError::GenerationBindingRequired(msg) => { + format!("Generation binding required: {}", msg) + } + NoteError::CapacityExceeded { limit } => { + format!("Tracker note capacity exceeded ({})", limit) + } NoteError::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } + NoteError::PublicationInProgress => { + "Tracker publication in progress; retry".to_string() + } + NoteError::PublicationLeaseMismatch => { + "Tracker publication lease mismatch".to_string() + } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -488,28 +364,15 @@ pub async fn get_notes_by_recipient( } }; - // Send command to tracker thread - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - if let Err(e) = state - .tx - .send(crate::TrackerCommand::GetNotesByRecipientWithIssuer { + let tracker_response = crate::tracker_request(&state.tx, |response_tx| { + crate::TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey, response_tx, - }) - .await - { - tracing::error!("Failed to send to tracker thread: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } + } + }) + .await; - // Wait for response from tracker thread - match response_rx.await { + match tracker_response { Ok(Ok(notes_with_issuer)) => { tracing::info!( "Successfully retrieved {} notes for recipient {}", @@ -544,9 +407,28 @@ pub async fn get_notes_by_recipient( NoteError::AmountOverflow => "Amount overflow".to_string(), NoteError::FutureTimestamp => "Future timestamp".to_string(), NoteError::PastTimestamp => "Past timestamp".to_string(), + NoteError::DebtRegression => "Cumulative debt cannot decrease".to_string(), NoteError::RedemptionTooEarly => "Redemption too early".to_string(), NoteError::InsufficientCollateral => "Insufficient collateral".to_string(), + NoteError::MigrationRequired(msg) => format!("Migration required: {}", msg), + NoteError::GenerationMismatch(msg) => format!("Generation mismatch: {}", msg), + NoteError::GenerationBindingRequired(msg) => { + format!("Generation binding required: {}", msg) + } + NoteError::CapacityExceeded { limit } => { + format!("Tracker note capacity exceeded ({})", limit) + } NoteError::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } + NoteError::PublicationInProgress => { + "Tracker publication in progress; retry".to_string() + } + NoteError::PublicationLeaseMismatch => { + "Tracker publication lease mismatch".to_string() + } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -631,28 +513,16 @@ pub async fn get_note_by_issuer_and_recipient( } }; - // Send command to tracker thread - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - if let Err(_) = state - .tx - .send(crate::TrackerCommand::GetNoteByIssuerAndRecipient { + let tracker_response = crate::tracker_request(&state.tx, |response_tx| { + crate::TrackerCommand::GetNoteByIssuerAndRecipient { issuer_pubkey, recipient_pubkey, response_tx, - }) - .await - { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } + } + }) + .await; - // Wait for response from tracker thread - match response_rx.await { + match tracker_response { Ok(Ok(Some(note))) => { tracing::info!( "Successfully retrieved note from {} to {}", @@ -693,9 +563,28 @@ pub async fn get_note_by_issuer_and_recipient( NoteError::AmountOverflow => "Amount overflow".to_string(), NoteError::FutureTimestamp => "Future timestamp".to_string(), NoteError::PastTimestamp => "Past timestamp".to_string(), + NoteError::DebtRegression => "Cumulative debt cannot decrease".to_string(), NoteError::RedemptionTooEarly => "Redemption too early".to_string(), NoteError::InsufficientCollateral => "Insufficient collateral".to_string(), + NoteError::MigrationRequired(msg) => format!("Migration required: {}", msg), + NoteError::GenerationMismatch(msg) => format!("Generation mismatch: {}", msg), + NoteError::GenerationBindingRequired(msg) => { + format!("Generation binding required: {}", msg) + } + NoteError::CapacityExceeded { limit } => { + format!("Tracker note capacity exceeded ({})", limit) + } NoteError::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } + NoteError::PublicationInProgress => { + "Tracker publication in progress; retry".to_string() + } + NoteError::PublicationLeaseMismatch => { + "Tracker publication lease mismatch".to_string() + } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -725,24 +614,12 @@ pub async fn get_all_notes( ) { tracing::debug!("Getting all notes"); - // Send command to tracker thread - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - if let Err(_) = state - .tx - .send(crate::TrackerCommand::GetNotes { response_tx }) - .await - { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } + let tracker_response = crate::tracker_request(&state.tx, |response_tx| { + crate::TrackerCommand::GetNotes { response_tx } + }) + .await; - // Wait for response from tracker thread - match response_rx.await { + match tracker_response { Ok(Ok(notes_with_issuer)) => { tracing::info!("Successfully retrieved {} notes", notes_with_issuer.len()); @@ -786,9 +663,28 @@ pub async fn get_all_notes( NoteError::AmountOverflow => "Amount overflow".to_string(), NoteError::FutureTimestamp => "Future timestamp".to_string(), NoteError::PastTimestamp => "Past timestamp".to_string(), + NoteError::DebtRegression => "Cumulative debt cannot decrease".to_string(), NoteError::RedemptionTooEarly => "Redemption too early".to_string(), NoteError::InsufficientCollateral => "Insufficient collateral".to_string(), + NoteError::MigrationRequired(msg) => format!("Migration required: {}", msg), + NoteError::GenerationMismatch(msg) => format!("Generation mismatch: {}", msg), + NoteError::GenerationBindingRequired(msg) => { + format!("Generation binding required: {}", msg) + } + NoteError::CapacityExceeded { limit } => { + format!("Tracker note capacity exceeded ({})", limit) + } NoteError::StorageError(msg) => format!("Storage error: {}", msg), + NoteError::StorageOutcomeUnknown(_) => { + "Storage outcome unknown; restart and reconcile".to_string() + } + NoteError::PublicationInProgress => { + "Tracker publication in progress; retry".to_string() + } + NoteError::PublicationLeaseMismatch => { + "Tracker publication lease mismatch".to_string() + } + NoteError::InvalidTransactionId => "Invalid transaction id".to_string(), NoteError::UnsupportedOperation => "Operation not supported".to_string(), }; ( @@ -808,6 +704,48 @@ pub async fn get_all_notes( } } +/// Load a serialized, conservative debt snapshot from the tracker thread. +/// +/// Returning `None` is deliberate fail-closed input for collateral predicates; +/// predicates that do not inspect collateral remain unaffected. +async fn load_projected_issuer_gross_debt( + state: &AppState, + issuer_pubkey: PubKey, + candidate_recipient: Option, + candidate_total_debt: u64, +) -> Option { + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + if let Err(e) = state + .tx + .try_send(TrackerCommand::GetProjectedIssuerGrossDebt { + issuer_pubkey, + candidate_recipient, + candidate_total_debt, + response_tx, + }) + { + tracing::error!("Failed to request projected issuer debt: {:?}", e); + return None; + } + + match tokio::time::timeout(std::time::Duration::from_secs(2), response_rx).await { + Ok(Ok(Ok(total))) => Some(total), + Ok(Ok(Err(e))) => { + tracing::error!("Failed to calculate projected issuer debt: {:?}", e); + None + } + Ok(Err(e)) => { + tracing::error!("Projected issuer debt response channel closed: {:?}", e); + None + } + Err(_) => { + tracing::error!("Timed out calculating projected issuer debt"); + None + } + } +} + /// Check if a note would be accepted by the server's acceptance policy /// /// First checks for a per-recipient policy in the database. If found, uses that policy. @@ -891,12 +829,25 @@ pub async fn check_acceptance( Ok(Some(predicate)) => { // Clone reserve tracker from mutex let reserve_tracker = state.reserve_tracker.lock().await.clone(); + let projected_issuer_gross_debt = + if predicate.requires_liability_snapshot() { + load_projected_issuer_gross_debt( + &state, + issuer_pubkey, + payload.recipient_pubkey.as_ref().map(|_| recipient_pubkey), + payload.total_debt, + ) + .await + } else { + None + }; // Build context let ctx = crate::acceptance::PredicateContext { issuer_pubkey, recipient_pubkey, total_debt: payload.total_debt, + projected_issuer_gross_debt, reserve_tracker: Some(reserve_tracker), }; @@ -950,12 +901,24 @@ pub async fn check_acceptance( if let Some(predicate) = &state.acceptance_predicate { // Clone reserve tracker from mutex let reserve_tracker = state.reserve_tracker.lock().await.clone(); + let projected_issuer_gross_debt = if predicate.requires_liability_snapshot() { + load_projected_issuer_gross_debt( + &state, + issuer_pubkey, + payload.recipient_pubkey.as_ref().map(|_| recipient_pubkey), + payload.total_debt, + ) + .await + } else { + None + }; // Build context let ctx = crate::acceptance::PredicateContext { issuer_pubkey, recipient_pubkey, total_debt: payload.total_debt, + projected_issuer_gross_debt, reserve_tracker: Some(reserve_tracker), }; @@ -987,10 +950,22 @@ pub async fn check_acceptance( // Fall back to global policy on error if let Some(predicate) = &state.acceptance_predicate { let reserve_tracker = state.reserve_tracker.lock().await.clone(); + let projected_issuer_gross_debt = if predicate.requires_liability_snapshot() { + load_projected_issuer_gross_debt( + &state, + issuer_pubkey, + payload.recipient_pubkey.as_ref().map(|_| recipient_pubkey), + payload.total_debt, + ) + .await + } else { + None + }; let ctx = crate::acceptance::PredicateContext { issuer_pubkey, recipient_pubkey, total_debt: payload.total_debt, + projected_issuer_gross_debt, reserve_tracker: Some(reserve_tracker), }; let acceptable = predicate.acceptable(&ctx); @@ -1280,20 +1255,22 @@ pub async fn get_policy_by_recipient( } } +#[derive(Debug, Deserialize)] +pub struct EventPageQuery { + page: Option, + page_size: Option, +} + // Get paginated tracker events from event store #[axum::debug_handler] pub async fn get_events_paginated( State(state): State, - axum::extract::Query(params): axum::extract::Query>, + axum::extract::Query(params): axum::extract::Query, ) -> (StatusCode, Json>>) { tracing::debug!("Getting paginated events: {:?}", params); - // Parse pagination parameters with defaults - let page = params.get("page").and_then(|p| p.parse().ok()).unwrap_or(0); - let page_size = params - .get("page_size") - .and_then(|ps| ps.parse().ok()) - .unwrap_or(20); + let page = params.page.unwrap_or(0); + let page_size = params.page_size.unwrap_or(20); // Get events from event store let events = match state @@ -1302,6 +1279,16 @@ pub async fn get_events_paginated( .await { Ok(events) => events, + Err( + e @ (crate::store::EventStoreError::InvalidPageSize + | crate::store::EventStoreError::PaginationOverflow), + ) => { + tracing::debug!("Rejected event pagination request: {e}"); + return ( + StatusCode::BAD_REQUEST, + Json(crate::models::error_response(e.to_string())), + ); + } Err(e) => { tracing::error!("Failed to retrieve events: {:?}", e); return ( @@ -1334,7 +1321,7 @@ pub async fn get_events( tracing::debug!("Getting recent events"); // Get recent events (last 50 events by default) - let events = match state.event_store.get_events_paginated(0, 50).await { + let events = match state.event_store.get_recent_events(50).await { Ok(events) => events, Err(e) => { tracing::error!("Failed to retrieve events: {:?}", e); @@ -1389,27 +1376,15 @@ pub async fn get_key_status( } }; - // Get total debt from note storage - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - if let Err(e) = state - .tx - .send(crate::TrackerCommand::GetNotesByIssuer { + let tracker_response = crate::tracker_request(&state.tx, |response_tx| { + crate::TrackerCommand::GetNotesByIssuer { issuer_pubkey, response_tx, - }) - .await - { - tracing::error!("Failed to send to tracker thread: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } + } + }) + .await; - let notes = match response_rx.await { + let notes = match tracker_response { Ok(Ok(notes)) => notes, Ok(Err(e)) => { tracing::error!("Failed to get notes: {:?}", e); @@ -1432,7 +1407,20 @@ pub async fn get_key_status( }; // Calculate total debt and note count - let total_debt: u64 = notes.iter().map(|note| note.outstanding_debt()).sum(); + let total_debt = match notes.iter().try_fold(0u64, |total, note| { + total.checked_add(note.outstanding_debt()) + }) { + Some(total) => total, + None => { + tracing::warn!("Issuer outstanding-debt aggregate overflow"); + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(crate::models::error_response( + "Outstanding debt aggregate exceeds the supported u64 range".to_string(), + )), + ); + } + }; let note_count = notes.len(); // Get collateral from reserve tracker @@ -1506,1832 +1494,72 @@ pub async fn get_key_status( ) } -// Initiate redemption process +/// Retired server-sign redemption endpoint. #[axum::debug_handler] -pub async fn initiate_redemption( - State(state): State, - Json(payload): Json, -) -> (StatusCode, Json>) { - tracing::debug!("Initiating redemption: {:?}", payload); - - // Convert recipient public key to P2PK address - let recipient_address = { - // Convert the public key to a P2PK address - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - // Decode the hex public key - let pubkey_bytes = match hex::decode(&payload.recipient_pubkey) { - Ok(bytes) => bytes, - Err(_) => { - // If hex decoding fails, abort redemption - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid hex encoding for recipient public key".to_string(), - )), - ); - } - }; - - // Create an EcPoint from the public key bytes - match EcPoint::sigma_parse_bytes(&pubkey_bytes) { - Ok(ec_point) => { - // Create a P2PK address from the public key - let prove_dlog = ProveDlog::from(ec_point); - let address = Address::P2Pk(prove_dlog); - // Use mainnet prefix by default, could be configurable - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); - encoder.address_to_str(&address) - } - Err(_) => { - // If conversion fails, abort redemption - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid public key format for recipient".to_string(), - )), - ); - } - } - }; - - // Find the reserve box ID and value for the issuer using normalized key matching - let (reserve_box_id, reserve_box_value, reserve_refund_initiation_height) = { - // Read reserves directly from database (not in-memory tracker) to avoid - // issues with scanner removing manually-inserted reserves - let scanner = state.ergo_scanner.lock().await; - let reserve_storage = scanner.reserve_storage(); +pub async fn initiate_redemption() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() +} - // Get all reserves from database - let all_reserves = match reserve_storage.get_all_reserves() { - Ok(reserves) => reserves, - Err(e) => { - tracing::error!("Failed to read reserves from database: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Failed to read reserves from database".to_string(), - )), - ); - } - }; +/// Legacy direct-completion endpoint. +/// +/// A caller-provided transaction id and accounting tuple are not evidence that +/// the expected reserve successor is confirmed on the active chain. Keep the +/// route as an explicit tombstone so older clients fail closed instead of +/// silently mutating tracker state. +#[axum::debug_handler] +pub async fn complete_redemption() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() +} - // Normalize the issuer public key - let normalized_issuer_key = basis_store::normalize_public_key(&payload.issuer_pubkey); - - // Find a reserve where the owner key matches (considering normalized forms) - let mut found_reserve: Option<(String, u64, u64)> = None; - for reserve in &all_reserves { - // Handle the case where the owner key might be double-encoded - // The database might store the hex string as ASCII characters, which are hex-encoded again - let actual_owner_key = { - // Try to decode the stored key as hex to get the original hex string - if let Ok(decoded_bytes) = hex::decode(&reserve.owner_pubkey) { - // If successful, try to interpret as ASCII string - if let Ok(decoded_string) = String::from_utf8(decoded_bytes) { - // Check if this looks like a valid hex string (all valid hex chars) - if decoded_string.chars().all(|c| c.is_ascii_hexdigit()) { - decoded_string - } else { - // If not a valid hex string, use the original - reserve.owner_pubkey.clone() - } - } else { - // If not valid UTF-8, use the original - reserve.owner_pubkey.clone() - } - } else { - // If hex decoding fails, use the original - reserve.owner_pubkey.clone() - } - }; +/// Retired v1 tracker-proof endpoint. +#[axum::debug_handler] +pub async fn get_tracker_proof() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() +} - let normalized_actual_key = basis_store::normalize_public_key(&actual_owner_key); - let original_reserve_key = &reserve.owner_pubkey; +/// Retired v1 reserve-proof endpoint. +#[axum::debug_handler] +pub async fn get_reserve_proof() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() +} - // Debug: Print the values being compared - tracing::debug!("Comparing keys - Issuer: {}, Normalized Issuer: {}, Actual Owner Key: {}, Normalized Actual: {}, Stored: {}", - payload.issuer_pubkey, normalized_issuer_key, actual_owner_key, normalized_actual_key, original_reserve_key); +/// Retired v1 tracker-signature endpoint. +#[axum::debug_handler] +pub async fn request_tracker_signature() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() +} - // Since we now strip the 0x07 prefix when reading from registers, - // we only need to match normalized keys (handles any remaining edge cases) - let matches = normalized_issuer_key == normalized_actual_key; +/// Retired v1 redemption-preparation endpoint. +#[axum::debug_handler] +pub async fn prepare_redemption() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() +} - if matches { - tracing::debug!( - "Key match found! Reserve box ID: {}, value: {}", - reserve.box_id, - reserve.base_info.collateral_amount - ); - found_reserve = Some(( - reserve.box_id.clone(), - reserve.base_info.collateral_amount, - reserve.base_info.refund_initiation_height, - )); - break; - } - } +/// Retired v1 aggregate redemption-proof endpoint. +#[axum::debug_handler] +pub async fn get_redemption_proof() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() +} - if found_reserve.is_none() { - tracing::warn!("No reserve found for issuer: {}", payload.issuer_pubkey); - tracing::debug!("Available reserves for debugging:"); - for reserve in &all_reserves { - tracing::debug!( - " Reserve box: {}, owner key: {}, value: {}", - reserve.box_id, - reserve.owner_pubkey, - reserve.base_info.collateral_amount - ); - } +// Get the latest tracker box ID from the tracker storage +#[axum::debug_handler] +pub async fn get_latest_tracker_box_id( + State(state): State, +) -> ( + StatusCode, + Json>, +) { + tracing::debug!("Getting latest tracker box ID"); - // Return a failed redemption response - let _response = crate::models::RedeemResponse { - redemption_id: "failed_no_matching_reserve".to_string(), - amount: payload.amount, - timestamp: payload.timestamp, - proof_available: false, - transaction_pending: false, - transaction_data: None, - transaction_bytes: None, - }; - - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response(format!( - "No matching reserve found for issuer: {}", - payload.issuer_pubkey - ))), - ); - } - - found_reserve.unwrap() - }; - - // Fetch blockchain data from Ergo node - let (tracker_box_id, tracker_nft_id, current_height) = { - // Get tracker_storage reference first (before any awaits) - let tracker_nft_id_config = state.config.ergo.tracker_nft_id.clone(); - let ergo_scanner_ref = state.ergo_scanner.clone(); - - // Get current blockchain height - let scanner_guard = ergo_scanner_ref.lock().await; - let current_height = match scanner_guard.get_current_height().await { - Ok(height) => height, - Err(e) => { - tracing::error!("Failed to get current blockchain height: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to get blockchain height: {}", - e - ))), - ); - } - }; - drop(scanner_guard); // Release lock early - - // Get tracker box ID from the live shared tracker state. The background - // updater refreshes this from the node every cycle, so it is always - // current; fall back to the confirmed snapshot if the primary id is empty. - let tracker_box_id = { - let shared = state.shared_tracker_state.lock().await; - shared - .get_tracker_box_id() - .or_else(|| shared.get_confirmed().box_id) - }; - let tracker_box_id = match tracker_box_id { - Some(box_id) => { - tracing::debug!("Found latest tracker box: {}", box_id); - box_id - } - None => { - tracing::error!("No tracker box found in live state - cannot initiate redemption"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "No tracker boxes found".to_string(), - )), - ); - } - }; - - // Get tracker NFT ID from configuration (R6 register value) - let tracker_nft_id = match tracker_nft_id_config { - Some(id) => id, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker NFT ID not configured".to_string(), - )), - ); - } - }; - - (tracker_box_id, tracker_nft_id, current_height) - }; - - // Get tracker signature for normal redemption (not needed for emergency) - let tracker_signature_hex = if !payload.emergency { - match get_tracker_signature_for_redemption( - &state, - &payload.issuer_pubkey, - &payload.recipient_pubkey, - payload.amount, - payload.timestamp, - payload.emergency, - ) - .await - { - Ok(sig) => Some(sig), - Err((status_code, error_resp)) => { - // Convert the error response to the correct type - return ( - status_code, - Json(crate::models::error_response(format!( - "Failed to get tracker signature: {:?}", - error_resp.0.error - ))), - ); - } - } - } else { - None // Emergency redemption doesn't require tracker signature - }; - - // Get change address from configuration - let change_address = state.config.get_change_address().unwrap_or_else(|e| { - tracing::warn!("Failed to get change address from config: {}", e); - // Fallback: derive from tracker public key directly - recipient_address.clone() // Use recipient address as fallback (not ideal but safe) - }); - - // Create redemption request with blockchain data - let redemption_request = basis_store::RedemptionRequest { - issuer_pubkey: payload.issuer_pubkey.clone(), - recipient_pubkey: payload.recipient_pubkey.clone(), - amount: payload.amount, - timestamp: payload.timestamp, - reserve_box_id: reserve_box_id.clone(), // Use the found reserve box ID - tracker_box_id, // Fetched from blockchain - tracker_nft_id, // From configuration (R6 register) - current_height, // Fetched from Ergo node - recipient_address: recipient_address.clone(), // Use derived address from public key - change_address, // From configuration or derived from tracker pubkey - issuer_signature: payload.issuer_signature.clone(), - emergency: payload.emergency, - tracker_signature: tracker_signature_hex, - reserve_box_value, // Actual reserve box value from blockchain - reserve_refund_initiation_height, // R7 refund height from blockchain - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - }; - - // Send command to tracker thread to initiate redemption - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - let cmd = TrackerCommand::InitiateRedemption { - request: redemption_request, - response_tx, - }; - - if let Err(e) = state.tx.send(cmd).await { - tracing::error!("Failed to send redemption command to tracker: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Failed to process redemption request".to_string(), - )), - ); - } - - // Wait for response from tracker thread - match response_rx.await { - Ok(Ok(redemption_data)) => { - // Get tracker NFT ID from configuration - let tracker_nft_id = match state.config.tracker_nft_bytes() { - Ok(bytes) => hex::encode(bytes), - Err(_) => { - tracing::error!("Tracker NFT ID is not properly configured"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker NFT ID is not properly configured".to_string(), - )), - ); - } - }; - - // Create transaction data that can be submitted to Ergo node - // Use the transaction data that was prepared by the redemption manager - let transaction_data = Some(crate::models::TransactionData { - address: recipient_address, // Use address derived from recipient public key - value: 100000, // Minimum ERG value for box (0.001 ERG) - registers: { - let mut regs = std::collections::HashMap::new(); - // R4: Issuer's public key (GroupElement) - from the redemption request - // R5: AVL proof for the note being redeemed (for reserve tree update) - regs.insert("R4".to_string(), payload.issuer_pubkey.clone()); // Issuer pubkey - regs.insert("R5".to_string(), hex::encode(&redemption_data.avl_proof)); // AVL proof - regs - }, - assets: vec![crate::models::TokenData { - token_id: tracker_nft_id, // Use configured tracker NFT ID - amount: 1, - }], - fee: redemption_data.estimated_fee, // Use actual estimated fee from redemption data - }); - - let response = RedeemResponse { - redemption_id: redemption_data.redemption_id, - amount: payload.amount, - timestamp: payload.timestamp, - proof_available: !redemption_data.avl_proof.is_empty(), - transaction_pending: true, - transaction_data, - transaction_bytes: Some(redemption_data.transaction_bytes), - }; - - tracing::info!( - "Redemption initiated successfully for {} -> {}: {}, transaction_data available", - payload.issuer_pubkey, - payload.recipient_pubkey, - response.redemption_id - ); - - ( - StatusCode::OK, - Json(crate::models::success_response(response)), - ) - } - Ok(Err(e)) => { - tracing::error!("Redemption failed: {:?}", e); - // Return a more specific error response based on the error type - let error_msg = format!("Redemption failed: {}", e); - let redemption_id = match e { - basis_store::RedemptionError::NoteNotFound => "failed_note_not_found".to_string(), - basis_store::RedemptionError::InvalidNoteSignature => { - "failed_invalid_signature".to_string() - } - basis_store::RedemptionError::InsufficientCollateral(_, _) => { - "failed_insufficient_collateral".to_string() - } - basis_store::RedemptionError::RedemptionTooEarly(_, _) => { - "failed_too_early".to_string() - } - basis_store::RedemptionError::StorageError(_) => "failed_storage_error".to_string(), - _ => "failed_other_error".to_string(), - }; - - // Return a response with more specific failure information - let _failure_response = RedeemResponse { - redemption_id, // Use specific failure ID - amount: payload.amount, - timestamp: payload.timestamp, - proof_available: false, - transaction_pending: false, - transaction_data: None, // No transaction data available on failure - transaction_bytes: None, - }; - - ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response(error_msg)), - ) - } - Err(_) => { - tracing::error!("Failed to receive redemption response from tracker"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Failed to process redemption request".to_string(), - )), - ) - } - } -} - -// Complete redemption process by removing the note from tracker state -#[axum::debug_handler] -pub async fn complete_redemption( - State(_state): State, - Json(payload): Json, -) -> (StatusCode, Json>) { - tracing::debug!("Completing redemption: {:?}", payload); - - // Parse public keys - let issuer_pubkey = match hex::decode(&payload.issuer_pubkey) { - Ok(bytes) => bytes, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid issuer_pubkey hex encoding".to_string(), - )), - ) - } - }; - - let recipient_pubkey = match hex::decode(&payload.recipient_pubkey) { - Ok(bytes) => bytes, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid recipient_pubkey hex encoding".to_string(), - )), - ) - } - }; - - let issuer_pubkey: PubKey = match issuer_pubkey.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes".to_string(), - )), - ) - } - }; - - let recipient_pubkey: PubKey = match recipient_pubkey.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes".to_string(), - )), - ) - } - }; - - // Send command to tracker thread to complete redemption - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - let cmd = TrackerCommand::CompleteRedemption { - issuer_pubkey, - recipient_pubkey, - redeemed_amount: payload.redeemed_amount, - new_already_redeemed: payload.new_already_redeemed, - response_tx, - }; - - if let Err(e) = _state.tx.send(cmd).await { - tracing::error!( - "Failed to send complete redemption command to tracker: {}", - e - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Failed to complete redemption".to_string(), - )), - ); - } - - // Wait for response from tracker thread - match response_rx.await { - Ok(Ok(())) => { - tracing::info!( - "Redemption completed successfully for {} -> {}", - payload.issuer_pubkey, - payload.recipient_pubkey - ); - - (StatusCode::OK, Json(crate::models::success_response(()))) - } - Ok(Err(e)) => { - tracing::error!("Redemption completion failed: {}", e); - ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response(format!( - "Redemption completion failed: {}", - e - ))), - ) - } - Err(_) => { - tracing::error!("Failed to receive redemption completion response from tracker"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Failed to complete redemption".to_string(), - )), - ) - } - } -} - -// Get tracker lookup proof for context var #8 -// Following specs/server/redemption_transaction_format_spec.md - GET /tracker/proof -#[axum::debug_handler] -pub async fn get_tracker_proof( - State(state): State, - axum::extract::Query(params): axum::extract::Query>, -) -> ( - StatusCode, - Json>, -) { - tracing::debug!("Getting tracker proof with params: {:?}", params); - - let empty_string = "".to_string(); - let issuer_pubkey = params.get("issuer_pubkey").unwrap_or(&empty_string); - let recipient_pubkey = params.get("recipient_pubkey").unwrap_or(&empty_string); - let _amount = params - .get("amount") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - - if issuer_pubkey.is_empty() || recipient_pubkey.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey and recipient_pubkey parameters are required".to_string(), - )), - ); - } - - // Validate hex encoding and length - let issuer_pubkey_bytes = match hex::decode(issuer_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - let recipient_pubkey_bytes = match hex::decode(recipient_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - // Convert to fixed-size arrays - let issuer_pubkey: basis_store::PubKey = match issuer_pubkey_bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes".to_string(), - )), - ); - } - }; - - let recipient_pubkey: basis_store::PubKey = match recipient_pubkey_bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes".to_string(), - )), - ); - } - }; - - // Get tracker state digest from shared state - let tracker_state_digest = { - let tracker_state = state.shared_tracker_state.lock().await; - hex::encode(&tracker_state.get_avl_root_digest()) - }; - - // Request tracker lookup proof from tracker thread - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - if let Err(e) = state - .tx - .send(TrackerCommand::GetTrackerLookupProof { - issuer_pubkey, - recipient_pubkey, - response_tx, - }) - .await - { - tracing::error!("Failed to send tracker proof command: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } - - // Wait for response from tracker thread - match response_rx.await { - Ok(Ok(proof)) => { - // Extract total debt from proof value - let total_debt = if proof.value.len() == 8 { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&proof.value); - u64::from_be_bytes(bytes) - } else { - 0u64 - }; - - let proof_data = crate::models::TrackerProofData { - key: hex::encode(&proof.key), - value: hex::encode(&proof.value), - proof: hex::encode(&proof.proof), - total_debt, - tracker_state_digest, - }; - - tracing::info!( - "Tracker proof generated for {} -> {} (total_debt: {})", - hex::encode(&issuer_pubkey), - hex::encode(&recipient_pubkey), - proof_data.total_debt - ); - - ( - StatusCode::OK, - Json(crate::models::success_response(proof_data)), - ) - } - Ok(Err(e)) => { - tracing::warn!("Failed to generate tracker proof: {:?}", e); - ( - StatusCode::NOT_FOUND, - Json(crate::models::error_response(format!( - "Debt record not found: {:?}", - e - ))), - ) - } - Err(_) => { - tracing::error!("Tracker thread response channel closed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Internal server error".to_string(), - )), - ) - } - } -} - -// Get reserve lookup proof for context var #7 -// Following specs/server/redemption_transaction_format_spec.md - GET /reserve/proof -#[axum::debug_handler] -pub async fn get_reserve_proof( - State(state): State, - axum::extract::Query(params): axum::extract::Query>, -) -> ( - StatusCode, - Json>, -) { - tracing::debug!("Getting reserve proof with params: {:?}", params); - - let empty_string = "".to_string(); - let issuer_pubkey = params.get("issuer_pubkey").unwrap_or(&empty_string); - let recipient_pubkey = params.get("recipient_pubkey").unwrap_or(&empty_string); - let amount = params - .get("amount") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - let timestamp_param = params.get("timestamp").and_then(|v| v.parse::().ok()); - - if issuer_pubkey.is_empty() || recipient_pubkey.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey and recipient_pubkey parameters are required".to_string(), - )), - ); - } - - // Validate hex encoding and length - let issuer_pubkey_bytes = match hex::decode(issuer_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - let recipient_pubkey_bytes = match hex::decode(recipient_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - // Convert to fixed-size arrays - let issuer_pubkey: basis_store::PubKey = match issuer_pubkey_bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes".to_string(), - )), - ); - } - }; - - let recipient_pubkey: basis_store::PubKey = match recipient_pubkey_bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes".to_string(), - )), - ); - } - }; - - // Request reserve lookup proof from tracker thread - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - if let Err(e) = state - .tx - .send(TrackerCommand::GetReserveLookupProof { - issuer_pubkey, - recipient_pubkey, - response_tx, - }) - .await - { - tracing::error!("Failed to send reserve proof command: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } - - // Wait for response from tracker thread - match response_rx.await { - Ok(Ok(proof)) => { - // The reserve tree stores timestamp || already_redeemed as a 16-byte big-endian value. - let already_redeemed = if proof.value.len() == 16 { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&proof.value[8..16]); - u64::from_be_bytes(bytes) - } else if proof.value.len() == 8 { - // Legacy fallback for old-format entries - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&proof.value); - u64::from_be_bytes(bytes) - } else { - 0u64 - }; - - // Calculate new_already_redeemed (current + requested redemption amount) - let new_already_redeemed = already_redeemed + amount; - - // Request reserve insert proof from tracker thread - let (insert_proof_tx, insert_proof_rx) = tokio::sync::oneshot::channel(); - let insert_proof = match state - .tx - .send(TrackerCommand::GetReserveInsertProof { - issuer_pubkey, - recipient_pubkey, - timestamp: timestamp_param.unwrap_or(0), - new_already_redeemed, - response_tx: insert_proof_tx, - }) - .await - { - Ok(_) => match insert_proof_rx.await { - Ok(Ok(proof_bytes)) => proof_bytes, - Ok(Err(e)) => { - tracing::warn!("Failed to generate reserve insert proof: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to generate reserve insert proof: {:?}", - e - ))), - ); - } - Err(_) => { - tracing::error!("Tracker thread response channel closed for insert proof"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } - }, - Err(e) => { - tracing::error!("Failed to send reserve insert proof command: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } - }; - - let proof_data = crate::models::ReserveProofData { - key: hex::encode(&proof.key), - value: hex::encode(&proof.value), - proof: proof.proof.clone().map(|p| hex::encode(p)), - already_redeemed, - is_first_redemption: proof.proof.is_none(), - insert_proof: hex::encode(&insert_proof.0), - new_reserve_state_digest: hex::encode(&insert_proof.1), - }; - - tracing::info!( - "Reserve proof generated for {} -> {} (already_redeemed: {}, is_first: {})", - hex::encode(&issuer_pubkey), - hex::encode(&recipient_pubkey), - proof_data.already_redeemed, - proof_data.is_first_redemption - ); - - ( - StatusCode::OK, - Json(crate::models::success_response(proof_data)), - ) - } - Ok(Err(e)) => { - tracing::warn!("Failed to generate reserve proof: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to generate reserve proof: {:?}", - e - ))), - ) - } - Err(_) => { - tracing::error!("Tracker thread response channel closed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Internal server error".to_string(), - )), - ) - } - } -} - -// Request tracker signature for redemption -// Following specs/server/redemption_state_spec.md - POST /tracker/signature -#[axum::debug_handler] -pub async fn request_tracker_signature( - State(state): State, - Json(payload): Json, -) -> (StatusCode, Json>) { - tracing::debug!("Requesting tracker signature for redemption: {:?}", payload); - - // Validate public keys - let issuer_pubkey_bytes = match hex::decode(&payload.issuer_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - let recipient_pubkey_bytes = match hex::decode(&payload.recipient_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - // Get tracker public key from configuration - let tracker_pubkey_bytes = match state.config.tracker_public_key_bytes() { - Ok(Some(key)) => key, - Ok(None) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker public key not configured".to_string(), - )), - ); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Invalid tracker public key format: {}", - e - ))), - ); - } - }; - - // Create message to be signed matching the deployed Basis reserve contract. - // message = key || longToByteArray(totalDebt) || longToByteArray(timestamp) (48 bytes) - // where key = blake2b256(ownerKeyBytes || receiverBytes) - // Both normal redemption and the reserve contract verify this exact message format. - let message_to_sign_bytes = basis_store::schnorr::signing_message( - &issuer_pubkey_bytes - .try_into() - .expect("issuer pubkey is 33 bytes"), - &recipient_pubkey_bytes - .try_into() - .expect("recipient pubkey is 33 bytes"), - payload.total_debt, - payload.timestamp, - ); - - let message_to_sign = hex::encode(&message_to_sign_bytes); - - // Try local signing first if tracker secret key is configured - let tracker_signature = if let Some(tracker_secret) = state.config.tracker_secret_key_bytes() { - tracing::info!("Signing tracker signature locally using configured secret key"); - - match basis_store::schnorr::schnorr_sign( - &message_to_sign_bytes, - &tracker_secret, - &tracker_pubkey_bytes, - ) { - Ok(signature) => { - let sig_hex = hex::encode(&signature); - tracing::info!("Local tracker signature generated successfully"); - sig_hex - } - Err(e) => { - tracing::error!("Failed to sign locally: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to sign locally: {:?}", - e - ))), - ); - } - } - } else { - // Fall back to Ergo node API - tracing::info!("No tracker secret key configured, using Ergo node API"); - - // Convert tracker public key to P2PK address format for the Ergo node API - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - let tracker_ec_point = match EcPoint::sigma_parse_bytes(&tracker_pubkey_bytes) { - Ok(point) => point, - Err(e) => { - tracing::error!("Failed to parse tracker public key as EcPoint: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to parse tracker public key: {}", - e - ))), - ); - } - }; - - let prove_dlog = ProveDlog::from(tracker_ec_point); - let tracker_address = Address::P2Pk(prove_dlog); - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); // Use appropriate network prefix - let tracker_p2pk_address = encoder.address_to_str(&tracker_address); - - // Get node URL and API key from configuration - let node_url = &state.config.ergo.node.node_url; - let api_key = state.config.ergo.node.api_key.as_deref(); - - // Call the Ergo node's schnorrSign API to generate the tracker signature - match call_schnorr_sign_api(node_url, api_key, &tracker_p2pk_address, &message_to_sign) - .await - { - Ok(signature) => signature, - Err(e) => { - tracing::error!( - "Failed to generate tracker signature via Ergo node API: {}", - e - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to generate tracker signature: {}", - e - ))), - ); - } - } - }; - - // Verify that the signature is compatible with our verification algorithm - if let Err(verification_error) = verify_ergo_node_signature_compatibility( - &tracker_signature, - &message_to_sign, - &tracker_pubkey_bytes, - ) - .await - { - tracing::warn!("Signature compatibility warning: {}", verification_error); - } - - let tracker_pubkey = hex::encode(&tracker_pubkey_bytes); - - let response = TrackerSignatureResponse { - success: true, - tracker_signature, - tracker_pubkey, - message_signed: message_to_sign, - is_emergency: if payload.emergency { Some(true) } else { None }, - }; - - tracing::info!( - "Tracker signature generated for redemption from {} to {} (emergency: {})", - payload.issuer_pubkey, - payload.recipient_pubkey, - payload.emergency - ); - - ( - StatusCode::OK, - Json(crate::models::success_response(response)), - ) -} - -/// Helper function to get tracker signature for redemption -/// Used by the redemption flow to include tracker signature in the request -/// -/// If tracker_secret_key is configured, signs locally. Otherwise, falls back to Ergo node API. -async fn get_tracker_signature_for_redemption( - state: &AppState, - issuer_pubkey: &str, - recipient_pubkey: &str, - total_debt: u64, - timestamp: u64, - _emergency: bool, -) -> Result>)> { - // Decode public keys - let issuer_pubkey_bytes = hex::decode(issuer_pubkey).map_err(|_| { - ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid issuer pubkey hex".to_string(), - )), - ) - })?; - - let recipient_pubkey_bytes = hex::decode(recipient_pubkey).map_err(|_| { - ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid recipient pubkey hex".to_string(), - )), - ) - })?; - - // Get tracker public key from configuration - let tracker_pubkey_bytes = state - .config - .tracker_public_key_bytes() - .ok() - .flatten() - .ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker public key not configured".to_string(), - )), - ) - })?; - - // Build signing message matching the deployed reserve contract: - // key || totalDebt || timestamp (48 bytes). The timestamp IS part of the message. - // Note: We use total_debt (cumulative debt) not the redemption amount for the message. - // This matches the contract expectation that the message covers the full debt state. - let issuer_pubkey_array: basis_store::PubKey = - issuer_pubkey_bytes.try_into().map_err(|_| { - ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid issuer pubkey length".to_string(), - )), - ) - })?; - let recipient_pubkey_array: basis_store::PubKey = - recipient_pubkey_bytes.try_into().map_err(|_| { - ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid recipient pubkey length".to_string(), - )), - ) - })?; - - let message_to_sign_bytes = basis_store::schnorr::signing_message( - &issuer_pubkey_array, - &recipient_pubkey_array, - total_debt, - timestamp, - ); - - // Check if we have a tracker secret key for local signing - if let Some(tracker_secret) = state.config.tracker_secret_key_bytes() { - tracing::info!("Signing tracker signature locally using configured secret key"); - - // Sign locally using our schnorr implementation - let signature = basis_store::schnorr::schnorr_sign( - &message_to_sign_bytes, - &tracker_secret, - &tracker_pubkey_bytes, - ) - .map_err(|e| { - tracing::error!("Failed to sign locally: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to sign locally: {:?}", - e - ))), - ) - })?; - - let signature_hex = hex::encode(&signature); - tracing::info!("Local tracker signature generated successfully"); - return Ok(signature_hex); - } - - // Fall back to Ergo node API if no local secret key is configured - tracing::info!("No tracker secret key configured, falling back to Ergo node API"); - - let message_to_sign = hex::encode(&message_to_sign_bytes); - - // Convert tracker public key to P2PK address - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - let tracker_ec_point = EcPoint::sigma_parse_bytes(&tracker_pubkey_bytes).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to parse tracker public key: {}", - e - ))), - ) - })?; - - let prove_dlog = ProveDlog::from(tracker_ec_point); - let tracker_address = Address::P2Pk(prove_dlog); - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); - let tracker_p2pk_address = encoder.address_to_str(&tracker_address); - - // Get node URL and API key from configuration - let node_url = &state.config.ergo.node.node_url; - let api_key = state.config.ergo.node.api_key.as_deref(); - - // Call the Ergo node's schnorrSign API - call_schnorr_sign_api(node_url, api_key, &tracker_p2pk_address, &message_to_sign) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to generate tracker signature: {}", - e - ))), - ) - }) -} - -// Prepare redemption with all necessary data -// Following specs/server/redemption_state_spec.md - POST /redemption/prepare -#[axum::debug_handler] -pub async fn prepare_redemption( - State(state): State, - Json(payload): Json, -) -> (StatusCode, Json>) { - tracing::debug!("Preparing redemption: {:?}", payload); - - // Validate public keys - if hex::decode(&payload.issuer_pubkey).is_err() - || hex::decode(&payload.recipient_pubkey).is_err() - { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid hex encoding for public keys".to_string(), - )), - ); - } - - // Get tracker public key from configuration - let tracker_pubkey_bytes = match state.config.tracker_public_key_bytes() { - Ok(Some(key)) => key, - Ok(None) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker public key not configured".to_string(), - )), - ); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Invalid tracker public key format: {}", - e - ))), - ); - } - }; - - // Generate a unique redemption ID - let redemption_id = format!( - "redemption_{}_{}_{}", - &payload.issuer_pubkey[..8], - &payload.recipient_pubkey[..8], - payload.timestamp - ); - - // Decode public keys for message generation - let issuer_pubkey_bytes = match hex::decode(&payload.issuer_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - let recipient_pubkey_bytes = match hex::decode(&payload.recipient_pubkey) { - Ok(bytes) if bytes.len() == 33 => bytes, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes hex-encoded".to_string(), - )), - ); - } - }; - - // Create message to be signed matching the deployed Basis reserve contract. - // message = key || longToByteArray(totalDebt) || longToByteArray(timestamp) (48 bytes) - // where key = blake2b256(ownerKeyBytes || receiverBytes) - // - // IMPORTANT: The message uses totalDebt (cumulative debt from tracker's AVL tree), - // NOT the redemption amount. This is required by the deployed reserve contract - // which verifies the signature against the full debt state, not the partial redemption amount. - let issuer_pubkey_array: basis_store::PubKey = match issuer_pubkey_bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid issuer pubkey length".to_string(), - )), - ); - } - }; - let recipient_pubkey_array: basis_store::PubKey = match recipient_pubkey_bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid recipient pubkey length".to_string(), - )), - ); - } - }; - - // Fetch total_debt from tracker storage (cumulative debt, not redemption amount) - let (note_response_tx, note_response_rx) = tokio::sync::oneshot::channel(); - if let Err(e) = state - .tx - .send(TrackerCommand::GetNoteByIssuerAndRecipient { - issuer_pubkey: issuer_pubkey_array, - recipient_pubkey: recipient_pubkey_array, - response_tx: note_response_tx, - }) - .await - { - tracing::error!( - "Failed to send note lookup command to tracker thread: {:?}", - e - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } - - let total_debt = match note_response_rx.await { - Ok(Ok(Some(note))) => note.amount_collected, - Ok(Ok(None)) => payload.amount, - Ok(Err(e)) => { - tracing::error!("Failed to look up note for total_debt: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to look up note for total debt: {:?}", - e - ))), - ); - } - Err(_) => { - tracing::error!("Tracker thread response channel closed for total_debt lookup"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Internal server error during total debt lookup".to_string(), - )), - ); - } - }; - - let message_to_sign_bytes = basis_store::schnorr::signing_message( - &issuer_pubkey_array, - &recipient_pubkey_array, - total_debt, - payload.timestamp, - ); - - let message_to_sign = hex::encode(&message_to_sign_bytes); - - // Convert tracker public key to P2PK address format for the Ergo node API - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - let tracker_ec_point = match EcPoint::sigma_parse_bytes(&tracker_pubkey_bytes) { - Ok(point) => point, - Err(e) => { - tracing::error!("Failed to parse tracker public key as EcPoint: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to parse tracker public key: {}", - e - ))), - ); - } - }; - - let prove_dlog = ProveDlog::from(tracker_ec_point); - let tracker_address = Address::P2Pk(prove_dlog); - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); // Use appropriate network prefix - let tracker_p2pk_address = encoder.address_to_str(&tracker_address); - - // Get node URL and API key from configuration - let node_url = &state.config.ergo.node.node_url; - let api_key = state.config.ergo.node.api_key.as_deref(); - - // Call the Ergo node's schnorrSign API to generate the tracker signature - let tracker_signature = - match call_schnorr_sign_api(node_url, api_key, &tracker_p2pk_address, &message_to_sign) - .await - { - Ok(signature) => signature, - Err(e) => { - tracing::error!( - "Failed to generate tracker signature via Ergo node API: {}", - e - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to generate tracker signature: {}", - e - ))), - ); - } - }; - - // Verify that the signature from the Ergo node is compatible with our verification algorithm - // Due to compatibility issues discovered between Ergo node and Basis server Schnorr implementations - // CRITICAL: If the Ergo node signature is incompatible, we REJECT it and require local signing. - // The Ergo node's /utils/schnorrSign API generates signatures that are NOT compatible with - // the Basis contract verification (basis.es). Only locally-generated signatures with proper - // z.bitLength <= 255 constraint will verify correctly on-chain. - if let Err(verification_error) = verify_ergo_node_signature_compatibility( - &tracker_signature, - &message_to_sign, - &tracker_pubkey_bytes, - ) - .await - { - tracing::error!( - "Ergo node signature is INCOMPATIBLE with Basis verification: {}. Rejecting signature.", - verification_error - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - format!("Tracker signature incompatible with Basis contract: {}. Please configure tracker_secret_key for local signing.", verification_error), - )), - ); - } - - // Get the current tracker state digest from shared tracker state - let tracker_state_digest = { - // Get the current AVL root digest from shared tracker state - let shared_state = state.shared_tracker_state.lock().await; - let current_digest = shared_state.get_avl_root_digest(); - drop(shared_state); // Release the lock early - hex::encode(¤t_digest) - }; - - // Generate a real AVL proof for the note - // Send command to tracker thread to generate the proof - let (proof_response_tx, proof_response_rx) = tokio::sync::oneshot::channel(); - - let issuer_pubkey_bytes = match hex::decode(&payload.issuer_pubkey) { - Ok(bytes) => match bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes".to_string(), - )), - ); - } - }, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid hex encoding for issuer public key".to_string(), - )), - ); - } - }; - - let recipient_pubkey_bytes = match hex::decode(&payload.recipient_pubkey) { - Ok(bytes) => match bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes".to_string(), - )), - ); - } - }, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid hex encoding for recipient public key".to_string(), - )), - ); - } - }; - - if let Err(e) = state - .tx - .send(TrackerCommand::GenerateProof { - issuer_pubkey: issuer_pubkey_bytes, - recipient_pubkey: recipient_pubkey_bytes, - response_tx: proof_response_tx, - }) - .await - { - tracing::error!( - "Failed to send proof generation command to tracker thread: {:?}", - e - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } - - // Wait for response from tracker thread - let proof_result = match proof_response_rx.await { - Ok(Ok(note_proof)) => { - // Convert the proof to a hex string for transmission - hex::encode(¬e_proof.avl_proof) - } - Ok(Err(e)) => { - tracing::error!("Failed to generate proof: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to generate proof: {:?}", - e - ))), - ); - } - Err(_) => { - tracing::error!("Tracker thread response channel closed"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Internal server error".to_string(), - )), - ); - } - }; - - let avl_proof = proof_result; - let tracker_pubkey = hex::encode(&tracker_pubkey_bytes); - - // Get current blockchain height from scanner - let block_height = { - let scanner_guard = state.ergo_scanner.lock().await; - match scanner_guard.get_current_height().await { - Ok(height) => height, - Err(e) => { - tracing::error!("Failed to get current blockchain height: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to get blockchain height: {}", - e - ))), - ); - } - } - }; - - let response = RedemptionPreparationResponse { - redemption_id, - avl_proof, - tracker_signature, - tracker_pubkey, - tracker_state_digest, - block_height, - }; - - tracing::info!( - "Redemption prepared for {} -> {} with ID {}", - payload.issuer_pubkey, - payload.recipient_pubkey, - response.redemption_id - ); - - ( - StatusCode::OK, - Json(crate::models::success_response(response)), - ) -} - -// Enhanced proof endpoint specifically for redemption -#[axum::debug_handler] -pub async fn get_redemption_proof( - State(state): State, - axum::extract::Query(params): axum::extract::Query>, -) -> (StatusCode, Json>) { - tracing::debug!("Getting redemption proof with params: {:?}", params); - - let empty_string = "".to_string(); - let issuer_pubkey = params.get("issuer_pubkey").unwrap_or(&empty_string); - let recipient_pubkey = params.get("recipient_pubkey").unwrap_or(&empty_string); - let _amount = params - .get("amount") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - let amount = params.get("amount").unwrap_or(&empty_string); - - if issuer_pubkey.is_empty() || recipient_pubkey.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey and recipient_pubkey parameters are required".to_string(), - )), - ); - } - - // Validate hex encoding - if hex::decode(issuer_pubkey).is_err() || hex::decode(recipient_pubkey).is_err() { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid hex encoding for public keys".to_string(), - )), - ); - } - - // Validate amount if provided - if !amount.is_empty() { - if amount.parse::().is_err() { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid amount parameter".to_string(), - )), - ); - } - } - - // Get the current tracker state digest from shared tracker state - let tracker_state_digest = { - // Get the current AVL root digest from shared tracker state - let shared_state = state.shared_tracker_state.lock().await; - let current_digest = shared_state.get_avl_root_digest(); - drop(shared_state); // Release the lock early - hex::encode(¤t_digest) - }; - - // Generate a real AVL proof for the note - // Send command to tracker thread to generate the proof - let (proof_response_tx, proof_response_rx) = tokio::sync::oneshot::channel(); - - let issuer_pubkey_bytes = match hex::decode(issuer_pubkey) { - Ok(bytes) => match bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "issuer_pubkey must be 33 bytes".to_string(), - )), - ) - } - }, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid hex encoding for issuer public key".to_string(), - )), - ) - } - }; - - let recipient_pubkey_bytes = match hex::decode(recipient_pubkey) { - Ok(bytes) => match bytes.try_into() { - Ok(arr) => arr, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "recipient_pubkey must be 33 bytes".to_string(), - )), - ) - } - }, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(crate::models::error_response( - "Invalid hex encoding for recipient public key".to_string(), - )), - ) - } - }; - - if let Err(e) = state - .tx - .send(TrackerCommand::GenerateProof { - issuer_pubkey: issuer_pubkey_bytes, - recipient_pubkey: recipient_pubkey_bytes, - response_tx: proof_response_tx, - }) - .await - { - tracing::error!( - "Failed to send proof generation command to tracker thread: {:?}", - e - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Tracker thread unavailable".to_string(), - )), - ); - } - - // Wait for response from tracker thread - let proof_result = match proof_response_rx.await { - Ok(Ok(note_proof)) => { - // Convert the proof to a hex string for transmission - hex::encode(¬e_proof.avl_proof) - } - Ok(Err(e)) => { - tracing::error!("Failed to generate proof: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to generate proof: {:?}", - e - ))), - ); - } - Err(_) => { - tracing::error!("Tracker thread response channel closed"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Internal server error".to_string(), - )), - ); - } - }; - - // Get current blockchain height from scanner - let block_height = { - let scanner_guard = state.ergo_scanner.lock().await; - match scanner_guard.get_current_height().await { - Ok(height) => height, - Err(e) => { - tracing::error!("Failed to get current blockchain height: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response(format!( - "Failed to get blockchain height: {}", - e - ))), - ); - } - } - }; - - // Get current timestamp in milliseconds (Java time format) - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - - let proof = ProofResponse { - issuer_pubkey: issuer_pubkey.clone(), - recipient_pubkey: recipient_pubkey.clone(), - proof_data: proof_result, - tracker_state_digest, - block_height, - timestamp, - }; - - tracing::info!( - "Redemption proof generated for {} -> {} with amount {}", - issuer_pubkey, - recipient_pubkey, - amount - ); - - (StatusCode::OK, Json(crate::models::success_response(proof))) -} - -// Get the latest tracker box ID from the tracker storage -#[axum::debug_handler] -pub async fn get_latest_tracker_box_id( - State(state): State, -) -> ( - StatusCode, - Json>, -) { - tracing::debug!("Getting latest tracker box ID"); - - // Use the live shared tracker state maintained by the background updater. - // The updater queries the node every cycle and records the confirmed - // on-chain tracker box, so this is always current. Falling back to the - // confirmed snapshot handles the brief window before the first updater run. - let shared = state.shared_tracker_state.lock().await; - let tracker_box_id = shared - .get_tracker_box_id() - .or_else(|| shared.get_confirmed().box_id); - drop(shared); + // Use the live shared tracker state maintained by the background updater. + // The updater queries the node every cycle and records the confirmed + // on-chain tracker box, so this is always current. Falling back to the + // confirmed snapshot handles the brief window before the first updater run. + let shared = state.shared_tracker_state.lock().await; + let tracker_box_id = shared + .get_tracker_box_id() + .or_else(|| shared.get_confirmed().box_id); + drop(shared); match tracker_box_id { Some(tracker_box_id) => { @@ -3420,21 +1648,18 @@ pub async fn create_reserve_payload( ); } - // Get the hardcoded reserve contract P2S address from configuration - let config = match crate::config::AppConfig::load() { - Ok(config) => config, - Err(e) => { - tracing::error!("Failed to load configuration: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Failed to load server configuration".to_string(), - )), - ); - } - }; + // Use the exact configuration installed in this running server. Reloading a + // second file/env view here could validate one P2S and build against another. + let config = state.config.clone(); + + if let Err(e) = config.reject_unsupported_reserve_builder() { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(crate::models::error_response(e)), + ); + } - let reserve_contract_address = config.ergo.basis_reserve_contract_p2s; + let reserve_contract_address = config.ergo.basis_reserve_contract_p2s.clone(); // Build properly serialized register values following Ergo constant format // R4: GroupElement (owner pubkey) - prefix 07 + 33-byte compressed pubkey @@ -3527,146 +1752,39 @@ pub async fn create_reserve_payload( ) } -// Request format expected by Ergo node's /wallet/payment/send endpoint -#[derive(Debug, Serialize)] -struct ErgoPaymentRequest { - address: String, - value: u64, - assets: Vec, - registers: std::collections::HashMap, -} - -#[derive(Debug, Serialize)] -struct ErgoAsset { - #[serde(rename = "tokenId")] - token_id: String, - amount: u64, -} - -// Submit a reserve creation payload to the tracker's configured Ergo node via -// /wallet/payment/send. This requires the node's wallet to hold the reserve NFT -// and sufficient ERG for the reserve value and fee. +/// Retired node-wallet proxy. +/// +/// Reserve creation payloads are returned by `/reserves/create` for review and +/// signing by the reserve owner's wallet. The tracker must never convert an +/// unauthenticated HTTP request into authority over its configured node wallet. #[axum::debug_handler] pub async fn submit_reserve_transaction( - State(state): State, - Json(payload): Json, -) -> ( - StatusCode, - Json>, -) { - let node_config = &state.config.ergo.node; - if node_config.node_url.is_empty() { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(crate::models::error_response( - "No Ergo node configured on the tracker".to_string(), - )), - ); - } - - let payment_requests: Vec = payload - .requests - .into_iter() - .map(|req| ErgoPaymentRequest { - address: req.address, - value: req.value, - assets: req - .assets - .into_iter() - .map(|a| ErgoAsset { - token_id: a.token_id, - amount: a.amount, - }) - .collect(), - registers: req.registers, - }) - .collect(); - - let url = format!( - "{}/wallet/payment/send", - node_config.node_url.trim_end_matches('/') - ); - let client = reqwest::Client::new(); - let mut request = client.post(&url).json(&payment_requests); - if let Some(ref api_key) = node_config.api_key { - request = request.header("api_key", api_key); - } - - match request.send().await { - Ok(response) => { - let status = response.status(); - match response.text().await { - Ok(body) => { - if status.is_success() { - // The Ergo node returns the transaction id as a quoted string. - let tx_id = body.trim().trim_matches('"').to_string(); - tracing::info!("Reserve creation transaction submitted: {}", tx_id); - ( - StatusCode::OK, - Json(crate::models::success_response( - crate::models::ReserveSubmissionResponse { tx_id }, - )), - ) - } else { - tracing::error!( - "Ergo node /wallet/payment/send returned {}: {}", - status, - body - ); - ( - StatusCode::BAD_GATEWAY, - Json(crate::models::error_response(format!( - "Ergo node returned {}: {}", - status, body - ))), - ) - } - } - Err(e) => { - tracing::error!("Failed to read Ergo node response: {}", e); - ( - StatusCode::BAD_GATEWAY, - Json(crate::models::error_response(format!( - "Failed to read Ergo node response: {}", - e - ))), - ) - } - } - } - Err(e) => { - tracing::error!("Failed to contact Ergo node at {}: {}", url, e); - ( - StatusCode::BAD_GATEWAY, - Json(crate::models::error_response(format!( - "Failed to contact Ergo node: {}", - e - ))), - ) - } - } + Json(_payload): Json, +) -> (StatusCode, Json>) { + ( + StatusCode::GONE, + Json(crate::models::error_response( + "Tracker-side reserve submission is retired; sign and submit the generated payload with the reserve owner's wallet" + .to_string(), + )), + ) } // Get the Basis reserve contract P2S address from server configuration #[axum::debug_handler] pub async fn get_basis_reserve_contract_p2s( - State(_state): State, + State(state): State, ) -> (StatusCode, Json>) { tracing::debug!("Getting Basis reserve contract P2S address from configuration"); - // Get the reserve contract address from the server configuration - let config = match crate::config::AppConfig::load() { - Ok(config) => config, - Err(e) => { - tracing::error!("Failed to load configuration: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(crate::models::error_response( - "Failed to load server configuration".to_string(), - )), - ); - } - }; + let config = state.config.clone(); + + if let Err(e) = config.reject_unsupported_reserve_builder() { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(crate::models::error_response(e)), + ); + } let reserve_contract_address = config.basis_reserve_contract_p2s(); @@ -3683,6 +1801,23 @@ pub async fn get_basis_reserve_contract_p2s( ) } +#[cfg(test)] +mod security_boundary_tests { + use super::*; + + #[tokio::test] + async fn reserve_wallet_proxy_is_a_gone_tombstone() { + let payload = ReserveCreationResponse { + requests: Vec::new(), + fee: 1_000_000, + change_address: "not-forwarded".to_string(), + }; + + let (status, _) = submit_reserve_transaction(Json(payload)).await; + assert_eq!(status, StatusCode::GONE); + } +} + /// Get the current tracker state: local digest, confirmed on-chain digest, and /// any in-flight pending update transaction. #[axum::debug_handler] @@ -3691,14 +1826,47 @@ pub async fn get_tracker_state( ) -> (StatusCode, Json>) { tracing::debug!("Getting tracker state"); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if state + .tx + .send(TrackerCommand::GetValidatedState { response_tx }) + .await + .is_err() + { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(crate::models::error_response( + "Tracker state actor unavailable".to_string(), + )), + ); + } + let local_state = match response_rx.await { + Ok(Ok(local_state)) => local_state, + Ok(Err(error)) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(crate::models::error_response(format!( + "Tracker state is unavailable: {error:?}" + ))), + ) + } + Err(_) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(crate::models::error_response( + "Tracker state actor stopped".to_string(), + )), + ) + } + }; + let shared = state.shared_tracker_state.lock().await; - let local_digest = shared.get_avl_root_digest(); let confirmed = shared.get_confirmed(); let pending = shared.get_pending(); let tracker_box_id = shared.get_tracker_box_id(); let response = TrackerStateResponse { - local_digest: hex::encode(local_digest), + local_digest: hex::encode(local_state.avl_root_digest), confirmed_digest: confirmed.digest.map(hex::encode), confirmed_box_id: confirmed.box_id, confirmed_height: confirmed.height, @@ -3775,22 +1943,16 @@ pub async fn get_note_state( } }; - // Fetch the note. - let (note_tx, note_rx) = tokio::sync::oneshot::channel(); - if let Err(e) = state - .tx - .send(TrackerCommand::GetNoteByIssuerAndRecipient { + let note_response = crate::tracker_request(&state.tx, |response_tx| { + TrackerCommand::GetNoteByIssuerAndRecipient { issuer_pubkey, recipient_pubkey, - response_tx: note_tx, - }) - .await - { - tracing::error!("Failed to send to tracker thread: {:?}", e); - return internal_error("Tracker thread unavailable"); - } + response_tx, + } + }) + .await; - let note = match note_rx.await { + let note = match note_response { Ok(Ok(Some(n))) => n, Ok(Ok(None)) => { return ( @@ -3816,22 +1978,15 @@ pub async fn get_note_state( Err(_) => return internal_error("Tracker thread response channel closed"), }; - // Fetch the confirmation record. - let (conf_tx, conf_rx) = tokio::sync::oneshot::channel(); - if let Err(e) = state - .tx - .send(TrackerCommand::GetConfirmation { + let confirmation_response = + crate::tracker_request(&state.tx, |response_tx| TrackerCommand::GetConfirmation { issuer_pubkey, recipient_pubkey, - response_tx: conf_tx, + response_tx, }) - .await - { - tracing::error!("Failed to send to tracker thread: {:?}", e); - return internal_error("Tracker thread unavailable"); - } + .await; - let confirmation = match conf_rx.await { + let confirmation = match confirmation_response { Ok(Ok(Some(c))) => Some(c), Ok(Ok(None)) => None, Ok(Err(e)) => { @@ -3909,20 +2064,13 @@ async fn fetch_confirmation( recipient_pubkey: PubKey, amount_redeemed: u64, ) -> Option { - let (conf_tx, conf_rx) = tokio::sync::oneshot::channel(); - if tx - .send(TrackerCommand::GetConfirmation { - issuer_pubkey, - recipient_pubkey, - response_tx: conf_tx, - }) - .await - .is_err() + match crate::tracker_request(tx, |response_tx| TrackerCommand::GetConfirmation { + issuer_pubkey, + recipient_pubkey, + response_tx, + }) + .await { - return None; - } - - match conf_rx.await { Ok(Ok(Some(c))) => Some(NoteConfirmationSummary::from_confirmation( &c, amount_redeemed, diff --git a/crates/basis_server/src/bounded_http.rs b/crates/basis_server/src/bounded_http.rs new file mode 100644 index 0000000..99061e4 --- /dev/null +++ b/crates/basis_server/src/bounded_http.rs @@ -0,0 +1,107 @@ +//! Re-export the one process-wide bounded Ergo node HTTP client. + +pub use basis_store::ergo_scanner::node_http; + +#[cfg(test)] +use basis_store::ergo_scanner::{BoundedHttpClient, BoundedHttpError}; + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn raw_server(response: Vec, delay: Duration) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + tokio::time::sleep(delay).await; + let _ = socket.write_all(&response).await; + }); + format!("http://{address}") + } + + async fn signaled_raw_server( + response: Vec, + delay: Duration, + ) -> (String, tokio::sync::oneshot::Receiver<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let _ = accepted_tx.send(()); + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + tokio::time::sleep(delay).await; + let _ = socket.write_all(&response).await; + }); + (format!("http://{address}"), accepted_rx) + } + + #[tokio::test] + async fn chunked_body_is_stopped_at_the_configured_cap() { + let payload = vec![b'x'; 65]; + let mut response = + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec(); + response.extend_from_slice(format!("{:X}\r\n", payload.len()).as_bytes()); + response.extend_from_slice(&payload); + response.extend_from_slice(b"\r\n0\r\n\r\n"); + let url = raw_server(response, Duration::ZERO).await; + let client = BoundedHttpClient::new(Duration::from_secs(1), 64, 1).unwrap(); + + assert_eq!( + client.execute(client.get(&url)).await.unwrap_err(), + BoundedHttpError::BodyTooLarge { limit: 64 } + ); + } + + #[tokio::test] + async fn declared_content_length_over_cap_is_rejected_before_body_read() { + let response = + b"HTTP/1.1 200 OK\r\nContent-Length: 65\r\nConnection: close\r\n\r\n".to_vec(); + let url = raw_server(response, Duration::ZERO).await; + let client = BoundedHttpClient::new(Duration::from_secs(1), 64, 1).unwrap(); + + assert_eq!( + client.execute(client.get(&url)).await.unwrap_err(), + BoundedHttpError::BodyTooLarge { limit: 64 } + ); + } + + #[tokio::test] + async fn stalled_request_hits_the_total_deadline() { + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(); + let url = raw_server(response, Duration::from_millis(200)).await; + let client = BoundedHttpClient::new(Duration::from_millis(20), 64, 1).unwrap(); + + assert_eq!( + client.execute(client.get(&url)).await.unwrap_err(), + BoundedHttpError::Timeout + ); + } + + #[tokio::test] + async fn concurrent_request_limit_rejects_without_queueing() { + let client = BoundedHttpClient::new(Duration::from_secs(1), 64, 1).unwrap(); + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(); + let (url, accepted) = signaled_raw_server(response, Duration::from_millis(100)).await; + let first_client = client.clone(); + let first_url = url.clone(); + let first = + tokio::spawn(async move { first_client.execute(first_client.get(&first_url)).await }); + accepted.await.unwrap(); + + assert_eq!( + client + .execute(client.get("http://127.0.0.1:1")) + .await + .unwrap_err(), + BoundedHttpError::Overloaded + ); + assert!(first.await.unwrap().is_ok()); + } +} diff --git a/crates/basis_server/src/config.rs b/crates/basis_server/src/config.rs index a68f5ba..797a952 100644 --- a/crates/basis_server/src/config.rs +++ b/crates/basis_server/src/config.rs @@ -8,6 +8,8 @@ use std::path::{Path, PathBuf}; // Import Ergo address handling for P2PK address support use ergo_lib::ergotree_ir::chain::address::{AddressEncoder, NetworkPrefix}; +type DefaultConfigBuilder = config::ConfigBuilder; + /// Main application configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { @@ -48,7 +50,7 @@ impl ServerConfig { } /// Ergo blockchain configuration -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct ErgoConfig { /// Ergo node configuration pub node: NodeConfig, @@ -56,13 +58,65 @@ pub struct ErgoConfig { pub basis_reserve_contract_p2s: String, /// Tracker NFT ID (hex-encoded) - identifies the tracker server for reserve contracts pub tracker_nft_id: Option, + /// One-shot operator approval to initialize a previously unbound, empty data + /// directory for the configured tracker NFT. Defaults to false. + #[serde(default)] + pub allow_fresh_tracker_generation: bool, + /// Successor depth required before a tracker publication is accepted. + #[serde(default)] + pub confirmed_chain_min_successor_depth: Option, + /// Maximum age of one coherent confirmed-chain evidence snapshot. + #[serde(default)] + pub confirmed_chain_max_evidence_age_ms: Option, + /// Explicit reorg-monitoring horizon. Absence disables publication + /// fail-closed; maintainers must ratify this application policy. + #[serde(default)] + pub confirmed_chain_reorg_monitor_depth: Option, + /// One-shot approval to create the journal manifest only for a genuinely + /// history-free BNS1 generation. + #[serde(default)] + pub allow_fresh_reconciliation_journal: bool, /// Tracker server's public key for the Ergo blockchain (hex-encoded, 33 bytes for compressed format) pub tracker_public_key: Option, - /// Tracker server's secret key for local signing (hex-encoded, 32 bytes) - /// If provided, the server will sign redemption transactions locally instead of using the Ergo node API + /// Tracker server's secret key for local signing (hex-encoded, 32 bytes). + /// The tracker-box publisher never sends this key to the Ergo node. pub tracker_secret_key: Option, } +impl std::fmt::Debug for ErgoConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ErgoConfig") + .field("node", &self.node) + .field( + "basis_reserve_contract_p2s", + &self.basis_reserve_contract_p2s, + ) + .field("tracker_nft_id", &self.tracker_nft_id) + .field( + "confirmed_chain_min_successor_depth", + &self.confirmed_chain_min_successor_depth, + ) + .field( + "confirmed_chain_max_evidence_age_ms", + &self.confirmed_chain_max_evidence_age_ms, + ) + .field( + "confirmed_chain_reorg_monitor_depth", + &self.confirmed_chain_reorg_monitor_depth, + ) + .field( + "allow_fresh_reconciliation_journal", + &self.allow_fresh_reconciliation_journal, + ) + .field("tracker_public_key", &self.tracker_public_key) + .field( + "tracker_secret_key", + &self.tracker_secret_key.as_ref().map(|_| ""), + ) + .finish() + } +} + /// Transaction configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransactionConfig { @@ -74,47 +128,76 @@ pub struct TransactionConfig { } impl AppConfig { - /// Load configuration from file - pub fn from_file>(path: P) -> Result { - let config = config::Config::builder() - .add_source(config::File::from(path.as_ref())) - .build()?; - - config.try_deserialize() - } + fn default_builder() -> Result { + let legacy_read_only_p2s = basis_store::contract_compiler::get_basis_reserve_contract_p2s() + .map_err(|e| config::ConfigError::Message(e.to_string()))?; - /// Load configuration from default locations - pub fn load() -> Result { - let config = config::Config::builder() - // Default configuration + config::Config::builder() .set_default("server.host", "0.0.0.0")? .set_default("server.port", 3048)? .set_default("server.data_dir", "data")? .set_default("server.database_url", "sqlite:data/basis.db")? - // Node configuration defaults - .set_default("ergo.node.start_height", "")? - .set_default("ergo.node.reserve_contract_p2s", "")? .set_default("ergo.node.node_url", "http://159.89.116.15:11088")? .set_default("ergo.node.scan_name", "Basis Reserve Scanner")? - .set_default("ergo.node.api_key", "")? // Set via config file or BASIS_ERGO_NODE_API_KEY env var - // Transaction configuration defaults - .set_default("transaction.fee", 1000000)? // 0.001 ERG - // Tracker public key (optional) + .set_default("ergo.node.api_key", "")? + .set_default("ergo.basis_reserve_contract_p2s", legacy_read_only_p2s)? + .set_default("transaction.fee", 1000000)? .set_default("ergo.tracker_public_key", "")? - // Tracker secret key (optional - for local signing) .set_default("ergo.tracker_secret_key", "")? - // Acceptance predicate configuration (optional) .set_default("acceptance.default", "reject")? - .set_default("acceptance.predicates", Vec::::new())? - // Environment variables - .add_source(config::Environment::with_prefix("BASIS")) - // Configuration file - .add_source(config::File::with_name("config/basis").required(false)) + .set_default("acceptance.predicates", Vec::::new()) + } + + fn environment() -> config::Environment { + config::Environment::with_prefix("BASIS") + .prefix_separator("_") + .separator("__") + .ignore_empty(true) + } + + fn load_with_sources( + file: F, + environment: config::Environment, + ) -> Result + where + F: config::Source + Send + Sync + 'static, + { + Self::default_builder()? + // Later sources have higher priority: explicit environment values + // override the optional configuration file. + .add_source(file) + .add_source(environment) + .build()? + .try_deserialize() + } + + /// Load configuration from file + pub fn from_file>(path: P) -> Result { + let config = config::Config::builder() + .add_source(config::File::from(path.as_ref())) .build()?; config.try_deserialize() } + /// Load configuration from default locations + pub fn load() -> Result { + Self::load_with_sources( + config::File::with_name("config/basis").required(false), + Self::environment(), + ) + } + + /// Load the process configuration without substituting a different + /// contract generation when parsing or deserialization fails. + pub fn load_for_startup() -> Result { + Self::require_loaded(Self::load()) + } + + fn require_loaded(result: Result) -> Result { + result.map_err(|error| format!("failed to load Basis configuration: {error}")) + } + /// Get the socket address for the server pub fn socket_addr(&self) -> std::net::SocketAddr { format!("{}:{}", self.server.host, self.server.port) @@ -132,10 +215,59 @@ impl AppConfig { &self.ergo.basis_reserve_contract_p2s } + /// Require the exact Basis v2 ERG identity from the pinned source-to-byte receipt. + pub fn validate_basis_v2_erg_contract(&self) -> Result<(), String> { + basis_store::contract_compiler::validate_basis_v2_contract_p2s( + self.basis_reserve_contract_p2s(), + basis_store::contract_compiler::BasisV2ContractKind::Erg, + ) + .map_err(|e| format!("Basis v2 contract identity check failed: {e}")) + } + + /// V2-A carries exact contract identity and message primitives only. The + /// current scanner and state store are still v1-shaped, so activating the + /// exact v2 tree here would be unsafe. The historical identity is retained + /// temporarily for compatibility; `/reserves/create`, + /// `/config/reserve-contract-p2s`, and `/redemption/build` remain + /// tombstones. This does not attest safety of legacy library builders or + /// acceptance state. + pub fn validate_runtime_contract_mode(&self) -> Result<(), String> { + let configured = self.basis_reserve_contract_p2s(); + let legacy = basis_store::contract_compiler::get_basis_reserve_contract_p2s() + .map_err(|e| format!("cannot resolve historical contract identity: {e}"))?; + if configured == legacy { + return Ok(()); + } + if self.validate_basis_v2_erg_contract().is_ok() { + return Err( + "Basis v2 contract identity is recognized, but v2 scanner and BNS2/BRS2 state are not installed; runtime activation is disabled" + .to_string(), + ); + } + Err("configured reserve contract is neither the supported read-only legacy identity nor the exact embedded Basis v2 ERG identity".to_string()) + } + + /// Existing construction code still emits the retired v1 ABI. It must not + /// be re-enabled merely because a non-legacy-looking P2S was configured. + pub fn reject_unsupported_reserve_builder(&self) -> Result<(), String> { + self.validate_basis_v2_erg_contract()?; + Err( + "the exact Basis v2 contract is configured, but reserve/redemption construction remains disabled until the v2 runtime builder is installed" + .to_string(), + ) + } + /// Get the tracker NFT ID bytes (required - server will fail if not configured) pub fn tracker_nft_bytes(&self) -> Result, hex::FromHexError> { match &self.ergo.tracker_nft_id { - Some(nft_id) if !nft_id.is_empty() => hex::decode(nft_id), + Some(nft_id) if !nft_id.is_empty() => { + let bytes = hex::decode(nft_id)?; + if bytes.len() == 32 { + Ok(bytes) + } else { + Err(hex::FromHexError::InvalidStringLength) + } + } _ => Err(hex::FromHexError::InvalidStringLength), } } @@ -328,6 +460,208 @@ impl AppConfig { mod tests { use super::*; + fn environment_from(entries: &[(&str, String)]) -> config::Environment { + AppConfig::environment().source(Some( + entries + .iter() + .map(|(key, value)| ((*key).to_string(), value.clone())) + .collect(), + )) + } + + fn write_config_file(contents: &str) -> (tempfile::TempDir, PathBuf) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("basis.toml"); + std::fs::write(&path, contents).unwrap(); + (directory, path) + } + + #[test] + fn nested_environment_values_override_the_file_source() { + let legacy = basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(); + let v2 = basis_store::contract_compiler::get_basis_v2_contract_p2s( + basis_store::contract_compiler::BasisV2ContractKind::Erg, + ) + .unwrap(); + let (_directory, path) = write_config_file(&format!( + r#" +[server] +port = 3048 +data_dir = "from-file" + +[ergo] +basis_reserve_contract_p2s = "{legacy}" + +[ergo.node] +node_url = "http://file-node:9053" + +[transaction] +fee = 1000000 +"# + )); + let environment = environment_from(&[ + ("BASIS_SERVER__PORT", "4050".to_string()), + ("BASIS_SERVER__DATA_DIR", "from-environment".to_string()), + ("BASIS_ERGO__BASIS_RESERVE_CONTRACT_P2S", v2.clone()), + ( + "BASIS_ERGO__NODE__NODE_URL", + "http://environment-node:9053".to_string(), + ), + ]); + + let config = AppConfig::load_with_sources(config::File::from(path), environment).unwrap(); + + assert_eq!(config.server.port, 4050); + assert_eq!(config.server.data_dir.as_deref(), Some("from-environment")); + assert_eq!(config.ergo.node.node_url, "http://environment-node:9053"); + assert_eq!(config.basis_reserve_contract_p2s(), v2); + assert!(config + .validate_runtime_contract_mode() + .unwrap_err() + .contains("runtime activation is disabled")); + } + + #[test] + fn environment_contract_identity_is_validated_without_legacy_fallback() { + let legacy = basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(); + let v2 = basis_store::contract_compiler::get_basis_v2_contract_p2s( + basis_store::contract_compiler::BasisV2ContractKind::Erg, + ) + .unwrap(); + let (_directory, path) = write_config_file(""); + + let legacy_config = AppConfig::load_with_sources( + config::File::from(path.clone()), + environment_from(&[("BASIS_ERGO__BASIS_RESERVE_CONTRACT_P2S", legacy.clone())]), + ) + .unwrap(); + legacy_config.validate_runtime_contract_mode().unwrap(); + + let v2_config = AppConfig::load_with_sources( + config::File::from(path.clone()), + environment_from(&[("BASIS_ERGO__BASIS_RESERVE_CONTRACT_P2S", v2)]), + ) + .unwrap(); + assert!(v2_config + .validate_runtime_contract_mode() + .unwrap_err() + .contains("runtime activation is disabled")); + + let unknown_config = AppConfig::load_with_sources( + config::File::from(path), + environment_from(&[( + "BASIS_ERGO__BASIS_RESERVE_CONTRACT_P2S", + "unknown-contract".to_string(), + )]), + ) + .unwrap(); + assert!(unknown_config + .validate_runtime_contract_mode() + .unwrap_err() + .contains("neither the supported read-only legacy identity")); + } + + #[test] + fn app_config_debug_redacts_node_and_tracker_secrets() { + let node_sentinel = "sentinel-node-api-key-do-not-log"; + let tracker_sentinel = "11".repeat(32); + let config = AppConfig { + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3048, + data_dir: None, + database_url: None, + }, + ergo: ErgoConfig { + node: NodeConfig { + api_key: Some(node_sentinel.to_string()), + ..NodeConfig::default() + }, + basis_reserve_contract_p2s: "configured-explicitly".to_string(), + tracker_nft_id: None, + tracker_public_key: None, + tracker_secret_key: Some(tracker_sentinel.clone()), + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, + }, + transaction: TransactionConfig { + fee: 1_000_000, + change_address: None, + }, + acceptance: AcceptanceConfig::empty(), + }; + + let rendered = format!("{config:?}"); + assert!(!rendered.contains(node_sentinel)); + assert!(!rendered.contains(&tracker_sentinel)); + assert!(rendered.contains("")); + } + + #[test] + fn validates_only_the_exact_basis_v2_contract() { + let mut config = AppConfig { + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3048, + data_dir: None, + database_url: None, + }, + ergo: ErgoConfig { + node: NodeConfig::default(), + basis_reserve_contract_p2s: + basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + tracker_nft_id: None, + tracker_public_key: None, + tracker_secret_key: None, + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, + }, + transaction: TransactionConfig { + fee: 1_000_000, + change_address: None, + }, + acceptance: AcceptanceConfig::empty(), + }; + + let error = config.validate_basis_v2_erg_contract().unwrap_err(); + assert!(error.contains("identity check failed")); + config.validate_runtime_contract_mode().unwrap(); + + config.ergo.basis_reserve_contract_p2s = + basis_store::contract_compiler::get_basis_v2_contract_p2s( + basis_store::contract_compiler::BasisV2ContractKind::Erg, + ) + .unwrap(); + config.validate_basis_v2_erg_contract().unwrap(); + assert!(config.reject_unsupported_reserve_builder().is_err()); + assert!(config + .validate_runtime_contract_mode() + .unwrap_err() + .contains("runtime activation is disabled")); + + config.ergo.basis_reserve_contract_p2s = "unrecognized".to_string(); + assert!(config + .validate_runtime_contract_mode() + .unwrap_err() + .contains("neither the supported read-only legacy identity")); + } + + #[test] + fn startup_configuration_errors_never_fall_back_to_legacy() { + let error = AppConfig::require_loaded(Err(config::ConfigError::Message( + "sentinel malformed configuration".to_string(), + ))) + .unwrap_err(); + assert!(error.contains("sentinel malformed configuration")); + assert!(!error.contains("default configuration")); + } + #[test] fn test_tracker_public_key_hex_format() { let config = AppConfig { @@ -347,6 +681,11 @@ mod tests { }, basis_reserve_contract_p2s: "test".to_string(), tracker_nft_id: None, + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, tracker_public_key: Some( "02dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7" .to_string(), diff --git a/crates/basis_server/src/create_reserve_tests.rs b/crates/basis_server/src/create_reserve_tests.rs index f248ade..85081b5 100644 --- a/crates/basis_server/src/create_reserve_tests.rs +++ b/crates/basis_server/src/create_reserve_tests.rs @@ -1,15 +1,20 @@ #[cfg(test)] mod create_reserve_tests { - use axum::{extract::State, http::StatusCode, Json}; + use axum::{ + body::Body, + extract::State, + http::{Method, Request, StatusCode}, + Json, + }; use std::sync::Arc; use tokio::sync::Mutex; use crate::{ - api::create_reserve_payload, - models::{CreateReserveRequest, ReserveCreationResponse}, - AppState, TrackerCommand, + api::create_reserve_payload, models::CreateReserveRequest, reserve_construction_routes, + retired_v1_redemption_routes, AppState, TrackerCommand, }; use basis_store::ergo_scanner::{NodeConfig, ServerState}; + use tower::ServiceExt; // Helper function to create a unique temporary directory for test storage fn unique_test_storage_path(prefix: &str) -> std::path::PathBuf { @@ -26,12 +31,19 @@ mod create_reserve_tests { // Helper function to create a test AppState that doesn't require file system access fn create_test_app_state() -> AppState { + create_test_app_state_with_p2s("test".to_string()) + } + + fn create_test_app_state_with_p2s(basis_reserve_contract_p2s: String) -> AppState { let (tx, _rx) = tokio::sync::mpsc::channel::(100); let event_store = std::sync::Arc::new(crate::store::EventStore::new_in_memory()); // Create a minimal configuration let config = NodeConfig { node_url: "http://localhost:9553".to_string(), + reserve_contract_p2s: Some( + basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + ), ..Default::default() }; @@ -45,14 +57,8 @@ mod create_reserve_tests { .as_nanos() )); let _ = std::fs::remove_dir_all(&data_dir); - let scanner = ServerState::new(config, &data_dir).unwrap_or_else(|_| { - // Fallback to a scanner with minimal initialization that doesn't access storage - let config = NodeConfig { - node_url: "http://example.com".to_string(), // Invalid URL to avoid file access - ..Default::default() - }; - ServerState::new(config, &data_dir).expect("Fallback scanner creation should succeed") - }); + let scanner = ServerState::new(config, &data_dir) + .expect("explicit historical scanner creation should succeed"); // Create a minimal config for testing let test_config = std::sync::Arc::new(crate::config::AppConfig { @@ -67,10 +73,15 @@ mod create_reserve_tests { node_url: "http://example.com".to_string(), ..Default::default() }, - basis_reserve_contract_p2s: "test".to_string(), + basis_reserve_contract_p2s, tracker_nft_id: Some( "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), ), + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, tracker_public_key: Some( "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), ), @@ -128,8 +139,12 @@ mod create_reserve_tests { } #[tokio::test] - async fn test_create_reserve_payload_success() { - let state = create_test_app_state(); + async fn test_create_reserve_payload_stays_disabled_until_v2_builder_is_installed() { + let exact_v2 = basis_store::contract_compiler::get_basis_v2_contract_p2s( + basis_store::contract_compiler::BasisV2ContractKind::Erg, + ) + .expect("embedded Basis v2 ERG contract should derive a P2S address"); + let state = create_test_app_state_with_p2s(exact_v2); let request_payload = CreateReserveRequest { nft_id: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(), @@ -142,38 +157,190 @@ mod create_reserve_tests { let (status, response_json) = result; - // Check if the error is due to config loading failure (which is expected in the test environment) - if status == StatusCode::INTERNAL_SERVER_ERROR { - // If config loading fails, the test is not testing the right functionality - // We should handle this differently in a test environment - eprintln!("Error response: {:?}", response_json); - assert!(response_json.error.is_some()); - } else { - assert_eq!(status, StatusCode::OK); - assert!(response_json.success); - assert!(response_json.data.is_some()); - - let response_data = response_json.data.clone().unwrap(); - let reserve_response: ReserveCreationResponse = response_data; - - // Verify the response structure - assert!(!reserve_response.requests.is_empty()); - // Verify other fields after making sure the requests array is not empty - if !reserve_response.requests.is_empty() { - assert_eq!(reserve_response.requests[0].value, 1000000000); - assert_eq!( - reserve_response.requests[0].assets[0].token_id, - "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - ); - assert_eq!( - reserve_response.requests[0].registers.get("R4").unwrap(), - "03e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12" - ); - assert!(reserve_response.fee > 0); // Should be the configured fee amount + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!(!response_json.success); + assert!(response_json.data.is_none()); + assert!(response_json + .error + .as_deref() + .unwrap_or_default() + .contains("v2 runtime builder")); + } + + #[tokio::test] + async fn test_create_reserve_payload_rejects_known_strict_insert_contract() { + let legacy = basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(); + let state = create_test_app_state_with_p2s(legacy); + let request_payload = CreateReserveRequest { + nft_id: "12".repeat(32), + owner_pubkey: "03e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12" + .to_string(), + erg_amount: 1_000_000_000, + }; + + let (status, response) = create_reserve_payload(State(state), Json(request_payload)).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!(!response.success); + assert!(response + .error + .as_deref() + .unwrap_or_default() + .contains("identity check failed")); + } + + #[tokio::test] + async fn all_server_construction_routes_reject_each_unactivated_generation() { + let exact_v2 = basis_store::contract_compiler::get_basis_v2_contract_p2s( + basis_store::contract_compiler::BasisV2ContractKind::Erg, + ) + .unwrap(); + let legacy = basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(); + + for configured in [exact_v2, legacy, "unknown-generation".to_string()] { + let state = create_test_app_state_with_p2s(configured); + let build_request = serde_json::json!({ + "issuer_pubkey": "02", + "recipient_pubkey": "03", + "amount": 1, + "timestamp": 1, + "issuer_signature": "", + "emergency": false, + "tracker_box_id": null + }); + let create_request = serde_json::json!({ + "nft_id": "12".repeat(32), + "owner_pubkey": "03e8c3e4877e2f7b79e0e407421a81a1619ea64e37e5e4e77454d1e361e6f80b12", + "erg_amount": 1_000_000 + }); + let app = reserve_construction_routes() + .merge(retired_v1_redemption_routes()) + .with_state(state); + let requests = [ + (Method::GET, "/config/reserve-contract-p2s", Body::empty()), + ( + Method::POST, + "/redemption/build", + Body::from(serde_json::to_vec(&build_request).unwrap()), + ), + ( + Method::POST, + "/reserves/create", + Body::from(serde_json::to_vec(&create_request).unwrap()), + ), + ]; + for (method, path, body) in requests { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(body) + .unwrap(), + ) + .await + .unwrap(); + let expected = if path == "/redemption/build" { + StatusCode::GONE + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + assert_eq!(response.status(), expected, "{path}"); } } } + #[tokio::test] + async fn every_legacy_redemption_route_is_an_unconditional_gone_tombstone() { + let app = retired_v1_redemption_routes().with_state(create_test_app_state()); + let cases = [ + ( + Method::POST, + "/redeem", + serde_json::json!({ + "issuer_pubkey": "issuer", + "recipient_pubkey": "receiver", + "amount": 1, + "timestamp": 1, + "issuer_signature": "signature" + }), + ), + ( + Method::POST, + "/redeem/complete", + serde_json::json!({ + "redemption_id": "id", + "issuer_pubkey": "issuer", + "recipient_pubkey": "receiver", + "redeemed_amount": 1 + }), + ), + (Method::GET, "/proof/redemption", serde_json::json!({})), + (Method::GET, "/tracker/proof", serde_json::json!({})), + (Method::GET, "/reserve/proof", serde_json::json!({})), + ( + Method::POST, + "/tracker/signature", + serde_json::json!({ + "issuer_pubkey": "issuer", + "recipient_pubkey": "receiver", + "total_debt": 1, + "timestamp": 1, + "emergency": true + }), + ), + ( + Method::POST, + "/redemption/prepare", + serde_json::json!({ + "issuer_pubkey": "issuer", + "recipient_pubkey": "receiver", + "amount": 1, + "timestamp": 1 + }), + ), + ( + Method::POST, + "/redemption/submit", + serde_json::json!({ "signed_tx": {} }), + ), + ( + Method::POST, + "/redemption/build", + serde_json::json!({ + "issuer_pubkey": "issuer", + "recipient_pubkey": "receiver", + "amount": 1, + "timestamp": 1, + "issuer_signature": "signature", + "emergency": false + }), + ), + ]; + + for (method, path, payload) in cases { + let body = if method == Method::GET { + Body::empty() + } else { + Body::from(serde_json::to_vec(&payload).unwrap()) + }; + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(body) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::GONE, "{path}"); + } + } + #[tokio::test] async fn test_create_reserve_payload_invalid_pubkey() { let state = create_test_app_state(); diff --git a/crates/basis_server/src/lib.rs b/crates/basis_server/src/lib.rs index 04a8a68..f365ccd 100644 --- a/crates/basis_server/src/lib.rs +++ b/crates/basis_server/src/lib.rs @@ -1,7 +1,41 @@ //! Basis Server library +//! +//! The legacy proof commands are structurally absent from the production +//! actor rather than hidden behind runtime branches: +//! +//! ```compile_fail +//! fn removed(command: basis_server::TrackerCommand) { +//! if let basis_server::TrackerCommand::GenerateProof { .. } = command {} +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(command: basis_server::TrackerCommand) { +//! if let basis_server::TrackerCommand::GetTrackerLookupProof { .. } = command {} +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(command: basis_server::TrackerCommand) { +//! if let basis_server::TrackerCommand::GetReserveLookupProof { .. } = command {} +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(command: basis_server::TrackerCommand) { +//! if let basis_server::TrackerCommand::GetReserveInsertProof { .. } = command {} +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(command: basis_server::TrackerCommand) { +//! if let basis_server::TrackerCommand::GetReserveStateDigest { .. } = command {} +//! } +//! ``` pub mod acceptance; pub mod api; +mod bounded_http; pub mod config; pub mod models; pub mod redemption_build; @@ -12,8 +46,26 @@ pub mod tracker_box_updater; #[cfg(test)] mod create_reserve_tests; +use axum::{ + http::StatusCode, + routing::{get, post}, + Json, Router, +}; use tokio::sync::Mutex; +pub const TRACKER_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum TrackerRequestError { + #[error("tracker request queue is full")] + QueueFull, + #[error("tracker request worker is unavailable")] + QueueClosed, + #[error("tracker request timed out")] + Timeout, + #[error("tracker response channel closed")] + ResponseClosed, +} + // Re-export main types for external use pub use acceptance::*; pub use api::*; @@ -44,6 +96,88 @@ pub struct AppState { // Tracker box ID is fetched from tracker_storage directly } +/// Opaque actor-issued fence held across tracker commitment signing and +/// broadcast. While a lease is active, the tracker actor rejects every other +/// command so no state transition can race the external effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PublicationLease { + pub id: u64, + pub digest: [u8; 33], +} + +/// Handle OPTIONS preflight requests for CORS. +pub async fn handle_options() -> impl axum::response::IntoResponse { + ( + axum::http::StatusCode::OK, + [("Access-Control-Allow-Origin", "*")], + "", + ) +} + +pub(crate) const V1_REDEMPTION_RETIRED: &str = + "Basis v1 redemption is retired; v2 remains disabled until confirmed-chain authority and exact manifest admission are integrated"; + +pub(crate) fn reject_retired_v1_redemption() -> (StatusCode, Json>) { + ( + StatusCode::GONE, + Json(models::error_response(V1_REDEMPTION_RETIRED.to_string())), + ) +} + +/// The generation-sensitive construction routes used by the production +/// server. Keeping their wiring here lets integration tests exercise the same +/// router that `main` merges into the application. +pub fn reserve_construction_routes() -> Router { + Router::new() + .route( + "/reserves/create", + post(api::create_reserve_payload).options(handle_options), + ) + .route( + "/config/reserve-contract-p2s", + get(api::get_basis_reserve_contract_p2s), + ) +} + +/// Compatibility tombstones for every retired v1 redemption endpoint. +/// +/// These routes intentionally remain visible as HTTP 410 responses so stale +/// clients cannot fall through to an older proof, signing, build, or broadcast +/// path. This router contains no v2 activation path. +pub fn retired_v1_redemption_routes() -> Router +where + S: Clone + Send + Sync + 'static, +{ + Router::new() + .route( + "/redeem", + post(api::initiate_redemption).options(handle_options), + ) + .route( + "/redeem/complete", + post(api::complete_redemption).options(handle_options), + ) + .route("/proof/redemption", get(api::get_redemption_proof)) + .route("/tracker/proof", get(api::get_tracker_proof)) + .route("/reserve/proof", get(api::get_reserve_proof)) + .route( + "/tracker/signature", + post(api::request_tracker_signature).options(handle_options), + ) + .route( + "/redemption/prepare", + post(api::prepare_redemption).options(handle_options), + ) + .route( + "/redemption/build", + post(redemption_build::build_redemption).options(handle_options), + ) + .route( + "/redemption/submit", + post(redemption_build::submit_redemption).options(handle_options), + ) +} + // Commands that can be sent to the tracker thread #[derive(Debug)] pub enum TrackerCommand { @@ -57,6 +191,12 @@ pub enum TrackerCommand { response_tx: tokio::sync::oneshot::Sender, basis_store::NoteError>>, }, + GetProjectedIssuerGrossDebt { + issuer_pubkey: basis_store::PubKey, + candidate_recipient: Option, + candidate_total_debt: u64, + response_tx: tokio::sync::oneshot::Sender>, + }, GetNotesByRecipient { recipient_pubkey: basis_store::PubKey, response_tx: @@ -80,52 +220,10 @@ pub enum TrackerCommand { Result, basis_store::NoteError>, >, }, - InitiateRedemption { - request: basis_store::RedemptionRequest, - response_tx: tokio::sync::oneshot::Sender< - Result, - >, - }, - CompleteRedemption { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - redeemed_amount: u64, - /// Explicit cumulative reserve-tree value to sync (from the on-chain build). - /// Falls back to the note's cumulative redeemed amount when `None`. - new_already_redeemed: Option, - response_tx: tokio::sync::oneshot::Sender>, - }, - GenerateProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, + /// Get the current BNS2-backed tracker state through its owning actor. + GetValidatedState { response_tx: - tokio::sync::oneshot::Sender>, - }, - GetTrackerLookupProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - response_tx: tokio::sync::oneshot::Sender< - Result, - >, - }, - GetReserveLookupProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - response_tx: tokio::sync::oneshot::Sender< - Result, - >, - }, - GetReserveInsertProof { - issuer_pubkey: basis_store::PubKey, - recipient_pubkey: basis_store::PubKey, - timestamp: u64, - new_already_redeemed: u64, - response_tx: - tokio::sync::oneshot::Sender, Vec), basis_store::NoteError>>, - }, - /// Get the current reserve AVL tree root digest (33 bytes). - GetReserveStateDigest { - response_tx: tokio::sync::oneshot::Sender>, + tokio::sync::oneshot::Sender>, }, /// Get the confirmation record for a single note. GetConfirmation { @@ -138,31 +236,171 @@ pub enum TrackerCommand { /// Get a snapshot of all confirmation records keyed by note key. GetAllConfirmations { response_tx: tokio::sync::oneshot::Sender< - std::collections::HashMap<[u8; 32], basis_store::NoteConfirmation>, + Result< + std::collections::HashMap<[u8; 32], basis_store::NoteConfirmation>, + basis_store::NoteError, + >, >, }, - /// Mark all currently-local notes as pending for an in-flight update tx. - MarkNotesPending { - digest: [u8; 33], + /// Validate and reconcile an observed tracker generation, then freeze the + /// actor until the external publication attempt is resolved. + BeginPublication { + tracker_nft_id: [u8; 32], + observed_root: [u8; 33], + box_id: String, + height: u64, + response_tx: tokio::sync::oneshot::Sender>, + }, + /// Durably bind the exact transaction identity before the broadcast request + /// crosses the node boundary. The actor fence remains held. + RecordPublicationAttempt { + lease: PublicationLease, tx_id: String, submitted_height: u64, response_tx: tokio::sync::oneshot::Sender>, }, - /// Promote all pending notes to confirmed after an update tx confirms. - ConfirmPendingNotes { - box_id: String, - height: u64, + /// Promote the durable attempt after active-chain confirmation and release + /// the actor fence. + ConfirmPublication { + effect: basis_store::chain_reconciliation::ValidatedChainEffect, response_tx: tokio::sync::oneshot::Sender>, }, - /// Revert all pending notes back to local state (update tx dropped/rejected). - RevertPendingNotes { + /// Demote exactly one previously accepted publication after its block is + /// proven absent from the selected chain at the original height. + RollbackPublication { + rollback: basis_store::chain_reconciliation::ValidatedRollback, response_tx: tokio::sync::oneshot::Sender>, }, - /// Reconcile confirmation records with an observed on-chain digest. - ReconcileWithConfirmedDigest { - digest: [u8; 33], - box_id: String, - height: u64, - response_tx: tokio::sync::oneshot::Sender>, + /// Release an actor fence after a no-op or failed publication attempt. + AbortPublication { + lease: PublicationLease, + response_tx: tokio::sync::oneshot::Sender>, }, } + +impl TrackerCommand { + /// Return true when the HTTP/request-side receiver has already gone away. + /// The single worker checks this before starting potentially expensive work, + /// so timed-out requests do not build an unbounded stale-work backlog. + pub fn response_is_closed(&self) -> bool { + match self { + Self::AddNote { response_tx, .. } => response_tx.is_closed(), + Self::GetNotesByIssuer { response_tx, .. } => response_tx.is_closed(), + Self::GetProjectedIssuerGrossDebt { response_tx, .. } => response_tx.is_closed(), + Self::GetNotesByRecipient { response_tx, .. } => response_tx.is_closed(), + Self::GetNotesByRecipientWithIssuer { response_tx, .. } => response_tx.is_closed(), + Self::GetNoteByIssuerAndRecipient { response_tx, .. } => response_tx.is_closed(), + Self::GetNotes { response_tx } => response_tx.is_closed(), + Self::GetValidatedState { response_tx } => response_tx.is_closed(), + Self::GetConfirmation { response_tx, .. } => response_tx.is_closed(), + Self::GetAllConfirmations { response_tx } => response_tx.is_closed(), + Self::BeginPublication { response_tx, .. } => response_tx.is_closed(), + Self::RecordPublicationAttempt { response_tx, .. } => response_tx.is_closed(), + Self::ConfirmPublication { response_tx, .. } => response_tx.is_closed(), + Self::RollbackPublication { response_tx, .. } => response_tx.is_closed(), + Self::AbortPublication { response_tx, .. } => response_tx.is_closed(), + } + } +} + +pub async fn tracker_request( + tx: &tokio::sync::mpsc::Sender, + make_command: impl FnOnce(tokio::sync::oneshot::Sender) -> TrackerCommand, +) -> Result { + tracker_request_with_timeout(tx, TRACKER_REQUEST_TIMEOUT, make_command).await +} + +async fn tracker_request_with_timeout( + tx: &tokio::sync::mpsc::Sender, + timeout: std::time::Duration, + make_command: impl FnOnce(tokio::sync::oneshot::Sender) -> TrackerCommand, +) -> Result { + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + match tx.try_send(make_command(response_tx)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + return Err(TrackerRequestError::QueueFull) + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + return Err(TrackerRequestError::QueueClosed) + } + } + + match tokio::time::timeout(timeout, response_rx).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Err(TrackerRequestError::ResponseClosed), + Err(_) => Err(TrackerRequestError::Timeout), + } +} + +#[cfg(test)] +mod tracker_request_tests { + use super::*; + + fn get_notes( + response_tx: tokio::sync::oneshot::Sender< + Result, basis_store::NoteError>, + >, + ) -> TrackerCommand { + TrackerCommand::GetNotes { response_tx } + } + + #[tokio::test] + async fn tracker_request_rejects_full_and_closed_queues_without_waiting() { + let (full_tx, mut full_rx) = tokio::sync::mpsc::channel(1); + let (occupied_tx, _occupied_rx) = tokio::sync::oneshot::channel(); + full_tx.try_send(get_notes(occupied_tx)).unwrap(); + assert!(matches!( + tracker_request_with_timeout( + &full_tx, + std::time::Duration::from_millis(10), + get_notes, + ) + .await, + Err(TrackerRequestError::QueueFull) + )); + assert!(full_rx.recv().await.is_some()); + + let (closed_tx, closed_rx) = tokio::sync::mpsc::channel(1); + drop(closed_rx); + assert!(matches!( + tracker_request_with_timeout( + &closed_tx, + std::time::Duration::from_millis(10), + get_notes, + ) + .await, + Err(TrackerRequestError::QueueClosed) + )); + } + + #[tokio::test] + async fn tracker_request_times_out_and_marks_stale_command_closed() { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + assert!(matches!( + tracker_request_with_timeout(&tx, std::time::Duration::from_millis(1), get_notes,) + .await, + Err(TrackerRequestError::Timeout) + )); + let command = rx.recv().await.unwrap(); + assert!(command.response_is_closed()); + } + + #[tokio::test] + async fn tracker_request_returns_the_worker_response() { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let worker = tokio::spawn(async move { + let TrackerCommand::GetNotes { response_tx } = rx.recv().await.unwrap() else { + panic!("unexpected command") + }; + response_tx.send(Ok(Vec::new())).unwrap(); + }); + let response = + tracker_request_with_timeout(&tx, std::time::Duration::from_millis(50), get_notes) + .await + .unwrap() + .unwrap(); + assert!(response.is_empty()); + worker.await.unwrap(); + } +} diff --git a/crates/basis_server/src/main.rs b/crates/basis_server/src/main.rs index af9f274..084c419 100644 --- a/crates/basis_server/src/main.rs +++ b/crates/basis_server/src/main.rs @@ -3,12 +3,13 @@ use axum::{ Router, }; use basis_server::{ - api::*, build_redemption, reserve_api::*, store::EventStore, submit_redemption, AppConfig, - AppState, ErgoConfig, EventType, ServerConfig, SharedTrackerState, TrackerBoxUpdateConfig, - TrackerBoxUpdater, TrackerCommand, TrackerEvent, TransactionConfig, + api::*, handle_options, reserve_api::*, reserve_construction_routes, + retired_v1_redemption_routes, store::EventStore, AppConfig, AppState, EventType, + PublicationLease, SharedTrackerState, TrackerBoxUpdateConfig, TrackerBoxUpdater, + TrackerCommand, TrackerEvent, }; use basis_store::{ - ergo_scanner::{start_scanner, NodeConfig, ReserveEvent, ServerState}, + ergo_scanner::{start_scanner, ReserveEvent, ServerState}, tracker_scanner::{create_tracker_server_state, TrackerNodeConfig}, ReserveTracker, }; @@ -16,54 +17,206 @@ use tokio::sync::Mutex; use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +struct UpdaterTaskHealthGuard { + shared_state: SharedTrackerState, + graceful: bool, +} + +impl UpdaterTaskHealthGuard { + fn new(shared_state: SharedTrackerState) -> Self { + Self { + shared_state, + graceful: false, + } + } + + fn mark_graceful(&mut self) { + self.graceful = true; + } +} + +impl Drop for UpdaterTaskHealthGuard { + fn drop(&mut self) { + if !self.graceful { + // Also runs during task unwinding, so a panic cannot silently + // leave the sole reorg watcher dead while effects remain readable. + self.shared_state.quarantine_publication(); + } + } +} + +fn reject_while_publication_is_fenced(command: TrackerCommand) { + use basis_store::NoteError; + + match command { + TrackerCommand::AddNote { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetNotesByIssuer { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetProjectedIssuerGrossDebt { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetNotesByRecipient { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetNotesByRecipientWithIssuer { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetNoteByIssuerAndRecipient { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetNotes { response_tx } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetValidatedState { response_tx } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetConfirmation { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::GetAllConfirmations { response_tx } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::BeginPublication { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationInProgress)); + } + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationLeaseMismatch)); + } + TrackerCommand::RollbackPublication { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationLeaseMismatch)); + } + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(NoteError::PublicationLeaseMismatch)); + } + } +} + +fn restore_pending_publication( + pending: Option<&basis_store::PendingTrackerPublication>, + shared_state: &SharedTrackerState, +) -> Option { + pending.map(|pending| { + shared_state.set_pending( + pending.digest(), + pending.tx_id().to_string(), + pending.submitted_height(), + ); + PublicationLease { + id: 0, + digest: pending.digest(), + } + }) +} + +fn handle_command_while_publication_is_fenced( + active_lease: PublicationLease, + command: TrackerCommand, + tracker: &mut basis_store::TrackerStateManager, + shared_state: &SharedTrackerState, +) -> Option { + match command { + TrackerCommand::RecordPublicationAttempt { + lease, + tx_id, + submitted_height, + response_tx, + } if lease == active_lease => { + let result = tracker.validated_state().and_then(|state| { + if state.avl_root_digest != lease.digest { + return Err(basis_store::NoteError::PublicationLeaseMismatch); + } + tracker.mark_notes_pending(lease.digest, &tx_id, submitted_height) + }); + if result.is_err() { + shared_state.quarantine_publication(); + } + let _ = response_tx.send(result); + Some(active_lease) + } + TrackerCommand::ConfirmPublication { + effect, + response_tx, + } => { + let result = tracker.confirm_validated_publication(&effect); + let confirmed = result.is_ok(); + if result.as_ref().is_err_and(|error| { + !matches!(error, basis_store::NoteError::PublicationLeaseMismatch) + }) { + shared_state.quarantine_publication(); + } + let _ = response_tx.send(result); + if confirmed { + None + } else { + Some(active_lease) + } + } + TrackerCommand::RollbackPublication { + rollback, + response_tx, + } => { + let result = tracker.rollback_validated_publication(&rollback); + if result.is_err() { + shared_state.quarantine_publication(); + } + let _ = response_tx.send(result); + Some(active_lease) + } + TrackerCommand::AbortPublication { lease, response_tx } if lease == active_lease => { + let result = tracker.pending_publication().and_then(|pending| { + if pending.is_some() { + Err(basis_store::NoteError::PublicationInProgress) + } else { + Ok(()) + } + }); + let released = result.is_ok(); + let _ = response_tx.send(result); + if released { + None + } else { + Some(active_lease) + } + } + other => { + reject_while_publication_is_fenced(other); + Some(active_lease) + } + } +} + #[tokio::main] async fn main() { tracing::info!("Starting basis server..."); // Load configuration tracing::info!("Loading configuration..."); - let config = match AppConfig::load() { - Ok(config) => config, - Err(e) => { - tracing::warn!("Failed to load configuration: {}", e); - tracing::info!("Using default configuration..."); - AppConfig::load().unwrap_or_else(|_| { - // Fallback to hardcoded defaults if config loading fails completely - AppConfig { - server: ServerConfig { - host: "0.0.0.0".to_string(), - port: 3048, - data_dir: Some("data".to_string()), - database_url: Some("sqlite:data/basis.db".to_string()), - }, - ergo: ErgoConfig { - node: NodeConfig { - start_height: None, - reserve_contract_p2s: None, - node_url: "http://127.0.0.1:9053".to_string(), - scan_name: Some("Basis Reserve Scanner".to_string()), - api_key: None, - }, - basis_reserve_contract_p2s: "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT".to_string(), - tracker_nft_id: None, - tracker_public_key: None, - tracker_secret_key: None, - }, - transaction: TransactionConfig { - fee: 1000000, // 0.001 ERG - change_address: None, // Will be derived from tracker public key - }, - acceptance: basis_server::acceptance::config::AcceptanceConfig::empty(), - } - }) - } - }; + let config = AppConfig::load_for_startup().unwrap_or_else(|error| { + tracing::error!("{}", error); + std::process::exit(1); + }); - // Validate that tracker NFT ID is properly configured - if let Err(_) = config.tracker_nft_bytes() { - tracing::error!("Tracker NFT ID is not properly configured in the configuration file. The server requires a valid tracker_nft_id value."); - std::process::exit(1); // Exit with error code if tracker NFT ID is not configured + if let Err(e) = config.validate_runtime_contract_mode() { + tracing::error!("{}", e); + std::process::exit(1); } + // Validate that tracker NFT ID is exactly the token id bound to persistent state. + let tracker_nft_bytes: [u8; 32] = match config + .tracker_nft_bytes() + .ok() + .and_then(|bytes| bytes.try_into().ok()) + { + Some(bytes) => bytes, + None => { + tracing::error!("Tracker NFT ID must be exactly 32 bytes of hex"); + std::process::exit(1); + } + }; + tracing::info!("Configuration loaded successfully"); // Resolve the configured data directory once. @@ -88,20 +241,10 @@ async fn main() { scanner_config.reserve_contract_p2s = Some(config.ergo.basis_reserve_contract_p2s.clone()); // Create real scanner state with configured node URL and contract template - let ergo_scanner = match ServerState::new(scanner_config, &data_dir) { - Ok(scanner) => scanner, - Err(e) => { - tracing::warn!("Failed to create Ergo scanner: {}", e); - tracing::info!("Continuing without blockchain scanner..."); - // Create a minimal scanner that won't actually scan - let minimal_config = NodeConfig { - node_url: "http://127.0.0.1:9053".to_string(), // Dummy URL that won't be used - ..Default::default() - }; - ServerState::new(minimal_config, &data_dir) - .unwrap_or_else(|_| panic!("Failed to create minimal scanner")) - } - }; + let ergo_scanner = ServerState::new(scanner_config, &data_dir).unwrap_or_else(|error| { + tracing::error!("Failed to create generation-bound Ergo scanner: {}", error); + std::process::exit(1); + }); // Start the scanner background task if let Err(e) = start_scanner(ergo_scanner.clone()).await { @@ -171,7 +314,6 @@ async fn main() { tracker_scanner_config, metadata_storage, tracker_storage, - &data_dir, ); // Process tracker boxes directly, no scan registration required @@ -243,64 +385,96 @@ async fn main() { // Create channel for communicating with tracker thread let (tx, mut rx) = tokio::sync::mpsc::channel::(100); - // Initialize tracker manager outside of the blocking task so it can be shared - use basis_store::TrackerStateManager; - let shared_tracker_state = - std::sync::Arc::new(std::sync::Mutex::new(TrackerStateManager::new(&data_dir))); + let generation = basis_store::TrackerGenerationConfig { + tracker_nft_id: tracker_nft_bytes, + fresh_generation: if config.ergo.allow_fresh_tracker_generation { + basis_store::FreshGenerationApproval::Approve + } else { + basis_store::FreshGenerationApproval::Deny + }, + }; - // Clone the data directory for the tracker thread before the async move. + // Open and validate state in its owning blocking thread, then wait for the + // startup result before exposing any API or publisher task. + let shared_state_for_tracker = shared_tracker_state_for_updater.clone(); // Also pass shared state for updater let data_dir_for_tracker_thread = data_dir.clone(); + let (init_tx, init_rx) = tokio::sync::oneshot::channel(); // Spawn tracker thread (using tokio::task::spawn_blocking for CPU-bound work) - let _shared_tracker_state_clone = shared_tracker_state.clone(); - let shared_state_for_tracker = shared_tracker_state_for_updater.clone(); // Also pass shared state for updater tokio::task::spawn_blocking(move || { - use basis_store::RedemptionManager; - tracing::debug!("Tracker thread started"); - let tracker = TrackerStateManager::new(&data_dir_for_tracker_thread); - - // Update shared state with the rebuilt AVL root digest after initialization - let initial_root = tracker.get_state().avl_root_digest; - shared_state_for_tracker.set_avl_root_digest(initial_root); + let mut tracker = match basis_store::TrackerStateManager::try_new_with_publication_health( + &data_dir_for_tracker_thread, + generation, + shared_state_for_tracker.publication_health(), + ) { + Ok(tracker) => tracker, + Err(error) => { + shared_state_for_tracker.quarantine_publication(); + let _ = init_tx.send(Err(format!("{error:?}"))); + return; + } + }; + let initial_root = match tracker.validated_state() { + Ok(state) => state.avl_root_digest, + Err(error) => { + shared_state_for_tracker.quarantine_publication(); + let _ = init_tx.send(Err(format!("{error:?}"))); + return; + } + }; + let initial_pending = match tracker.pending_publication() { + Ok(pending) => pending, + Err(error) => { + shared_state_for_tracker.quarantine_publication(); + let _ = init_tx.send(Err(format!("{error:?}"))); + return; + } + }; + let initial_confirmation = match tracker.validated_confirmation_anchor() { + Ok(anchor) => anchor, + Err(error) => { + shared_state_for_tracker.quarantine_publication(); + let _ = init_tx.send(Err(format!("{error:?}"))); + return; + } + }; + let confirmation_history_present = match tracker.has_persisted_confirmation_history() { + Ok(present) => present, + Err(error) => { + shared_state_for_tracker.quarantine_publication(); + let _ = init_tx.send(Err(format!("{error:?}"))); + return; + } + }; + shared_state_for_tracker.set_confirmation_history_present(confirmation_history_present); + shared_state_for_tracker.set_historical_confirmation(initial_confirmation); + let mut active_publication = + restore_pending_publication(initial_pending.as_ref(), &shared_state_for_tracker); + let _ = init_tx.send(Ok((initial_root, initial_pending.clone()))); tracing::info!( "Tracker thread initialized with AVL root digest: {}", hex::encode(&initial_root) ); - let mut redemption_manager = RedemptionManager::new(tracker); + let mut next_publication_id = 1u64; + while let Some(cmd) = rx.blocking_recv() { + if cmd.response_is_closed() { + tracing::debug!("Dropping tracker command whose requester already closed"); + continue; + } + tracing::debug!("Tracker thread received command: {:?}", cmd); - // Temporary startup repair: allow re-inserting a lost reserve AVL tree entry - // from the environment so a restarted server can continue a redemption sequence. - if let (Ok(issuer_hex), Ok(recipient_hex), Ok(ts_str), Ok(already_str)) = ( - std::env::var("REPAIR_RESERVE_ISSUER"), - std::env::var("REPAIR_RESERVE_RECIPIENT"), - std::env::var("REPAIR_RESERVE_TIMESTAMP"), - std::env::var("REPAIR_RESERVE_ALREADY_REDEEMED"), - ) { - if let (Ok(ts), Ok(already)) = (ts_str.parse::(), already_str.parse::()) { - let issuer = hex::decode(&issuer_hex) - .ok() - .and_then(|v| v.try_into().ok()); - let recipient = hex::decode(&recipient_hex) - .ok() - .and_then(|v| v.try_into().ok()); - if let (Some(issuer), Some(recipient)) = (issuer, recipient) { - match redemption_manager - .tracker - .update_already_redeemed(&issuer, &recipient, ts, already) - { - Ok(_) => tracing::info!( - "Reserve tree repaired: ts={ts}, already_redeemed={already}" - ), - Err(e) => tracing::warn!("Failed to repair reserve tree: {e:?}"), - } - } + if let Some(active_lease) = active_publication { + active_publication = handle_command_while_publication_is_fenced( + active_lease, + cmd, + &mut tracker, + &shared_state_for_tracker, + ); + continue; } - } - while let Some(cmd) = rx.blocking_recv() { - tracing::debug!("Tracker thread received command: {:?}", cmd); match cmd { TrackerCommand::AddNote { issuer_pubkey, @@ -308,14 +482,11 @@ async fn main() { response_tx, } => { // Get mutable access to the tracker for adding a note - let result = redemption_manager.tracker.add_note(&issuer_pubkey, ¬e); + let result = tracker.add_note(&issuer_pubkey, ¬e); // Update shared state for tracker box updater if successful if result.is_ok() { // Update the shared AVL root digest to match the current tracker state - let current_root = redemption_manager.tracker.get_state().avl_root_digest; - shared_state_for_tracker.set_avl_root_digest(current_root); - // Note: In a real implementation, we'd send this back to the async context to store // For now, we'll handle event storage in the async handler } @@ -326,176 +497,144 @@ async fn main() { issuer_pubkey, response_tx, } => { - let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetNotesByRecipient { - recipient_pubkey, - response_tx, - } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); + let result = tracker.get_issuer_notes(&issuer_pubkey); let _ = response_tx.send(result); } - TrackerCommand::GetNotesByRecipientWithIssuer { - recipient_pubkey, - response_tx, - } => { - let result = redemption_manager - .tracker - .get_recipient_notes_with_issuer(&recipient_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetNoteByIssuerAndRecipient { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = redemption_manager - .tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .map(Some); - let _ = response_tx.send(result); - } - TrackerCommand::InitiateRedemption { - request, - response_tx, - } => { - let result = redemption_manager.initiate_redemption(&request); - let _ = response_tx.send(result); - } - TrackerCommand::CompleteRedemption { + TrackerCommand::GetProjectedIssuerGrossDebt { issuer_pubkey, - recipient_pubkey, - redeemed_amount, - new_already_redeemed, + candidate_recipient, + candidate_total_debt, response_tx, } => { - let result = redemption_manager.complete_redemption( + let result = tracker.projected_issuer_gross_debt( &issuer_pubkey, - &recipient_pubkey, - redeemed_amount, - new_already_redeemed, + candidate_recipient.as_ref(), + candidate_total_debt, ); - - // Update shared state for tracker box updater if successful - if result.is_ok() { - // Update the shared AVL root digest to match the current tracker state - let current_root = redemption_manager.tracker.get_state().avl_root_digest; - shared_state_for_tracker.set_avl_root_digest(current_root); - } - let _ = response_tx.send(result); } - TrackerCommand::GetNotes { response_tx } => { - let result = redemption_manager.tracker.get_all_notes_with_issuer(); - let _ = response_tx.send(result); - } - TrackerCommand::GenerateProof { - issuer_pubkey, + TrackerCommand::GetNotesByRecipient { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker - .generate_proof(&issuer_pubkey, &recipient_pubkey); + let result = tracker.get_recipient_notes(&recipient_pubkey); let _ = response_tx.send(result); } - TrackerCommand::GetTrackerLookupProof { - issuer_pubkey, + TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker - .generate_tracker_lookup_proof(&issuer_pubkey, &recipient_pubkey); + let result = tracker.get_recipient_notes_with_issuer(&recipient_pubkey); let _ = response_tx.send(result); } - TrackerCommand::GetReserveLookupProof { + TrackerCommand::GetNoteByIssuerAndRecipient { issuer_pubkey, recipient_pubkey, response_tx, } => { - let result = redemption_manager - .tracker - .generate_reserve_lookup_proof(&issuer_pubkey, &recipient_pubkey); + let result = tracker + .lookup_note(&issuer_pubkey, &recipient_pubkey) + .map(Some); let _ = response_tx.send(result); } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey, - recipient_pubkey, - timestamp, - new_already_redeemed, - response_tx, - } => { - let result = redemption_manager.tracker.generate_reserve_insert_proof( - &issuer_pubkey, - &recipient_pubkey, - timestamp, - new_already_redeemed, - ); + TrackerCommand::GetNotes { response_tx } => { + let result = tracker.get_all_notes_with_issuer(); let _ = response_tx.send(result); } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); - let _ = response_tx.send(digest); + TrackerCommand::GetValidatedState { response_tx } => { + let _ = response_tx.send(tracker.validated_state()); } TrackerCommand::GetConfirmation { issuer_pubkey, recipient_pubkey, response_tx, } => { - let result = Ok(redemption_manager - .tracker - .get_confirmation(&issuer_pubkey, &recipient_pubkey)); + let result = tracker.try_get_confirmation(&issuer_pubkey, &recipient_pubkey); let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); + let result = tracker.try_all_confirmations(); + let _ = response_tx.send(result); } - TrackerCommand::MarkNotesPending { - digest, - tx_id, - submitted_height, + TrackerCommand::BeginPublication { + tracker_nft_id, + observed_root, + box_id: _, + height: _, response_tx, } => { - let result = redemption_manager.tracker.mark_notes_pending( - digest, - &tx_id, - submitted_height, - ); - let _ = response_tx.send(result); + let result = tracker + .validate_observed_generation(&tracker_nft_id, observed_root) + .and_then(|_| tracker.validated_state()) + .and_then(|state| { + next_publication_id + .checked_add(1) + .ok_or(basis_store::NoteError::PublicationLeaseMismatch)?; + Ok(PublicationLease { + id: next_publication_id, + digest: state.avl_root_digest, + }) + }); + + match result { + Ok(lease) => { + if response_tx.send(Ok(lease)).is_ok() { + active_publication = Some(lease); + next_publication_id += 1; + } + } + Err(error) => { + shared_state_for_tracker.quarantine_publication(); + let _ = response_tx.send(Err(error)); + } + } + } + TrackerCommand::RecordPublicationAttempt { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::PublicationLeaseMismatch)); } - TrackerCommand::ConfirmPendingNotes { - box_id, - height, + TrackerCommand::ConfirmPublication { + effect, response_tx, } => { - let result = redemption_manager - .tracker - .confirm_pending_notes(&box_id, height); - let _ = response_tx.send(result); - } - TrackerCommand::RevertPendingNotes { response_tx } => { - let result = redemption_manager.tracker.revert_pending_notes(); + let result = tracker.confirm_validated_publication(&effect); + if result.as_ref().is_err_and(|error| { + !matches!(error, basis_store::NoteError::PublicationLeaseMismatch) + }) { + shared_state_for_tracker.quarantine_publication(); + } let _ = response_tx.send(result); } - TrackerCommand::ReconcileWithConfirmedDigest { - digest, - box_id, - height, + TrackerCommand::RollbackPublication { + rollback, response_tx, } => { - let result = redemption_manager - .tracker - .reconcile_with_confirmed_digest(&digest, &box_id, height); + let result = tracker.rollback_validated_publication(&rollback); + if result.is_err() { + shared_state_for_tracker.quarantine_publication(); + } let _ = response_tx.send(result); } + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::PublicationLeaseMismatch)); + } } } }); + match init_rx.await { + Ok(Ok((_root, _pending))) => {} + Ok(Err(error)) => { + shared_tracker_state_for_updater.quarantine_publication(); + tracing::error!(error, "Tracker state initialization failed closed"); + std::process::exit(1); + } + Err(_) => { + shared_tracker_state_for_updater.quarantine_publication(); + tracing::error!("Tracker state thread ended during initialization"); + std::process::exit(1); + } + } + // Create tracker box updater tracing::info!("Initializing tracker box updater..."); @@ -523,8 +662,16 @@ async fn main() { api_key: config.ergo.node.api_key.clone(), update_interval_seconds: 600, // 10 minutes fee: config.transaction.fee, - change_address: config.get_change_address().ok(), tracker_secret_key: config.tracker_secret_key_bytes(), + min_successor_depth: config.ergo.confirmed_chain_min_successor_depth.unwrap_or(6), + max_evidence_age_ms: config + .ergo + .confirmed_chain_max_evidence_age_ms + .unwrap_or(60_000), + reorg_monitor_depth: config.ergo.confirmed_chain_reorg_monitor_depth, + reconciliation_journal_path: data_dir.join("confirmed-chain"), + allow_fresh_reconciliation_journal: config.ergo.allow_fresh_reconciliation_journal, + ..TrackerBoxUpdateConfig::default() }; let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1); @@ -537,7 +684,8 @@ async fn main() { let updater_cmd_tx = tx.clone(); let updater_shutdown_rx = shutdown_tx.subscribe(); tokio::spawn(async move { - if let Err(e) = TrackerBoxUpdater::start( + let mut health_guard = UpdaterTaskHealthGuard::new(shared_state_clone.clone()); + match TrackerBoxUpdater::start( updater_config, shared_state_clone, updater_shutdown_rx, @@ -545,7 +693,11 @@ async fn main() { ) .await { - tracing::error!("Tracker box updater failed: {}", e); + Ok(()) => health_guard.mark_graceful(), + Err(e) => tracing::error!( + "Tracker box updater failed and commitment effects were quarantined: {}", + e + ), } }); tracing::info!("Tracker box updater started successfully"); @@ -747,37 +899,9 @@ async fn main() { "/acceptance/policy/{pubkey}", get(get_policy_by_recipient).options(handle_options), ) - .route("/redeem", post(initiate_redemption).options(handle_options)) - .route( - "/redeem/complete", - post(complete_redemption).options(handle_options), - ) - .route("/proof/redemption", get(get_redemption_proof)) - .route("/tracker/proof", get(get_tracker_proof)) .route("/tracker/state", get(get_tracker_state)) .route("/tracker/pending-tx", get(get_pending_tx)) - .route("/reserve/proof", get(get_reserve_proof)) - .route( - "/tracker/signature", - post(request_tracker_signature).options(handle_options), - ) - .route( - "/redemption/prepare", - post(prepare_redemption).options(handle_options), - ) - .route( - "/redemption/build", - post(build_redemption).options(handle_options), - ) - .route( - "/redemption/submit", - post(submit_redemption).options(handle_options), - ) .route("/reserves", get(get_all_reserves)) - .route( - "/reserves/create", - post(create_reserve_payload).options(handle_options), - ) .route( "/reserves/submit", post(submit_reserve_transaction).options(handle_options), @@ -796,10 +920,8 @@ async fn main() { .route("/reserves/issuer/{pubkey}", get(get_reserves_by_issuer)) .route("/key-status/{pubkey}", get(get_key_status)) .route("/tracker/latest-box-id", get(get_latest_tracker_box_id)) - .route( - "/config/reserve-contract-p2s", - get(get_basis_reserve_contract_p2s), - ) + .merge(retired_v1_redemption_routes()) + .merge(reserve_construction_routes()) .with_state(app_state.clone()) .layer(tower_http::trace::TraceLayer::new_for_http()) .layer( @@ -825,7 +947,7 @@ async fn main() { tracing::debug!(" GET /events"); tracing::debug!(" GET /events/paginated"); tracing::debug!(" GET /key-status/{{pubkey}}"); - tracing::debug!(" POST /redeem"); + tracing::debug!(" Legacy redemption routes return HTTP 410 (v1 retired)"); tracing::debug!(" POST /acceptance/check"); tracing::debug!(" POST /acceptance/policy"); tracing::debug!(" GET /tracker/latest-box-id"); @@ -857,6 +979,188 @@ async fn main() { }; } +#[cfg(test)] +mod publication_fence_tests { + use super::*; + + fn generation( + fresh_generation: basis_store::FreshGenerationApproval, + ) -> basis_store::TrackerGenerationConfig { + basis_store::TrackerGenerationConfig { + tracker_nft_id: [0x42; 32], + fresh_generation, + } + } + + fn issuer_pubkey(secret: &[u8; 32]) -> [u8; 33] { + let secp = secp256k1::Secp256k1::new(); + let secret = secp256k1::SecretKey::from_slice(secret).expect("valid test secret"); + secp256k1::PublicKey::from_secret_key(&secp, &secret).serialize() + } + + #[tokio::test] + async fn active_publication_rejects_state_mutation_and_root_exposure() { + let (add_tx, add_rx) = tokio::sync::oneshot::channel(); + reject_while_publication_is_fenced(TrackerCommand::AddNote { + issuer_pubkey: [2u8; 33], + note: basis_store::IouNote { + recipient_pubkey: [3u8; 33], + amount_collected: 1, + amount_redeemed: 0, + timestamp: 1, + signature: [0u8; 65], + }, + response_tx: add_tx, + }); + assert!(matches!( + add_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) + )); + + let (state_tx, state_rx) = tokio::sync::oneshot::channel(); + reject_while_publication_is_fenced(TrackerCommand::GetValidatedState { + response_tx: state_tx, + }); + assert!(matches!( + state_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) + )); + } + + #[test] + fn updater_task_guard_quarantines_unexpected_termination_only() { + let failed = SharedTrackerState::new(); + { + let _guard = UpdaterTaskHealthGuard::new(failed.clone()); + } + assert!(!failed.is_publication_healthy()); + + let graceful = SharedTrackerState::new(); + { + let mut guard = UpdaterTaskHealthGuard::new(graceful.clone()); + guard.mark_graceful(); + } + assert!(graceful.is_publication_healthy()); + } + + #[tokio::test] + async fn stale_publication_receipt_cannot_release_the_actor_fence() { + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + reject_while_publication_is_fenced(TrackerCommand::AbortPublication { + lease: PublicationLease { + id: 7, + digest: [7u8; 33], + }, + response_tx, + }); + assert!(matches!( + response_rx.await, + Ok(Err(basis_store::NoteError::PublicationLeaseMismatch)) + )); + } + + #[tokio::test] + async fn durable_receipt_restores_the_actor_and_updater_fences_after_restart() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + let note = + basis_store::IouNote::create_and_sign(recipient, 1_000, 1, &issuer_secret).unwrap(); + let tx_id = "11".repeat(32); + let digest; + + { + let mut manager = basis_store::TrackerStateManager::try_new( + temp_dir.path(), + generation(basis_store::FreshGenerationApproval::Approve), + ) + .unwrap(); + manager.add_note(&issuer, ¬e).unwrap(); + digest = manager.validated_state().unwrap().avl_root_digest; + manager.mark_notes_pending(digest, &tx_id, 100).unwrap(); + } + + let restarted_shared = SharedTrackerState::new(); + let reopened = basis_store::TrackerStateManager::try_new_with_publication_health( + temp_dir.path(), + generation(basis_store::FreshGenerationApproval::Deny), + restarted_shared.publication_health(), + ) + .unwrap(); + let pending = reopened.pending_publication().unwrap().unwrap(); + let mut active = restore_pending_publication(Some(&pending), &restarted_shared); + assert_eq!(active.unwrap().digest, digest); + let updater_pending = restarted_shared.get_pending(); + assert_eq!(updater_pending.tx_id.as_deref(), Some(tx_id.as_str())); + assert_eq!(updater_pending.digest, Some(digest)); + assert_eq!(updater_pending.submitted_height, Some(100)); + assert_eq!( + TrackerBoxUpdater::restored_pending_transaction(&restarted_shared).unwrap(), + Some((tx_id.clone(), digest)) + ); + + let mut tracker = reopened; + + let (state_tx, state_rx) = tokio::sync::oneshot::channel(); + active = handle_command_while_publication_is_fenced( + active.unwrap(), + TrackerCommand::GetValidatedState { + response_tx: state_tx, + }, + &mut tracker, + &restarted_shared, + ); + assert!(matches!( + state_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) + )); + + let (add_tx, add_rx) = tokio::sync::oneshot::channel(); + active = handle_command_while_publication_is_fenced( + active.unwrap(), + TrackerCommand::AddNote { + issuer_pubkey: issuer, + note: note.clone(), + response_tx: add_tx, + }, + &mut tracker, + &restarted_shared, + ); + assert!(matches!( + add_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) + )); + + let (abort_tx, abort_rx) = tokio::sync::oneshot::channel(); + let restored_lease = active.unwrap(); + active = handle_command_while_publication_is_fenced( + restored_lease, + TrackerCommand::AbortPublication { + lease: restored_lease, + response_tx: abort_tx, + }, + &mut tracker, + &restarted_shared, + ); + assert!(matches!( + abort_rx.await, + Ok(Err(basis_store::NoteError::PublicationInProgress)) + )); + assert!(active.is_some()); + assert!(restarted_shared.is_publication_healthy()); + assert_eq!( + tracker.pending_publication().unwrap().unwrap().tx_id(), + tx_id + ); + assert_eq!( + TrackerBoxUpdater::restored_pending_transaction(&restarted_shared).unwrap(), + Some((tx_id.clone(), digest)) + ); + assert!(active.is_some()); + } +} + /// Background task that continuously scans the blockchain for reserve events #[allow(dead_code)] async fn background_scanner_task(state: AppState, config: AppConfig) { @@ -965,15 +1269,6 @@ async fn background_scanner_task(state: AppState, config: AppConfig) { } } -/// Handle OPTIONS preflight requests for CORS -async fn handle_options() -> impl axum::response::IntoResponse { - ( - axum::http::StatusCode::OK, - [("Access-Control-Allow-Origin", "*")], - "", - ) -} - /// Process a reserve event and store it in the event store #[allow(dead_code)] async fn process_reserve_event( diff --git a/crates/basis_server/src/models.rs b/crates/basis_server/src/models.rs index bddfcea..eef1311 100644 --- a/crates/basis_server/src/models.rs +++ b/crates/basis_server/src/models.rs @@ -143,223 +143,6 @@ pub struct KeyStatusResponse { pub has_pending_refund: bool, } -// Redemption request -#[derive(Debug, Deserialize)] -pub struct RedeemRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub amount: u64, - pub timestamp: u64, - /// Reserve box ID (optional - will be looked up if not provided) - #[serde(default)] - pub reserve_box_id: String, - /// Recipient address for redemption output (optional - derived from recipient_pubkey if not provided) - #[serde(default)] - pub recipient_address: String, - /// Issuer's Schnorr signature (65 bytes, hex encoded = 130 chars) - pub issuer_signature: String, - /// Whether this is an emergency redemption - #[serde(default)] - pub emergency: bool, -} - -// Redemption completion request -#[derive(Debug, Deserialize)] -pub struct CompleteRedemptionRequest { - pub redemption_id: String, - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub redeemed_amount: u64, - /// Optional explicit cumulative reserve-tree value (from the on-chain build). - /// When omitted, the note's cumulative redeemed amount is used. - #[serde(default)] - pub new_already_redeemed: Option, -} - -// Redemption response -#[derive(Debug, Serialize)] -pub struct RedeemResponse { - pub redemption_id: String, - pub amount: u64, - pub timestamp: u64, - pub proof_available: bool, - pub transaction_pending: bool, - /// Prepared transaction data that can be submitted to Ergo node - /// Contains all necessary fields for wallet payment API - pub transaction_data: Option, - /// Raw Ergo transaction JSON (hex encoded) that can be signed and submitted - pub transaction_bytes: Option, -} - -// Transaction data that can be submitted to Ergo node -#[derive(Debug, Serialize)] -pub struct TransactionData { - /// Target address for the transaction - pub address: String, - /// Value in nanoERG - pub value: u64, - /// Register values - pub registers: std::collections::HashMap, - /// Assets to include in transaction - pub assets: Vec, - /// Transaction fee - pub fee: u64, -} - -// Token/Asset data for transaction -#[derive(Debug, Serialize)] -pub struct TokenData { - pub token_id: String, - pub amount: u64, -} - -// Proof response -#[derive(Debug, Serialize)] -pub struct ProofResponse { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub proof_data: String, - pub tracker_state_digest: String, - pub block_height: u64, - pub timestamp: u64, -} - -// Request for tracker signature -// Following specs/server/redemption_state_spec.md - POST /tracker/signature -#[derive(Debug, Deserialize)] -pub struct TrackerSignatureRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub total_debt: u64, - /// Payment timestamp in milliseconds since Unix epoch - pub timestamp: u64, - #[serde(default)] - pub emergency: bool, -} - -// Response for tracker signature -// Following specs/server/redemption_state_spec.md -#[derive(Debug, Serialize)] -pub struct TrackerSignatureResponse { - pub success: bool, - pub tracker_signature: String, - pub tracker_pubkey: String, - pub message_signed: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub is_emergency: Option, -} - -// Request for redemption preparation -#[derive(Debug, Deserialize)] -pub struct RedemptionPreparationRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub amount: u64, - pub timestamp: u64, -} - -// Response for redemption preparation -#[derive(Debug, Serialize)] -pub struct RedemptionPreparationResponse { - pub redemption_id: String, - pub avl_proof: String, - pub tracker_signature: String, - pub tracker_pubkey: String, - pub tracker_state_digest: String, - pub block_height: u64, -} - -// Redemption proof response - following specs/server/redemption_state_spec.md -// GET /proof/redemption endpoint response -#[derive(Debug, Serialize)] -pub struct RedemptionProofResponse { - pub success: bool, - pub data: RedemptionProofData, - pub error: Option, -} - -#[derive(Debug, Serialize)] -pub struct RedemptionProofData { - pub tracker_lookup_proof: String, - pub reserve_lookup_proof: Option, - pub reserve_insert_proof: String, - pub tracker_state_digest: String, - pub reserve_state_digest: String, - pub total_debt: u64, - pub already_redeemed: u64, - pub proof_valid: bool, - pub is_first_redemption: bool, -} - -// Tracker lookup proof response - for context var #8 -// GET /tracker/proof endpoint response -#[derive(Debug, Serialize)] -pub struct TrackerProofResponse { - pub success: bool, - pub data: TrackerProofData, - pub error: Option, -} - -#[derive(Debug, Serialize)] -pub struct TrackerProofData { - /// Hex-encoded AVL tree key: hash(ownerKey || receiverKey) - pub key: String, - /// Hex-encoded value: totalDebt as 8-byte big-endian - pub value: String, - /// Hex-encoded AVL proof bytes - pub proof: String, - /// Total debt as integer - pub total_debt: u64, - /// Current tracker state digest (R5 register value) - pub tracker_state_digest: String, -} - -// Reserve lookup proof response - for context var #7 -// GET /reserve/proof endpoint response -#[derive(Debug, Serialize)] -pub struct ReserveProofData { - /// Hex-encoded AVL tree key: hash(ownerKey || receiverKey) - pub key: String, - /// Hex-encoded value: already_redeemed as 8-byte big-endian - pub value: String, - /// Hex-encoded AVL proof bytes (None for first redemption) - for context var #7 (lookup) - pub proof: Option, - /// Already redeemed amount as integer - pub already_redeemed: u64, - /// Whether this is the first redemption (no lookup proof needed) - pub is_first_redemption: bool, - /// Hex-encoded AVL insert proof for context var #5 (insert operation) - /// This proof is used to INSERT the new already_redeemed amount into the reserve tree - pub insert_proof: String, - /// Hex-encoded updated reserve state digest after the insert operation (R5 register value) - pub new_reserve_state_digest: String, -} - -// Redemption preparation response - updated with new fields -#[derive(Debug, Serialize)] -pub struct RedemptionPreparationResponseV2 { - pub success: bool, - pub data: RedemptionPreparationData, - pub error: Option, -} - -#[derive(Debug, Serialize)] -pub struct RedemptionPreparationData { - pub redemption_id: String, - pub tracker_lookup_proof: String, - pub reserve_lookup_proof: Option, - pub reserve_insert_proof: String, - pub tracker_signature: String, - pub reserve_signature: String, - pub tracker_pubkey: String, - pub tracker_state_digest: String, - pub reserve_state_digest: String, - pub total_debt: u64, - pub already_redeemed: u64, - pub is_first_redemption: bool, - pub transaction_bytes: String, -} - // Request for creating a reserve #[derive(Debug, Deserialize)] pub struct CreateReserveRequest { @@ -390,11 +173,6 @@ pub struct Asset { pub amount: u64, } -// Response for reserve submission via the tracker's Ergo node. -#[derive(Debug, Clone, Serialize)] -pub struct ReserveSubmissionResponse { - pub tx_id: String, -} #[derive(Debug, Serialize)] pub struct TrackerBoxIdResponse { pub tracker_box_id: String, diff --git a/crates/basis_server/src/redemption_build.rs b/crates/basis_server/src/redemption_build.rs index 6bc15f8..f85e91c 100644 --- a/crates/basis_server/src/redemption_build.rs +++ b/crates/basis_server/src/redemption_build.rs @@ -1,1220 +1,22 @@ -//! Tracker-assisted 2-phase redemption build/submit endpoints. +//! Structural tombstones for the retired tracker-assisted v1 redemption API. //! -//! This mirrors the proven CLI local-sign flow (`basis_cli::commands::transaction`), split across -//! two parties: -//! * `POST /redemption/build` — the tracker constructs the unsigned redemption transaction -//! (same byte layout the node and reserve contract accept, context extension in Scala order), -//! selects wallet fee inputs, and signs ONLY the fee input(s) locally with its own secret. -//! It returns the unsigned tx, the fee-signed partial tx, and the sigma-serialized input/data -//! boxes the client needs to finish signing. -//! * the client (TUI) adds ONLY the reserve input's `proveDlog(receiver)` proof over the same -//! `bytes_to_sign`, then calls `POST /redemption/submit` with the fully-signed tx. -//! * `POST /redemption/submit` — the tracker broadcasts the fully-signed transaction. -//! -//! Neither end reorders the reserve-input context extension: the tracker emits it in Scala -//! `ContextExtension` order, so both ends sign an identical `bytes_to_sign`. - -// Axum handlers returning `(StatusCode, Json>)` have a large `Err` variant by -// construction; boxing the error would add noise without benefit here. -#![allow(clippy::result_large_err)] - -use axum::{extract::State, http::StatusCode, Json}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashMap; - -use ergo_lib::chain::ergo_state_context::ErgoStateContext; -use ergo_lib::chain::parameters::Parameters; -use ergo_lib::chain::transaction::unsigned::UnsignedTransaction; -use ergo_lib::ergo_chain_types::{Header, PreHeader}; -use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; -use ergo_lib::ergotree_ir::serialization::SigmaSerializable; -use ergo_lib::wallet::secret_key::SecretKey; - -use basis_offchain::ergo_tx::{ - address_to_ergo_tree, build_savl_tree_from_digest, decode_box_id, ergo_tree_to_p2pk_address, - pubkey_to_address, reorder_reserve_extension_scala, serialize_coll_bytes, serialize_ergo_byte, - serialize_ergo_long, -}; -use basis_offchain::signing::add_input_proof; - -use crate::models::{error_response, success_response, ApiResponse}; -use crate::{AppState, TrackerCommand}; - -/// Minimum post-redemption reserve output value (0.001 ERG min box value). -const MIN_RESERVE_REMAINDER: u64 = 1_000_000; -/// Miner-fee recipient contract from the Scala reference implementation. -const FEE_CONTRACT_TREE: &str = "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a701730073011001020402d19683030193a38cc7b2a57300000193c2b2a57301007473027303830108cdeeac93b1a57304"; - -// ----- request / response models ----- - -#[derive(Debug, Deserialize)] -pub struct RedemptionBuildRequest { - pub issuer_pubkey: String, - pub recipient_pubkey: String, - pub amount: u64, - /// Note payment timestamp (ms since Unix epoch), from the note. - pub timestamp: u64, - /// Issuer's 65-byte Schnorr signature (hex) over - /// `signing_message(issuer || receiver || totalDebt || timestamp)`. - pub issuer_signature: String, - #[serde(default)] - pub emergency: bool, - /// Optional tracker box id; fetched from storage if omitted. - #[serde(default)] - pub tracker_box_id: Option, - /// Optional change address; derived from the selected fee box if omitted. - #[serde(default)] - pub change_address: Option, -} - -#[derive(Debug, Serialize)] -pub struct RedemptionBuildResponse { - /// Node-canonical unsigned transaction (reserve-input extension in Scala order). - pub unsigned_tx: Value, - /// Fee-signed transaction (reserve input proof empty); base for the client to splice the - /// reserve proof into. - pub partial_tx: Value, - /// Sigma-serialized input boxes (hex), in transaction-input order. - pub input_box_binaries: Vec, - /// Sigma-serialized data-input boxes (hex). - pub data_box_binaries: Vec, - /// Last 10 block headers; the client derives the `PreHeader` and `ErgoStateContext` from these - /// to finish signing without needing its own node connection. - pub headers: Vec
, - pub reserve_box_id: String, - pub tracker_box_id: String, - pub reserve_output_value: u64, - pub recipient_output_value: u64, - pub total_debt: u64, - pub change_amount: u64, - pub change_address: String, - pub recipient_address: String, - pub is_first_redemption: bool, - pub fee: u64, - /// Cumulative `already_redeemed` value this redemption inserts into the reserve tree - /// (previous reserve-tree value + redeemed amount). The client round-trips it to - /// `/redemption/submit` so the tracker can sync its local reserve tree with the value - /// that was actually proven on-chain. - pub new_already_redeemed: u64, -} +//! This module deliberately contains no transaction construction, proof, +//! signing, node submission, broadcast, or state-mutation implementation. -#[derive(Debug, Deserialize)] -pub struct RedemptionSubmitRequest { - pub signed_tx: Value, - /// Issuer (reserve owner) compressed public key, hex. - pub issuer_pubkey: String, - /// Recipient (creditor) compressed public key, hex. - pub recipient_pubkey: String, - /// Amount redeemed by this transaction (nanoERG). - pub redeemed_amount: u64, - /// Cumulative reserve-tree `already_redeemed` from the build response. - pub new_already_redeemed: u64, -} - -#[derive(Debug, Serialize)] -pub struct RedemptionSubmitResponse { - pub tx_id: String, -} - -// ----- node client ----- - -#[derive(Debug, Clone, Deserialize)] -struct NodeAsset { - #[serde(alias = "tokenId")] - token_id: String, - #[allow(dead_code)] - amount: u64, -} - -#[derive(Debug, Clone, Deserialize)] -struct NodeBox { - #[serde(alias = "boxId")] - box_id: String, - value: u64, - #[serde(alias = "ergoTree")] - ergo_tree: String, - #[serde(default)] - assets: Vec, - #[serde(default, alias = "additionalRegisters")] - additional_registers: HashMap, -} - -impl NodeBox { - /// The 33-byte reserve AVL tree digest held in R5, if present. R5 serializes as - /// `0x64 || 33-byte digest || flags || keylen || valuelen`, so the digest is hex chars [2..68]. - fn r5_digest_hex(&self) -> Option { - let r5 = self.additional_registers.get("R5")?; - if r5.len() >= 68 && r5.starts_with("64") { - Some(r5[2..68].to_string()) - } else { - None - } - } -} - -struct NodeClient { - url: String, - api_key: Option, -} +use axum::{http::StatusCode, Json}; -impl NodeClient { - fn from_state(state: &AppState) -> Self { - NodeClient { - url: state - .config - .ergo - .node - .node_url - .trim_end_matches('/') - .to_string(), - api_key: state - .config - .ergo - .node - .api_key - .clone() - .filter(|k| !k.is_empty()), - } - } +use crate::models::ApiResponse; - async fn get_json(&self, path: &str) -> Result { - let client = reqwest::Client::new(); - let url = format!("{}{}", self.url, path); - let mut req = client.get(&url); - if let Some(ref k) = self.api_key { - req = req.header("api_key", k); - } - let resp = req.send().await.map_err(|e| format!("GET {url}: {e}"))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("GET {url} HTTP {status}: {body}")); - } - resp.json::() - .await - .map_err(|e| format!("GET {url} json: {e}")) - } - - async fn box_binary(&self, box_id: &str) -> Result { - let v = self.get_json(&format!("/utxo/byIdBinary/{box_id}")).await?; - v["bytes"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| format!("missing 'bytes' for box {box_id}")) - } - - async fn box_details(&self, box_id: &str) -> Result { - let v = self.get_json(&format!("/utxo/byId/{box_id}")).await?; - serde_json::from_value(v).map_err(|e| format!("parse box {box_id}: {e}")) - } - - async fn wallet_boxes(&self) -> Result, String> { - let v = self - .get_json("/wallet/boxes/unspent?minConfirmations=0&maxConfirmations=-1") - .await?; - #[derive(Deserialize)] - struct Entry { - #[serde(rename = "box")] - details: NodeBox, - } - let entries: Vec = - serde_json::from_value(v).map_err(|e| format!("parse wallet boxes: {e}"))?; - Ok(entries.into_iter().map(|e| e.details).collect()) - } - - async fn last_headers(&self) -> Result, String> { - let v = self.get_json("/blocks/lastHeaders/10").await?; - serde_json::from_value(v).map_err(|e| format!("parse last headers: {e}")) - } - - async fn broadcast(&self, tx: &Value) -> Result { - let client = reqwest::Client::new(); - let url = format!("{}/transactions", self.url); - let mut req = client.post(&url).json(tx); - if let Some(ref k) = self.api_key { - req = req.header("api_key", k); - } - let resp = req.send().await.map_err(|e| format!("broadcast: {e}"))?; - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!("broadcast rejected HTTP {status}: {body}")); - } - Ok(parse_broadcast_tx_id(&body)) - } -} - -fn parse_broadcast_tx_id(body: &str) -> String { - if let Ok(value) = serde_json::from_str::(body) { - match value { - Value::String(s) => return s, - Value::Object(map) => { - if let Some(Value::String(id)) = map.get("id") { - return id.clone(); - } - } - _ => {} - } - } - body.trim().trim_matches('"').to_string() -} - -/// Select wallet boxes covering the required fee, excluding the reserve box and token-bearing boxes. -fn select_fee_inputs( - wallet_boxes: &[NodeBox], - required: u64, - reserve_box_id: &str, -) -> Option<(Vec, u64)> { - let mut candidates: Vec<&NodeBox> = wallet_boxes - .iter() - .filter(|b| b.box_id != reserve_box_id && b.assets.is_empty()) - .collect(); - candidates.sort_by_key(|b| b.value); - - if let Some(b) = candidates.iter().find(|b| b.value >= required) { - return Some((vec![(*b).clone()], b.value)); - } - let mut selected = Vec::new(); - let mut total = 0u64; - for b in candidates { - total += b.value; - selected.push(b.clone()); - if total >= required { - return Some((selected, total)); - } - } - None -} - -type ApiResult = Result>)>; - -fn api_err(status: StatusCode, msg: impl Into) -> ApiResult { - Err((status, Json(error_response(msg.into())))) -} - -// ----- handlers ----- - -/// Build the unsigned redemption transaction and sign the fee input(s) locally. +/// Retired v1 build endpoint. V2 has a separate exact-manifest boundary and is +/// intentionally not activated by this compatibility route. #[axum::debug_handler] -pub async fn build_redemption( - State(state): State, - Json(payload): Json, -) -> (StatusCode, Json>) { - match build_redemption_inner(&state, &payload).await { - Ok(resp) => (StatusCode::OK, Json(success_response(resp))), - Err(e) => e, - } -} - -async fn build_redemption_inner( - state: &AppState, - payload: &RedemptionBuildRequest, -) -> ApiResult { - // Validate public keys. - let issuer_pubkey_bytes = match hex::decode(&payload.issuer_pubkey) { - Ok(b) if b.len() == 33 => b, - _ => return api_err(StatusCode::BAD_REQUEST, "issuer_pubkey must be 33-byte hex"), - }; - let recipient_pubkey_bytes = match hex::decode(&payload.recipient_pubkey) { - Ok(b) if b.len() == 33 => b, - _ => { - return api_err( - StatusCode::BAD_REQUEST, - "recipient_pubkey must be 33-byte hex", - ) - } - }; - let issuer_pk: [u8; 33] = issuer_pubkey_bytes.clone().try_into().unwrap(); - let recipient_pk: [u8; 33] = recipient_pubkey_bytes.clone().try_into().unwrap(); - let issuer_pubkey: basis_store::PubKey = issuer_pk; - let recipient_pubkey: basis_store::PubKey = recipient_pk; - - let recipient_address = match pubkey_to_address(&payload.recipient_pubkey) { - Ok(a) => a, - Err(e) => return api_err(StatusCode::BAD_REQUEST, format!("recipient address: {e}")), - }; - - let node = NodeClient::from_state(state); - - // Look up the note to confirm it exists (timestamp/total debt are authoritative from proofs). - { - let (tx, rx) = tokio::sync::oneshot::channel(); - if state - .tx - .send(TrackerCommand::GetNoteByIssuerAndRecipient { - issuer_pubkey, - recipient_pubkey, - response_tx: tx, - }) - .await - .is_err() - { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker thread unavailable", - ); - } - match rx.await { - Ok(Ok(Some(_note))) => {} - Ok(Ok(None)) => { - return api_err( - StatusCode::BAD_REQUEST, - "note not found for issuer/recipient", - ) - } - Ok(Err(e)) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("note lookup failed: {e:?}"), - ) - } - Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), - } - } - - // Select the smallest reserve that covers the redemption, leaves a valid remainder, is actually - // unspent on-chain, AND whose on-chain R5 (reserve AVL tree) matches the tracker's current - // reserve tree digest — otherwise the insert proof cannot verify on-chain. - let reserve_box_id = { - // The tracker's current reserve tree root digest; the spent reserve's R5 must equal this. - let tracker_reserve_digest = { - let (tx, rx) = tokio::sync::oneshot::channel(); - if state - .tx - .send(TrackerCommand::GetReserveStateDigest { response_tx: tx }) - .await - .is_err() - { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker thread unavailable", - ); - } - match rx.await { - Ok(d) => hex::encode(d), - Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), - } - }; - - let scanner = state.ergo_scanner.lock().await; - let reserves = match scanner.reserve_storage().get_all_reserves() { - Ok(r) => r, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("read reserves: {e:?}"), - ) - } - }; - drop(scanner); - let normalized_issuer = basis_store::normalize_public_key(&payload.issuer_pubkey); - let required = payload.amount.saturating_add(MIN_RESERVE_REMAINDER); - - // Gather matching candidates (issuer owner, sufficient collateral), smallest first. - let mut candidates: Vec<(String, u64)> = Vec::new(); - for reserve in &reserves { - // Owner key may be double-hex-encoded in the DB. - let owner = if let Ok(b) = hex::decode(&reserve.owner_pubkey) { - if let Ok(s) = String::from_utf8(b.clone()) { - if s.chars().all(|c| c.is_ascii_hexdigit()) { - s - } else { - reserve.owner_pubkey.clone() - } - } else { - reserve.owner_pubkey.clone() - } - } else { - reserve.owner_pubkey.clone() - }; - if basis_store::normalize_public_key(&owner) != normalized_issuer { - continue; - } - let collateral = reserve.base_info.collateral_amount; - if collateral < required { - continue; - } - candidates.push((decode_box_id(&reserve.box_id), collateral)); - } - candidates.sort_by_key(|(_, c)| *c); - - // Pick the smallest candidate that is unspent on-chain and whose R5 matches the tracker's - // current reserve tree digest. - let mut found: Option = None; - for (box_id, _) in &candidates { - match node.box_details(box_id).await { - Ok(b) if b.value >= required => match b.r5_digest_hex() { - Some(d) if d == tracker_reserve_digest => { - found = Some(box_id.clone()); - break; - } - Some(d) => { - tracing::warn!( - "reserve {} R5 {} != tracker tree {}; skipping", - box_id, - &d[..16.min(d.len())], - &tracker_reserve_digest[..16.min(tracker_reserve_digest.len())] - ); - } - None => { - tracing::warn!("reserve {} has no R5 digest; skipping", box_id); - } - }, - _ => { - tracing::warn!("skipping unavailable/insufficient reserve box {}", box_id); - } - } - } - match found { - Some(id) => id, - None => { - return api_err( - StatusCode::BAD_REQUEST, - format!( - "no unspent reserve with >= {required} nanoERG collateral and a reserve tree matching the tracker (digest {}) for issuer {}", - &tracker_reserve_digest[..16.min(tracker_reserve_digest.len())], - payload.issuer_pubkey - ), - ) - } - } - }; - - // Tracker box id (data input). Prefer the live shared state over the static - // tracker storage, which can contain spent boxes. - let tracker_box_id = match &payload.tracker_box_id { - Some(id) => id.clone(), - None => { - let shared = state.shared_tracker_state.lock().await; - match shared - .get_tracker_box_id() - .or_else(|| shared.get_confirmed().box_id) - { - Some(id) => id, - None => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "no tracker box in live state", - ) - } - } - } - }; - - let tracker_nft_id = match &state.config.ergo.tracker_nft_id { - Some(id) if !id.is_empty() => id.clone(), - _ => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker NFT id not configured", - ) - } - }; - - // Tracker lookup proof (context var #8) + authoritative total debt. - let (tracker_lookup_proof, total_debt) = { - let (tx, rx) = tokio::sync::oneshot::channel(); - if state - .tx - .send(TrackerCommand::GetTrackerLookupProof { - issuer_pubkey, - recipient_pubkey, - response_tx: tx, - }) - .await - .is_err() - { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker thread unavailable", - ); - } - match rx.await { - Ok(Ok(proof)) => { - let total_debt = if proof.value.len() == 8 { - let mut b = [0u8; 8]; - b.copy_from_slice(&proof.value); - u64::from_be_bytes(b) - } else { - 0 - }; - (proof.proof, total_debt) - } - Ok(Err(e)) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("tracker proof: {e:?}"), - ) - } - Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), - } - }; - - // Reserve proofs (insert #5, lookup #7, new R5 digest) plus the cumulative - // `already_redeemed` value placed into the reserve tree by this redemption. - let ( - reserve_lookup_proof, - insert_proof, - new_reserve_digest, - is_first_redemption, - new_already_redeemed, - ) = { - let (tx, rx) = tokio::sync::oneshot::channel(); - if state - .tx - .send(TrackerCommand::GetReserveLookupProof { - issuer_pubkey, - recipient_pubkey, - response_tx: tx, - }) - .await - .is_err() - { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker thread unavailable", - ); - } - let lookup = match rx.await { - Ok(Ok(p)) => p, - Ok(Err(e)) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("reserve lookup proof: {e:?}"), - ) - } - Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), - }; - let already_redeemed = if lookup.value.len() == 16 { - let mut b = [0u8; 8]; - b.copy_from_slice(&lookup.value[8..16]); - u64::from_be_bytes(b) - } else if lookup.value.len() == 8 { - let mut b = [0u8; 8]; - b.copy_from_slice(&lookup.value); - u64::from_be_bytes(b) - } else { - 0 - }; - let is_first = lookup.proof.is_none(); - let new_already_redeemed = already_redeemed + payload.amount; - - let (itx, irx) = tokio::sync::oneshot::channel(); - if state - .tx - .send(TrackerCommand::GetReserveInsertProof { - issuer_pubkey, - recipient_pubkey, - timestamp: payload.timestamp, - new_already_redeemed, - response_tx: itx, - }) - .await - .is_err() - { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker thread unavailable", - ); - } - let (insert_bytes, new_digest) = match irx.await { - Ok(Ok(v)) => v, - Ok(Err(e)) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("reserve insert proof: {e:?}"), - ) - } - Err(_) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "tracker unavailable"), - }; - ( - lookup.proof, - insert_bytes, - new_digest, - is_first, - new_already_redeemed, - ) - }; - - // Issuer signature (context var #2) supplied by the caller. - let issuer_signature = match hex::decode(payload.issuer_signature.trim()) { - Ok(b) if b.len() == 65 => b, - _ => { - return api_err( - StatusCode::BAD_REQUEST, - "issuer_signature must be 65-byte hex", - ) - } - }; - - // Tracker signature (context var #6), signed locally with the configured tracker secret. - let tracker_signature: Vec = if !payload.emergency { - let tracker_secret = match state.config.tracker_secret_key_bytes() { - Some(s) => s, - None => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker secret key not configured for local signing", - ) - } - }; - let tracker_pubkey_bytes = match state.config.tracker_public_key_bytes() { - Ok(Some(p)) => p, - _ => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker public key not configured", - ) - } - }; - let message = basis_store::schnorr::signing_message( - &issuer_pk, - &recipient_pk, - total_debt, - payload.timestamp, - ); - match basis_store::schnorr::schnorr_sign(&message, &tracker_secret, &tracker_pubkey_bytes) { - Ok(sig) => sig.to_vec(), - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("tracker schnorr sign: {e:?}"), - ) - } - } - } else { - Vec::new() - }; - - // Fetch node data (height) — node client created above. - let current_height = { - let scanner = state.ergo_scanner.lock().await; - match scanner.get_current_height().await { - Ok(h) => h, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("current height: {e}"), - ) - } - } - }; - - let reserve_box = match node.box_details(&reserve_box_id).await { - Ok(b) => b, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("reserve box: {e}"), - ) - } - }; - let reserve_nft_id = reserve_box - .assets - .first() - .map(|a| a.token_id.clone()) - .unwrap_or_else(|| tracker_nft_id.clone()); - - let wallet_boxes = match node.wallet_boxes().await { - Ok(b) => b, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("wallet boxes: {e}"), - ) - } - }; - let fee = state.config.transaction_fee(); - let (fee_boxes, fee_total) = match select_fee_inputs(&wallet_boxes, fee, &reserve_box_id) { - Some(v) => v, - None => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("no wallet boxes covering {fee} nanoERG fee"), - ) - } - }; - - // Change address: the owner of the first selected fee box (which we sign), else config/recipient. - let change_address = match &payload.change_address { - Some(a) => a.clone(), - None => ergo_tree_to_p2pk_address(&fee_boxes[0].ergo_tree) - .or_else(|_| state.config.get_change_address().map_err(|e| e.to_string())) - .unwrap_or_else(|_| recipient_address.clone()), - }; - - // Build the reserve R5 register (updated reserve state as SAvlTree constant). - let r5_bytes = build_savl_tree_from_digest(&hex::encode(&new_reserve_digest)); - let r5_hex = hex::encode(&r5_bytes); - - // Preserve the refund initiation height from the spent reserve box's R7 register. - let refund_initiation_height = basis_store::ergo_scanner::decode_ergo_long_register( - reserve_box.additional_registers.get("R7"), - ); - - // Context extension (Scala order applied below, before parsing). - let mut context_extension: HashMap = HashMap::new(); - context_extension.insert("0".to_string(), serialize_ergo_byte(0)); - context_extension.insert("1".to_string(), format!("07{}", payload.recipient_pubkey)); - context_extension.insert("2".to_string(), serialize_coll_bytes(&issuer_signature)); - context_extension.insert("3".to_string(), serialize_ergo_long(total_debt as i64)); - context_extension.insert( - "4".to_string(), - serialize_ergo_long(payload.timestamp as i64), - ); - context_extension.insert("5".to_string(), serialize_coll_bytes(&insert_proof)); - if !payload.emergency { - context_extension.insert("6".to_string(), serialize_coll_bytes(&tracker_signature)); - } - if let Some(ref lp) = reserve_lookup_proof { - context_extension.insert("7".to_string(), serialize_coll_bytes(lp)); - } - context_extension.insert("8".to_string(), serialize_coll_bytes(&tracker_lookup_proof)); - - // Output ergoTrees. - let reserve_ergo_tree = match address_to_ergo_tree(state.config.basis_reserve_contract_p2s()) { - Ok(t) => t, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("reserve contract P2S: {e}"), - ) - } - }; - let recipient_ergo_tree = match address_to_ergo_tree(&recipient_address) { - Ok(t) => t, - Err(e) => return api_err(StatusCode::BAD_REQUEST, format!("recipient tree: {e}")), - }; - let change_ergo_tree = match address_to_ergo_tree(&change_address) { - Ok(t) => t, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("change tree: {e}"), - ) - } - }; - - let reserve_output_value = reserve_box.value.saturating_sub(payload.amount); - if reserve_output_value == 0 { - return api_err( - StatusCode::BAD_REQUEST, - "reserve output value would be zero after redemption", - ); - } - let recipient_output_value = payload.amount; - let change_amount = fee_total.saturating_sub(fee); - - // Inputs: reserve (index 0) then fee inputs. - let mut inputs = vec![json!({ "boxId": reserve_box_id, "extension": context_extension })]; - for fb in &fee_boxes { - inputs.push(json!({ "boxId": fb.box_id, "extension": json!({}) })); - } - - let mut outputs = vec![ - json!({ - "value": reserve_output_value, - "ergoTree": reserve_ergo_tree, - "creationHeight": current_height, - "assets": [ { "tokenId": reserve_nft_id, "amount": 1 } ], - "additionalRegisters": { - "R4": format!("07{}", payload.issuer_pubkey), - "R5": r5_hex, - "R6": format!("0e{:02x}{}", tracker_nft_id.len() / 2, tracker_nft_id), - "R7": serialize_ergo_long(refund_initiation_height as i64) - } - }), - json!({ - "value": recipient_output_value, - "ergoTree": recipient_ergo_tree, - "creationHeight": current_height, - "assets": [], - "additionalRegisters": {} - }), - json!({ - "value": fee, - "ergoTree": FEE_CONTRACT_TREE, - "creationHeight": current_height, - "assets": [], - "additionalRegisters": {} - }), - ]; - if change_amount > 0 { - outputs.push(json!({ - "value": change_amount, - "ergoTree": change_ergo_tree, - "creationHeight": current_height, - "assets": [], - "additionalRegisters": {} - })); - } - - let mut unsigned_tx = json!({ - "inputs": inputs, - "dataInputs": [ { "boxId": tracker_box_id } ], - "outputs": outputs - }); - - // Reorder reserve-input extension into Scala ContextExtension order before parsing. - reorder_reserve_extension_scala(&mut unsigned_tx); - - // Parse boxes for local fee signing. - let reserve_box_binary = match node.box_binary(&reserve_box_id).await { - Ok(b) => b, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("reserve bin: {e}"), - ) - } - }; - let tracker_box_binary = match node.box_binary(&tracker_box_id).await { - Ok(b) => b, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("tracker bin: {e}"), - ) - } - }; - let mut fee_binaries = Vec::with_capacity(fee_boxes.len()); - for fb in &fee_boxes { - match node.box_binary(&fb.box_id).await { - Ok(b) => fee_binaries.push(b), - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("fee bin {}: {e}", fb.box_id), - ) - } - } - } - - let unsigned: UnsignedTransaction = match serde_json::from_value(unsigned_tx.clone()) { - Ok(u) => u, - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("parse unsigned tx: {e}"), - ) - } - }; - - let parse_box = |hexstr: &str, label: &str| -> ApiResult { - let bytes = hex::decode(hexstr).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(error_response(format!("{label} hex: {e}"))), - ) - })?; - ErgoBox::sigma_parse_bytes(&bytes).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(error_response(format!("{label} parse: {e:?}"))), - ) - }) - }; - let reserve_ebox = parse_box(&reserve_box_binary, "reserve box")?; - let tracker_ebox = parse_box(&tracker_box_binary, "tracker box")?; - let mut fee_eboxes = Vec::with_capacity(fee_binaries.len()); - for (i, fb) in fee_binaries.iter().enumerate() { - fee_eboxes.push(parse_box(fb, &format!("fee box {i}"))?); - } - - let mut input_boxes = vec![reserve_ebox]; - input_boxes.extend(fee_eboxes); - let data_boxes = vec![tracker_ebox]; - - // State context for proving (contract does not read headers/params; height from PreHeader). - let headers = match node.last_headers().await { - Ok(h) if h.len() >= 10 => h, - Ok(h) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("need >=10 headers, got {}", h.len()), - ) - } - Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("headers: {e}")), - }; - let pre_header = PreHeader::from(headers[0].clone()); - let headers_array: [Header; 10] = headers[..10].to_vec().try_into().map_err(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(error_response("headers array".to_string())), - ) - })?; - let _state_context = ErgoStateContext::new( - pre_header.clone(), - headers_array.clone(), - Parameters::default(), - ); - - // Sign the fee input(s) locally with the tracker secret. - let fee_secret = match state.config.tracker_secret_key_bytes() { - Some(s) => s, - None => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "tracker secret key not configured", - ) - } - }; - let fee_sk = match SecretKey::dlog_from_bytes(&fee_secret) { - Some(sk) => sk, - None => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - "invalid tracker dlog secret", - ) - } - }; - - let n_inputs = unsigned.inputs.len(); - let mut partial: Option = None; - for fee_idx in 1..n_inputs { - match add_input_proof( - &unsigned, - partial.as_ref(), - &input_boxes, - &data_boxes, - &pre_header, - &headers_array, - fee_idx, - &fee_sk, - ) { - Ok(tx) => partial = Some(tx), - Err(e) => { - return api_err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("fee input {fee_idx} signing: {e:?}"), - ) - } - } - } - - let partial_tx = match partial { - Some(ref tx) => serde_json::to_value(tx).unwrap_or(Value::Null), - None => unsigned_tx.clone(), // no fee inputs (fee covered by reserve? not expected) - }; - - let mut input_box_binaries = vec![reserve_box_binary]; - input_box_binaries.extend(fee_binaries); - - Ok(RedemptionBuildResponse { - unsigned_tx, - partial_tx, - input_box_binaries, - data_box_binaries: vec![tracker_box_binary], - headers: headers_array.to_vec(), - reserve_box_id, - tracker_box_id, - reserve_output_value, - recipient_output_value, - total_debt, - change_amount, - change_address, - recipient_address, - is_first_redemption, - fee, - new_already_redeemed, - }) +pub async fn build_redemption() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() } -/// Broadcast a fully-signed redemption transaction to the Ergo node. -/// -/// After a successful broadcast the tracker's local state is synced: the note's -/// `amount_redeemed` is incremented by `redeemed_amount` and the reserve AVL tree entry -/// is set to `new_already_redeemed` (the cumulative value proven on-chain by the build). +/// Retired v1 submit endpoint. There is no node or broadcast client in this +/// module. #[axum::debug_handler] -pub async fn submit_redemption( - State(state): State, - Json(payload): Json, -) -> (StatusCode, Json>) { - let issuer_pubkey_bytes = match hex::decode(&payload.issuer_pubkey) { - Ok(b) if b.len() == 33 => b, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(error_response( - "issuer_pubkey must be 33-byte hex".to_string(), - )), - ) - } - }; - let recipient_pubkey_bytes = match hex::decode(&payload.recipient_pubkey) { - Ok(b) if b.len() == 33 => b, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(error_response( - "recipient_pubkey must be 33-byte hex".to_string(), - )), - ) - } - }; - let issuer_pubkey: basis_store::PubKey = issuer_pubkey_bytes.try_into().unwrap(); - let recipient_pubkey: basis_store::PubKey = recipient_pubkey_bytes.try_into().unwrap(); - - let node = NodeClient::from_state(&state); - let tx_id = match node.broadcast(&payload.signed_tx).await { - Ok(tx_id) => tx_id, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(error_response(format!("broadcast failed: {e}"))), - ) - } - }; - - // Sync local note + reserve tree state with the broadcast transaction. The reserve - // tree value must be the cumulative amount proven on-chain (from the build), which - // can differ from the note's cumulative redeemed amount when reserves were created - // fresh or state was repaired. - let (tx, rx) = tokio::sync::oneshot::channel(); - if state - .tx - .send(TrackerCommand::CompleteRedemption { - issuer_pubkey, - recipient_pubkey, - redeemed_amount: payload.redeemed_amount, - new_already_redeemed: Some(payload.new_already_redeemed), - response_tx: tx, - }) - .await - .is_err() - { - tracing::error!(tx_id, "tracker thread unavailable; local state not synced"); - } else { - match rx.await { - Ok(Ok(())) => {} - Ok(Err(e)) => tracing::error!(tx_id, ?e, "local redemption state sync failed"), - Err(_) => tracing::error!(tx_id, "tracker dropped state sync response"), - } - } - - ( - StatusCode::OK, - Json(success_response(RedemptionSubmitResponse { tx_id })), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn wallet_box(id: &str, value: u64, with_token: bool) -> NodeBox { - let assets = if with_token { - vec![NodeAsset { - token_id: "aa".repeat(32), - amount: 1, - }] - } else { - Vec::new() - }; - NodeBox { - box_id: id.to_string(), - value, - ergo_tree: "00".to_string(), - assets, - additional_registers: Default::default(), - } - } - - #[test] - fn select_fee_inputs_picks_exact_single_box() { - let boxes = vec![ - wallet_box("a", 5_000_000, false), - wallet_box("b", 1_000_000, false), - ]; - let (selected, total) = select_fee_inputs(&boxes, 1_000_000, "zz").unwrap(); - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].box_id, "b"); - assert_eq!(total, 1_000_000); - } - - #[test] - fn select_fee_inputs_prefers_smallest_sufficient() { - let boxes = vec![ - wallet_box("big", 50_000_000, false), - wallet_box("mid", 2_000_000, false), - wallet_box("small", 500_000, false), - ]; - let (selected, total) = select_fee_inputs(&boxes, 1_000_000, "zz").unwrap(); - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].box_id, "mid"); - assert_eq!(total, 2_000_000); - } - - #[test] - fn select_fee_inputs_aggregates_when_no_single_box_suffices() { - let boxes = vec![ - wallet_box("a", 400_000, false), - wallet_box("b", 300_000, false), - wallet_box("c", 500_000, false), - ]; - let (selected, total) = select_fee_inputs(&boxes, 1_000_000, "zz").unwrap(); - assert_eq!(selected.len(), 3); - assert_eq!(total, 1_200_000); - } - - #[test] - fn select_fee_inputs_excludes_reserve_box() { - let boxes = vec![wallet_box("reserve", 10_000_000, false)]; - assert!(select_fee_inputs(&boxes, 1_000_000, "reserve").is_none()); - } - - #[test] - fn select_fee_inputs_excludes_token_boxes() { - let boxes = vec![ - wallet_box("token", 10_000_000, true), - wallet_box("free", 2_000_000, false), - ]; - let (selected, _) = select_fee_inputs(&boxes, 1_000_000, "zz").unwrap(); - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].box_id, "free"); - } - - #[test] - fn select_fee_inputs_returns_none_when_insufficient() { - let boxes = vec![wallet_box("a", 100_000, false)]; - assert!(select_fee_inputs(&boxes, 1_000_000, "zz").is_none()); - assert!(select_fee_inputs(&[], 1_000_000, "zz").is_none()); - } - - fn r5_box(r5: Option<&str>) -> NodeBox { - let mut registers = std::collections::HashMap::new(); - if let Some(v) = r5 { - registers.insert("R5".to_string(), v.to_string()); - } - NodeBox { - box_id: "x".to_string(), - value: 0, - ergo_tree: "00".to_string(), - assets: Vec::new(), - additional_registers: registers, - } - } - - #[test] - fn r5_digest_hex_extracts_digest_from_real_onchain_r5() { - // Real R5 from the empty-tree reserve created on-chain (tx 2a5e653a...). - // The new reserve uses flags 0x03 (insert+update allowed) for insertOrUpdate. - let b = r5_box(Some( - "644ec61f485b98eb87153f7c57db4f5ecd75556fddbc403b41acf8441fde8e160900032000", - )); - assert_eq!( - b.r5_digest_hex().unwrap(), - "4ec61f485b98eb87153f7c57db4f5ecd75556fddbc403b41acf8441fde8e160900" - ); - } - - #[test] - fn r5_digest_hex_handles_missing_or_malformed_r5() { - assert!(r5_box(None).r5_digest_hex().is_none()); - // Wrong type prefix (not 0x64). - assert!(r5_box(Some("0702aaaaaaaa")).r5_digest_hex().is_none()); - // Too short to contain a 33-byte digest. - assert!(r5_box(Some("64abcd")).r5_digest_hex().is_none()); - } +pub async fn submit_redemption() -> (StatusCode, Json>) { + crate::reject_retired_v1_redemption() } diff --git a/crates/basis_server/src/store.rs b/crates/basis_server/src/store.rs index 0c715ff..2f6c523 100644 --- a/crates/basis_server/src/store.rs +++ b/crates/basis_server/src/store.rs @@ -1,37 +1,54 @@ use crate::models::TrackerEvent; +use std::collections::VecDeque; use std::sync::atomic::AtomicU64; use tokio::sync::Mutex; -// Simple file-based event store with sequential IDs +pub const MAX_STORED_EVENTS: usize = 10_000; +pub const MAX_EVENT_PAGE_SIZE: usize = 100; + +#[derive(Debug, thiserror::Error)] +pub enum EventStoreError { + #[error("event identifier space exhausted")] + IdentifierExhausted, + #[error("page_size must be between 1 and {MAX_EVENT_PAGE_SIZE}")] + InvalidPageSize, + #[error("event pagination offset overflow")] + PaginationOverflow, +} + +// Simple file-based event store with sequential IDs. pub struct EventStore { - events: Mutex>, + events: Mutex>, next_id: AtomicU64, } impl EventStore { - pub async fn new() -> Result> { - // In a real implementation, this would load from disk - // For now, we'll use in-memory but structured for easy disk persistence + pub async fn new() -> Result { + // In a real implementation, this would load from disk. The in-memory + // fallback is deliberately bounded so a public event stream cannot + // consume process memory without limit. Ok(Self { - events: Mutex::new(Vec::new()), + events: Mutex::new(VecDeque::new()), next_id: AtomicU64::new(1), }) } - pub async fn add_event( - &self, - mut event: TrackerEvent, - ) -> Result> { + pub async fn add_event(&self, mut event: TrackerEvent) -> Result { let id = self .next_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |next| next.checked_add(1), + ) + .map_err(|_| EventStoreError::IdentifierExhausted)?; event.id = id; - // In a real implementation, this would append to a disk file - // For now, we'll use a mutex-protected vector let mut events = self.events.lock().await; - events.push(event); - + if events.len() == MAX_STORED_EVENTS { + events.pop_front(); + } + events.push_back(event); Ok(id) } @@ -39,18 +56,105 @@ impl EventStore { &self, page: usize, page_size: usize, - ) -> Result, Box> { + ) -> Result, EventStoreError> { + if !(1..=MAX_EVENT_PAGE_SIZE).contains(&page_size) { + return Err(EventStoreError::InvalidPageSize); + } + let events = self.events.lock().await; + let start = page + .checked_mul(page_size) + .ok_or(EventStoreError::PaginationOverflow)?; + if start >= events.len() { + return Ok(Vec::new()); + } + Ok(events.iter().skip(start).take(page_size).cloned().collect()) + } + + pub async fn get_recent_events( + &self, + limit: usize, + ) -> Result, EventStoreError> { + if !(1..=MAX_EVENT_PAGE_SIZE).contains(&limit) { + return Err(EventStoreError::InvalidPageSize); + } let events = self.events.lock().await; - let start = page * page_size; - let end = std::cmp::min(start + page_size, events.len()); - Ok(events[start..end].to_vec()) + let start = events.len().saturating_sub(limit); + Ok(events.iter().skip(start).cloned().collect()) } - /// Create an in-memory event store for testing + /// Create an in-memory event store for testing. pub fn new_in_memory() -> Self { Self { - events: Mutex::new(Vec::new()), + events: Mutex::new(VecDeque::new()), next_id: AtomicU64::new(1), } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::EventType; + + fn event(timestamp: u64) -> TrackerEvent { + TrackerEvent { + id: 0, + event_type: EventType::Commitment, + timestamp, + issuer_pubkey: None, + recipient_pubkey: None, + amount: None, + reserve_box_id: None, + collateral_amount: None, + redeemed_amount: None, + height: None, + } + } + + #[tokio::test] + async fn pagination_rejects_zero_oversized_and_overflowing_requests() { + let store = EventStore::new_in_memory(); + assert!(matches!( + store.get_events_paginated(0, 0).await, + Err(EventStoreError::InvalidPageSize) + )); + assert!(matches!( + store.get_events_paginated(0, MAX_EVENT_PAGE_SIZE + 1).await, + Err(EventStoreError::InvalidPageSize) + )); + assert!(matches!( + store.get_events_paginated(usize::MAX, 2).await, + Err(EventStoreError::PaginationOverflow) + )); + } + + #[tokio::test] + async fn event_retention_is_bounded_and_recent_reads_use_the_tail() { + let store = EventStore::new_in_memory(); + for timestamp in 0..=(MAX_STORED_EVENTS as u64) { + store.add_event(event(timestamp)).await.unwrap(); + } + + let first = store.get_events_paginated(0, 1).await.unwrap(); + assert_eq!(first[0].timestamp, 1); + let recent = store.get_recent_events(2).await.unwrap(); + assert_eq!( + recent + .iter() + .map(|event| event.timestamp) + .collect::>(), + vec![MAX_STORED_EVENTS as u64 - 1, MAX_STORED_EVENTS as u64] + ); + } + + #[tokio::test] + async fn out_of_range_pages_are_empty_without_panicking() { + let store = EventStore::new_in_memory(); + store.add_event(event(1)).await.unwrap(); + assert!(store + .get_events_paginated(100, 10) + .await + .unwrap() + .is_empty()); + } +} diff --git a/crates/basis_server/src/tracker_box_updater.rs b/crates/basis_server/src/tracker_box_updater.rs index c41f9ba..eb052da 100644 --- a/crates/basis_server/src/tracker_box_updater.rs +++ b/crates/basis_server/src/tracker_box_updater.rs @@ -1,13 +1,49 @@ //! Tracker Box Updater Service //! //! This module implements a background service that periodically updates the R4 and R5 register values -//! of the tracker box every 10 minutes by submitting transactions to the Ergo blockchain via the -//! node's /wallet/transaction/sign and /transactions endpoints. - -use std::sync::{Arc, RwLock}; +//! of the tracker box every 10 minutes. Exact node box bytes and one linked state context are +//! validated locally, signed with ergo-lib, and only the signed transaction is sent to the node. + +use basis_store::chain_reconciliation::{ + validate_anchor_still_active, validate_chain_effect, validate_reorg_horizon, validate_rollback, + ActiveChainProof, JournalBootstrap, ReconciliationError, ReconciliationIntent, + ReconciliationJournal, ReconciliationJournalBinding, ReconciliationPolicy, RecoveryAction, + ReorgHorizonDecision, TransactionChainEvidence, ValidatedChainEffect, ValidatedRollback, + MAX_REORG_MONITOR_DEPTH, +}; +use ergo_lib::chain::{ + ergo_state_context::{ErgoStateContext, Headers}, + parameters::Parameters, + transaction::{unsigned::UnsignedTransaction, Transaction, UnsignedInput}, +}; +use ergo_lib::ergo_chain_types::{EcPoint, Header, PreHeader}; +use ergo_lib::ergotree_ir::{ + chain::{ + address::Address, + context_extension::ContextExtension, + ergo_box::{ + box_value::BoxValue, ErgoBox, ErgoBoxCandidate, NonMandatoryRegisterId, + NonMandatoryRegisters, + }, + }, + ergo_tree::ErgoTree, + mir::constant::{Constant, TryExtractInto}, + serialization::SigmaSerializable, + sigma_protocol::sigma_boolean::ProveDlog, +}; +use ergo_lib::wallet::{ + secret_key::SecretKey, tx_builder::new_miner_fee_box, tx_context::TransactionContext, Wallet, +}; +use std::{ + collections::HashSet, + path::PathBuf, + sync::{Arc, RwLock}, +}; use tokio::time::{interval, Duration}; use tracing::{error, info, warn}; +const RECONCILIATION_NETWORK_ID: &str = "ergo-mainnet"; + /// Create a default tracker public key that looks realistic (compressed format with proper prefix) fn create_default_tracker_pubkey() -> [u8; 33] { [ @@ -26,6 +62,12 @@ pub struct ConfirmedState { pub box_id: Option, /// Height at which the confirmed box was observed. pub height: Option, + /// Transaction which created the accepted tracker successor. + pub tx_id: Option, + /// Active-chain block containing `tx_id`. + pub block_id: Option, + /// Policy-accepted successor depth. + pub successor_depth: Option, } /// Snapshot of an in-flight tracker box update transaction. @@ -42,12 +84,14 @@ pub struct PendingState { /// Shared state for the tracker box updater #[derive(Debug, Clone)] pub struct SharedTrackerState { - pub avl_root_digest: Arc>, pub tracker_pubkey: Arc>, pub tracker_box_id: Arc>>, pub tracker_nft_id: Arc>>, - pub confirmed: Arc>, + confirmed: Arc>, pub pending: Arc>, + historical_confirmation: Arc>>, + confirmation_history_present: Arc>, + publication_health: basis_store::PublicationHealth, } impl SharedTrackerState { @@ -55,29 +99,27 @@ impl SharedTrackerState { /// This should only be used in tests - production code should use new_with_tracker_key. pub fn new() -> Self { Self { - avl_root_digest: Arc::new(RwLock::new([0u8; 33])), tracker_pubkey: Arc::new(RwLock::new(create_default_tracker_pubkey())), tracker_box_id: Arc::new(RwLock::new(None)), tracker_nft_id: Arc::new(RwLock::new(None)), confirmed: Arc::new(RwLock::new(ConfirmedState::default())), pending: Arc::new(RwLock::new(PendingState::default())), + historical_confirmation: Arc::new(RwLock::new(None)), + confirmation_history_present: Arc::new(RwLock::new(false)), + publication_health: basis_store::PublicationHealth::new(), } } pub fn new_with_tracker_key(tracker_pubkey: [u8; 33]) -> Self { Self { - avl_root_digest: Arc::new(RwLock::new([0u8; 33])), tracker_pubkey: Arc::new(RwLock::new(tracker_pubkey)), tracker_box_id: Arc::new(RwLock::new(None)), tracker_nft_id: Arc::new(RwLock::new(None)), confirmed: Arc::new(RwLock::new(ConfirmedState::default())), pending: Arc::new(RwLock::new(PendingState::default())), - } - } - - pub fn set_avl_root_digest(&self, digest: [u8; 33]) { - if let Ok(mut root_lock) = self.avl_root_digest.write() { - *root_lock = digest; + historical_confirmation: Arc::new(RwLock::new(None)), + confirmation_history_present: Arc::new(RwLock::new(false)), + publication_health: basis_store::PublicationHealth::new(), } } @@ -99,12 +141,17 @@ impl SharedTrackerState { } } - pub fn get_avl_root_digest(&self) -> [u8; 33] { - if let Ok(root_lock) = self.avl_root_digest.read() { - *root_lock - } else { - [0u8; 33] - } + /// Shared one-way health signal for the state manager and publisher. + pub fn publication_health(&self) -> basis_store::PublicationHealth { + self.publication_health.clone() + } + + pub fn quarantine_publication(&self) { + self.publication_health.quarantine(); + } + + pub fn is_publication_healthy(&self) -> bool { + self.publication_health.is_healthy() } pub fn get_tracker_pubkey(&self) -> [u8; 33] { @@ -148,11 +195,28 @@ impl SharedTrackerState { } /// Record the confirmed on-chain state. - pub fn set_confirmed(&self, digest: [u8; 33], box_id: String, height: u64) { + pub fn set_confirmed( + &self, + digest: [u8; 33], + tx_id: String, + box_id: String, + block_id: String, + height: u64, + successor_depth: u64, + ) { if let Ok(mut confirmed) = self.confirmed.write() { confirmed.digest = Some(digest); + confirmed.tx_id = Some(tx_id); confirmed.box_id = Some(box_id); + confirmed.block_id = Some(block_id); confirmed.height = Some(height); + confirmed.successor_depth = Some(successor_depth); + } + } + + pub fn clear_confirmed(&self) { + if let Ok(mut confirmed) = self.confirmed.write() { + *confirmed = ConfirmedState::default(); } } @@ -163,19 +227,94 @@ impl SharedTrackerState { /// Snapshot the confirmed state. pub fn get_confirmed(&self) -> ConfirmedState { + if !self.is_publication_healthy() { + return ConfirmedState::default(); + } self.confirmed.read().map(|c| c.clone()).unwrap_or_default() } + + /// Seed or update the data-only BNS1 projection used by the startup join. + /// This does not authorize confirmation; only a journal-private validated + /// effect can do that. + pub fn set_historical_confirmation( + &self, + anchor: Option, + ) { + if anchor.is_some() { + self.set_confirmation_history_present(true); + } + if let Ok(mut stored) = self.historical_confirmation.write() { + *stored = anchor; + } + } + + fn get_historical_confirmation(&self) -> Option { + self.historical_confirmation + .read() + .map(|stored| stored.clone()) + .unwrap_or(None) + } + + pub fn set_confirmation_history_present(&self, present: bool) { + if let Ok(mut stored) = self.confirmation_history_present.write() { + *stored = present; + } + } + + fn has_confirmation_history(&self) -> bool { + self.confirmation_history_present + .read() + .map(|present| *present) + .unwrap_or(true) + } } /// Configuration for the tracker box updater -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct TrackerBoxUpdateConfig { pub node_url: String, pub api_key: Option, pub update_interval_seconds: u64, pub fee: u64, - pub change_address: Option, pub tracker_secret_key: Option<[u8; 32]>, + /// Application policy expressed as successor depth (tip inclusion = 0). + pub min_successor_depth: u64, + /// Maximum age of the coherent evidence bundle used for acceptance. + pub max_evidence_age_ms: u64, + /// Explicit, maintainer-ratified successor depth after which an accepted + /// anchor is durably retired from active reorg polling. + pub reorg_monitor_depth: Option, + /// Deadline applied to each node request. + pub request_timeout_seconds: u64, + /// Dedicated single-writer confirmed-chain journal. + pub reconciliation_journal_path: PathBuf, + /// One-shot permission to bind a new empty journal to a fresh BNS1 + /// generation. Never set this for an existing state directory. + pub allow_fresh_reconciliation_journal: bool, + /// V2 remains disabled until its complete runtime activation is approved. + pub allow_v2_reconciliation: bool, +} + +impl std::fmt::Debug for TrackerBoxUpdateConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrackerBoxUpdateConfig") + .field("node_url", &self.node_url) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field("update_interval_seconds", &self.update_interval_seconds) + .field("fee", &self.fee) + .field("min_successor_depth", &self.min_successor_depth) + .field("max_evidence_age_ms", &self.max_evidence_age_ms) + .field("reorg_monitor_depth", &self.reorg_monitor_depth) + .field( + "allow_fresh_reconciliation_journal", + &self.allow_fresh_reconciliation_journal, + ) + .field( + "tracker_secret_key", + &self.tracker_secret_key.as_ref().map(|_| ""), + ) + .finish() + } } impl Default for TrackerBoxUpdateConfig { @@ -185,17 +324,45 @@ impl Default for TrackerBoxUpdateConfig { api_key: None, update_interval_seconds: 600, fee: 1_000_000, - change_address: None, tracker_secret_key: None, + min_successor_depth: 6, + max_evidence_age_ms: 60_000, + reorg_monitor_depth: None, + request_timeout_seconds: 15, + reconciliation_journal_path: PathBuf::from("data/confirmed-chain"), + allow_fresh_reconciliation_journal: false, + allow_v2_reconciliation: false, } } } +#[cfg(test)] +mod secret_redaction_tests { + use super::TrackerBoxUpdateConfig; + + #[test] + fn updater_config_debug_redacts_all_secrets() { + let api_sentinel = "sentinel-updater-api-key-do-not-log"; + let config = TrackerBoxUpdateConfig { + api_key: Some(api_sentinel.to_string()), + tracker_secret_key: Some([0xab; 32]), + ..TrackerBoxUpdateConfig::default() + }; + + let rendered = format!("{config:?}"); + assert!(!rendered.contains(api_sentinel)); + assert!(!rendered.contains("171, 171")); + assert!(rendered.matches("").count() >= 2); + } +} + /// Error type for tracker box updater operations #[derive(Debug, thiserror::Error)] pub enum TrackerBoxUpdaterError { #[error("HTTP request failed: {0}")] HttpError(String), + #[error("node response or local observation integrity failed: {0}")] + InvalidNodeResponse(String), #[error("No tracker NFT ID configured")] NoTrackerNftId, #[error("No tracker box found on chain")] @@ -208,14 +375,26 @@ pub enum TrackerBoxUpdaterError { NoFeeInputs, #[error("Insufficient wallet funds to pay transaction fee: {available} < {required}")] InsufficientFeeInputs { available: u64, required: u64 }, - #[error("Failed to sign transaction: {0}")] + #[error("Tracker signing key is not configured")] + MissingTrackerSecretKey, + #[error("Tracker input validation failed: {0}")] + InputValidation(String), + #[error("Tracker state context validation failed: {0}")] + StateContextValidation(String), + #[error("Tracker transaction arithmetic failed: {0}")] + ArithmeticError(String), + #[error("Failed to sign transaction locally: {0}")] SigningFailed(String), - #[error("Failed to broadcast transaction: {0}")] - BroadcastFailed(String), + #[error("Broadcast outcome is unknown; tracker publication remains fenced: {0}")] + BroadcastOutcomeUnknown(String), + #[error("Invalid confirmed-chain updater configuration: {0}")] + InvalidConfiguration(String), + #[error("Confirmed-chain reconciliation failed: {0}")] + Reconciliation(#[from] ReconciliationError), } /// Ergo box as returned by the blockchain API -#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct ErgoBoxApi { pub box_id: String, @@ -224,10 +403,12 @@ pub struct ErgoBoxApi { pub assets: Vec, pub additional_registers: std::collections::HashMap, pub creation_height: u32, + pub transaction_id: String, + pub index: u16, } /// Asset in an Ergo box -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AssetApi { pub token_id: String, @@ -267,18 +448,156 @@ fn vlq_encode(mut value: usize) -> Vec { /// Tracker box updater service pub struct TrackerBoxUpdater; +struct PreparedTrackerUpdate { + signed_bytes: Vec, + tx_id: String, + intent: ReconciliationIntent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NodeTip { + id: String, + height: u64, +} + +enum TransactionObservation { + Pending, + Accepted(ValidatedChainEffect), +} + +struct LocalSigningMaterial { + secret: SecretKey, + tracker_point: EcPoint, + p2pk_tree: ErgoTree, +} + +struct LocalSigningContext { + state_context: ErgoStateContext, + creation_height: u32, +} + impl TrackerBoxUpdater { + #[doc(hidden)] + pub fn restored_pending_transaction( + shared_state: &SharedTrackerState, + ) -> Result, TrackerBoxUpdaterError> { + let pending = shared_state.get_pending(); + match (pending.tx_id, pending.digest, pending.submitted_height) { + (Some(tx_id), Some(digest), Some(_)) => Ok(Some((tx_id, digest))), + (None, None, None) => Ok(None), + _ => { + shared_state.quarantine_publication(); + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "incomplete durable publication receipt at updater startup".to_string(), + )) + } + } + } + + fn journal_bootstrap_policy( + has_confirmation_history: bool, + has_pending_publication: bool, + fresh_approved: bool, + ) -> JournalBootstrap { + if has_confirmation_history || has_pending_publication || !fresh_approved { + JournalBootstrap::ExistingRequired + } else { + JournalBootstrap::FreshAllowed + } + } + /// Start the tracker box updater service as an async background task pub async fn start( + config: TrackerBoxUpdateConfig, + shared_state: SharedTrackerState, + shutdown_rx: tokio::sync::broadcast::Receiver<()>, + cmd_tx: Option>, + ) -> Result<(), TrackerBoxUpdaterError> { + let health = shared_state.clone(); + let result = Self::run(config, shared_state, shutdown_rx, cmd_tx).await; + if result.is_err() { + // Every non-graceful updater termination closes the same one-way + // gate consumed by tracker accounting and redemption reads. + health.quarantine_publication(); + } + result + } + + async fn run( config: TrackerBoxUpdateConfig, shared_state: SharedTrackerState, mut shutdown_rx: tokio::sync::broadcast::Receiver<()>, cmd_tx: Option>, ) -> Result<(), TrackerBoxUpdaterError> { + if config.allow_v2_reconciliation { + return Err(TrackerBoxUpdaterError::InvalidConfiguration( + "v2 reconciliation has no activated complete reserve/claim manifest".to_string(), + )); + } + let tracker_nft_id = match shared_state.get_tracker_nft_id() { + Some(id) => id, + None => { + info!("Tracker NFT is not configured; confirmed-chain publisher remains disabled"); + let _ = shutdown_rx.recv().await; + return Ok(()); + } + }; + let tracker_nft_bytes: [u8; 32] = hex::decode(&tracker_nft_id) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidConfiguration( + "configured tracker NFT is not exactly 32 bytes".to_string(), + ) + })?; + let reorg_monitor_depth = config.reorg_monitor_depth.ok_or_else(|| { + TrackerBoxUpdaterError::InvalidConfiguration( + "confirmed-chain reorg_monitor_depth requires explicit maintainer approval" + .to_string(), + ) + })?; + if config.update_interval_seconds == 0 + || config.request_timeout_seconds == 0 + || config.max_evidence_age_ms == 0 + || reorg_monitor_depth < config.min_successor_depth + || reorg_monitor_depth > MAX_REORG_MONITOR_DEPTH + { + return Err(TrackerBoxUpdaterError::InvalidConfiguration( + "interval, request timeout, evidence lifetime and finality horizon are invalid" + .to_string(), + )); + } + let client = Self::node_client(&config)?; + let source_id = + ReconciliationPolicy::source_id_for(RECONCILIATION_NETWORK_ID, &config.node_url); + let policy = ReconciliationPolicy::new( + config.min_successor_depth, + config.max_evidence_age_ms, + reorg_monitor_depth, + RECONCILIATION_NETWORK_ID, + source_id, + ); + let restored_pending = Self::restored_pending_transaction(&shared_state)?; + let historical_confirmation = shared_state.get_historical_confirmation(); + let confirmation_history_present = shared_state.has_confirmation_history(); + let bootstrap = Self::journal_bootstrap_policy( + confirmation_history_present, + restored_pending.is_some(), + config.allow_fresh_reconciliation_journal, + ); + let journal = ReconciliationJournal::open( + &config.reconciliation_journal_path, + ReconciliationJournalBinding::tracker_v1(tracker_nft_bytes), + bootstrap, + )?; let mut ticker = interval(Duration::from_secs(config.update_interval_seconds)); - - let mut last_submitted_digest: Option<[u8; 33]> = None; - let mut pending_tx: Option<(String, [u8; 33])> = None; + Self::validate_startup_join( + &journal, + restored_pending.as_ref(), + historical_confirmation.as_ref(), + confirmation_history_present, + &policy, + )?; info!( "Tracker box updater started with {}s interval", @@ -288,52 +607,151 @@ impl TrackerBoxUpdater { loop { tokio::select! { _ = ticker.tick() => {} - _ = shutdown_rx.recv() => { - info!("Tracker box updater received shutdown signal, stopping"); - return Ok(()); + shutdown = shutdown_rx.recv() => { + match shutdown { + Ok(()) => { + info!("Tracker box updater received shutdown signal, stopping"); + return Ok(()); + } + Err(error) => { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(format!( + "tracker updater shutdown channel failed: {error}" + ))); + } + } } } - if let Some((ref tx_id, expected_digest)) = pending_tx { - match Self::check_transaction_confirmation(&config, tx_id).await { - Ok(true) => { - info!("Transaction {} confirmed on chain. Update complete.", tx_id); - last_submitted_digest = Some(expected_digest); + // Terminal storage quarantine is process-wide for publication. Do + // not even reconcile an older in-flight commitment after the state + // manager has lost a trustworthy durable outcome. + if !shared_state.is_publication_healthy() { + error!("Tracker state is quarantined; refusing all commitment processing"); + continue; + } - // Look up the confirming box to record its height/id. - let (box_id, height) = Self::fetch_tracker_box_summary(&config, tx_id) + let mut recovery_action = journal.recovery_action()?; + // Consume decisions already sealed in the durable journal before + // any node I/O. A node outage cannot postpone a known demotion or + // an acceptance-ready idempotent local apply. + match recovery_action.clone() { + RecoveryAction::ApplyAccepted(effect) => { + if let Err(error) = + Self::apply_validated_publication(&cmd_tx, &shared_state, &journal, &effect) + .await + { + shared_state.quarantine_publication(); + return Err(error); + } + continue; + } + RecoveryAction::ApplyRollback(rollback) => { + if let Err(error) = + Self::apply_validated_rollback(&cmd_tx, &shared_state, &journal, &rollback) + .await + { + shared_state.quarantine_publication(); + return Err(error); + } + continue; + } + RecoveryAction::RestoreRetired(effect) => { + if let Err(error) = + Self::restore_retired_publication(&cmd_tx, &shared_state, &journal, &effect) .await - .unwrap_or_else(|_| (tx_id.clone(), 0)); - - shared_state.set_confirmed(expected_digest, box_id.clone(), height); - shared_state.clear_pending(); - pending_tx = None; - - if let Some(ref tx) = cmd_tx { - let (rtx, rrx) = tokio::sync::oneshot::channel(); - let _ = tx - .send(crate::TrackerCommand::ConfirmPendingNotes { - box_id, - height, - response_tx: rtx, - }) - .await; - let _ = rrx.await; + { + shared_state.quarantine_publication(); + return Err(error); + } + // A retired anchor is final under the explicit bounded + // policy. Restore its local projection once, then proceed + // as idle without any further chain polling. + recovery_action = RecoveryAction::Idle; + } + _ => {} + } + + // An older accepted anchor remains economically relevant while a + // newer successor is pending. Revalidate it independently so a + // deep reorg cannot be hidden behind a perpetually pending tx id. + let accepted_to_revalidate = match &recovery_action { + RecoveryAction::RevalidateAccepted(effect) => Some(effect.clone()), + _ => journal.accepted_effect()?, + }; + if let Some(accepted) = accepted_to_revalidate { + if let Err(error) = Self::revalidate_accepted_anchor( + &config, + &client, + &cmd_tx, + &shared_state, + &journal, + &accepted, + &policy, + ) + .await + { + // Once an effect is exposed as Confirmed, loss of its sole + // reorg monitor is an effect-consumer health failure, not + // an availability-only retry. Quarantine before returning. + shared_state.quarantine_publication(); + return Err(error); + } + } + + match recovery_action { + RecoveryAction::SubmitPrepared(intent) => { + Self::ensure_pending_matches(&shared_state, &intent)?; + journal.arm_submission(intent.intent_id())?; + match Self::broadcast_transaction( + &config, + &client, + intent.signed_transaction_json(), + intent.tx_id(), + ) + .await + { + Ok(tx_id) => info!(%tx_id, "Prepared tracker transaction submitted"), + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(error)) => { + warn!(tx_id = %intent.tx_id(), %error, "Submission outcome unknown; querying the exact transaction only"); } + Err(error) => return Err(error), } - Ok(false) => { - info!( - "Transaction {} still pending, waiting for next cycle...", - tx_id - ); - continue; + continue; + } + RecoveryAction::QueryExactTransaction(intent) => { + Self::ensure_pending_matches(&shared_state, &intent)?; + match Self::observe_transaction(&config, &client, &intent, &policy).await { + Ok(TransactionObservation::Pending) => { + info!(tx_id = %intent.tx_id(), "Exact tracker transaction is not yet policy-accepted"); + } + Ok(TransactionObservation::Accepted(effect)) => { + journal.record_validated_effect(effect)?; + } + Err(error) if Self::is_retryable_pending_observation_error(&error) => { + warn!(tx_id = %intent.tx_id(), %error, "Confirmed-chain evidence unavailable or invalid; retaining the fence"); + } + Err(error) => { + shared_state.quarantine_publication(); + return Err(error); + } } - Err(e) => { - error!( - "Failed to check transaction {} status: {}. Will retry.", - tx_id, e - ); - continue; + continue; + } + RecoveryAction::ApplyAccepted(_) + | RecoveryAction::ApplyRollback(_) + | RecoveryAction::RestoreRetired(_) => { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "durable local recovery action escaped pre-I/O dispatch".to_string(), + )); + } + RecoveryAction::RevalidateAccepted(_) => {} + RecoveryAction::Idle => { + if shared_state.get_pending().tx_id.is_some() { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "actor has a durable pending publication but the signed-intent journal is idle" + .to_string(), + )); } } } @@ -346,14 +764,8 @@ impl TrackerBoxUpdater { } }; - let current_digest = shared_state.get_avl_root_digest(); let tracker_pubkey = shared_state.get_tracker_pubkey(); - if current_digest == [0u8; 33] { - info!("AVL root digest not initialized yet, skipping update"); - continue; - } - let tracker_box = match Self::find_tracker_box(&config, &tracker_nft_id).await { Ok(box_data) => box_data, Err(e) => { @@ -362,60 +774,46 @@ impl TrackerBoxUpdater { } }; - // Refresh the confirmed box id in shared state from the live node. + // This is only a construction input. Unspent-box presence never + // promotes accounting or confirmation state. shared_state.set_tracker_box_id(tracker_box.box_id.clone()); - if let Some(r5_value) = tracker_box.additional_registers.get("R5") { - if let Ok(r5_bytes) = hex::decode(r5_value) { - if r5_bytes.len() >= 34 { - let onchain_digest = &r5_bytes[1..34]; - let mut onchain_digest_arr = [0u8; 33]; - onchain_digest_arr.copy_from_slice(onchain_digest); - - // Always record the currently-confirmed on-chain state so - // clients can read it via /tracker/state. - shared_state.set_confirmed( - onchain_digest_arr, - tracker_box.box_id.clone(), - tracker_box.creation_height as u64, - ); - - // Reconcile per-note confirmation records with the - // observed on-chain digest (handles restarts where the - // local tree already matches the on-chain commitment). - if let Some(ref tx) = cmd_tx { - let (rtx, rrx) = tokio::sync::oneshot::channel(); - let _ = tx - .send(crate::TrackerCommand::ReconcileWithConfirmedDigest { - digest: onchain_digest_arr, - box_id: tracker_box.box_id.clone(), - height: tracker_box.creation_height as u64, - response_tx: rtx, - }) - .await; - let _ = rrx.await; - } - - if onchain_digest == current_digest.as_slice() { - info!("On-chain tracker box already has current AVL root digest"); - last_submitted_digest = Some(current_digest); - continue; - } - } - } - } - - // If a previous submission for a different digest is still pending - // and never confirmed, skip submitting a new one (the confirmation - // check at the top of the loop handles the in-flight tx). - if let Some(last) = last_submitted_digest { - if last == current_digest { - info!("AVL root digest unchanged, skipping redundant update"); - continue; + let onchain_digest = Self::tracker_root_from_box(&tracker_box)?; + let tracker_nft_bytes: [u8; 32] = hex::decode(&tracker_nft_id) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidConfiguration( + "configured tracker NFT is not exactly 32 bytes".to_string(), + ) + })?; + let publication_lease = Self::begin_publication( + &cmd_tx, + tracker_nft_bytes, + onchain_digest, + tracker_box.box_id.clone(), + tracker_box.creation_height as u64, + ) + .await + .ok_or_else(|| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor refused the publication fence".to_string(), + ) + })?; + let current_digest = publication_lease.digest; + + if onchain_digest == current_digest { + info!("Tracker successor already commits the current local AVL root"); + if !Self::abort_publication(&cmd_tx, publication_lease).await { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor did not release a no-op publication fence".to_string(), + )); } + continue; } - match Self::submit_tracker_update( + let prepared = match Self::prepare_tracker_update( &tracker_nft_id, &config, &tracker_box, @@ -424,238 +822,835 @@ impl TrackerBoxUpdater { ) .await { - Ok(tx_id) => { - info!( - "Tracker box update submitted. Transaction ID: {}, Box ID: {}. Waiting for confirmation...", - tx_id, tracker_box.box_id - ); - - let submitted_height = Self::get_node_height(&config).await.unwrap_or(0) as u64; - shared_state.set_pending(current_digest, tx_id.clone(), submitted_height); - pending_tx = Some((tx_id.clone(), current_digest)); - - if let Some(ref tx) = cmd_tx { - let (rtx, rrx) = tokio::sync::oneshot::channel(); - let _ = tx - .send(crate::TrackerCommand::MarkNotesPending { - digest: current_digest, - tx_id, - submitted_height, - response_tx: rtx, - }) - .await; - let _ = rrx.await; + Ok(prepared) => prepared, + Err(e) => { + error!("Failed to prepare tracker box update: {}", e); + if !Self::abort_publication(&cmd_tx, publication_lease).await { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor did not release an unbroadcast publication fence" + .to_string(), + )); } + continue; } - Err(e) => { - error!("Failed to submit tracker box update: {}", e); + }; + + let submitted_height = Self::fetch_tip(&config, &client).await?.height; + if !Self::record_publication_attempt( + &cmd_tx, + publication_lease, + prepared.tx_id.clone(), + submitted_height, + ) + .await + { + shared_state.quarantine_publication(); + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor did not durably record the transaction before broadcast" + .to_string(), + )); + } + // The actor receipt is durable before the signed intent is + // journaled. A crash in this narrow window is detected as an + // outcome-unknown mismatch at startup and remains fenced. + journal.record_prepared(prepared.intent.clone())?; + shared_state.set_pending(current_digest, prepared.tx_id.clone(), submitted_height); + journal.arm_submission(prepared.intent.intent_id())?; + match Self::broadcast_transaction( + &config, + &client, + &prepared.signed_bytes, + &prepared.tx_id, + ) + .await + { + Ok(tx_id) => info!( + "Tracker box update submitted. Transaction ID: {}, Box ID: {}. Waiting for confirmation...", + tx_id, tracker_box.box_id + ), + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(error)) => { + // The expected tx id was durably recorded before the + // request. Keep the actor fenced and poll that exact id on + // the next cycle (and after restart). + warn!( + error = %error, + tx_id = %prepared.tx_id, + "Tracker broadcast outcome is unknown; retaining durable publication fence" + ); + } + Err(error) => { + shared_state.quarantine_publication(); + return Err(error); } } } } - /// Fetch a minimal summary (box_id, creation_height) for a tracker box by - /// spending transaction id. Falls back to the supplied tx id on error. - async fn fetch_tracker_box_summary( + fn node_client( + _config: &TrackerBoxUpdateConfig, + ) -> Result<&'static basis_store::ergo_scanner::BoundedHttpClient, TrackerBoxUpdaterError> { + crate::bounded_http::node_http() + .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string())) + } + + #[doc(hidden)] + pub async fn probe_transaction_observation( config: &TrackerBoxUpdateConfig, tx_id: &str, - ) -> Result<(String, u64), TrackerBoxUpdaterError> { - let client = reqwest::Client::new(); - let url = format!( - "{}/blockchain/transaction/byId/{}", - config.node_url.trim_end_matches('/'), - tx_id - ); - - let mut request = client.get(&url); - if let Some(ref api_key) = config.api_key { - request = request.header("api_key", api_key); - } + ) -> Result<(), TrackerBoxUpdaterError> { + let client = Self::node_client(config)?; + let path = format!("/blockchain/transaction/byId/{tx_id}"); + let _ = Self::get_node_bytes(config, &client, &path, true).await?; + Ok(()) + } - let response = request - .send() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + fn validate_startup_join( + journal: &ReconciliationJournal, + restored_pending: Option<&(String, [u8; 33])>, + historical_confirmation: Option<&basis_store::ConfirmedProjectionAnchor>, + confirmation_history_present: bool, + policy: &ReconciliationPolicy, + ) -> Result<(), TrackerBoxUpdaterError> { + journal + .validate_tracker_startup_join( + restored_pending, + historical_confirmation, + confirmation_history_present, + policy, + ) + .map_err(|error| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown(format!( + "tracker startup reconciliation join failed: {error}" + )) + }) + } - if !response.status().is_success() { - return Err(TrackerBoxUpdaterError::HttpError(format!( - "HTTP {}", - response.status() - ))); + fn ensure_pending_matches( + shared_state: &SharedTrackerState, + intent: &ReconciliationIntent, + ) -> Result<(), TrackerBoxUpdaterError> { + let pending = shared_state.get_pending(); + let root = intent.tracker_root().ok_or_else(|| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "journaled transaction is not a tracker publication".to_string(), + ) + })?; + if pending + .tx_id + .as_deref() + .is_some_and(|tx_id| tx_id.eq_ignore_ascii_case(intent.tx_id())) + && pending.digest == Some(root) + && pending.submitted_height.is_some() + { + Ok(()) + } else { + shared_state.quarantine_publication(); + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "actor receipt no longer matches the exact journaled transaction".to_string(), + )) } + } - let body: serde_json::Value = response - .json() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; + fn unix_time_ms() -> Result { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))? + .as_millis(); + u64::try_from(millis).map_err(|_| { + TrackerBoxUpdaterError::InvalidNodeResponse( + "system time exceeds u64 milliseconds".to_string(), + ) + }) + } - // The new tracker box is the first output of the update transaction. - if let Some(outputs) = body.get("outputs").and_then(|o| o.as_array()) { - if let Some(first) = outputs.first() { - let box_id = first - .get("boxId") - .and_then(|b| b.as_str()) - .unwrap_or(tx_id) - .to_string(); - let height = first - .get("creationHeight") - .and_then(|h| h.as_u64()) - .unwrap_or(0); - return Ok((box_id, height)); - } + fn is_retryable_pending_observation_error(error: &TrackerBoxUpdaterError) -> bool { + match error { + TrackerBoxUpdaterError::HttpError(_) => true, + TrackerBoxUpdaterError::Reconciliation( + ReconciliationError::IncoherentSnapshot | ReconciliationError::StaleEvidence, + ) => true, + _ => false, } - - Ok((tx_id.to_string(), 0)) } - /// Find the tracker box on chain using the tracker NFT ID - async fn find_tracker_box( + async fn get_node_bytes( config: &TrackerBoxUpdateConfig, - tracker_nft_id: &str, - ) -> Result { - let client = reqwest::Client::new(); + client: &basis_store::ergo_scanner::BoundedHttpClient, + path_and_query: &str, + missing_is_pending: bool, + ) -> Result>, TrackerBoxUpdaterError> { let url = format!( - "{}/blockchain/box/unspent/byTokenId/{}?limit=5", + "{}{}", config.node_url.trim_end_matches('/'), - tracker_nft_id + path_and_query ); - let mut request = client.get(&url); - if let Some(ref api_key) = config.api_key { + if let Some(api_key) = &config.api_key { request = request.header("api_key", api_key); } - - let response = request - .send() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; - + let response = tokio::time::timeout( + Duration::from_secs(config.request_timeout_seconds), + client.execute(request), + ) + .await + .map_err(|_| { + TrackerBoxUpdaterError::HttpError( + "confirmed-chain node request exceeded its configured deadline".to_string(), + ) + })? + .map_err(|error| TrackerBoxUpdaterError::HttpError(error.to_string()))?; + if missing_is_pending && response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); return Err(TrackerBoxUpdaterError::HttpError(format!( - "HTTP {}: {}", - status, body + "node returned HTTP {} for {}", + response.status(), + path_and_query.split('?').next().unwrap_or("request") ))); } + Ok(Some(response.bytes().to_vec())) + } - let boxes: Vec = response - .json() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; - - if boxes.is_empty() { - return Err(TrackerBoxUpdaterError::NoTrackerBoxFound); - } - - if boxes.len() > 1 { - warn!( - "Found {} tracker boxes for NFT {} - expected at most 1. \ - Using the first box (box_id={}).", - boxes.len(), - tracker_nft_id, - boxes[0].box_id - ); + async fn fetch_tip( + config: &TrackerBoxUpdateConfig, + client: &basis_store::ergo_scanner::BoundedHttpClient, + ) -> Result { + let bytes = Self::get_node_bytes(config, client, "/info", false) + .await? + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing /info body".to_string()) + })?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))?; + let height = value + .get("fullHeight") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("/info lacks fullHeight".to_string()) + })?; + let ids = ["bestFullHeaderId", "bestHeaderId"] + .iter() + .filter_map(|key| value.get(*key).and_then(serde_json::Value::as_str)) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .collect::>(); + if ids.len() != 1 { + return Err(TrackerBoxUpdaterError::InvalidNodeResponse( + "/info does not expose one coherent full-chain tip id".to_string(), + )); } - - Ok(boxes.into_iter().next().unwrap()) + Ok(NodeTip { + id: ids.into_iter().next().unwrap_or_default(), + height, + }) } - /// Fetch wallet-owned unspent boxes from the Ergo node. - async fn get_wallet_boxes( + async fn collect_selected_chain( config: &TrackerBoxUpdateConfig, - ) -> Result, TrackerBoxUpdaterError> { - let client = reqwest::Client::new(); - let url = format!( - "{}/wallet/boxes/unspent?minConfirmations=0&maxConfirmations=-1", - config.node_url.trim_end_matches('/') - ); + client: &basis_store::ergo_scanner::BoundedHttpClient, + inclusion_height: u64, + ) -> Result { + let before = Self::fetch_tip(config, client).await?; + let to_height = before.height.checked_add(1).ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("node height overflow".to_string()) + })?; + let path = format!("/blocks/chainSlice?fromHeight={inclusion_height}&toHeight={to_height}"); + let chain_slice = Self::get_node_bytes(config, client, &path, false) + .await? + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing chain slice".to_string()) + })?; + let after = Self::fetch_tip(config, client).await?; + Ok(ActiveChainProof::from_node_responses( + before.id, + before.height, + after.id, + after.height, + inclusion_height, + &chain_slice, + Self::unix_time_ms()?, + )?) + } - let mut request = client.get(&url); - if let Some(ref api_key) = config.api_key { - request = request.header("api_key", api_key); + async fn collect_selected_window( + config: &TrackerBoxUpdateConfig, + client: &basis_store::ergo_scanner::BoundedHttpClient, + inclusion_height: u64, + selected_through_height: u64, + ) -> Result { + let before = Self::fetch_tip(config, client).await?; + if before.height < selected_through_height { + return Err(TrackerBoxUpdaterError::Reconciliation( + ReconciliationError::IncompleteAncestry, + )); } + let to_height = selected_through_height.checked_add(1).ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("node height overflow".to_string()) + })?; + let path = format!("/blocks/chainSlice?fromHeight={inclusion_height}&toHeight={to_height}"); + let chain_slice = Self::get_node_bytes(config, client, &path, false) + .await? + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing chain slice".to_string()) + })?; + let after = Self::fetch_tip(config, client).await?; + Ok(ActiveChainProof::from_bounded_node_responses( + before.id, + before.height, + after.id, + after.height, + inclusion_height, + selected_through_height, + &chain_slice, + Self::unix_time_ms()?, + )?) + } - let response = request - .send() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(TrackerBoxUpdaterError::HttpError(format!( - "HTTP {} fetching wallet boxes: {}", - status, body - ))); + async fn observe_transaction( + config: &TrackerBoxUpdateConfig, + client: &basis_store::ergo_scanner::BoundedHttpClient, + intent: &ReconciliationIntent, + policy: &ReconciliationPolicy, + ) -> Result { + let transaction_path = format!("/blockchain/transaction/byId/{}", intent.tx_id()); + let Some(observation_bytes) = + Self::get_node_bytes(config, client, &transaction_path, true).await? + else { + return Ok(TransactionObservation::Pending); + }; + let observation: serde_json::Value = serde_json::from_slice(&observation_bytes) + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))?; + let Some(inclusion_height) = observation + .get("inclusionHeight") + .and_then(serde_json::Value::as_u64) + else { + return Ok(TransactionObservation::Pending); + }; + let transaction_json = serde_json::to_vec(&serde_json::json!({ + "id": observation.get("id").cloned().unwrap_or(serde_json::Value::Null), + "inputs": observation.get("inputs").cloned().unwrap_or(serde_json::Value::Null), + "dataInputs": observation.get("dataInputs").cloned().unwrap_or_else(|| serde_json::json!([])), + "outputs": observation.get("outputs").cloned().unwrap_or(serde_json::Value::Null), + })) + .map_err(|error| TrackerBoxUpdaterError::InvalidNodeResponse(error.to_string()))?; + + let before = Self::fetch_tip(config, client).await?; + if before + .height + .checked_sub(inclusion_height) + .is_none_or(|depth| depth < policy.min_successor_depth()) + { + return Ok(TransactionObservation::Pending); } + let to_height = before.height.checked_add(1).ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("node height overflow".to_string()) + })?; + let chain_path = + format!("/blocks/chainSlice?fromHeight={inclusion_height}&toHeight={to_height}"); + let chain_slice = Self::get_node_bytes(config, client, &chain_path, false) + .await? + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing chain slice".to_string()) + })?; + let first_header: serde_json::Value = + serde_json::from_slice::(&chain_slice) + .ok() + .and_then(|value| { + value + .as_array() + .and_then(|headers| headers.first()) + .cloned() + }) + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("empty chain slice".to_string()) + })?; + let block_id = first_header + .get("id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("first header lacks id".to_string()) + })?; + let full_block = + Self::get_node_bytes(config, client, &format!("/blocks/{block_id}"), false) + .await? + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing full block".to_string()) + })?; + let predecessor = Self::get_node_bytes( + config, + client, + &format!("/blockchain/box/byId/{}", intent.predecessor().box_id()), + false, + ) + .await? + .ok_or_else(|| { + TrackerBoxUpdaterError::InvalidNodeResponse("missing predecessor".to_string()) + })?; + let after = Self::fetch_tip(config, client).await?; + let chain = ActiveChainProof::from_node_responses( + before.id, + before.height, + after.id, + after.height, + inclusion_height, + &chain_slice, + Self::unix_time_ms()?, + )?; + let selected_block_id = chain.first_block_id().to_string(); + let evidence = TransactionChainEvidence::from_node_snapshot( + transaction_json, + full_block, + selected_block_id, + inclusion_height, + vec![predecessor], + chain, + )?; + Ok(TransactionObservation::Accepted(validate_chain_effect( + intent, + &evidence, + policy.clone(), + Self::unix_time_ms()?, + )?)) + } - let entries: Vec = response - .json() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; + async fn apply_validated_publication( + cmd_tx: &Option>, + shared_state: &SharedTrackerState, + journal: &ReconciliationJournal, + effect: &ValidatedChainEffect, + ) -> Result<(), TrackerBoxUpdaterError> { + if !Self::confirm_publication(cmd_tx, effect.clone()).await { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor rejected a policy-validated private ticket".to_string(), + )); + } + journal.mark_applied(effect)?; + Self::set_shared_confirmed(shared_state, effect)?; + shared_state.clear_pending(); + Ok(()) + } - Ok(entries.into_iter().map(|e| e.box_details).collect()) + async fn apply_validated_rollback( + cmd_tx: &Option>, + shared_state: &SharedTrackerState, + journal: &ReconciliationJournal, + rollback: &ValidatedRollback, + ) -> Result<(), TrackerBoxUpdaterError> { + if !Self::rollback_publication(cmd_tx, rollback.clone()).await { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "tracker actor rejected a validated reorg rollback".to_string(), + )); + } + journal.mark_rollback_applied(rollback)?; + shared_state.clear_confirmed(); + shared_state.set_historical_confirmation(None); + shared_state.set_confirmation_history_present(false); + if journal.pending_intent()?.is_none() { + shared_state.clear_pending(); + } + Ok(()) } - /// Select wallet boxes covering the required fee, excluding the tracker box itself. - /// Prefers boxes without tokens; falls back to token-bearing boxes and preserves their tokens - /// in the change output. - fn select_fee_inputs( - wallet_boxes: &[ErgoBoxApi], - required: u64, - tracker_box_id: &str, - ) -> (Vec, u64) { - let candidates: Vec<&ErgoBoxApi> = wallet_boxes - .iter() - .filter(|b| b.box_id != tracker_box_id) - .collect(); + async fn restore_retired_publication( + cmd_tx: &Option>, + shared_state: &SharedTrackerState, + journal: &ReconciliationJournal, + effect: &ValidatedChainEffect, + ) -> Result<(), TrackerBoxUpdaterError> { + if journal.pending_intent()?.is_some() { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "retired anchor restore collided with a pending ticket".to_string(), + )); + } + if Self::shared_matches_effect(shared_state, effect)? { + return Ok(()); + } + if !Self::confirm_publication(cmd_tx, effect.clone()).await { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "retired publication could not be replayed exactly".to_string(), + )); + } + Self::set_shared_confirmed(shared_state, effect) + } - // Try token-free boxes first. - let mut token_free: Vec<&ErgoBoxApi> = candidates - .iter() - .filter(|b| b.assets.is_empty()) - .copied() - .collect(); - token_free.sort_by_key(|b| b.value); + async fn revalidate_accepted_anchor( + config: &TrackerBoxUpdateConfig, + client: &basis_store::ergo_scanner::BoundedHttpClient, + cmd_tx: &Option>, + shared_state: &SharedTrackerState, + journal: &ReconciliationJournal, + effect: &ValidatedChainEffect, + policy: &ReconciliationPolicy, + ) -> Result<(), TrackerBoxUpdaterError> { + let tip = Self::fetch_tip(config, client).await?; + let observed_depth = tip + .height + .checked_sub(effect.inclusion_height()) + .ok_or_else(|| { + TrackerBoxUpdaterError::Reconciliation(ReconciliationError::DepthMismatch) + })?; + if observed_depth >= policy.reorg_monitor_depth() { + let selected_through = effect + .inclusion_height() + .checked_add(policy.reorg_monitor_depth()) + .ok_or_else(|| { + TrackerBoxUpdaterError::Reconciliation(ReconciliationError::DepthMismatch) + })?; + let selected_window = Self::collect_selected_window( + config, + client, + effect.inclusion_height(), + selected_through, + ) + .await?; + return match validate_reorg_horizon( + effect, + &selected_window, + policy, + Self::unix_time_ms()?, + )? { + ReorgHorizonDecision::Retire(retirement) => { + journal.retire_accepted(&retirement)?; + if journal.pending_intent()?.is_none() { + Self::restore_retired_publication(cmd_tx, shared_state, journal, effect) + .await?; + } + Ok(()) + } + ReorgHorizonDecision::Rollback(rollback) => { + journal.record_rollback(rollback.clone())?; + Self::apply_validated_rollback(cmd_tx, shared_state, journal, &rollback).await + } + }; + } + let selected_chain = + Self::collect_selected_chain(config, client, effect.inclusion_height()).await?; + let now = Self::unix_time_ms()?; + if validate_anchor_still_active(effect, &selected_chain, policy, now).is_ok() { + if journal.pending_intent()?.is_some() { + // The actor is fenced by a newer transaction. Its persisted + // records retain the older provenance, but remain Pending and + // therefore non-redeemable until the newer outcome resolves. + return Ok(()); + } + if !Self::shared_matches_effect(shared_state, effect)? { + // Restores restart-demoted local projections only through the + // same sealed accepted ticket. + if !Self::confirm_publication(cmd_tx, effect.clone()).await { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "accepted publication could not be replayed exactly".to_string(), + )); + } + Self::set_shared_confirmed(shared_state, effect)?; + } + return Ok(()); + } + let rollback = validate_rollback(effect, &selected_chain, policy, now)?; + journal.record_rollback(rollback.clone())?; + Self::apply_validated_rollback(cmd_tx, shared_state, journal, &rollback).await + } - if let Some(box_) = token_free.iter().find(|b| b.value >= required) { - return (vec![box_.box_id.clone()], box_.value); + fn shared_matches_effect( + shared_state: &SharedTrackerState, + effect: &ValidatedChainEffect, + ) -> Result { + let root = effect.tracker_root().ok_or_else(|| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "accepted ticket has no tracker root".to_string(), + ) + })?; + let shared = shared_state.get_confirmed(); + Ok(shared.digest == Some(root) + && shared + .tx_id + .as_deref() + .is_some_and(|tx_id| tx_id.eq_ignore_ascii_case(effect.tx_id())) + && shared.block_id.as_deref() == Some(effect.block_id()) + && shared.box_id.as_deref() == Some(effect.successor_box_id()) + && shared.height == Some(effect.inclusion_height()) + && shared.successor_depth == Some(effect.successor_depth())) + } + + fn set_shared_confirmed( + shared_state: &SharedTrackerState, + effect: &ValidatedChainEffect, + ) -> Result<(), TrackerBoxUpdaterError> { + let root = effect.tracker_root().ok_or_else(|| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "accepted ticket has no tracker root".to_string(), + ) + })?; + shared_state.set_confirmed( + root, + effect.tx_id().to_string(), + effect.successor_box_id().to_string(), + effect.block_id().to_string(), + effect.inclusion_height(), + effect.successor_depth(), + ); + shared_state.set_historical_confirmation(Some( + basis_store::ConfirmedProjectionAnchor::from_parts( + effect.tx_id().to_string(), + effect.successor_box_id().to_string(), + effect.block_id().to_string(), + effect.inclusion_height(), + effect.successor_depth(), + effect.intent_id().to_string(), + root, + ), + )); + Ok(()) + } + + fn tracker_root_from_box(box_data: &ErgoBoxApi) -> Result<[u8; 33], TrackerBoxUpdaterError> { + let encoded = box_data.additional_registers.get("R5").ok_or_else(|| { + TrackerBoxUpdaterError::SerializationError( + "tracker box is missing its R5 AVL commitment".to_string(), + ) + })?; + let bytes = hex::decode(encoded) + .map_err(|error| TrackerBoxUpdaterError::SerializationError(error.to_string()))?; + if bytes.len() != 37 || bytes[0] != 0x64 || bytes[34..] != [0x03, 0x20, 0x00] { + return Err(TrackerBoxUpdaterError::SerializationError( + "tracker R5 is not the exact 33-byte insert/update AVL ABI".to_string(), + )); } + let mut root = [0u8; 33]; + root.copy_from_slice(&bytes[1..34]); + Ok(root) + } - let mut selected = Vec::new(); - let mut total = 0u64; - for box_ in token_free { - total += box_.value; - selected.push(box_.box_id.clone()); - if total >= required { - return (selected, total); - } + async fn begin_publication( + cmd_tx: &Option>, + tracker_nft_id: [u8; 32], + observed_root: [u8; 33], + box_id: String, + height: u64, + ) -> Option { + let tx = cmd_tx.as_ref()?; + crate::tracker_request(tx, |response_tx| crate::TrackerCommand::BeginPublication { + tracker_nft_id, + observed_root, + box_id, + height, + response_tx, + }) + .await + .ok()? + .ok() + } + + async fn abort_publication( + cmd_tx: &Option>, + lease: crate::PublicationLease, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + matches!( + crate::tracker_request(tx, |response_tx| { + crate::TrackerCommand::AbortPublication { lease, response_tx } + }) + .await, + Ok(Ok(())) + ) + } + + async fn record_publication_attempt( + cmd_tx: &Option>, + lease: crate::PublicationLease, + tx_id: String, + submitted_height: u64, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + matches!( + crate::tracker_request(tx, |response_tx| { + crate::TrackerCommand::RecordPublicationAttempt { + lease, + tx_id, + submitted_height, + response_tx, + } + }) + .await, + Ok(Ok(_)) + ) + } + + async fn confirm_publication( + cmd_tx: &Option>, + effect: ValidatedChainEffect, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + matches!( + crate::tracker_request(tx, |response_tx| { + crate::TrackerCommand::ConfirmPublication { + effect, + response_tx, + } + }) + .await, + Ok(Ok(_)) + ) + } + + async fn rollback_publication( + cmd_tx: &Option>, + rollback: ValidatedRollback, + ) -> bool { + let Some(tx) = cmd_tx else { + return false; + }; + matches!( + crate::tracker_request(tx, |response_tx| { + crate::TrackerCommand::RollbackPublication { + rollback, + response_tx, + } + }) + .await, + Ok(Ok(_)) + ) + } + + /// Find the tracker box on chain using the tracker NFT ID + async fn find_tracker_box( + config: &TrackerBoxUpdateConfig, + tracker_nft_id: &str, + ) -> Result { + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + let url = format!( + "{}/blockchain/box/unspent/byTokenId/{}?limit=5", + config.node_url.trim_end_matches('/'), + tracker_nft_id + ); + + let mut request = client.get(&url); + if let Some(ref api_key) = config.api_key { + request = request.header("api_key", api_key); + } + + let response = client + .execute(request) + .await + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text_lossy(); + return Err(TrackerBoxUpdaterError::HttpError(format!( + "HTTP {}: {}", + status, body + ))); + } + + let boxes: Vec = response + .json() + .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; + + if boxes.is_empty() { + return Err(TrackerBoxUpdaterError::NoTrackerBoxFound); + } + + if boxes.len() > 1 { + warn!( + "Found {} tracker boxes for NFT {} - expected at most 1. \ + Using the first box (box_id={}).", + boxes.len(), + tracker_nft_id, + boxes[0].box_id + ); + } + + Ok(boxes.into_iter().next().unwrap()) + } + + /// Fetch wallet-owned unspent boxes from the Ergo node. + async fn get_wallet_boxes( + config: &TrackerBoxUpdateConfig, + ) -> Result, TrackerBoxUpdaterError> { + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + let url = format!( + "{}/wallet/boxes/unspent?minConfirmations=0&maxConfirmations=-1", + config.node_url.trim_end_matches('/') + ); + + let mut request = client.get(&url); + if let Some(ref api_key) = config.api_key { + request = request.header("api_key", api_key); + } + + let response = client + .execute(request) + .await + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text_lossy(); + return Err(TrackerBoxUpdaterError::HttpError(format!( + "HTTP {} fetching wallet boxes: {}", + status, body + ))); } - // Fall back to token-bearing boxes if necessary. - let mut token_boxes: Vec<&ErgoBoxApi> = candidates + let entries: Vec = response + .json() + .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; + + Ok(entries.into_iter().map(|e| e.box_details).collect()) + } + + /// Select only token-free fee boxes whose advertised tree is the exact local signer P2PK. + /// The selected JSON is subsequently rebound field-for-field to canonical Sigma bytes. + fn select_fee_inputs<'a>( + wallet_boxes: &'a [ErgoBoxApi], + required: u64, + tracker_box_id: &str, + owner_tree: &ErgoTree, + ) -> Result<(Vec<&'a ErgoBoxApi>, u64), TrackerBoxUpdaterError> { + let owner_tree_bytes = owner_tree.sigma_serialize_bytes().map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to serialize fee-owner tree: {error}" + )) + })?; + let mut candidates = wallet_boxes .iter() - .filter(|b| !b.assets.is_empty()) - .copied() - .collect(); - token_boxes.sort_by_key(|b| b.value); + .filter(|box_| box_.box_id != tracker_box_id && box_.assets.is_empty()) + .filter(|box_| { + hex::decode(&box_.ergo_tree) + .map(|bytes| bytes == owner_tree_bytes) + .unwrap_or(false) + }) + .collect::>(); + candidates.sort_by_key(|box_| box_.value); - if let Some(box_) = token_boxes.iter().find(|b| b.value >= required) { - return (vec![box_.box_id.clone()], box_.value); + if let Some(box_) = candidates.iter().find(|box_| box_.value >= required) { + return Ok((vec![*box_], box_.value)); } let mut selected = Vec::new(); let mut total = 0u64; - for box_ in token_boxes { - total += box_.value; - selected.push(box_.box_id.clone()); + for box_ in candidates { + total = total.checked_add(box_.value).ok_or_else(|| { + TrackerBoxUpdaterError::ArithmeticError( + "fee-input value sum overflowed u64".to_string(), + ) + })?; + selected.push(box_); if total >= required { - return (selected, total); + break; } } - - (selected, total) + Ok((selected, total)) } /// Fetch the hex-encoded serialized bytes of a box from the Ergo node. @@ -663,7 +1658,8 @@ impl TrackerBoxUpdater { config: &TrackerBoxUpdateConfig, box_id: &str, ) -> Result { - let client = reqwest::Client::new(); + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; let url = format!( "{}/utxo/byIdBinary/{}", config.node_url.trim_end_matches('/'), @@ -675,14 +1671,14 @@ impl TrackerBoxUpdater { request = request.header("api_key", api_key); } - let response = request - .send() + let response = client + .execute(request) .await .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; if !response.status().is_success() { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = response.text_lossy(); return Err(TrackerBoxUpdaterError::HttpError(format!( "HTTP {} fetching box binary {}: {}", status, box_id, body @@ -691,360 +1687,2045 @@ impl TrackerBoxUpdater { let binary: BoxBinaryResponse = response .json() - .await .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; Ok(binary.bytes) } - /// Get the current blockchain height from the Ergo node. - async fn get_node_height( + /// Fetch exactly ten linked headers and the matching live parameter set from one node tip. + async fn get_signing_context( config: &TrackerBoxUpdateConfig, - ) -> Result { - let client = reqwest::Client::new(); - let url = format!("{}/info", config.node_url.trim_end_matches('/')); + ) -> Result { + let client = crate::bounded_http::node_http() + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + let base = config.node_url.trim_end_matches('/'); + let headers_url = format!("{base}/blocks/lastHeaders/10"); + let info_url = format!("{base}/info"); - let mut request = client.get(&url); + let mut headers_request = client.get(&headers_url); + let mut info_request = client.get(&info_url); if let Some(ref api_key) = config.api_key { - request = request.header("api_key", api_key); + headers_request = headers_request.header("api_key", api_key); + info_request = info_request.header("api_key", api_key); } - let response = request - .send() + let headers_response = client + .execute(headers_request) .await .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); + if !headers_response.status().is_success() { + let status = headers_response.status(); + let body = headers_response.text_lossy(); return Err(TrackerBoxUpdaterError::HttpError(format!( - "HTTP {} fetching node height: {}", + "HTTP {} fetching signing headers: {}", status, body ))); } + let headers: Vec
= headers_response.json().map_err(|error| { + TrackerBoxUpdaterError::StateContextValidation(format!("invalid header JSON: {error}")) + })?; - let body: serde_json::Value = response - .json() + let info_response = client + .execute(info_request) .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(format!("JSON parse error: {}", e)))?; + .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + if !info_response.status().is_success() { + let status = info_response.status(); + let body = info_response.text_lossy(); + return Err(TrackerBoxUpdaterError::HttpError(format!( + "HTTP {} fetching signing parameters: {}", + status, body + ))); + } + let info: serde_json::Value = info_response.json().map_err(|error| { + TrackerBoxUpdaterError::StateContextValidation(format!("invalid /info JSON: {error}")) + })?; + Self::validate_signing_context(headers, info) + } - body["fullHeight"] - .as_u64() - .map(|h| h as u32) - .ok_or_else(|| TrackerBoxUpdaterError::HttpError("Missing fullHeight".to_string())) + fn validate_signing_context( + headers: Vec
, + info: serde_json::Value, + ) -> Result { + if headers.len() != 10 { + return Err(TrackerBoxUpdaterError::StateContextValidation(format!( + "expected exactly 10 headers, got {}", + headers.len() + ))); + } + for pair in headers.windows(2) { + let expected_parent_height = pair[0].height.checked_sub(1).ok_or_else(|| { + TrackerBoxUpdaterError::StateContextValidation( + "header height underflow".to_string(), + ) + })?; + if pair[0].parent_id != pair[1].id || pair[1].height != expected_parent_height { + return Err(TrackerBoxUpdaterError::StateContextValidation( + "headers are not one descending parent-linked chain".to_string(), + )); + } + } + + let tip_height = headers[0].height; + let info_height = info + .get("fullHeight") + .and_then(serde_json::Value::as_u64) + .and_then(|height| u32::try_from(height).ok()) + .ok_or_else(|| { + TrackerBoxUpdaterError::StateContextValidation( + "/info fullHeight is missing or out of range".to_string(), + ) + })?; + if info_height != tip_height { + return Err(TrackerBoxUpdaterError::StateContextValidation(format!( + "/info and header tip differ: {info_height} != {tip_height}" + ))); + } + let info_tip_id = info + .get("bestFullHeaderId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + TrackerBoxUpdaterError::StateContextValidation( + "/info bestFullHeaderId is missing".to_string(), + ) + })?; + if !info_tip_id.eq_ignore_ascii_case(&headers[0].id.to_string()) { + return Err(TrackerBoxUpdaterError::StateContextValidation( + "/info and header chain do not share one tip id".to_string(), + )); + } + + let parameters_json = info.get("parameters").cloned().ok_or_else(|| { + TrackerBoxUpdaterError::StateContextValidation( + "/info has no parameters object".to_string(), + ) + })?; + let parameters_height = parameters_json + .get("height") + .and_then(serde_json::Value::as_u64) + .and_then(|height| u32::try_from(height).ok()) + .ok_or_else(|| { + TrackerBoxUpdaterError::StateContextValidation( + "/info parameter height is missing or out of range".to_string(), + ) + })?; + if parameters_height > tip_height { + return Err(TrackerBoxUpdaterError::StateContextValidation( + "/info parameter height is ahead of the pinned tip".to_string(), + )); + } + let parameters: Parameters = serde_json::from_value(parameters_json).map_err(|error| { + TrackerBoxUpdaterError::StateContextValidation(format!( + "/info has no complete parameter set: {error}" + )) + })?; + if parameters.block_version() != i32::from(headers[0].version) + || parameters.storage_fee_factor() <= 0 + || parameters.min_value_per_byte() <= 0 + || parameters.max_block_size() <= 0 + || parameters.max_block_cost() <= 0 + || parameters.token_access_cost() < 0 + || parameters.input_cost() < 0 + || parameters.data_input_cost() < 0 + || parameters.output_cost() < 0 + { + return Err(TrackerBoxUpdaterError::StateContextValidation( + "/info parameters are invalid or not pinned to the header version".to_string(), + )); + } + + let headers: Headers = headers.try_into().map_err(|headers: Vec
| { + TrackerBoxUpdaterError::StateContextValidation(format!( + "expected exactly 10 headers, got {}", + headers.len() + )) + })?; + let pre_header = PreHeader::from(headers[0].clone()); + Ok(LocalSigningContext { + state_context: ErgoStateContext::new(pre_header, headers, parameters), + creation_height: tip_height, + }) } - /// Sign an unsigned transaction using the Ergo node's /wallet/transaction/sign endpoint. - async fn sign_transaction( - config: &TrackerBoxUpdateConfig, - unsigned_tx: serde_json::Value, - ) -> Result { - info!("Signing unsigned transaction: {}", unsigned_tx); + fn bind_exact_box( + advertised: &ErgoBoxApi, + raw_hex: &str, + ) -> Result { + if raw_hex.is_empty() || raw_hex.len() % 2 != 0 || raw_hex.len() > ErgoBox::MAX_BOX_SIZE * 2 + { + return Err(TrackerBoxUpdaterError::InputValidation(format!( + "box {} raw encoding has an invalid length", + advertised.box_id + ))); + } + let raw = hex::decode(raw_hex).map_err(|_| { + TrackerBoxUpdaterError::InputValidation(format!( + "box {} raw encoding is not base16", + advertised.box_id + )) + })?; + let exact = ErgoBox::sigma_parse_bytes(&raw).map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!( + "box {} is not a canonical Sigma box: {error}", + advertised.box_id + )) + })?; + let canonical = exact.sigma_serialize_bytes().map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to reserialize exact box {}: {error}", + advertised.box_id + )) + })?; + if canonical != raw { + return Err(Self::box_mismatch(advertised, "raw canonical bytes")); + } + if !exact + .box_id() + .to_string() + .eq_ignore_ascii_case(&advertised.box_id) + { + return Err(Self::box_mismatch(advertised, "box id")); + } + if *exact.value.as_u64() != advertised.value { + return Err(Self::box_mismatch(advertised, "value")); + } + let tree_bytes = exact.ergo_tree.sigma_serialize_bytes().map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to serialize exact tree {}: {error}", + advertised.box_id + )) + })?; + if hex::decode(&advertised.ergo_tree) + .map(|bytes| bytes != tree_bytes) + .unwrap_or(true) + { + return Err(Self::box_mismatch(advertised, "ergo tree")); + } - let client = reqwest::Client::new(); - let url = format!( - "{}/wallet/transaction/sign", - config.node_url.trim_end_matches('/') - ); + let exact_assets = exact + .tokens + .as_ref() + .map(|tokens| tokens.as_vec().as_slice()) + .unwrap_or_default(); + if exact_assets.len() != advertised.assets.len() { + return Err(Self::box_mismatch(advertised, "asset cardinality")); + } + for (exact_asset, advertised_asset) in exact_assets.iter().zip(&advertised.assets) { + if hex::decode(&advertised_asset.token_id) + .map(|bytes| bytes.as_slice() != exact_asset.token_id.as_ref()) + .unwrap_or(true) + || *exact_asset.amount.as_u64() != advertised_asset.amount + { + return Err(Self::box_mismatch(advertised, "ordered assets")); + } + } - let mut request = client.post(&url).json(&unsigned_tx); - if let Some(ref api_key) = config.api_key { - request = request.header("api_key", api_key); + let mut exact_registers = std::collections::HashMap::new(); + for register_id in NonMandatoryRegisterId::REG_IDS { + if let Some(constant) = exact + .additional_registers + .get_constant(register_id) + .map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!( + "box {} has an invalid {register_id}: {error}", + advertised.box_id + )) + })? + { + exact_registers.insert( + register_id.to_string(), + constant.sigma_serialize_bytes().map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to serialize box {} {register_id}: {error}", + advertised.box_id + )) + })?, + ); + } } + if exact_registers.len() != advertised.additional_registers.len() { + return Err(Self::box_mismatch(advertised, "register key set")); + } + for (register_id, advertised_value) in &advertised.additional_registers { + let Some(exact_value) = exact_registers.get(register_id) else { + return Err(Self::box_mismatch(advertised, "register key set")); + }; + if hex::decode(advertised_value) + .map(|bytes| bytes != *exact_value) + .unwrap_or(true) + { + return Err(Self::box_mismatch(advertised, "register bytes")); + } + } + if exact.creation_height != advertised.creation_height { + return Err(Self::box_mismatch(advertised, "creation height")); + } + Ok(exact) + } - let response = request - .send() - .await - .map_err(|e| TrackerBoxUpdaterError::SigningFailed(e.to_string()))?; + fn box_mismatch(advertised: &ErgoBoxApi, field: &str) -> TrackerBoxUpdaterError { + TrackerBoxUpdaterError::InputValidation(format!( + "box {} JSON/raw {field} mismatch", + advertised.box_id + )) + } - let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); - info!( - "/wallet/transaction/sign response: status={}, body={}", - status, body_text - ); + fn local_signing_material( + tracker_pubkey: &[u8; 33], + tracker_secret_key: Option<&[u8; 32]>, + tracker_box: &ErgoBox, + ) -> Result { + let tracker_point = EcPoint::sigma_parse_bytes(tracker_pubkey).map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!( + "configured tracker public key is invalid: {error}" + )) + })?; + if tracker_point + .sigma_serialize_bytes() + .map(|bytes| bytes.as_slice() != tracker_pubkey) + .unwrap_or(true) + { + return Err(TrackerBoxUpdaterError::InputValidation( + "configured tracker public key is not canonical".to_string(), + )); + } + let secret_bytes = + tracker_secret_key.ok_or(TrackerBoxUpdaterError::MissingTrackerSecretKey)?; + let secret = SecretKey::dlog_from_bytes(secret_bytes).ok_or_else(|| { + TrackerBoxUpdaterError::InputValidation( + "configured tracker secret is not a valid dlog scalar".to_string(), + ) + })?; + let expected_address = Address::P2Pk(ProveDlog::new(tracker_point.clone())); + let derived_address = secret.get_address_from_public_image(); + if derived_address != expected_address { + return Err(TrackerBoxUpdaterError::InputValidation( + "tracker secret does not match configured public key".to_string(), + )); + } + let p2pk_tree = derived_address.script().map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to derive tracker P2PK tree: {error}" + )) + })?; - if !status.is_success() { - return Err(TrackerBoxUpdaterError::SigningFailed(format!( - "HTTP {}: {}", - status, body_text - ))); + let r4 = tracker_box + .additional_registers + .get_constant(NonMandatoryRegisterId::R4) + .map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!("tracker R4 is invalid: {error}")) + })? + .ok_or_else(|| { + TrackerBoxUpdaterError::InputValidation("tracker R4 is missing".to_string()) + })?; + let r4_point: EcPoint = r4.try_extract_into().map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!( + "tracker R4 is not a GroupElement: {error}" + )) + })?; + if r4_point + .sigma_serialize_bytes() + .map(|bytes| bytes.as_slice() != tracker_pubkey) + .unwrap_or(true) + { + return Err(TrackerBoxUpdaterError::InputValidation( + "tracker R4 does not match configured public key".to_string(), + )); } - serde_json::from_str(&body_text) - .map_err(|e| TrackerBoxUpdaterError::SigningFailed(format!("JSON parse error: {}", e))) + Ok(LocalSigningMaterial { + secret, + tracker_point, + p2pk_tree, + }) + } + + fn validate_input_closure( + input_ids: impl Iterator, + exact_boxes: &[ErgoBox], + ) -> Result<(), TrackerBoxUpdaterError> + where + T: ToString, + { + let input_ids = input_ids.map(|id| id.to_string()).collect::>(); + if input_ids.len() != exact_boxes.len() { + return Err(TrackerBoxUpdaterError::InputValidation( + "transaction inputs and exact boxes differ in cardinality".to_string(), + )); + } + let mut unique = HashSet::with_capacity(input_ids.len()); + for (input_id, exact_box) in input_ids.iter().zip(exact_boxes) { + if !unique.insert(input_id.to_ascii_lowercase()) { + return Err(TrackerBoxUpdaterError::InputValidation( + "transaction input ids are not unique".to_string(), + )); + } + if !input_id.eq_ignore_ascii_case(&exact_box.box_id().to_string()) { + return Err(TrackerBoxUpdaterError::InputValidation( + "transaction input order differs from exact box order".to_string(), + )); + } + } + Ok(()) + } + + fn validate_output_dust( + unsigned_tx: &UnsignedTransaction, + parameters: &Parameters, + ) -> Result<(), TrackerBoxUpdaterError> { + let min_value_per_byte = u64::try_from(parameters.min_value_per_byte()).map_err(|_| { + TrackerBoxUpdaterError::StateContextValidation("negative minValuePerByte".to_string()) + })?; + for (index, candidate) in unsigned_tx.output_candidates.iter().enumerate() { + let output = ErgoBox::from_box_candidate(candidate, unsigned_tx.id(), index as u16) + .map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to materialize output {index}: {error}" + )) + })?; + let size = u64::try_from( + output + .sigma_serialize_bytes() + .map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to size output {index}: {error}" + )) + })? + .len(), + ) + .map_err(|_| { + TrackerBoxUpdaterError::ArithmeticError("output size does not fit u64".to_string()) + })?; + let minimum = size.checked_mul(min_value_per_byte).ok_or_else(|| { + TrackerBoxUpdaterError::ArithmeticError("dust threshold overflowed u64".to_string()) + })?; + if *candidate.value.as_u64() < minimum { + return Err(TrackerBoxUpdaterError::InputValidation(format!( + "output {index} is dust: {} < {minimum}", + candidate.value.as_u64() + ))); + } + } + Ok(()) + } + + fn sign_locally( + unsigned_tx: UnsignedTransaction, + exact_boxes: Vec, + material: &LocalSigningMaterial, + state_context: &ErgoStateContext, + ) -> Result { + if unsigned_tx.data_inputs.is_some() { + return Err(TrackerBoxUpdaterError::InputValidation( + "tracker update must not contain data inputs".to_string(), + )); + } + Self::validate_input_closure( + unsigned_tx.inputs.iter().map(|input| input.box_id), + &exact_boxes, + )?; + let unsigned_tx_id = unsigned_tx.id(); + let signing_context = TransactionContext::new(unsigned_tx, exact_boxes.clone(), Vec::new()) + .map_err(|error| TrackerBoxUpdaterError::InputValidation(error.to_string()))?; + let wallet = Wallet::from_secrets(vec![material.secret.clone()]); + let signed = wallet + .sign_transaction(signing_context, state_context, None) + .map_err(|error| TrackerBoxUpdaterError::SigningFailed(error.to_string()))?; + if signed.data_inputs.is_some() { + return Err(TrackerBoxUpdaterError::InputValidation( + "signed tracker update unexpectedly contains data inputs".to_string(), + )); + } + if signed.id() != unsigned_tx_id { + return Err(TrackerBoxUpdaterError::InputValidation( + "local signer changed the unsigned transaction intent".to_string(), + )); + } + Self::validate_input_closure(signed.inputs.iter().map(|input| input.box_id), &exact_boxes)?; + TransactionContext::new(signed.clone(), exact_boxes, Vec::new()) + .map_err(|error| TrackerBoxUpdaterError::InputValidation(error.to_string()))? + .validate(state_context) + .map_err(|error| { + TrackerBoxUpdaterError::SigningFailed(format!( + "post-sign transaction validation failed: {error}" + )) + })?; + Ok(signed) } /// Broadcast a signed transaction to the Ergo node's /transactions endpoint. async fn broadcast_transaction( config: &TrackerBoxUpdateConfig, - signed_tx: &serde_json::Value, + client: &basis_store::ergo_scanner::BoundedHttpClient, + signed_bytes: &[u8], + expected_tx_id: &str, ) -> Result { - info!("Broadcasting signed transaction: {}", signed_tx); + info!("Broadcasting signed tracker-box update transaction"); - let client = reqwest::Client::new(); let url = format!("{}/transactions", config.node_url.trim_end_matches('/')); - let mut request = client.post(&url).json(signed_tx); + let mut request = client + .post(&url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(signed_bytes.to_vec()); if let Some(ref api_key) = config.api_key { request = request.header("api_key", api_key); } - let response = request - .send() + let response = client + .execute(request) .await - .map_err(|e| TrackerBoxUpdaterError::BroadcastFailed(e.to_string()))?; + .map_err(|e| TrackerBoxUpdaterError::BroadcastOutcomeUnknown(e.to_string()))?; let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); - info!( - "/transactions broadcast response: status={}, body={}", - status, body_text - ); + info!(status = %status, "Transaction broadcast request completed"); + let body_text = response.text_lossy(); + Self::parse_broadcast_response(status, &body_text, expected_tx_id) + } + + fn parse_broadcast_response( + status: reqwest::StatusCode, + body_text: &str, + expected_tx_id: &str, + ) -> Result { + // Once the request crossed the network boundary, an HTTP error does not + // prove the node failed before admission. Keep the actor fence intact + // until restart/reconciliation rather than authorizing a competing + // successor transaction. if !status.is_success() { - return Err(TrackerBoxUpdaterError::BroadcastFailed(format!( - "HTTP {}: {}", - status, body_text + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(format!( + "HTTP {} returned after transaction submission", + status ))); } - let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { - TrackerBoxUpdaterError::BroadcastFailed(format!("JSON parse error: {}", e)) + let body: serde_json::Value = serde_json::from_str(body_text).map_err(|e| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown(format!("JSON parse error: {}", e)) })?; - // The Ergo node's /transactions endpoint returns the tx id as a plain - // JSON string (e.g. "abc..."), not an object. Handle both forms. - match body { - serde_json::Value::String(tx_id) => Ok(tx_id), + // The Ergo node normally returns a JSON string, while some compatible + // nodes wrap the id. Neither shape is authoritative until it exactly + // matches the transaction id computed from the signed transaction. + let returned = match body { + serde_json::Value::String(tx_id) => Some(tx_id), _ => body["id"] .as_str() .or_else(|| body["txId"].as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| { - TrackerBoxUpdaterError::BroadcastFailed("Missing tx id".to_string()) - }), + .map(str::to_owned), + } + .ok_or_else(|| { + TrackerBoxUpdaterError::BroadcastOutcomeUnknown("Missing tx id".to_string()) + })?; + let returned_is_tx_id = returned.len() == 64 + && hex::decode(&returned) + .map(|bytes| bytes.len() == 32) + .unwrap_or(false); + if !returned_is_tx_id || !returned.eq_ignore_ascii_case(expected_tx_id) { + return Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown( + "Node response did not match the locally derived transaction id".to_string(), + )); } + Ok(expected_tx_id.to_ascii_lowercase()) } - /// Submit a tracker box update transaction via /wallet/transaction/sign - async fn submit_tracker_update( + fn signed_transaction_id( + signed_tx: &serde_json::Value, + ) -> Result { + let declared_tx_id = signed_tx + .get("id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + TrackerBoxUpdaterError::SerializationError( + "Signed transaction JSON is missing its transaction id".to_string(), + ) + })?; + if declared_tx_id.len() != 64 + || hex::decode(declared_tx_id) + .map(|bytes| bytes.len() != 32) + .unwrap_or(true) + { + return Err(TrackerBoxUpdaterError::SerializationError( + "Signed transaction id is not 32 bytes".to_string(), + )); + } + let transaction: Transaction = + serde_json::from_value(signed_tx.clone()).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Signed transaction JSON is invalid: {}", + error + )) + })?; + let tx_id = transaction.id().to_string(); + if tx_id.len() != 64 + || hex::decode(&tx_id) + .map(|bytes| bytes.len() != 32) + .unwrap_or(true) + { + return Err(TrackerBoxUpdaterError::SerializationError( + "Derived transaction id is not 32 bytes".to_string(), + )); + } + if !declared_tx_id.eq_ignore_ascii_case(&tx_id) { + return Err(TrackerBoxUpdaterError::SerializationError( + "Signed transaction id does not match its serialized body".to_string(), + )); + } + Ok(tx_id) + } + + /// Bind exact inputs, build the tracker successor, and sign it locally with ergo-lib. + async fn prepare_tracker_update( tracker_nft_id: &str, config: &TrackerBoxUpdateConfig, tracker_box: &ErgoBoxApi, tracker_pubkey: &[u8; 33], avl_root_digest: &[u8; 33], - ) -> Result { - let mut r4_bytes = vec![0x07u8]; - r4_bytes.extend_from_slice(tracker_pubkey); - let r4_value = hex::encode(&r4_bytes); - + ) -> Result { let mut r5_bytes = vec![0x64u8]; r5_bytes.extend_from_slice(avl_root_digest); r5_bytes.push(0x03u8); // insert + update allowed (insertOrUpdate contract) r5_bytes.extend_from_slice(&vlq_encode(32)); r5_bytes.extend_from_slice(&vlq_encode(0)); - let r5_value = hex::encode(&r5_bytes); - - let mut output_registers = tracker_box.additional_registers.clone(); - output_registers.insert("R4".to_string(), r4_value); - output_registers.insert("R5".to_string(), r5_value); - - let change_address = match &config.change_address { - Some(addr) if !addr.is_empty() => addr.clone(), - _ => derive_change_address(tracker_pubkey)?, - }; + let r5_constant = Constant::sigma_parse_bytes(&r5_bytes).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to construct tracker R5: {error}" + )) + })?; + if r5_constant + .sigma_serialize_bytes() + .map(|bytes| bytes != r5_bytes) + .unwrap_or(true) + { + return Err(TrackerBoxUpdaterError::SerializationError( + "Tracker R5 serialization is not canonical".to_string(), + )); + } - let current_height = Self::get_node_height(config).await?; + let local_context = Self::get_signing_context(config).await?; let wallet_boxes = Self::get_wallet_boxes(config).await?; + let tracker_raw = Self::get_box_binary(config, &tracker_box.box_id).await?; + let exact_tracker_box = Self::bind_exact_box(tracker_box, &tracker_raw)?; + let material = Self::local_signing_material( + tracker_pubkey, + config.tracker_secret_key.as_ref(), + &exact_tracker_box, + )?; + + let tracker_nft = hex::decode(tracker_nft_id).map_err(|_| { + TrackerBoxUpdaterError::InputValidation( + "configured tracker NFT id is not base16".to_string(), + ) + })?; + if tracker_nft.len() != 32 { + return Err(TrackerBoxUpdaterError::InputValidation( + "configured tracker NFT id is not 32 bytes".to_string(), + )); + } + let tracker_tokens = exact_tracker_box + .tokens + .as_ref() + .map(|tokens| tokens.as_vec().as_slice()) + .unwrap_or_default(); + if tracker_tokens.first().map(|token| { + token.token_id.as_ref() == tracker_nft.as_slice() && *token.amount.as_u64() == 1 + }) != Some(true) + || tracker_tokens + .iter() + .filter(|token| token.token_id.as_ref() == tracker_nft.as_slice()) + .count() + != 1 + { + return Err(TrackerBoxUpdaterError::InputValidation( + "tracker input must carry its singleton NFT first and exactly once".to_string(), + )); + } - let (fee_input_ids, fee_input_total) = - Self::select_fee_inputs(&wallet_boxes, config.fee, &tracker_box.box_id); + let (fee_inputs, advertised_fee_total) = Self::select_fee_inputs( + &wallet_boxes, + config.fee, + &tracker_box.box_id, + &material.p2pk_tree, + )?; - if fee_input_ids.is_empty() { + if fee_inputs.is_empty() { return Err(TrackerBoxUpdaterError::NoFeeInputs); } - - if fee_input_total < config.fee { + if advertised_fee_total < config.fee { return Err(TrackerBoxUpdaterError::InsufficientFeeInputs { - available: fee_input_total, + available: advertised_fee_total, required: config.fee, }); } - let mut inputs = vec![serde_json::json!({ - "boxId": tracker_box.box_id, - "extension": serde_json::json!({}) - })]; - let mut inputs_raw = vec![Self::get_box_binary(config, &tracker_box.box_id).await?]; - - for fee_box_id in &fee_input_ids { - inputs.push(serde_json::json!({ - "boxId": fee_box_id, - "extension": serde_json::json!({}) - })); - inputs_raw.push(Self::get_box_binary(config, fee_box_id).await?); + let owner_tree_bytes = material + .p2pk_tree + .sigma_serialize_bytes() + .map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to serialize fee-owner tree: {error}" + )) + })?; + let mut exact_boxes = vec![exact_tracker_box.clone()]; + let mut exact_fee_total = 0u64; + for advertised_fee_box in fee_inputs { + let raw = Self::get_box_binary(config, &advertised_fee_box.box_id).await?; + let exact_fee_box = Self::bind_exact_box(advertised_fee_box, &raw)?; + if exact_fee_box.tokens.is_some() + || exact_fee_box + .ergo_tree + .sigma_serialize_bytes() + .map(|bytes| bytes != owner_tree_bytes) + .unwrap_or(true) + { + return Err(TrackerBoxUpdaterError::InputValidation(format!( + "fee input {} is not token-free exact signer P2PK", + advertised_fee_box.box_id + ))); + } + exact_fee_total = exact_fee_total + .checked_add(*exact_fee_box.value.as_u64()) + .ok_or_else(|| { + TrackerBoxUpdaterError::ArithmeticError( + "exact fee-input value sum overflowed u64".to_string(), + ) + })?; + exact_boxes.push(exact_fee_box); + } + if exact_fee_total < config.fee { + return Err(TrackerBoxUpdaterError::InsufficientFeeInputs { + available: exact_fee_total, + required: config.fee, + }); + } + let total_input_value = exact_tracker_box + .value + .as_u64() + .checked_add(exact_fee_total) + .ok_or_else(|| { + TrackerBoxUpdaterError::ArithmeticError( + "tracker and fee-input sum overflowed u64".to_string(), + ) + })?; + if total_input_value > BoxValue::MAX_RAW { + return Err(TrackerBoxUpdaterError::ArithmeticError( + "tracker and fee-input sum exceeds BoxValue::MAX_RAW".to_string(), + )); } + let change_amount = exact_fee_total.checked_sub(config.fee).ok_or_else(|| { + TrackerBoxUpdaterError::ArithmeticError("fee subtraction underflowed".to_string()) + })?; - let change_amount = fee_input_total.saturating_sub(config.fee); + let mut register_constants = Vec::new(); + for register_id in NonMandatoryRegisterId::REG_IDS { + match exact_tracker_box + .additional_registers + .get_constant(register_id) + .map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!( + "tracker {register_id} is invalid: {error}" + )) + })? { + Some(constant) => register_constants.push(constant), + None => break, + } + } + if register_constants.len() < 2 { + return Err(TrackerBoxUpdaterError::InputValidation( + "tracker input must contain R4 and R5".to_string(), + )); + } + register_constants[0] = Constant::from(material.tracker_point.clone()); + register_constants[1] = r5_constant; + let output_registers = + NonMandatoryRegisters::try_from(register_constants).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to construct tracker registers: {error}" + )) + })?; + + let current_height = local_context.creation_height; + let tracker_output = ErgoBoxCandidate { + value: exact_tracker_box.value, + ergo_tree: exact_tracker_box.ergo_tree.clone(), + tokens: exact_tracker_box.tokens.clone(), + additional_registers: output_registers, + creation_height: current_height, + }; + let fee_value = BoxValue::new(config.fee).map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!( + "configured fee is not a valid box value: {error}" + )) + })?; + let fee_output = new_miner_fee_box(fee_value, current_height).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to construct miner-fee output: {error}" + )) + })?; + let mut outputs = vec![tracker_output, fee_output]; + if change_amount > 0 { + let change_value = BoxValue::new(change_amount).map_err(|error| { + TrackerBoxUpdaterError::InputValidation(format!( + "fee-input change is not a valid box value: {error}" + )) + })?; + outputs.push(ErgoBoxCandidate { + value: change_value, + ergo_tree: material.p2pk_tree.clone(), + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: current_height, + }); + } - // Preserve any tokens from the fee inputs in the change output so they are not burned. - let change_assets: Vec = fee_input_ids + let inputs = exact_boxes .iter() - .flat_map(|id| { - wallet_boxes - .iter() - .find(|b| &b.box_id == id) - .map(|b| { - b.assets.iter().map(|a| { - serde_json::json!({ - "tokenId": a.token_id, - "amount": a.amount - }) - }) - }) - .into_iter() - .flatten() - }) + .map(|box_| UnsignedInput::new(box_.box_id(), ContextExtension::empty())) .collect(); + let unsigned_tx = + UnsignedTransaction::new_from_vec(inputs, Vec::new(), outputs).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to build unsigned tracker update: {error}" + )) + })?; + Self::validate_input_closure( + unsigned_tx.inputs.iter().map(|input| input.box_id), + &exact_boxes, + )?; + Self::validate_output_dust(&unsigned_tx, &local_context.state_context.parameters)?; + + let signed = Self::sign_locally( + unsigned_tx, + exact_boxes, + &material, + &local_context.state_context, + )?; + let signed_tx = serde_json::to_value(signed).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "Failed to serialize signed tracker update: {error}" + )) + })?; + let tx_id = Self::signed_transaction_id(&signed_tx)?; + let signed_bytes = serde_json::to_vec(&signed_tx).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "cannot serialize signed transaction: {error}" + )) + })?; + let predecessor_box_json = serde_json::to_vec(tracker_box).map_err(|error| { + TrackerBoxUpdaterError::SerializationError(format!( + "cannot serialize tracker predecessor: {error}" + )) + })?; + let intent = ReconciliationIntent::tracker_publication( + signed_bytes.clone(), + predecessor_box_json, + *avl_root_digest, + )?; + if !intent.tx_id().eq_ignore_ascii_case(&tx_id) { + return Err(TrackerBoxUpdaterError::SerializationError( + "signed intent transaction id changed during construction".to_string(), + )); + } + Ok(PreparedTrackerUpdate { + signed_bytes, + tx_id, + intent, + }) + } +} + +#[cfg(test)] +mod signing_boundary_tests { + use super::*; + use ergo_lib::ergotree_ir::{ + chain::{ + ergo_box::BoxTokens, + token::{Token, TokenAmount, TokenId}, + tx_id::TxId, + }, + mir::{ + create_provedlog::CreateProveDlog, expr::Expr, extract_reg_as::ExtractRegisterAs, + global_vars::GlobalVars, option_get::OptionGet, unary_op::OneArgOpTryBuild, + }, + types::stype::SType, + }; + use std::str::FromStr; + + const GENERATOR_P2PK_TREE: &str = + "0008cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + fn signer() -> (SecretKey, [u8; 32], [u8; 33], ErgoTree) { + let secret_bytes = [1u8; 32]; + let secret = SecretKey::dlog_from_bytes(&secret_bytes).expect("valid scalar"); + let address = secret.get_address_from_public_image(); + let tracker_pubkey = match &address { + Address::P2Pk(prove_dlog) => prove_dlog + .h + .sigma_serialize_bytes() + .expect("serialize public key") + .try_into() + .expect("33-byte public key"), + _ => panic!("dlog secret must derive P2PK"), + }; + let p2pk_tree = address.script().expect("derive P2PK tree"); + (secret, secret_bytes, tracker_pubkey, p2pk_tree) + } + + fn tracker_contract() -> ErgoTree { + let r4: Expr = ExtractRegisterAs::new( + GlobalVars::SelfBox.into(), + NonMandatoryRegisterId::R4 as i8, + SType::SOption(SType::SGroupElement.into()), + ) + .expect("R4 extraction") + .into(); + let r4 = OptionGet::try_build(r4).expect("R4 get").into(); + let proposition: Expr = CreateProveDlog::try_build(r4) + .expect("proveDlog from R4") + .into(); + ErgoTree::try_from(proposition).expect("tracker contract tree") + } + + fn r5_constant(byte: u8) -> Constant { + let mut bytes = vec![0x64]; + bytes.extend_from_slice(&[byte; 33]); + bytes.extend_from_slice(&[0x03, 0x20, 0x00]); + Constant::sigma_parse_bytes(&bytes).expect("AVL constant") + } + + fn token(id_byte: u8, amount: u64) -> Token { + Token::from(( + TokenId::from_str(&hex::encode([id_byte; 32])).expect("token id"), + TokenAmount::try_from(amount).expect("token amount"), + )) + } - // Ensure the tracker NFT is always the first token in the output tracker box, - // followed by any other tokens preserved from the input tracker box. - let mut output_assets = Vec::new(); - let mut other_assets = Vec::new(); - for asset in &tracker_box.assets { - if asset.token_id == tracker_nft_id { - output_assets.push(asset.clone()); + fn make_box( + tree: ErgoTree, + value: u64, + tokens: Vec, + registers: Vec, + height: u32, + tx_byte: u8, + index: u16, + ) -> ErgoBox { + ErgoBox::new( + BoxValue::new(value).expect("box value"), + tree, + if tokens.is_empty() { + None } else { - other_assets.push(asset.clone()); + Some(BoxTokens::try_from(tokens).expect("box tokens")) + }, + NonMandatoryRegisters::try_from(registers).expect("registers"), + height, + TxId::from_str(&hex::encode([tx_byte; 32])).expect("tx id"), + index, + ) + .expect("box") + } + + fn tracker_box() -> (ErgoBox, [u8; 32], [u8; 33], [u8; 32], ErgoTree) { + let (_secret, secret_bytes, pubkey, p2pk_tree) = signer(); + let point = EcPoint::sigma_parse_bytes(&pubkey).expect("public point"); + let nft = [0x11; 32]; + let tracker = make_box( + tracker_contract(), + 3_000_000, + vec![token(0x11, 1), token(0x22, 7)], + vec![Constant::from(point), r5_constant(0x33)], + 90, + 0x44, + 0, + ); + (tracker, nft, pubkey, secret_bytes, p2pk_tree) + } + + fn api_and_raw(box_: &ErgoBox) -> (ErgoBoxApi, String) { + let value = serde_json::to_value(box_.clone()).expect("box JSON"); + let api = serde_json::from_value(value).expect("API projection"); + let raw = hex::encode(box_.sigma_serialize_bytes().expect("box bytes")); + (api, raw) + } + + fn linked_headers() -> Vec
{ + (0..10) + .map(|index| { + let id = format!("{:064x}", index + 1); + let parent_id = if index < 9 { + format!("{:064x}", index + 2) + } else { + "00".repeat(32) + }; + serde_json::from_value(serde_json::json!({ + "version": 3, + "id": id, + "parentId": parent_id, + "adProofsRoot": "00".repeat(32), + "stateRoot": "00".repeat(33), + "transactionsRoot": "00".repeat(32), + "timestamp": 1_700_000_000_000u64 + index as u64, + "nBits": 117586360, + "height": 100 - index, + "extensionHash": "00".repeat(32), + "powSolutions": { + "pk": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "n": "0000000000000000" + }, + "votes": "000000", + "unparsedBytes": "" + })) + .expect("header JSON") + }) + .collect() + } + + fn node_info() -> serde_json::Value { + serde_json::json!({ + "fullHeight": 100, + "bestFullHeaderId": format!("{:064x}", 1), + "parameters": { + "height": 90, + "blockVersion": 3, + "storageFeeFactor": 1_250_000, + "minValuePerByte": 360, + "maxBlockSize": 1_275_000, + "maxBlockCost": 8_000_000, + "tokenAccessCost": 100, + "inputCost": 2_000, + "dataInputCost": 100, + "outputCost": 100 } + }) + } + + #[test] + fn exact_box_binding_rejects_independent_json_and_raw_mutants() { + let (box_, _, _, _, _) = tracker_box(); + let (api, raw) = api_and_raw(&box_); + assert_eq!(TrackerBoxUpdater::bind_exact_box(&api, &raw).unwrap(), box_); + + let mut mutants = Vec::new(); + let mut id = api.clone(); + id.box_id = "aa".repeat(32); + mutants.push(("id", id)); + let mut value = api.clone(); + value.value += 1; + mutants.push(("value", value)); + let mut tree = api.clone(); + tree.ergo_tree = GENERATOR_P2PK_TREE.to_string(); + mutants.push(("tree", tree)); + let mut asset_id = api.clone(); + asset_id.assets[0].token_id = "bb".repeat(32); + mutants.push(("asset id", asset_id)); + let mut asset_amount = api.clone(); + asset_amount.assets[0].amount += 1; + mutants.push(("asset amount", asset_amount)); + let mut asset_order = api.clone(); + asset_order.assets.swap(0, 1); + mutants.push(("asset order", asset_order)); + let mut register = api.clone(); + register + .additional_registers + .insert("R4".to_string(), "0e00".to_string()); + mutants.push(("register", register)); + let mut extra_register = api.clone(); + extra_register + .additional_registers + .insert("R9".to_string(), "0101".to_string()); + mutants.push(("register key set", extra_register)); + let mut height = api.clone(); + height.creation_height += 1; + mutants.push(("height", height)); + + for (name, mutant) in mutants { + assert!( + TrackerBoxUpdater::bind_exact_box(&mutant, &raw).is_err(), + "{name} mutant must reject" + ); } - output_assets.extend(other_assets); - - let mut outputs = vec![ - serde_json::json!({ - "value": tracker_box.value, - "ergoTree": tracker_box.ergo_tree, - "creationHeight": current_height, - "assets": output_assets, - "additionalRegisters": output_registers - }), - serde_json::json!({ - "value": config.fee, - "ergoTree": "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a701730073011001020402d19683030193a38cc7b2a57300000193c2b2a57301007473027303830108cdeeac93b1a57304", - "creationHeight": current_height, - "assets": [], - "additionalRegisters": {} - }), - ]; + assert!(TrackerBoxUpdater::bind_exact_box(&api, &format!("{raw}00")).is_err()); + assert!( + TrackerBoxUpdater::bind_exact_box(&api, &"00".repeat(ErgoBox::MAX_BOX_SIZE + 1)) + .is_err() + ); + } - if change_amount > 0 { - outputs.push(serde_json::json!({ - "value": change_amount, - "ergoTree": change_address_to_ergo_tree(&change_address)?, - "creationHeight": current_height, - "assets": change_assets, - "additionalRegisters": {} - })); + #[test] + fn tracker_authority_binds_secret_pubkey_and_group_element_r4() { + let (tracker, _, pubkey, secret, _) = tracker_box(); + TrackerBoxUpdater::local_signing_material(&pubkey, Some(&secret), &tracker) + .expect("matching authority"); + + let wrong_secret = [2u8; 32]; + assert!( + TrackerBoxUpdater::local_signing_material(&pubkey, Some(&wrong_secret), &tracker) + .is_err() + ); + assert!( + TrackerBoxUpdater::local_signing_material(&pubkey, Some(&[0u8; 32]), &tracker).is_err() + ); + assert!(TrackerBoxUpdater::local_signing_material(&pubkey, None, &tracker).is_err()); + + let wrong_type = Constant::from(pubkey.iter().map(|byte| *byte as i8).collect::>()); + let wrong_r4 = make_box( + tracker_contract(), + 3_000_000, + vec![token(0x11, 1)], + vec![wrong_type, r5_constant(0x33)], + 90, + 0x45, + 0, + ); + assert!( + TrackerBoxUpdater::local_signing_material(&pubkey, Some(&secret), &wrong_r4).is_err() + ); + + let (_, _, other_pubkey, _, _) = { + let other = SecretKey::dlog_from_bytes(&[2u8; 32]).expect("valid scalar"); + let address = other.get_address_from_public_image(); + let other_pubkey: [u8; 33] = match &address { + Address::P2Pk(p) => p.h.sigma_serialize_bytes().unwrap().try_into().unwrap(), + _ => unreachable!(), + }; + ( + other, + [2u8; 32], + other_pubkey, + [0u8; 32], + address.script().unwrap(), + ) + }; + assert!(TrackerBoxUpdater::local_signing_material( + &other_pubkey, + Some(&[2u8; 32]), + &tracker + ) + .is_err()); + } + + #[test] + fn state_context_requires_ten_linked_headers_and_same_info_pin() { + TrackerBoxUpdater::validate_signing_context(linked_headers(), node_info()) + .expect("linked state context"); + + let mut short = linked_headers(); + short.pop(); + assert!(TrackerBoxUpdater::validate_signing_context(short, node_info()).is_err()); + let mut wrong_parent = linked_headers(); + wrong_parent[0].parent_id = wrong_parent[9].id; + assert!(TrackerBoxUpdater::validate_signing_context(wrong_parent, node_info()).is_err()); + let mut wrong_height = linked_headers(); + wrong_height[1].height -= 1; + assert!(TrackerBoxUpdater::validate_signing_context(wrong_height, node_info()).is_err()); + let mut stale_info = node_info(); + stale_info["fullHeight"] = serde_json::json!(99); + assert!(TrackerBoxUpdater::validate_signing_context(linked_headers(), stale_info).is_err()); + let mut wrong_tip = node_info(); + wrong_tip["bestFullHeaderId"] = serde_json::json!("ff".repeat(32)); + assert!(TrackerBoxUpdater::validate_signing_context(linked_headers(), wrong_tip).is_err()); + let mut incomplete_info = node_info(); + incomplete_info + .get_mut("parameters") + .unwrap() + .as_object_mut() + .unwrap() + .remove("maxBlockCost"); + assert!( + TrackerBoxUpdater::validate_signing_context(linked_headers(), incomplete_info).is_err() + ); + let mut wrong_version = node_info(); + wrong_version["parameters"]["blockVersion"] = serde_json::json!(4); + assert!( + TrackerBoxUpdater::validate_signing_context(linked_headers(), wrong_version).is_err() + ); + let mut future_parameters = node_info(); + future_parameters["parameters"]["height"] = serde_json::json!(101); + assert!( + TrackerBoxUpdater::validate_signing_context(linked_headers(), future_parameters) + .is_err() + ); + let mut flat_parameters = node_info(); + let parameters = flat_parameters + .as_object_mut() + .unwrap() + .remove("parameters") + .unwrap(); + flat_parameters + .as_object_mut() + .unwrap() + .extend(parameters.as_object().unwrap().clone()); + assert!( + TrackerBoxUpdater::validate_signing_context(linked_headers(), flat_parameters).is_err() + ); + } + + #[test] + fn input_closure_rejects_cardinality_order_and_duplicate_ids() { + let (tracker, _, _, _, p2pk_tree) = tracker_box(); + let fee = make_box(p2pk_tree, 2_000_000, vec![], vec![], 90, 0x55, 0); + let boxes = vec![tracker.clone(), fee.clone()]; + let ids = vec![tracker.box_id().to_string(), fee.box_id().to_string()]; + TrackerBoxUpdater::validate_input_closure(ids.clone().into_iter(), &boxes).unwrap(); + assert!( + TrackerBoxUpdater::validate_input_closure(ids[..1].iter().cloned(), &boxes).is_err() + ); + assert!( + TrackerBoxUpdater::validate_input_closure(ids.iter().rev().cloned(), &boxes).is_err() + ); + assert!(TrackerBoxUpdater::validate_input_closure( + vec![ids[0].clone(), ids[0].clone()].into_iter(), + &[tracker.clone(), tracker] + ) + .is_err()); + } + + #[test] + fn fee_selection_requires_exact_owner_token_free_and_checked_sum() { + let (tracker, _, _, _, owner_tree) = tracker_box(); + let owner = make_box(owner_tree.clone(), 1_100_000, vec![], vec![], 90, 0x51, 0); + let other_tree = SecretKey::dlog_from_bytes(&[2u8; 32]) + .expect("other scalar") + .get_address_from_public_image() + .script() + .expect("other P2PK tree"); + let other_owner = make_box(other_tree, 9_000_000, vec![], vec![], 90, 0x52, 0); + let token_bearing = make_box( + owner_tree.clone(), + 9_000_000, + vec![token(0x77, 1)], + vec![], + 90, + 0x53, + 0, + ); + let mut advertised = vec![ + api_and_raw(&other_owner).0, + api_and_raw(&token_bearing).0, + api_and_raw(&owner).0, + ]; + let (selected, total) = TrackerBoxUpdater::select_fee_inputs( + &advertised, + 1_000_000, + &tracker.box_id().to_string(), + &owner_tree, + ) + .expect("one exact owner box"); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].box_id, owner.box_id().to_string()); + assert_eq!(total, 1_100_000); + + let owner_tree_hex = hex::encode(owner_tree.sigma_serialize_bytes().unwrap()); + for (index, value) in [u64::MAX - 2, u64::MAX - 1].into_iter().enumerate() { + advertised.push(ErgoBoxApi { + box_id: hex::encode([0x80 + index as u8; 32]), + value, + ergo_tree: owner_tree_hex.clone(), + assets: Vec::new(), + additional_registers: std::collections::HashMap::new(), + creation_height: 90, + transaction_id: hex::encode([0x40 + index as u8; 32]), + index: index as u16, + }); } + assert!(matches!( + TrackerBoxUpdater::select_fee_inputs( + &advertised[3..], + u64::MAX, + &tracker.box_id().to_string(), + &owner_tree, + ), + Err(TrackerBoxUpdaterError::ArithmeticError(_)) + )); + } - let secrets = config - .tracker_secret_key - .as_ref() - .map(|sk| vec![hex::encode(sk)]) - .unwrap_or_default(); + #[test] + fn wallet_locally_signs_contract_tracker_and_exact_p2pk_fee_inputs() { + let (tracker, _, pubkey, secret, p2pk_tree) = tracker_box(); + assert_ne!( + tracker.ergo_tree, p2pk_tree, + "tracker is a contract, not P2PK" + ); + let material = + TrackerBoxUpdater::local_signing_material(&pubkey, Some(&secret), &tracker).unwrap(); + let local_context = + TrackerBoxUpdater::validate_signing_context(linked_headers(), node_info()).unwrap(); + + for fee_values in [vec![2_000_000], vec![600_000, 600_000]] { + let fee_boxes = fee_values + .into_iter() + .enumerate() + .map(|(index, value)| { + make_box( + p2pk_tree.clone(), + value, + vec![], + vec![], + 90, + 0x60 + index as u8, + 0, + ) + }) + .collect::>(); + let fee_total = fee_boxes + .iter() + .map(|box_| *box_.value.as_u64()) + .sum::(); + let mut boxes = vec![tracker.clone()]; + boxes.extend(fee_boxes); + let inputs = boxes + .iter() + .map(|box_| UnsignedInput::new(box_.box_id(), ContextExtension::empty())) + .collect(); + let outputs = vec![ + ErgoBoxCandidate { + value: tracker.value, + ergo_tree: tracker.ergo_tree.clone(), + tokens: tracker.tokens.clone(), + additional_registers: tracker.additional_registers.clone(), + creation_height: local_context.creation_height, + }, + new_miner_fee_box( + BoxValue::new(1_000_000).unwrap(), + local_context.creation_height, + ) + .unwrap(), + ErgoBoxCandidate { + value: BoxValue::new(fee_total - 1_000_000).unwrap(), + ergo_tree: p2pk_tree.clone(), + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: local_context.creation_height, + }, + ]; + let unsigned = UnsignedTransaction::new_from_vec(inputs, Vec::new(), outputs).unwrap(); + TrackerBoxUpdater::validate_output_dust( + &unsigned, + &local_context.state_context.parameters, + ) + .unwrap(); + let signed = TrackerBoxUpdater::sign_locally( + unsigned, + boxes, + &material, + &local_context.state_context, + ) + .expect("local contract and P2PK proofs"); + assert!(signed.inputs.iter().all(|input| !input + .spending_proof + .proof + .clone() + .to_bytes() + .is_empty())); + let signed_json = serde_json::to_string(&signed).expect("signed transaction JSON"); + assert!(!signed_json.contains(&hex::encode(secret))); + } - let unsigned_tx = serde_json::json!({ - "tx": { - "inputs": inputs, - "dataInputs": [], - "outputs": outputs + let dust_fee = make_box(p2pk_tree.clone(), 1_010_800, vec![], vec![], 90, 0x70, 0); + let dust_boxes = vec![tracker.clone(), dust_fee]; + let dust_inputs = dust_boxes + .iter() + .map(|box_| UnsignedInput::new(box_.box_id(), ContextExtension::empty())) + .collect(); + let dust_outputs = vec![ + ErgoBoxCandidate { + value: tracker.value, + ergo_tree: tracker.ergo_tree.clone(), + tokens: tracker.tokens.clone(), + additional_registers: tracker.additional_registers.clone(), + creation_height: local_context.creation_height, + }, + new_miner_fee_box( + BoxValue::new(1_000_000).unwrap(), + local_context.creation_height, + ) + .unwrap(), + ErgoBoxCandidate { + value: BoxValue::new(10_800).unwrap(), + ergo_tree: p2pk_tree.clone(), + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: local_context.creation_height, + }, + ]; + let dust_tx = + UnsignedTransaction::new_from_vec(dust_inputs, Vec::new(), dust_outputs).unwrap(); + assert!(TrackerBoxUpdater::validate_output_dust( + &dust_tx, + &local_context.state_context.parameters + ) + .is_err()); + + let invalid_fee = make_box(p2pk_tree, 2_000_000, vec![], vec![], 90, 0x71, 0); + let invalid_boxes = vec![tracker.clone(), invalid_fee]; + let invalid_inputs = invalid_boxes + .iter() + .map(|box_| UnsignedInput::new(box_.box_id(), ContextExtension::empty())) + .collect(); + let invalid_outputs = vec![ + ErgoBoxCandidate { + value: tracker.value, + ergo_tree: tracker.ergo_tree.clone(), + tokens: tracker.tokens.clone(), + additional_registers: tracker.additional_registers.clone(), + creation_height: local_context.creation_height, }, - "inputsRaw": inputs_raw, - "dataInputsRaw": [], - "secrets": { - "dlog": secrets + new_miner_fee_box( + BoxValue::new(1_000_000).unwrap(), + local_context.creation_height, + ) + .unwrap(), + ]; + let invalid_tx = + UnsignedTransaction::new_from_vec(invalid_inputs, Vec::new(), invalid_outputs).unwrap(); + assert!(matches!( + TrackerBoxUpdater::sign_locally( + invalid_tx, + invalid_boxes, + &material, + &local_context.state_context, + ), + Err(TrackerBoxUpdaterError::SigningFailed(message)) + if message.contains("post-sign transaction validation failed") + )); + } +} + +#[cfg(test)] +mod publication_health_tests { + use super::{ + AssetApi, ErgoBoxApi, SharedTrackerState, TrackerBoxUpdateConfig, TrackerBoxUpdater, + TrackerBoxUpdaterError, + }; + use basis_store::{ + chain_reconciliation::{ + JournalBootstrap, ReconciliationError, ReconciliationJournal, + ReconciliationJournalBinding, + }, + ConfirmedProjectionAnchor, + }; + use ergo_lib::ergotree_ir::{ergo_tree::ErgoTree, serialization::SigmaSerializable}; + use std::collections::HashMap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::time::Duration; + + const SIGNED_TRANSACTION_JSON: &str = r#"{ + "id": "9148408c04c2e38a6402a7950d6157730fa7d49e9ab3b9cadec481d7769918e9", + "inputs": [{ + "boxId": "9126af0675056b80d1fda7af9bf658464dbfa0b128afca7bf7dae18c27fe8456", + "spendingProof": {"proofBytes": "", "extension": {}} + }], + "dataInputs": [], + "outputs": [{ + "boxId": "b979c439dc698ce5e823b21c722a6e23721af010e4df8c72de0bfd0c3d9ccf6b", + "value": 74187765000000000, + "ergoTree": "101004020e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a7017300730110010204020404040004c0fd4f05808c82f5f6030580b8c9e5ae040580f882ad16040204c0944004c0f407040004000580f882ad16d19683030191a38cc7a7019683020193c2b2a57300007473017302830108cdeeac93a38cc7b2a573030001978302019683040193b1a5730493c2a7c2b2a573050093958fa3730673079973089c73097e9a730a9d99a3730b730c0599c1a7c1b2a5730d00938cc7b2a5730e0001a390c1a7730f", + "assets": [], + "creationHeight": 284761, + "additionalRegisters": {}, + "transactionId": "9148408c04c2e38a6402a7950d6157730fa7d49e9ab3b9cadec481d7769918e9", + "index": 0 + }, { + "boxId": "e56847ed19b3dc6b72828fcfb992fdf7310828cf291221269b7ffc72fd66706e", + "value": 67500000000, + "ergoTree": "100204a00b08cd021dde34603426402615658f1d970cfa7c7bd92ac81a8b16eeebff264d59ce4604ea02d192a39a8cc7a70173007301", + "assets": [], + "creationHeight": 284761, + "additionalRegisters": {}, + "transactionId": "9148408c04c2e38a6402a7950d6157730fa7d49e9ab3b9cadec481d7769918e9", + "index": 1 + }] + }"#; + + async fn one_response( + status: &'static str, + body: String, + delay_ms: u64, + ) -> (String, tokio::task::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + let count = stream.read(&mut buffer).await.unwrap_or(0); + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + let Some(header_end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } } + if delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + request }); + (format!("http://{address}"), task) + } - let signed_tx = Self::sign_transaction(config, unsigned_tx).await?; - let tx_id = Self::broadcast_transaction(config, &signed_tx).await?; + fn request_body(request: &[u8]) -> &[u8] { + request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|offset| &request[offset + 4..]) + .unwrap_or_default() + } - Ok(tx_id) + #[test] + fn publication_quarantine_is_one_way() { + let state = SharedTrackerState::new(); + assert!(state.is_publication_healthy()); + state.set_confirmed( + [0x42; 33], + "11".repeat(32), + "22".repeat(32), + "33".repeat(32), + 100, + 6, + ); + assert_eq!(state.get_confirmed().digest, Some([0x42; 33])); + + state.quarantine_publication(); + assert!(!state.is_publication_healthy()); + let hidden = state.get_confirmed(); + assert!(hidden.digest.is_none()); + assert!(hidden.tx_id.is_none()); + assert!(hidden.box_id.is_none()); + + state.quarantine_publication(); + assert!(!state.is_publication_healthy()); } - /// Check if a transaction has been confirmed on-chain by querying the blockchain API - pub async fn check_transaction_confirmation( - config: &TrackerBoxUpdateConfig, - tx_id: &str, - ) -> Result { - let client = reqwest::Client::new(); - let url = format!( - "{}/blockchain/transaction/byId/{}", - config.node_url.trim_end_matches('/'), - tx_id + #[test] + fn only_pending_transport_or_coherent_snapshot_races_are_retryable() { + assert!(TrackerBoxUpdater::is_retryable_pending_observation_error( + &TrackerBoxUpdaterError::HttpError("node unavailable".to_string()) + )); + assert!(TrackerBoxUpdater::is_retryable_pending_observation_error( + &TrackerBoxUpdaterError::Reconciliation(ReconciliationError::StaleEvidence) + )); + assert!(!TrackerBoxUpdater::is_retryable_pending_observation_error( + &TrackerBoxUpdaterError::InvalidNodeResponse("malformed /info".to_string()) + )); + assert!(!TrackerBoxUpdater::is_retryable_pending_observation_error( + &TrackerBoxUpdaterError::Reconciliation(ReconciliationError::TransactionRootMismatch) + )); + assert!(!TrackerBoxUpdater::is_retryable_pending_observation_error( + &TrackerBoxUpdaterError::Reconciliation(ReconciliationError::OutcomeUnknown( + "journal persist".to_string(), + )) + )); + assert!(!TrackerBoxUpdater::is_retryable_pending_observation_error( + &TrackerBoxUpdaterError::BroadcastOutcomeUnknown("actor rejected".to_string()) + )); + } + + #[test] + fn tracker_fee_selection_rejects_token_bearing_boxes() { + let token_box = ErgoBoxApi { + box_id: "11".repeat(32), + value: 2_000_000, + ergo_tree: "00".to_string(), + assets: vec![AssetApi { + token_id: "22".repeat(32), + amount: 1, + }], + additional_registers: HashMap::new(), + creation_height: 100, + transaction_id: "44".repeat(32), + index: 0, + }; + + let owner_tree = ErgoTree::sigma_parse_bytes( + &hex::decode( + "0008cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .expect("P2PK hex"), + ) + .expect("P2PK tree"); + let wallet_boxes = [token_box]; + let (selected, total) = + TrackerBoxUpdater::select_fee_inputs(&wallet_boxes, 1_000_000, "33", &owner_tree) + .expect("selection must not overflow"); + assert!(selected.is_empty()); + assert_eq!(total, 0); + } + + #[test] + fn updater_restores_a_complete_durable_publication_receipt() { + let state = SharedTrackerState::new(); + let tx_id = "11".repeat(32); + let digest = [0x42; 33]; + state.set_pending(digest, tx_id.clone(), 100); + + assert_eq!( + TrackerBoxUpdater::restored_pending_transaction(&state).unwrap(), + Some((tx_id, digest)) ); + assert!(state.is_publication_healthy()); + } - let mut request = client.get(&url); - if let Some(ref api_key) = config.api_key { - request = request.header("api_key", api_key); + #[test] + fn updater_quarantines_an_incomplete_publication_receipt() { + let state = SharedTrackerState::new(); + state.pending.write().unwrap().tx_id = Some("11".repeat(32)); + + assert!(matches!( + TrackerBoxUpdater::restored_pending_transaction(&state), + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) + )); + assert!(!state.is_publication_healthy()); + } + + #[test] + fn non_success_broadcast_response_has_unknown_outcome() { + let expected = "11".repeat(32); + assert!(matches!( + TrackerBoxUpdater::parse_broadcast_response( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "node failed", + &expected, + ), + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) + )); + } + + #[test] + fn success_without_transaction_id_has_unknown_outcome() { + let expected = "11".repeat(32); + assert!(matches!( + TrackerBoxUpdater::parse_broadcast_response(reqwest::StatusCode::OK, "{}", &expected,), + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) + )); + } + + #[test] + fn empty_or_mismatched_transaction_id_never_releases_publication() { + let expected = "11".repeat(32); + for response in ["\"\"".to_string(), format!("\"{}\"", "22".repeat(32))] { + assert!(matches!( + TrackerBoxUpdater::parse_broadcast_response( + reqwest::StatusCode::OK, + &response, + &expected, + ), + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) + )); } + } - let response = request - .send() - .await - .map_err(|e| TrackerBoxUpdaterError::HttpError(e.to_string()))?; + #[test] + fn matching_32_byte_transaction_id_is_accepted() { + let expected = "11".repeat(32); + let response = format!("\"{}\"", expected); + assert_eq!( + TrackerBoxUpdater::parse_broadcast_response( + reqwest::StatusCode::OK, + &response, + &expected, + ) + .unwrap(), + expected + ); + } - match response.status().as_u16() { - 200 => Ok(true), - 404 => Ok(false), - status => { - let body = response.text().await.unwrap_or_default(); - Err(TrackerBoxUpdaterError::HttpError(format!( - "HTTP {} checking transaction: {}", - status, body - ))) - } + #[test] + fn signed_transaction_id_is_derived_from_the_serialized_body() { + let signed_tx: serde_json::Value = serde_json::from_str(SIGNED_TRANSACTION_JSON) + .expect("valid signed transaction fixture"); + assert_eq!( + TrackerBoxUpdater::signed_transaction_id(&signed_tx).unwrap(), + "9148408c04c2e38a6402a7950d6157730fa7d49e9ab3b9cadec481d7769918e9" + ); + } + + #[test] + fn signed_transaction_rejects_a_forged_embedded_id() { + let mut signed_tx: serde_json::Value = serde_json::from_str(SIGNED_TRANSACTION_JSON) + .expect("valid signed transaction fixture"); + signed_tx["id"] = serde_json::Value::String("11".repeat(32)); + assert!(matches!( + TrackerBoxUpdater::signed_transaction_id(&signed_tx), + Err(TrackerBoxUpdaterError::SerializationError(_)) + )); + } + + #[test] + fn signed_transaction_rejects_a_mutated_body_with_the_old_id() { + let mut signed_tx: serde_json::Value = serde_json::from_str(SIGNED_TRANSACTION_JSON) + .expect("valid signed transaction fixture"); + signed_tx["outputs"][0]["value"] = serde_json::json!(74187765000000001u64); + assert!(matches!( + TrackerBoxUpdater::signed_transaction_id(&signed_tx), + Err(TrackerBoxUpdaterError::SerializationError(_)) + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn transaction_404_is_pending_evidence_not_confirmation() { + let (node_url, server) = one_response("404 Not Found", "{}".to_string(), 0).await; + let config = TrackerBoxUpdateConfig { + node_url, + ..TrackerBoxUpdateConfig::default() + }; + let client = TrackerBoxUpdater::node_client(&config).unwrap(); + let state = SharedTrackerState::new(); + let pending_root = [0x42; 33]; + let pending_tx = "11".repeat(32); + state.set_pending(pending_root, pending_tx.clone(), 100); + let result = TrackerBoxUpdater::get_node_bytes( + &config, + &client, + &format!("/blockchain/transaction/byId/{}", "11".repeat(32)), + true, + ) + .await + .unwrap(); + assert!(result.is_none()); + let still_fenced = state.get_pending(); + assert_eq!(still_fenced.tx_id, Some(pending_tx)); + assert_eq!(still_fenced.digest, Some(pending_root)); + assert_eq!(still_fenced.submitted_height, Some(100)); + let _ = server.await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn absent_reorg_horizon_refuses_before_any_node_io() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let node_url = format!("http://{}", listener.local_addr().unwrap()); + let state = SharedTrackerState::new(); + state.set_tracker_nft_id("11".repeat(32)); + let temp = tempfile::tempdir().unwrap(); + let config = TrackerBoxUpdateConfig { + node_url, + reconciliation_journal_path: temp.path().join("journal"), + reorg_monitor_depth: None, + ..TrackerBoxUpdateConfig::default() + }; + let (_shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1); + assert!(matches!( + TrackerBoxUpdater::start(config, state.clone(), shutdown_rx, None).await, + Err(TrackerBoxUpdaterError::InvalidConfiguration(_)) + )); + assert!(!state.is_publication_healthy()); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } + + #[test] + fn fresh_journal_bootstrap_is_allowed_only_for_history_free_approved_state() { + assert_eq!( + TrackerBoxUpdater::journal_bootstrap_policy(false, false, true), + JournalBootstrap::FreshAllowed + ); + for (history, pending, approved) in [ + (true, false, true), + (false, true, true), + (false, false, false), + (true, true, false), + ] { + assert_eq!( + TrackerBoxUpdater::journal_bootstrap_policy(history, pending, approved), + JournalBootstrap::ExistingRequired + ); } } -} -/// Derive a P2PK change address from a compressed tracker public key. -fn derive_change_address(tracker_pubkey: &[u8; 33]) -> Result { - use ergo_lib::ergo_chain_types::EcPoint; - use ergo_lib::ergotree_ir::chain::address::{Address, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; - - let ec_point = EcPoint::sigma_parse_bytes(tracker_pubkey).map_err(|e| { - TrackerBoxUpdaterError::SerializationError(format!("Invalid tracker pubkey: {}", e)) - })?; - let prove_dlog = ProveDlog::new(ec_point); - let address = Address::P2Pk(prove_dlog); - let encoder = - ergo_lib::ergotree_ir::chain::address::AddressEncoder::new(NetworkPrefix::Mainnet); - Ok(encoder.address_to_str(&address)) -} + fn historical_confirmation() -> ConfirmedProjectionAnchor { + ConfirmedProjectionAnchor::from_parts( + "33".repeat(32), + "22".repeat(32), + "44".repeat(32), + 100, + 6, + "55".repeat(32), + [0x66; 33], + ) + } -/// Convert a P2PK or P2S address string to its hex-encoded ergoTree bytes. -fn change_address_to_ergo_tree(address_str: &str) -> Result { - use ergo_lib::ergotree_ir::chain::address::{AddressEncoder, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; + #[tokio::test(flavor = "current_thread")] + async fn historical_bns1_rejects_a_missing_journal_without_node_io_or_writes() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let node_url = format!("http://{}", listener.local_addr().unwrap()); + let state = SharedTrackerState::new(); + state.set_tracker_nft_id("11".repeat(32)); + state.set_historical_confirmation(Some(historical_confirmation())); + let parent = tempfile::tempdir().unwrap(); + let journal_path = parent.path().join("missing-journal"); + let config = TrackerBoxUpdateConfig { + node_url, + reorg_monitor_depth: Some(12), + allow_fresh_reconciliation_journal: true, + reconciliation_journal_path: journal_path.clone(), + ..TrackerBoxUpdateConfig::default() + }; + let (_shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1); + assert!(matches!( + TrackerBoxUpdater::start(config, state, shutdown_rx, None).await, + Err(TrackerBoxUpdaterError::Reconciliation( + ReconciliationError::JournalBindingRequired + )) + )); + assert!(!journal_path.exists()); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); - let address = encoder.parse_address_from_str(address_str).map_err(|e| { - TrackerBoxUpdaterError::SerializationError(format!( - "Invalid address '{}': {}", - address_str, e - )) - })?; - let tree = address.script().map_err(|e| { - TrackerBoxUpdaterError::SerializationError(format!( - "Failed to get script for address '{}': {}", - address_str, e - )) - })?; - Ok(hex::encode(tree.sigma_serialize_bytes().map_err(|e| { - TrackerBoxUpdaterError::SerializationError(format!("Failed to serialize ergoTree: {:?}", e)) - })?)) + #[tokio::test(flavor = "current_thread")] + async fn historical_bns1_rejects_manifest_only_journal_state() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let node_url = format!("http://{}", listener.local_addr().unwrap()); + let state = SharedTrackerState::new(); + state.set_tracker_nft_id("11".repeat(32)); + state.set_historical_confirmation(Some(historical_confirmation())); + let parent = tempfile::tempdir().unwrap(); + let journal_path = parent.path().join("journal"); + { + let empty = ReconciliationJournal::open( + &journal_path, + ReconciliationJournalBinding::tracker_v1([0x11; 32]), + JournalBootstrap::FreshAllowed, + ) + .unwrap(); + assert!(matches!( + empty.recovery_action().unwrap(), + basis_store::chain_reconciliation::RecoveryAction::Idle + )); + } + let manifest_path = journal_path.join("confirmed-chain.manifest"); + let manifest_before = std::fs::read(&manifest_path).unwrap(); + let config = TrackerBoxUpdateConfig { + node_url, + reorg_monitor_depth: Some(12), + allow_fresh_reconciliation_journal: true, + reconciliation_journal_path: journal_path.clone(), + ..TrackerBoxUpdateConfig::default() + }; + let (_shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1); + assert!(matches!( + TrackerBoxUpdater::start(config, state, shutdown_rx, None).await, + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) + )); + assert_eq!(std::fs::read(manifest_path).unwrap(), manifest_before); + let reopened = ReconciliationJournal::open( + journal_path, + ReconciliationJournalBinding::tracker_v1([0x11; 32]), + JournalBootstrap::ExistingRequired, + ) + .unwrap(); + assert!(matches!( + reopened.recovery_action().unwrap(), + basis_store::chain_reconciliation::RecoveryAction::Idle + )); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn orphan_confirmation_rows_reject_idle_journal_before_node_io() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let node_url = format!("http://{}", listener.local_addr().unwrap()); + let state = SharedTrackerState::new(); + state.set_tracker_nft_id("11".repeat(32)); + // BNS1 row fragments exist, but their global BCP1 projection receipt + // was lost. This must not be mistaken for a fresh Idle generation. + state.set_confirmation_history_present(true); + let parent = tempfile::tempdir().unwrap(); + let journal_path = parent.path().join("journal"); + { + let _empty = ReconciliationJournal::open( + &journal_path, + ReconciliationJournalBinding::tracker_v1([0x11; 32]), + JournalBootstrap::FreshAllowed, + ) + .unwrap(); + } + let manifest_path = journal_path.join("confirmed-chain.manifest"); + let manifest_before = std::fs::read(&manifest_path).unwrap(); + let config = TrackerBoxUpdateConfig { + node_url, + reorg_monitor_depth: Some(12), + allow_fresh_reconciliation_journal: true, + reconciliation_journal_path: journal_path, + ..TrackerBoxUpdateConfig::default() + }; + let (_shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1); + assert!(matches!( + TrackerBoxUpdater::start(config, state.clone(), shutdown_rx, None).await, + Err(TrackerBoxUpdaterError::BroadcastOutcomeUnknown(_)) + )); + assert!(!state.is_publication_healthy()); + assert_eq!(std::fs::read(manifest_path).unwrap(), manifest_before); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn historical_bns1_rejects_wrong_journal_binding_without_rewrite() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let node_url = format!("http://{}", listener.local_addr().unwrap()); + let state = SharedTrackerState::new(); + state.set_tracker_nft_id("11".repeat(32)); + state.set_historical_confirmation(Some(historical_confirmation())); + let parent = tempfile::tempdir().unwrap(); + let journal_path = parent.path().join("journal"); + { + let _wrong = ReconciliationJournal::open( + &journal_path, + ReconciliationJournalBinding::tracker_v1([0x22; 32]), + JournalBootstrap::FreshAllowed, + ) + .unwrap(); + } + let manifest_path = journal_path.join("confirmed-chain.manifest"); + let manifest_before = std::fs::read(&manifest_path).unwrap(); + let config = TrackerBoxUpdateConfig { + node_url, + reorg_monitor_depth: Some(12), + allow_fresh_reconciliation_journal: true, + reconciliation_journal_path: journal_path, + ..TrackerBoxUpdateConfig::default() + }; + let (_shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1); + assert!(matches!( + TrackerBoxUpdater::start(config, state, shutdown_rx, None).await, + Err(TrackerBoxUpdaterError::Reconciliation( + ReconciliationError::JournalBindingMismatch + )) + )); + assert_eq!(std::fs::read(manifest_path).unwrap(), manifest_before); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn request_timeout_never_becomes_confirmation() { + let (node_url, server) = one_response("200 OK", "{}".to_string(), 1_500).await; + let config = TrackerBoxUpdateConfig { + node_url, + request_timeout_seconds: 1, + ..TrackerBoxUpdateConfig::default() + }; + let client = TrackerBoxUpdater::node_client(&config).unwrap(); + assert!(matches!( + TrackerBoxUpdater::get_node_bytes(&config, &client, "/info", false).await, + Err(TrackerBoxUpdaterError::HttpError(_)) + )); + let _ = server.await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn reconciliation_probe_rejects_a_node_body_over_the_process_cap() { + let body = "x".repeat(basis_store::ergo_scanner::NODE_HTTP_MAX_BODY_BYTES + 1); + let (node_url, server) = one_response("200 OK", body, 0).await; + let config = TrackerBoxUpdateConfig { + node_url, + ..TrackerBoxUpdateConfig::default() + }; + + assert!(matches!( + TrackerBoxUpdater::probe_transaction_observation(&config, &"11".repeat(32)).await, + Err(TrackerBoxUpdaterError::HttpError(_)) + )); + let _ = server.await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn broadcast_posts_the_exact_journaled_signed_bytes() { + let signed_bytes = SIGNED_TRANSACTION_JSON.as_bytes().to_vec(); + let expected_tx_id = "9148408c04c2e38a6402a7950d6157730fa7d49e9ab3b9cadec481d7769918e9"; + let (node_url, server) = one_response("200 OK", format!("\"{expected_tx_id}\""), 0).await; + let config = TrackerBoxUpdateConfig { + node_url, + ..TrackerBoxUpdateConfig::default() + }; + let client = TrackerBoxUpdater::node_client(&config).unwrap(); + assert_eq!( + TrackerBoxUpdater::broadcast_transaction( + &config, + &client, + &signed_bytes, + expected_tx_id, + ) + .await + .unwrap(), + expected_tx_id + ); + let request = server.await.unwrap(); + assert_eq!(request_body(&request), signed_bytes); + } + + #[test] + fn tracker_r5_shape_is_exact_not_prefix_only() { + let mut r5 = vec![0x64]; + r5.extend_from_slice(&[0x42; 33]); + r5.extend_from_slice(&[0x03, 0x20, 0x00]); + let mut box_json = serde_json::json!({ + "boxId": "11".repeat(32), "value": 1_000_000u64, + "ergoTree": "00", "assets": [], + "additionalRegisters": {"R5": hex::encode(&r5)}, + "creationHeight": 1, "transactionId": "22".repeat(32), "index": 0 + }); + let parsed: ErgoBoxApi = serde_json::from_value(box_json.clone()).unwrap(); + assert_eq!( + TrackerBoxUpdater::tracker_root_from_box(&parsed).unwrap(), + [0x42; 33] + ); + r5[34] = 0x01; + box_json["additionalRegisters"]["R5"] = serde_json::json!(hex::encode(r5)); + let malformed: ErgoBoxApi = serde_json::from_value(box_json).unwrap(); + assert!(TrackerBoxUpdater::tracker_root_from_box(&malformed).is_err()); + } + + #[tokio::test(flavor = "current_thread")] + async fn v2_activation_is_explicitly_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let config = TrackerBoxUpdateConfig { + allow_v2_reconciliation: true, + reconciliation_journal_path: temp.path().join("journal"), + ..TrackerBoxUpdateConfig::default() + }; + let (_shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1); + assert!(matches!( + TrackerBoxUpdater::start(config, SharedTrackerState::new(), shutdown_rx, None).await, + Err(TrackerBoxUpdaterError::InvalidConfiguration(_)) + )); + } } diff --git a/crates/basis_server/tests/acceptance_api_integration_tests.rs b/crates/basis_server/tests/acceptance_api_integration_tests.rs index 909172c..2619a52 100644 --- a/crates/basis_server/tests/acceptance_api_integration_tests.rs +++ b/crates/basis_server/tests/acceptance_api_integration_tests.rs @@ -119,6 +119,69 @@ async fn test_check_acceptance_whitelist() { assert_eq!(json["data"]["acceptable"], false); } +#[tokio::test] +async fn test_check_acceptance_rejects_aggregate_debt_above_reserve() { + use basis_server::acceptance::{CollateralizationPredicate, NotePredicate}; + use basis_store::reserve_tracker::ExtendedReserveInfo; + use basis_store::{IouNote, ReserveInfo}; + use std::sync::Arc; + + let mut issuer = [0u8; 33]; + issuer[0] = 0x02; + issuer[1] = 1; + let mut existing_recipient = [0u8; 33]; + existing_recipient[0] = 0x02; + existing_recipient[1] = 2; + let mut candidate_recipient = [0u8; 33]; + candidate_recipient[0] = 0x02; + candidate_recipient[1] = 3; + + let existing_note = IouNote::new(existing_recipient, 60, 0, 1, [0u8; 65]); + let reserve = ExtendedReserveInfo { + base_info: ReserveInfo { + collateral_amount: 100, + last_updated_height: 0, + contract_address: "test".to_string(), + tracker_nft_id: "test".to_string(), + refund_initiation_height: 0, + }, + // Scanner-created reserves currently carry this zero placeholder. + total_debt: 0, + box_id: "box1".to_string(), + owner_pubkey: hex::encode(issuer), + last_updated_timestamp: 0, + }; + let predicate: Arc = + Arc::new(CollateralizationPredicate::new("full_collateral", 1.0)); + let app = + create_test_app_with_liability_state(Some(predicate), vec![existing_note], Some(reserve)) + .await; + + let request = Request::builder() + .method(Method::POST) + .uri("/acceptance/check") + .header("Content-Type", "application/json") + .body(Body::from( + json!({ + "issuer_pubkey": hex::encode(issuer), + "recipient_pubkey": hex::encode(candidate_recipient), + "total_debt": 50 + }) + .to_string(), + )) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["success"], true); + assert_eq!(json["data"]["acceptable"], false); +} + #[tokio::test] async fn test_check_acceptance_invalid_pubkey() { let app = create_test_app(None).await; @@ -672,13 +735,60 @@ static STORAGE_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// Helper to create a test app with optional acceptance predicate async fn create_test_app( acceptance_predicate: Option>, +) -> axum::Router { + create_test_app_with_liability_state(acceptance_predicate, Vec::new(), None).await +} + +/// Helper with an explicit tracker-note snapshot and scanned reserve. +async fn create_test_app_with_liability_state( + acceptance_predicate: Option>, + tracker_notes: Vec, + reserve: Option, ) -> axum::Router { use basis_server::*; use basis_store::ergo_scanner::NodeConfig; use std::sync::Arc; use tokio::sync::Mutex; - let (tx, _rx) = tokio::sync::mpsc::channel::(100); + let (tx, mut rx) = tokio::sync::mpsc::channel::(100); + tokio::spawn(async move { + while let Some(command) = rx.recv().await { + match command { + TrackerCommand::GetNotesByIssuer { response_tx, .. } => { + let _ = response_tx.send(Ok(tracker_notes.clone())); + } + TrackerCommand::GetProjectedIssuerGrossDebt { + candidate_recipient, + candidate_total_debt, + response_tx, + .. + } => { + let result = (|| { + let mut total = 0u64; + let mut replaced = false; + for note in &tracker_notes { + let mut edge_debt = note.amount_collected; + if candidate_recipient.as_ref() == Some(¬e.recipient_pubkey) { + edge_debt = edge_debt.max(candidate_total_debt); + replaced = true; + } + total = total + .checked_add(edge_debt) + .ok_or(basis_store::NoteError::AmountOverflow)?; + } + if candidate_recipient.is_none() || !replaced { + total = total + .checked_add(candidate_total_debt) + .ok_or(basis_store::NoteError::AmountOverflow)?; + } + Ok(total) + })(); + let _ = response_tx.send(result); + } + _ => {} + } + } + }); let event_store = Arc::new(store::EventStore::new_in_memory()); let server_temp_dir = tempfile::tempdir().unwrap(); @@ -697,6 +807,11 @@ async fn create_test_app( }, basis_reserve_contract_p2s: "test".to_string(), tracker_nft_id: Some("test".to_string()), + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, tracker_public_key: None, tracker_secret_key: None, }, @@ -712,6 +827,9 @@ async fn create_test_app( let scanner = basis_store::ergo_scanner::ServerState::new( NodeConfig { node_url: "http://example.com".to_string(), + reserve_contract_p2s: Some( + basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + ), ..Default::default() }, scanner_data_dir, @@ -734,11 +852,16 @@ async fn create_test_app( (tracker_storage, policy_storage) }; + let reserve_tracker = basis_store::ReserveTracker::new(); + if let Some(reserve) = reserve { + reserve_tracker.update_reserve(reserve).unwrap(); + } + let app_state = AppState { tx, event_store, ergo_scanner: Arc::new(Mutex::new(scanner)), - reserve_tracker: Arc::new(Mutex::new(basis_store::ReserveTracker::new())), + reserve_tracker: Arc::new(Mutex::new(reserve_tracker)), config, shared_tracker_state: Arc::new(tokio::sync::Mutex::new( tracker_box_updater::SharedTrackerState::new(), @@ -784,6 +907,11 @@ async fn create_test_app_with_policy_routes( }, basis_reserve_contract_p2s: "test".to_string(), tracker_nft_id: Some("test".to_string()), + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, tracker_public_key: None, tracker_secret_key: None, }, @@ -799,6 +927,9 @@ async fn create_test_app_with_policy_routes( let scanner = basis_store::ergo_scanner::ServerState::new( NodeConfig { node_url: "http://example.com".to_string(), + reserve_contract_p2s: Some( + basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + ), ..Default::default() }, scanner_data_dir, @@ -877,6 +1008,11 @@ async fn create_test_app_with_all_routes( }, basis_reserve_contract_p2s: "test".to_string(), tracker_nft_id: Some("test".to_string()), + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, tracker_public_key: None, tracker_secret_key: None, }, @@ -892,6 +1028,9 @@ async fn create_test_app_with_all_routes( let scanner = basis_store::ergo_scanner::ServerState::new( NodeConfig { node_url: "http://example.com".to_string(), + reserve_contract_p2s: Some( + basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + ), ..Default::default() }, scanner_data_dir, diff --git a/crates/basis_server/tests/api_avl_integration_tests.rs b/crates/basis_server/tests/api_avl_integration_tests.rs index c01fa22..ad3cc9a 100644 --- a/crates/basis_server/tests/api_avl_integration_tests.rs +++ b/crates/basis_server/tests/api_avl_integration_tests.rs @@ -1,8 +1,7 @@ #[cfg(test)] mod api_avl_integration_tests { - use basis_store::{IouNote, PubKey, Signature, TrackerStateManager}; + use basis_store::{IouNote, PubKey, TrackerStateManager}; use secp256k1; - use std::sync::Arc; /// Helper to generate a test public key fn generate_test_pubkey(seed: u8) -> PubKey { @@ -238,49 +237,6 @@ mod api_avl_integration_tests { assert_eq!(root_after_update.len(), 33, "AVL root should be 33 bytes"); } - #[tokio::test] - async fn test_avl_tree_proof_generation_integration() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - - // Generate test keys - let issuer_secret_key = [5u8; 32]; // Use a different secret key for this test - let secp = secp256k1::Secp256k1::new(); - let secret_key = secp256k1::SecretKey::from_slice(&issuer_secret_key).unwrap(); - let issuer_pubkey_obj = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); - let issuer_pubkey = issuer_pubkey_obj.serialize(); - - let recipient_pubkey = generate_test_pubkey(2); - - // Create and store a note - let note = create_signed_note(recipient_pubkey, 1000, 1000000, &issuer_secret_key); - - let add_result = tracker.add_note(&issuer_pubkey, ¬e); - assert!(add_result.is_ok(), "Should be able to add note"); - - // Generate a proof for this note - let proof_result = tracker.generate_proof(&issuer_pubkey, &recipient_pubkey); - assert!( - proof_result.is_ok(), - "Should be able to generate proof for stored note" - ); - - let proof = proof_result.unwrap(); - - // Verify that the proof contains the expected note - assert_eq!(proof.note.amount_collected, 1000); - assert_eq!(proof.note.recipient_pubkey, recipient_pubkey); - - // Verify that the proof contains AVL proof data (not empty) - assert!(!proof.avl_proof.is_empty(), "AVL proof should not be empty"); - - // Verify AVL tree state commitment exists - let state = tracker.get_state(); - assert_ne!( - state.avl_root_digest, [0u8; 33], - "Tracker state should have valid AVL root" - ); - } - #[tokio::test] async fn test_tracker_state_manager_initial_empty_state() { let tracker = TrackerStateManager::new_with_temp_storage(); diff --git a/crates/basis_server/tests/api_integration_tests.rs b/crates/basis_server/tests/api_integration_tests.rs index 7c67a19..5e13089 100644 --- a/crates/basis_server/tests/api_integration_tests.rs +++ b/crates/basis_server/tests/api_integration_tests.rs @@ -2,7 +2,7 @@ #[cfg(test)] mod api_tests { - use basis_store::{IouNote, RedemptionRequest}; + use basis_store::IouNote; #[test] fn test_note_creation_validation() { @@ -14,36 +14,6 @@ mod api_tests { assert_eq!(note.timestamp, 1234567890); } - #[test] - fn test_redemption_request_structure() { - let request = RedemptionRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 500, - timestamp: 1234567890, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), - current_height: 1000, - recipient_address: "test_recipient_address".to_string(), - change_address: "test_change_address".to_string(), - issuer_signature: "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101".to_string(), - emergency: false, - tracker_signature: Some("020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202".to_string()), - reserve_box_value: 500 + 1000000 + 1000000, // Reserve must cover debt + fee + buffer - fee_input_box_ids: vec![], - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - assert!(!request.issuer_pubkey.is_empty()); - assert!(!request.recipient_pubkey.is_empty()); - assert!(request.amount > 0); - assert!(!request.reserve_box_id.is_empty()); - } - #[test] fn test_outstanding_debt_calculation() { let note = IouNote::new([1u8; 33], 1000, 250, 1234567890, [2u8; 65]); diff --git a/crates/basis_server/tests/cors_tests.rs b/crates/basis_server/tests/cors_tests.rs index 0ae70ec..fcc94b9 100644 --- a/crates/basis_server/tests/cors_tests.rs +++ b/crates/basis_server/tests/cors_tests.rs @@ -33,6 +33,9 @@ mod cors_tests { // Create a default NodeConfig for the scanner let config = basis_store::ergo_scanner::NodeConfig { node_url: "http://localhost:9053".to_string(), + reserve_contract_p2s: Some( + basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + ), ..Default::default() }; @@ -44,11 +47,10 @@ mod cors_tests { // Spawn tracker thread for tests tokio::task::spawn_blocking(move || { - use basis_store::{RedemptionManager, TrackerStateManager}; + use basis_store::TrackerStateManager; tracing::debug!("Test tracker thread started"); - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); + let mut tracker = TrackerStateManager::new_with_temp_storage(); while let Some(cmd) = rx.blocking_recv() { tracing::debug!("Test tracker thread received command: {:?}", cmd); @@ -58,56 +60,44 @@ mod cors_tests { note, response_tx, } => { - let result = redemption_manager.tracker.add_note(&issuer_pubkey, ¬e); + let result = tracker.add_note(&issuer_pubkey, ¬e); let _ = response_tx.send(result); } TrackerCommand::GetNotesByIssuer { issuer_pubkey, response_tx, } => { - let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); + let result = tracker.get_issuer_notes(&issuer_pubkey); let _ = response_tx.send(result); } - TrackerCommand::GetNotesByRecipient { - recipient_pubkey, - response_tx, - } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetNoteByIssuerAndRecipient { + TrackerCommand::GetProjectedIssuerGrossDebt { issuer_pubkey, - recipient_pubkey, + candidate_recipient, + candidate_total_debt, response_tx, } => { - let result = redemption_manager - .tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .map(Some); + let result = tracker.projected_issuer_gross_debt( + &issuer_pubkey, + candidate_recipient.as_ref(), + candidate_total_debt, + ); let _ = response_tx.send(result); } - TrackerCommand::InitiateRedemption { - request, + TrackerCommand::GetNotesByRecipient { + recipient_pubkey, response_tx, } => { - let result = redemption_manager.initiate_redemption(&request); + let result = tracker.get_recipient_notes(&recipient_pubkey); let _ = response_tx.send(result); } - TrackerCommand::CompleteRedemption { + TrackerCommand::GetNoteByIssuerAndRecipient { issuer_pubkey, recipient_pubkey, - redeemed_amount, - new_already_redeemed, response_tx, } => { - let result = redemption_manager.complete_redemption( - &issuer_pubkey, - &recipient_pubkey, - redeemed_amount, - new_already_redeemed, - ); + let result = tracker + .lookup_note(&issuer_pubkey, &recipient_pubkey) + .map(Some); let _ = response_tx.send(result); } TrackerCommand::GetNotes { response_tx } => { @@ -115,56 +105,6 @@ mod cors_tests { let result = Ok(Vec::new()); let _ = response_tx.send(result); } - TrackerCommand::GenerateProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - // For testing purposes, return a mock proof - let mock_proof = basis_store::NoteProof { - note: basis_store::IouNote::new([0u8; 33], 0, 0, 0, [0u8; 65]), - avl_proof: vec![1, 2, 3, 4], // Mock proof data - operations: vec![], - }; - let result = Ok(mock_proof); - let _ = response_tx.send(result); - } - TrackerCommand::GetTrackerLookupProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - // Mock tracker lookup proof - let mock_proof = basis_store::TrackerLookupProof { - key: vec![0u8; 64], - value: vec![0u8; 8], - proof: vec![1, 2, 3, 4], - }; - let _ = response_tx.send(Ok(mock_proof)); - } - TrackerCommand::GetReserveLookupProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - // Mock reserve lookup proof - let mock_proof = basis_store::ReserveLookupProof { - key: vec![0u8; 64], - value: vec![0u8; 8], - proof: Some(vec![1, 2, 3, 4]), - }; - let _ = response_tx.send(Ok(mock_proof)); - } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey: _, - recipient_pubkey: _, - timestamp: _, - new_already_redeemed: _, - response_tx, - } => { - // Mock reserve insert proof - let _ = response_tx.send(Ok((vec![1, 2, 3, 4], vec![5, 6, 7, 8]))); - } TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey: _, response_tx, @@ -177,55 +117,26 @@ mod cors_tests { recipient_pubkey, response_tx, } => { - let result = Ok(redemption_manager - .tracker - .get_confirmation(&issuer_pubkey, &recipient_pubkey)); + let result = + Ok(tracker.get_confirmation(&issuer_pubkey, &recipient_pubkey)); let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); + let _ = response_tx.send(Ok(tracker.all_confirmations())); } - TrackerCommand::MarkNotesPending { - digest, - tx_id, - submitted_height, - response_tx, - } => { - let result = redemption_manager.tracker.mark_notes_pending( - digest, - &tx_id, - submitted_height, - ); - let _ = response_tx.send(result); - } - TrackerCommand::ConfirmPendingNotes { - box_id, - height, - response_tx, - } => { - let result = redemption_manager - .tracker - .confirm_pending_notes(&box_id, height); - let _ = response_tx.send(result); + TrackerCommand::GetValidatedState { response_tx } => { + let _ = response_tx.send(tracker.validated_state()); } - TrackerCommand::RevertPendingNotes { response_tx } => { - let result = redemption_manager.tracker.revert_pending_notes(); - let _ = response_tx.send(result); + TrackerCommand::BeginPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); - let _ = response_tx.send(digest); + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } + | TrackerCommand::RollbackPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } - TrackerCommand::ReconcileWithConfirmedDigest { - digest, - box_id, - height, - response_tx, - } => { - let result = redemption_manager - .tracker - .reconcile_with_confirmed_digest(&digest, &box_id, height); - let _ = response_tx.send(result); + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } } } @@ -248,6 +159,11 @@ mod cors_tests { tracker_nft_id: Some( "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), ), + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, tracker_public_key: Some( "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), ), diff --git a/crates/basis_server/tests/http_api_integration_tests.rs b/crates/basis_server/tests/http_api_integration_tests.rs index db297be..bb5203d 100644 --- a/crates/basis_server/tests/http_api_integration_tests.rs +++ b/crates/basis_server/tests/http_api_integration_tests.rs @@ -6,9 +6,10 @@ mod http_api_tests { use basis_server::{ api::{ get_note_state, get_notes_by_issuer, get_notes_by_recipient, get_pending_tx, - get_tracker_state, + get_tracker_state, submit_reserve_transaction, }, config, + models::ReserveCreationResponse, store::EventStore, AppState, TrackerCommand, }; @@ -35,6 +36,9 @@ mod http_api_tests { // Create a default NodeConfig for the scanner let config = basis_store::ergo_scanner::NodeConfig { node_url: "http://localhost:9053".to_string(), + reserve_contract_p2s: Some( + basis_store::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + ), ..Default::default() }; let ergo_scanner = Arc::new(tokio::sync::Mutex::new( @@ -44,11 +48,10 @@ mod http_api_tests { // Spawn tracker thread for tests tokio::task::spawn_blocking(move || { - use basis_store::{RedemptionManager, TrackerStateManager}; + use basis_store::TrackerStateManager; tracing::debug!("Test tracker thread started"); - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); + let mut tracker = TrackerStateManager::new_with_temp_storage(); while let Some(cmd) = rx.blocking_recv() { tracing::debug!("Test tracker thread received command: {:?}", cmd); @@ -58,56 +61,44 @@ mod http_api_tests { note, response_tx, } => { - let result = redemption_manager.tracker.add_note(&issuer_pubkey, ¬e); + let result = tracker.add_note(&issuer_pubkey, ¬e); let _ = response_tx.send(result); } TrackerCommand::GetNotesByIssuer { issuer_pubkey, response_tx, } => { - let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); + let result = tracker.get_issuer_notes(&issuer_pubkey); let _ = response_tx.send(result); } - TrackerCommand::GetNotesByRecipient { - recipient_pubkey, - response_tx, - } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetNoteByIssuerAndRecipient { + TrackerCommand::GetProjectedIssuerGrossDebt { issuer_pubkey, - recipient_pubkey, + candidate_recipient, + candidate_total_debt, response_tx, } => { - let result = redemption_manager - .tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .map(Some); + let result = tracker.projected_issuer_gross_debt( + &issuer_pubkey, + candidate_recipient.as_ref(), + candidate_total_debt, + ); let _ = response_tx.send(result); } - TrackerCommand::InitiateRedemption { - request, + TrackerCommand::GetNotesByRecipient { + recipient_pubkey, response_tx, } => { - let result = redemption_manager.initiate_redemption(&request); + let result = tracker.get_recipient_notes(&recipient_pubkey); let _ = response_tx.send(result); } - TrackerCommand::CompleteRedemption { + TrackerCommand::GetNoteByIssuerAndRecipient { issuer_pubkey, recipient_pubkey, - redeemed_amount, - new_already_redeemed, response_tx, } => { - let result = redemption_manager.complete_redemption( - &issuer_pubkey, - &recipient_pubkey, - redeemed_amount, - new_already_redeemed, - ); + let result = tracker + .lookup_note(&issuer_pubkey, &recipient_pubkey) + .map(Some); let _ = response_tx.send(result); } TrackerCommand::GetNotes { response_tx } => { @@ -115,56 +106,6 @@ mod http_api_tests { let result = Ok(Vec::new()); let _ = response_tx.send(result); } - TrackerCommand::GenerateProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - // For testing purposes, return a mock proof - let mock_proof = basis_store::NoteProof { - note: basis_store::IouNote::new([0u8; 33], 0, 0, 0, [0u8; 65]), - avl_proof: vec![1, 2, 3, 4], // Mock proof data - operations: vec![], - }; - let result = Ok(mock_proof); - let _ = response_tx.send(result); - } - TrackerCommand::GetTrackerLookupProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - // Mock tracker lookup proof - let mock_proof = basis_store::TrackerLookupProof { - key: vec![0u8; 64], - value: vec![0u8; 8], - proof: vec![1, 2, 3, 4], - }; - let _ = response_tx.send(Ok(mock_proof)); - } - TrackerCommand::GetReserveLookupProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - // Mock reserve lookup proof - let mock_proof = basis_store::ReserveLookupProof { - key: vec![0u8; 64], - value: vec![0u8; 8], - proof: Some(vec![1, 2, 3, 4]), - }; - let _ = response_tx.send(Ok(mock_proof)); - } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey: _, - recipient_pubkey: _, - timestamp: _, - new_already_redeemed: _, - response_tx, - } => { - // Mock reserve insert proof - let _ = response_tx.send(Ok((vec![1, 2, 3, 4], vec![5, 6, 7, 8]))); - } TrackerCommand::GetNotesByRecipientWithIssuer { recipient_pubkey: _, response_tx, @@ -177,55 +118,26 @@ mod http_api_tests { recipient_pubkey, response_tx, } => { - let result = Ok(redemption_manager - .tracker - .get_confirmation(&issuer_pubkey, &recipient_pubkey)); + let result = + Ok(tracker.get_confirmation(&issuer_pubkey, &recipient_pubkey)); let _ = response_tx.send(result); } TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); + let _ = response_tx.send(Ok(tracker.all_confirmations())); } - TrackerCommand::MarkNotesPending { - digest, - tx_id, - submitted_height, - response_tx, - } => { - let result = redemption_manager.tracker.mark_notes_pending( - digest, - &tx_id, - submitted_height, - ); - let _ = response_tx.send(result); + TrackerCommand::GetValidatedState { response_tx } => { + let _ = response_tx.send(tracker.validated_state()); } - TrackerCommand::ConfirmPendingNotes { - box_id, - height, - response_tx, - } => { - let result = redemption_manager - .tracker - .confirm_pending_notes(&box_id, height); - let _ = response_tx.send(result); + TrackerCommand::BeginPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } - TrackerCommand::RevertPendingNotes { response_tx } => { - let result = redemption_manager.tracker.revert_pending_notes(); - let _ = response_tx.send(result); + TrackerCommand::RecordPublicationAttempt { response_tx, .. } + | TrackerCommand::ConfirmPublication { response_tx, .. } + | TrackerCommand::RollbackPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } - TrackerCommand::ReconcileWithConfirmedDigest { - digest, - box_id, - height, - response_tx, - } => { - let result = redemption_manager - .tracker - .reconcile_with_confirmed_digest(&digest, &box_id, height); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); - let _ = response_tx.send(digest); + TrackerCommand::AbortPublication { response_tx, .. } => { + let _ = response_tx.send(Err(basis_store::NoteError::UnsupportedOperation)); } } } @@ -248,6 +160,11 @@ mod http_api_tests { tracker_nft_id: Some( "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), ), + allow_fresh_tracker_generation: false, + confirmed_chain_min_successor_depth: None, + confirmed_chain_max_evidence_age_ms: None, + confirmed_chain_reorg_monitor_depth: None, + allow_fresh_reconciliation_journal: false, tracker_public_key: Some( "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), ), @@ -478,17 +395,30 @@ mod http_api_tests { async fn test_get_tracker_state() { let state = create_mock_app_state().await; let digest = [1u8; 33]; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + state + .tx + .send(TrackerCommand::GetValidatedState { response_tx }) + .await + .unwrap(); + let local_digest = response_rx.await.unwrap().unwrap().avl_root_digest; { let shared = state.shared_tracker_state.lock().await; - shared.set_avl_root_digest(digest); - shared.set_confirmed(digest, "box1".to_string(), 100); + shared.set_confirmed( + digest, + "11".repeat(32), + "box1".to_string(), + "22".repeat(32), + 100, + 6, + ); } let response = get_tracker_state(axum::extract::State(state)).await; assert_eq!(response.0, StatusCode::OK); assert!(response.1.success); let data = response.1.data.clone().unwrap(); - assert_eq!(data.local_digest, hex::encode(digest)); + assert_eq!(data.local_digest, hex::encode(local_digest)); assert_eq!(data.confirmed_digest, Some(hex::encode(digest))); assert_eq!(data.confirmed_box_id, Some("box1".to_string())); assert_eq!(data.confirmed_height, Some(100)); @@ -512,6 +442,18 @@ mod http_api_tests { assert_eq!(data.submitted_height, Some(150)); } + #[tokio::test] + async fn reserve_wallet_proxy_is_a_gone_tombstone() { + let payload = ReserveCreationResponse { + requests: Vec::new(), + fee: 1_000_000, + change_address: "not-forwarded".to_string(), + }; + + let response = submit_reserve_transaction(axum::extract::Json(payload)).await; + assert_eq!(response.0, StatusCode::GONE); + } + #[tokio::test] async fn test_get_note_state_not_found() { let state = create_mock_app_state().await; diff --git a/crates/basis_server/tests/legacy_command_surface_guard.rs b/crates/basis_server/tests/legacy_command_surface_guard.rs new file mode 100644 index 0000000..7e3c429 --- /dev/null +++ b/crates/basis_server/tests/legacy_command_surface_guard.rs @@ -0,0 +1,78 @@ +#[test] +fn production_actor_has_no_legacy_proof_commands_or_repair_environment() { + let library = include_str!("../src/lib.rs"); + let binary = include_str!("../src/main.rs"); + let library_code = library + .lines() + .filter(|line| !line.trim_start().starts_with("//!")) + .collect::>() + .join("\n"); + let forbidden_commands = [ + "GenerateProof", + "GetTrackerLookupProof", + "GetReserveLookupProof", + "GetReserveInsertProof", + "GetReserveStateDigest", + ]; + + for command in forbidden_commands { + assert!( + !library_code.contains(command), + "legacy TrackerCommand variant remains: {command}" + ); + assert!( + !binary.contains(command), + "legacy tracker actor arm remains: {command}" + ); + } + + assert!(!binary.contains("REPAIR_RESERVE_")); +} + +#[test] +fn openapi_exposes_exactly_the_nine_retired_routes_as_gone() { + let openapi = include_str!("../../../openapi.yaml").replace("\r\n", "\n"); + assert!(!openapi.contains(" /proof:\n")); + + let routes = [ + "/redeem:", + "/redeem/complete:", + "/proof/redemption:", + "/tracker/proof:", + "/reserve/proof:", + "/tracker/signature:", + "/redemption/prepare:", + "/redemption/build:", + "/redemption/submit:", + ]; + + for (index, route) in routes.iter().enumerate() { + let start = openapi + .find(&format!(" {route}\n")) + .unwrap_or_else(|| panic!("missing retired OpenAPI route {route}")); + let end = routes + .iter() + .filter_map(|other| openapi[start + 1..].find(&format!(" {other}\n"))) + .min() + .map(|offset| start + 1 + offset) + .unwrap_or(openapi.len()); + let block = &openapi[start..end]; + assert!( + block.contains("deprecated: true"), + "{route} is not deprecated" + ); + assert!( + block.contains("'410':"), + "{route} does not document HTTP 410" + ); + assert!( + !block.contains("'200':"), + "{route} still documents a successful response" + ); + assert_eq!( + openapi.matches(&format!(" {route}\n")).count(), + 1, + "{route} appears more than once (index {index})" + ); + } +} diff --git a/crates/basis_server/tests/redemption_api_integration_tests.rs b/crates/basis_server/tests/redemption_api_integration_tests.rs index 0ed36ed..f872a03 100644 --- a/crates/basis_server/tests/redemption_api_integration_tests.rs +++ b/crates/basis_server/tests/redemption_api_integration_tests.rs @@ -1,939 +1,65 @@ -//! Integration tests for redemption API endpoints -//! -//! This module tests the redemption-related HTTP endpoints: -//! - POST /redeem: Initiate redemption -//! - POST /redeem/complete: Complete redemption -//! - GET /proof/redemption: Get redemption proof -//! - POST /redemption/prepare: Prepare redemption data -//! - POST /tracker/signature: Request tracker signature -//! -//! Tests use the direct handler call pattern (Pattern A) with mock AppState, -//! reusing the create_mock_app_state helper from http_api_integration_tests.rs. -//! -//! NOTE: These tests use persistent storage (Fjall/LSM-tree) which creates -//! directories on disk. Due to storage locking, tests should be run with -//! --test-threads=1 to avoid conflicts between parallel test executions. -//! Example: cargo test -p basis_server --test redemption_api_integration_tests -- --test-threads=1 - -#[cfg(test)] -mod redemption_api_tests { - use axum::http::StatusCode; - use basis_server::{ - api::{ - complete_redemption, get_redemption_proof, initiate_redemption, prepare_redemption, - request_tracker_signature, - }, - models::{ - CompleteRedemptionRequest, RedeemRequest, RedemptionPreparationRequest, - TrackerSignatureRequest, - }, - AppState, TrackerCommand, - }; - use basis_store::schnorr::generate_keypair; - use std::sync::Arc; - use tokio::sync::mpsc; - - /// Global lock to serialize Fjall storage initialization across concurrent tests. - /// - /// Fjall's keyspace creation can race when multiple databases are opened - /// concurrently in the same process, leading to intermittent "No such file or - /// directory" errors. Holding this lock while creating test storage avoids that. - static STORAGE_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - // ============================================================================ - // Test helper: create mock app state with tracker thread handling redemption commands - // ============================================================================ - - async fn create_mock_app_state() -> AppState { - let (tx, mut rx) = mpsc::channel(100); - let event_store = Arc::new(basis_server::store::EventStore::new().await.unwrap()); - - // Unique temporary directory for this test invocation. - let temp_dir = tempfile::tempdir().unwrap(); - let data_dir = temp_dir.path(); - - // Create a default NodeConfig for the scanner - let config = basis_store::ergo_scanner::NodeConfig { - node_url: "http://localhost:9053".to_string(), - ..Default::default() - }; - let ergo_scanner = Arc::new(tokio::sync::Mutex::new( - basis_store::ergo_scanner::ServerState::new(config, data_dir).unwrap(), - )); - let reserve_tracker = Arc::new(tokio::sync::Mutex::new(basis_store::ReserveTracker::new())); - - // Spawn tracker thread for tests - tokio::task::spawn_blocking(move || { - use basis_store::{RedemptionManager, TrackerStateManager}; - - tracing::debug!("Test tracker thread started"); - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - while let Some(cmd) = rx.blocking_recv() { - tracing::debug!("Test tracker thread received command: {:?}", cmd); - match cmd { - TrackerCommand::AddNote { - issuer_pubkey, - note, - response_tx, - } => { - let result = redemption_manager.tracker.add_note(&issuer_pubkey, ¬e); - let _ = response_tx.send(result); - } - TrackerCommand::GetNotesByIssuer { - issuer_pubkey, - response_tx, - } => { - let result = redemption_manager.tracker.get_issuer_notes(&issuer_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetNotesByRecipient { - recipient_pubkey, - response_tx, - } => { - let result = redemption_manager - .tracker - .get_recipient_notes(&recipient_pubkey); - let _ = response_tx.send(result); - } - TrackerCommand::GetNoteByIssuerAndRecipient { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = redemption_manager - .tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .map(Some); - let _ = response_tx.send(result); - } - TrackerCommand::InitiateRedemption { - request, - response_tx, - } => { - let result = redemption_manager.initiate_redemption(&request); - let _ = response_tx.send(result); - } - TrackerCommand::CompleteRedemption { - issuer_pubkey, - recipient_pubkey, - redeemed_amount, - new_already_redeemed, - response_tx, - } => { - let result = redemption_manager.complete_redemption( - &issuer_pubkey, - &recipient_pubkey, - redeemed_amount, - new_already_redeemed, - ); - let _ = response_tx.send(result); - } - TrackerCommand::GetNotes { response_tx } => { - let result = Ok(Vec::new()); - let _ = response_tx.send(result); - } - TrackerCommand::GenerateProof { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let mock_proof = basis_store::NoteProof { - note: basis_store::IouNote::new([0u8; 33], 0, 0, 0, [0u8; 65]), - avl_proof: vec![1, 2, 3, 4], - operations: vec![], - }; - let result = Ok(mock_proof); - let _ = response_tx.send(result); - } - TrackerCommand::GetTrackerLookupProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - let mock_proof = basis_store::TrackerLookupProof { - key: vec![0u8; 64], - value: vec![0u8; 8], - proof: vec![1, 2, 3, 4], - }; - let _ = response_tx.send(Ok(mock_proof)); - } - TrackerCommand::GetReserveLookupProof { - issuer_pubkey: _, - recipient_pubkey: _, - response_tx, - } => { - let mock_proof = basis_store::ReserveLookupProof { - key: vec![0u8; 64], - value: vec![0u8; 8], - proof: Some(vec![1, 2, 3, 4]), - }; - let _ = response_tx.send(Ok(mock_proof)); - } - TrackerCommand::GetReserveInsertProof { - issuer_pubkey: _, - recipient_pubkey: _, - timestamp: _, - new_already_redeemed: _, - response_tx, - } => { - let _ = response_tx.send(Ok((vec![1, 2, 3, 4], vec![5, 6, 7, 8]))); - } - TrackerCommand::GetNotesByRecipientWithIssuer { - recipient_pubkey: _, - response_tx, - } => { - let _ = response_tx.send(Ok(Vec::new())); - } - TrackerCommand::GetConfirmation { - issuer_pubkey, - recipient_pubkey, - response_tx, - } => { - let result = Ok(redemption_manager - .tracker - .get_confirmation(&issuer_pubkey, &recipient_pubkey)); - let _ = response_tx.send(result); - } - TrackerCommand::GetAllConfirmations { response_tx } => { - let _ = response_tx.send(redemption_manager.tracker.all_confirmations()); - } - TrackerCommand::MarkNotesPending { - digest, - tx_id, - submitted_height, - response_tx, - } => { - let result = redemption_manager.tracker.mark_notes_pending( - digest, - &tx_id, - submitted_height, - ); - let _ = response_tx.send(result); - } - TrackerCommand::ConfirmPendingNotes { - box_id, - height, - response_tx, - } => { - let result = redemption_manager - .tracker - .confirm_pending_notes(&box_id, height); - let _ = response_tx.send(result); - } - TrackerCommand::RevertPendingNotes { response_tx } => { - let result = redemption_manager.tracker.revert_pending_notes(); - let _ = response_tx.send(result); - } - TrackerCommand::GetReserveStateDigest { response_tx } => { - let digest = redemption_manager.tracker.reserve_state_digest(); - let _ = response_tx.send(digest); - } - TrackerCommand::ReconcileWithConfirmedDigest { - digest, - box_id, - height, - response_tx, - } => { - let result = redemption_manager - .tracker - .reconcile_with_confirmed_digest(&digest, &box_id, height); - let _ = response_tx.send(result); - } - } - } - }); - - // Create a minimal config for testing - let test_config = std::sync::Arc::new(basis_server::config::AppConfig { - server: basis_server::config::ServerConfig { - host: "127.0.0.1".to_string(), - port: 3048, - data_dir: Some(data_dir.to_string_lossy().to_string()), - database_url: Some("sqlite::memory:".to_string()), - }, - ergo: basis_server::config::ErgoConfig { - node: basis_store::ergo_scanner::NodeConfig { - node_url: "http://localhost:9053".to_string(), - ..Default::default() - }, - basis_reserve_contract_p2s: "test".to_string(), - tracker_nft_id: Some( - "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), - ), - tracker_public_key: Some( - "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - ), - tracker_secret_key: None, - }, - transaction: basis_server::config::TransactionConfig { - fee: 1000000, - change_address: None, - }, - acceptance: basis_server::acceptance::config::AcceptanceConfig::empty(), - }); - - let temp_dir = std::env::temp_dir().join(format!( - "basis_test_tracker_storage_redemption_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); - - let (tracker_storage, policy_storage) = { - let _guard = STORAGE_INIT_LOCK.lock().unwrap(); - let tracker_storage = basis_store::persistence::TrackerStorage::open(&temp_dir) - .expect("Failed to create tracker storage"); - let policy_storage = - basis_store::persistence::AcceptancePolicyStorage::open(temp_dir.join("policies")) - .expect("Failed to create policy storage"); - (tracker_storage, policy_storage) - }; - - AppState { - tx, - event_store, - ergo_scanner, - reserve_tracker, - config: test_config, - shared_tracker_state: std::sync::Arc::new(tokio::sync::Mutex::new( - basis_server::tracker_box_updater::SharedTrackerState::new(), - )), - tracker_storage, - acceptance_predicate: None, - policy_storage, - } - } - - /// Helper to add a note to the tracker for testing - async fn add_test_note( - state: &AppState, - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - timestamp: u64, - ) { - let (secret, _) = generate_keypair(); - let recipient_bytes = hex::decode(recipient_pubkey).unwrap(); - let recipient_arr: [u8; 33] = recipient_bytes.try_into().unwrap(); - - let note = basis_store::IouNote::create_and_sign(recipient_arr, amount, timestamp, &secret) +//! Structural regression tests for the nine retired v1 redemption routes. + +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, +}; +use basis_server::retired_v1_redemption_routes; +use tower::ServiceExt; + +#[tokio::test] +async fn every_retired_route_returns_gone_before_body_or_query_parsing() { + // The router is intentionally constructible without AppState. That makes + // actor, storage, scanner, node, signer, and broadcast effects unreachable. + let app = retired_v1_redemption_routes::<()>(); + let routes = [ + (Method::POST, "/redeem"), + (Method::POST, "/redeem/complete"), + (Method::GET, "/proof/redemption?issuer_pubkey=not-hex"), + (Method::GET, "/tracker/proof?issuer_pubkey=not-hex"), + (Method::GET, "/reserve/proof?issuer_pubkey=not-hex"), + (Method::POST, "/tracker/signature"), + (Method::POST, "/redemption/prepare"), + (Method::POST, "/redemption/build"), + (Method::POST, "/redemption/submit"), + ]; + + for (method, uri) in routes { + let request = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(Body::from("{")) .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::GONE, "route {uri}"); - let issuer_bytes = hex::decode(issuer_pubkey).unwrap(); - let issuer_arr: [u8; 33] = issuer_bytes.try_into().unwrap(); - - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - let cmd = TrackerCommand::AddNote { - issuer_pubkey: issuer_arr, - note, - response_tx, - }; - - state.tx.send(cmd).await.unwrap(); - let _ = response_rx.await.unwrap(); - } - - // ============================================================================ - // POST /redeem tests - // ============================================================================ - - #[tokio::test] - async fn test_redeem_invalid_hex_pubkey() { - // Test that invalid hex encoding for recipient pubkey returns 400 - let state = create_mock_app_state().await; - - let request = RedeemRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "not-valid-hex!!!".to_string(), - amount: 1000, - timestamp: 1234567890, - reserve_box_id: "".to_string(), - recipient_address: "".to_string(), - issuer_signature: "01".repeat(65), - emergency: false, - }; - - let response = - initiate_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - assert!(body.data.is_none()); - } - - #[tokio::test] - async fn test_redeem_invalid_pubkey_length() { - // Test that wrong-length public key returns 400 - let state = create_mock_app_state().await; - - // 32 bytes instead of 33 - let wrong_length = "0101010101010101010101010101010101010101010101010101010101010101"; - - let request = RedeemRequest { - issuer_pubkey: wrong_length.to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 1000, - timestamp: 1234567890, - reserve_box_id: "".to_string(), - recipient_address: "".to_string(), - issuer_signature: "01".repeat(65), - emergency: false, - }; - - let response = - initiate_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - // The handler will try to find a reserve and fail, or the tracker thread will fail - // Either way, it should not succeed - let body = &response.1; + let bytes = axum::body::to_bytes(response.into_body(), 16 * 1024) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["success"], false, "route {uri}"); + assert!(body["data"].is_null(), "route {uri}"); assert!( - !body.success || body.data.is_none(), - "Expected failure or no data, got: {:?}", - body - ); - } - - #[tokio::test] - async fn test_redeem_note_not_found() { - // Test redemption for a note that doesn't exist in the tracker - let state = create_mock_app_state().await; - - let request = RedeemRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 1000, - timestamp: 1234567890, - reserve_box_id: "".to_string(), - recipient_address: "".to_string(), - issuer_signature: "01".repeat(65), - emergency: false, - }; - - let response = - initiate_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - // Should fail because no reserve exists for this issuer - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_redeem_emergency_flag_structure() { - // Test that emergency redemption flag is accepted in request structure - let state = create_mock_app_state().await; - - let request = RedeemRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 1000, - timestamp: 1234567890, - reserve_box_id: "".to_string(), - recipient_address: "".to_string(), - issuer_signature: "01".repeat(65), - emergency: true, // Emergency flag set - }; - - // This will fail at the reserve lookup stage (no reserve in DB), - // but the request structure with emergency=true should be accepted - let response = - initiate_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - // Should fail at reserve lookup, not at request parsing - let body = &response.1; - if !body.success { - let default_msg = "unknown".to_string(); - let error_msg = body.error.as_ref().unwrap_or(&default_msg); - assert!( - error_msg.contains("reserve") - || error_msg.contains("Reserve") - || error_msg.contains("No matching reserve"), - "Expected reserve-related error, got: {}", - error_msg - ); - } - } - - // ============================================================================ - // POST /redeem/complete tests - // ============================================================================ - - #[tokio::test] - async fn test_complete_redemption_invalid_hex() { - // Test that invalid hex encoding returns 400 - let state = create_mock_app_state().await; - - let request = CompleteRedemptionRequest { - redemption_id: "test-redemption-1".to_string(), - issuer_pubkey: "not-hex!!!".to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - redeemed_amount: 1000, - new_already_redeemed: None, - }; - - let response = - complete_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_complete_redemption_wrong_length_pubkey() { - // Test that wrong-length pubkey returns 400 - let state = create_mock_app_state().await; - - // 32 bytes instead of 33 - let wrong_length = "0101010101010101010101010101010101010101010101010101010101010101"; - - let request = CompleteRedemptionRequest { - redemption_id: "test-redemption-1".to_string(), - issuer_pubkey: wrong_length.to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - redeemed_amount: 1000, - new_already_redeemed: None, - }; - - let response = - complete_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_complete_redemption_note_not_found() { - // Test completing redemption for a non-existent note - let state = create_mock_app_state().await; - - let request = CompleteRedemptionRequest { - redemption_id: "test-redemption-1".to_string(), - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - redeemed_amount: 1000, - new_already_redeemed: None, - }; - - let response = - complete_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - // Should fail because note doesn't exist - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - // ============================================================================ - // GET /proof/redemption tests - // ============================================================================ - - #[tokio::test] - async fn test_get_redemption_proof_invalid_hex() { - // Test that invalid hex pubkey returns 400 - let state = create_mock_app_state().await; - - let mut params = std::collections::HashMap::new(); - params.insert("issuer_pubkey".to_string(), "not-hex!!!".to_string()); - params.insert( - "recipient_pubkey".to_string(), - "020202020202020202020202020202020202020202020202020202020202020202".to_string(), - ); - - let response = - get_redemption_proof(axum::extract::State(state), axum::extract::Query(params)).await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_get_redemption_proof_wrong_length() { - // Test that wrong-length pubkey returns 400 - let state = create_mock_app_state().await; - - let mut params = std::collections::HashMap::new(); - // 32 bytes instead of 33 - params.insert( - "issuer_pubkey".to_string(), - "0101010101010101010101010101010101010101010101010101010101010101".to_string(), - ); - params.insert( - "recipient_pubkey".to_string(), - "020202020202020202020202020202020202020202020202020202020202020202".to_string(), + body["error"] + .as_str() + .unwrap_or_default() + .contains("v1 redemption is retired"), + "route {uri}" ); - - let response = - get_redemption_proof(axum::extract::State(state), axum::extract::Query(params)).await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - // ============================================================================ - // POST /tracker/signature tests - // ============================================================================ - - #[tokio::test] - async fn test_request_tracker_signature_invalid_hex() { - // Test that invalid hex pubkey returns 400 - let state = create_mock_app_state().await; - - let request = TrackerSignatureRequest { - issuer_pubkey: "not-hex!!!".to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - total_debt: 1000, - timestamp: 1234567890, - emergency: false, - }; - - let response = - request_tracker_signature(axum::extract::State(state), axum::extract::Json(request)) - .await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_request_tracker_signature_wrong_length() { - // Test that wrong-length pubkey returns 400 - let state = create_mock_app_state().await; - - // 32 bytes instead of 33 - let wrong_length = "0101010101010101010101010101010101010101010101010101010101010101"; - - let request = TrackerSignatureRequest { - issuer_pubkey: wrong_length.to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - total_debt: 1000, - timestamp: 1234567890, - emergency: false, - }; - - let response = - request_tracker_signature(axum::extract::State(state), axum::extract::Json(request)) - .await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_request_tracker_signature_valid_structure() { - // Test that valid request structure is accepted (will fail at signing stage due to no secret key) - let state = create_mock_app_state().await; - - let request = TrackerSignatureRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - total_debt: 1000, - timestamp: 1234567890, - emergency: false, - }; - - let response = - request_tracker_signature(axum::extract::State(state), axum::extract::Json(request)) - .await; - - // Without tracker_secret_key configured, it falls back to Ergo node API which will fail - // in test environment. The request structure itself should be validated. - let body = &response.1; - if !body.success { - let default_msg = "unknown".to_string(); - let error_msg = body.error.as_ref().unwrap_or(&default_msg); - assert!( - error_msg.contains("tracker") - || error_msg.contains("Tracker") - || error_msg.contains("sign") - || error_msg.contains("node"), - "Expected tracker/signing-related error for valid request structure, got: {}", - error_msg - ); - } - } - - #[tokio::test] - async fn test_request_tracker_signature_emergency_flag() { - // Test that emergency flag is accepted in request - let state = create_mock_app_state().await; - - let request = TrackerSignatureRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - total_debt: 1000, - timestamp: 1234567890, - emergency: true, // Emergency flag - }; - - let response = - request_tracker_signature(axum::extract::State(state), axum::extract::Json(request)) - .await; - - // The emergency flag should be accepted in the request structure - let body = &response.1; - if !body.success { - let default_msg = "unknown".to_string(); - let error_msg = body.error.as_ref().unwrap_or(&default_msg); - assert!( - error_msg.contains("tracker") - || error_msg.contains("Tracker") - || error_msg.contains("sign") - || error_msg.contains("node"), - "Expected tracker/signing-related error for valid request structure, got: {}", - error_msg - ); - } - } - - // ============================================================================ - // POST /redemption/prepare tests - // ============================================================================ - - #[tokio::test] - async fn test_prepare_redemption_invalid_hex() { - // Test that invalid hex pubkey returns 400 - let state = create_mock_app_state().await; - - let request = RedemptionPreparationRequest { - issuer_pubkey: "not-hex!!!".to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 1000, - timestamp: 1234567890, - }; - - let response = - prepare_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - assert_eq!(response.0, StatusCode::BAD_REQUEST); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_prepare_redemption_wrong_length_pubkey() { - // Test that wrong-length pubkey returns error (400 or 500 depending on validation stage) - let state = create_mock_app_state().await; - - // 32 bytes instead of 33 - let wrong_length = "0101010101010101010101010101010101010101010101010101010101010101"; - - let request = RedemptionPreparationRequest { - issuer_pubkey: wrong_length.to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 1000, - timestamp: 1234567890, - }; - - let response = - prepare_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - // The handler validates hex first (passes for wrong_length since it's valid hex), - // then later validates length which may return 500 (internal error) or 400 - // depending on where the validation happens. We just assert it doesn't succeed. - assert_ne!(response.0, StatusCode::OK); - let body = &response.1; - assert!(!body.success); - assert!(body.error.is_some()); - } - - #[tokio::test] - async fn test_prepare_redemption_valid_structure() { - // Test that valid request structure is accepted (will fail at Ergo node API call) - let state = create_mock_app_state().await; - - let request = RedemptionPreparationRequest { - issuer_pubkey: "010101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - recipient_pubkey: "020202020202020202020202020202020202020202020202020202020202020202" - .to_string(), - amount: 1000, - timestamp: 1234567890, - }; - - let response = - prepare_redemption(axum::extract::State(state), axum::extract::Json(request)).await; - - // Without Ergo node available, it will fail at the signing stage. - // The request structure should be validated correctly. - let body = &response.1; - if !body.success { - let default_msg = "unknown".to_string(); - let error_msg = body.error.as_ref().unwrap_or(&default_msg); - assert!( - error_msg.contains("tracker") - || error_msg.contains("Tracker") - || error_msg.contains("sign") - || error_msg.contains("node"), - "Expected tracker/signing-related error for valid request structure, got: {}", - error_msg - ); - } - } - - // ============================================================================ - // Request/response model validation tests - // ============================================================================ - - #[tokio::test] - async fn test_redeem_request_deserialization() { - // Test that RedeemRequest deserializes correctly from JSON - let json = r#"{ - "issuer_pubkey": "010101010101010101010101010101010101010101010101010101010101010101", - "recipient_pubkey": "020202020202020202020202020202020202020202020202020202020202020202", - "amount": 1000, - "timestamp": 1234567890, - "issuer_signature": "01" - }"#; - - let request: RedeemRequest = serde_json::from_str(json).unwrap(); - - assert_eq!( - request.issuer_pubkey, - "010101010101010101010101010101010101010101010101010101010101010101" - ); - assert_eq!( - request.recipient_pubkey, - "020202020202020202020202020202020202020202020202020202020202020202" - ); - assert_eq!(request.amount, 1000); - assert_eq!(request.timestamp, 1234567890); - assert_eq!(request.reserve_box_id, ""); // Default - assert_eq!(request.recipient_address, ""); // Default - assert_eq!(request.emergency, false); // Default - assert_eq!(request.issuer_signature, "01"); } +} - #[tokio::test] - async fn test_complete_redemption_request_deserialization() { - // Test CompleteRedemptionRequest deserialization from JSON - let json = r#"{ - "redemption_id": "redemption_test_123", - "issuer_pubkey": "010101010101010101010101010101010101010101010101010101010101010101", - "recipient_pubkey": "020202020202020202020202020202020202020202020202020202020202020202", - "redeemed_amount": 500 - }"#; - - let request: CompleteRedemptionRequest = serde_json::from_str(json).unwrap(); - - assert_eq!(request.redemption_id, "redemption_test_123"); - assert_eq!( - request.issuer_pubkey, - "010101010101010101010101010101010101010101010101010101010101010101" - ); - assert_eq!( - request.recipient_pubkey, - "020202020202020202020202020202020202020202020202020202020202020202" - ); - assert_eq!(request.redeemed_amount, 500); - } - - #[tokio::test] - async fn test_tracker_signature_request_deserialization() { - // Test TrackerSignatureRequest deserialization from JSON - let json = r#"{ - "issuer_pubkey": "010101010101010101010101010101010101010101010101010101010101010101", - "recipient_pubkey": "020202020202020202020202020202020202020202020202020202020202020202", - "total_debt": 10000, - "timestamp": 1234567890, - "emergency": true - }"#; - - let request: TrackerSignatureRequest = serde_json::from_str(json).unwrap(); - - assert_eq!( - request.issuer_pubkey, - "010101010101010101010101010101010101010101010101010101010101010101" - ); - assert_eq!( - request.recipient_pubkey, - "020202020202020202020202020202020202020202020202020202020202020202" - ); - assert_eq!(request.total_debt, 10000); - assert_eq!(request.timestamp, 1234567890); - assert_eq!(request.emergency, true); - } - - #[tokio::test] - async fn test_redemption_preparation_request_deserialization() { - // Test RedemptionPreparationRequest deserialization from JSON - let json = r#"{ - "issuer_pubkey": "010101010101010101010101010101010101010101010101010101010101010101", - "recipient_pubkey": "020202020202020202020202020202020202020202020202020202020202020202", - "amount": 1000, - "timestamp": 1234567890 - }"#; - - let request: RedemptionPreparationRequest = serde_json::from_str(json).unwrap(); - - assert_eq!( - request.issuer_pubkey, - "010101010101010101010101010101010101010101010101010101010101010101" - ); - assert_eq!( - request.recipient_pubkey, - "020202020202020202020202020202020202020202020202020202020202020202" - ); - assert_eq!(request.amount, 1000); - assert_eq!(request.timestamp, 1234567890); - } - - #[tokio::test] - async fn test_redeem_request_default_fields() { - // Test that serde default fields work correctly - let json = r#"{ - "issuer_pubkey": "010101010101010101010101010101010101010101010101010101010101010101", - "recipient_pubkey": "020202020202020202020202020202020202020202020202020202020202020202", - "amount": 1000, - "timestamp": 1234567890, - "issuer_signature": "01" - }"#; - - let request: RedeemRequest = serde_json::from_str(json).unwrap(); - - // Default values should be applied - assert_eq!(request.reserve_box_id, ""); - assert_eq!(request.recipient_address, ""); - assert_eq!(request.emergency, false); - assert_eq!(request.issuer_signature, "01"); - } +#[tokio::test] +async fn absent_generic_proof_route_is_not_a_tombstone_alias() { + let response = retired_v1_redemption_routes::<()>() + .oneshot( + Request::builder() + .uri("/proof") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/crates/basis_server/tests/tracker_box_updater_integration.rs b/crates/basis_server/tests/tracker_box_updater_integration.rs index 1b0cd03..b59217f 100644 --- a/crates/basis_server/tests/tracker_box_updater_integration.rs +++ b/crates/basis_server/tests/tracker_box_updater_integration.rs @@ -7,14 +7,12 @@ mod integration_tests { // Create shared state with some test values let shared_state = SharedTrackerState::new(); - // Set some test values - let test_root = [0x11u8; 33]; // Test AVL root digest (33 bytes) + // Set a test publisher identity. let test_pubkey = [0x02u8; 33]; // Test compressed public key (33 bytes) - shared_state.set_avl_root_digest(test_root); shared_state.set_tracker_pubkey(test_pubkey); - // Verify the values were set correctly - assert_eq!(shared_state.get_avl_root_digest(), test_root); + // Roots are intentionally not cached in SharedTrackerState: only the + // tracker actor owns and validates them. assert_eq!(shared_state.get_tracker_pubkey(), test_pubkey); // Test creating and starting the updater @@ -44,13 +42,10 @@ mod integration_tests { } #[tokio::test] - async fn test_tracker_box_updates_avl_digest() { + async fn test_tracker_manager_exposes_validated_digest() { use basis_store::{IouNote, TrackerStateManager}; use secp256k1::{Secp256k1, SecretKey}; - // Create shared state - let shared_state = SharedTrackerState::new(); - // Create a test tracker and add a note to update the AVL tree let mut tracker = TrackerStateManager::new_with_temp_storage(); @@ -81,14 +76,8 @@ mod integration_tests { ); // Get the new AVL root digest after the update - let new_root = tracker.get_state().avl_root_digest; - - // Update the shared state to match - shared_state.set_avl_root_digest(new_root); - - // Verify that the shared state was updated - assert_eq!(shared_state.get_avl_root_digest(), new_root); - assert_ne!(shared_state.get_avl_root_digest(), [0u8; 33]); // Should not be all zeros + let new_root = tracker.validated_state().unwrap().avl_root_digest; + assert_ne!(new_root, [0u8; 33]); } #[tokio::test] @@ -122,18 +111,17 @@ mod integration_tests { } #[tokio::test] - async fn test_transaction_confirmation_check_not_found() { - // Test that check_transaction_confirmation handles a non-existent tx gracefully - // Using a local/mock URL that will definitely timeout/fail + async fn test_transaction_observation_rejects_an_unreachable_node() { + // A transport failure cannot be interpreted as confirmation evidence. let config = TrackerBoxUpdateConfig { node_url: "http://localhost:99999".to_string(), // Invalid port - will fail fast ..Default::default() }; - // Check a non-existent transaction ID (64 hex chars) + // Probe one syntactically valid transaction ID (64 hex chars). let fake_tx_id = "0000000000000000000000000000000000000000000000000000000000000000"; - let result = TrackerBoxUpdater::check_transaction_confirmation(&config, fake_tx_id).await; + let result = TrackerBoxUpdater::probe_transaction_observation(&config, fake_tx_id).await; // Should return an error since the node is unreachable assert!(result.is_err(), "Should error when node is unreachable"); diff --git a/crates/basis_server/tests/tracker_signing_surface_guard.rs b/crates/basis_server/tests/tracker_signing_surface_guard.rs new file mode 100644 index 0000000..30f5619 --- /dev/null +++ b/crates/basis_server/tests/tracker_signing_surface_guard.rs @@ -0,0 +1,12 @@ +#[test] +fn tracker_publisher_has_no_node_signer_or_configurable_change_surface() { + let updater = include_str!("../src/tracker_box_updater.rs"); + + assert!(updater.contains("Wallet::from_secrets")); + assert!(updater.contains("sign_transaction(signing_context, state_context, None)")); + assert!(updater.contains("/transactions")); + assert!(!updater.contains("/wallet/transaction/sign")); + assert!(!updater.contains("\"inputsRaw\"")); + assert!(!updater.contains("\"secrets\"")); + assert!(!updater.contains("change_address")); +} diff --git a/crates/basis_store/Cargo.toml b/crates/basis_store/Cargo.toml index ee7fcab..7c52752 100644 --- a/crates/basis_store/Cargo.toml +++ b/crates/basis_store/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] ergo_avltree_rust = { workspace = true } fjall = { workspace = true } +fs2 = "0.4.3" serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } @@ -43,6 +44,7 @@ basis_core = { path = "../basis_core" } proptest = "1.0" criterion = "0.5" tempfile = "3.10.0" +sha2 = "0.10.9" [features] default = ["ergo_scanner"] diff --git a/crates/basis_store/contracts/basis-token-v2.p2s b/crates/basis_store/contracts/basis-token-v2.p2s new file mode 100644 index 0000000..debc245 --- /dev/null +++ b/crates/basis_store/contracts/basis-token-v2.p2s @@ -0,0 +1 @@ +1ba80f6d01000e0100041404140400040404000502040004020402050004400440050005000440043001000400040004020100040201000400040204020e08424153495302000105000430040004100410042004200430050005000500040404000400050204020402050005000402040004000500043005000500050005000500048001048401040004420442010001010400010004000402040004000502010004400410041001000480010484010400044204420100040204040400010004040400040004000502040204020402050001000402040205020500053c04020402040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d80bd6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e47202d609db6a01ddd60a9f72097b7301d60be4c6a70564d60c9d72037302d60d9e7203730395efededededededed9272037304ededed93b172047305938cb27204730600027307948cb27204730800018cb2720473090001918cb27204730a0002730b93b17205730c93b1e4c6a7090e730d917206730e927207730f947208720aededededed93db6403720b7310e6db6404720b93e4db6404720b7311db6405720bdb6406720befdb6407720bd173129593720c7313d806d60ee30107d60fe3020ed610e30305d611e30405d612e3050ed613e3070e95ecefed92720d731491b1a59a720d7315efededededede6720ee6720fe67210e67211e67212e67213d17316d804d614b2a5720d00d615c672140407d616b2a59a720d731700d617c67216040e95ecefededededede67215e6c672140564e6c67214060ee6c672140705e6c672140805e6c67214090eefe67217d17318d81ad618db63087214d6198cb2720473190001d61ab27204731a00d61b8c721a01d61ce4720ed61dcd721cd61e998c721a028cb27218731b0002d61fdb07027208d620cbb3b3b3b3b3731c7219721b7205721fdb0702721cd621dc640a720b027220e47213d622e67221d6237a731dd624b3b3722372237223d625e572217224d6269593b17225731e72257224d6277cb47226731f7320d6287cb4722673217322d6297cb4722673237324d62ae47211d62be47210d62ced91721e732590721e95ed927229732692722b722999722b72297327d62d7a722ad62e7a722bd62fe4720fd630b1722fd631b3b37220722e722dea02d1edededededededededededededededed93c27214c2a7edededed93b172187328938cb27218732900017219938cb27218732a0002732b938cb27218732c0001721b918cb27218732d0002732e93e47215720893e4c67214060e720593e4c672140705720793e4c672140805720693e4c67214090ec5a794721c720aededededed93c27216d0721d91c17216732f93b1db630872167330938cb2db6308721673310001721b938cb2db6308721673320002721e93e47217c5a7ed92c17214c1a791721e7333ecef722293b172257334ededed9272277335927228733692722973379072297228957222eced93722a722793722b7228ed91722a722792722b7228ed91722a733891722b7339722c93e4dc6410720b0283013c0e0e86027220b3b3722d722e7a95722c9a7229721e7229e47212e4c67214056495ed927230733a907230733bd802d632b4722f733c733dd633ee7232ed947233720a939f72097bb4722f733e7230a072339f72087bcbb3b372327231721f733f95927ea30572067340d803d632e3060ed633e3080ed634db6501fe95ececefe67232efe6723390b1723473417342d803d635b27234734300d636c672350407d637db6308723595ecefede67236e6c672350564efeded93b172377344938cb27237734500017205938cb272377346000273477348d805d638e47236d639e4c672350564d63adc640a7239027220e47233d63be47232d63cb1723bededed947238720aededededed93db640372397349e6db6404723993e4db64047239734adb64057239db64067239efdb6407723995e6723ad801d63de4723aed93b1723d734b927c723d722b734c95ed92723c734d90723c734ed802d63db4723b734f7350d63eee723ded94723e720a939f72097bb4723b7351723ca0723e9f72387bcbb3b3723d7231db070272387352721dd801d60e93720c735395ec720e93720c735495efed92720d735591b1a5720dd17356d803d60fb2a5720d00d610c6720f0407d611db6308720f95ecefededededede67210e6c6720f0564e6c6720f060ee6c6720f0705e6c6720f0805e6c6720f090eefedededed93b172117357938cb27211735800018cb2720473590001938cb27211735a0002735b938cb27211735c00018cb27204735d0001918cb27211735e0002735fd17360d801d612ededededed93c2720fc2a793e47210720893e4c6720f0564720b93e4c6720f060e720593e4c6720f0805720693e4c6720f090ec5a795720ed1ededed721293e4c6720f0705720792c1720fc1a792998cb27211736100028cb27204736200027363ea02d1ededededed7212937207736492e4c6720f07057ea30590e4c6720f07059a7ea305736592c1720fc1a7928cb27211736600028cb2720473670002cd72089593720c7368ea02d1eded9172077369927ea305736a907207997ea305736bcd7208d1736c diff --git a/crates/basis_store/contracts/basis-v2-provenance.json b/crates/basis_store/contracts/basis-v2-provenance.json new file mode 100644 index 0000000..7ce93b2 --- /dev/null +++ b/crates/basis_store/contracts/basis-v2-provenance.json @@ -0,0 +1,29 @@ +{ + "abi_generation": 2, + "network": "ergo-mainnet", + "source_repository": "BetterMoneyLabs/chaincash", + "chaincash_base_commit": "78475e30362571acf56e4e38276a9d6c0a84ce0c", + "contract_source_commit": "9a274396d5f78f7be5ed76bacee5329c42570317", + "claim_key_vector_commit": "04031626f09c6590a20ad20d5583c6eccc14412d", + "claim_key_vector_test_blob": "52cf88c3f692e0f0c23903562f603fae700b2ebe", + "contracts": { + "erg": { + "source_path": "contracts/offchain/basis-v2.es", + "source_blob": "47b3e0647f90db2dd8e667641294d66e287d8f6a", + "normalized_source_sha256": "31ff4271b1c79302064df83a6bfa3d4f6f5f153747002c1c0206b2f3d88b507a", + "golden_path": "contracts/offchain/basis-v2.p2s", + "golden_blob": "f806b34edab4b18b89a395625db753d7acdf94db", + "ergo_tree_bytes": 1682, + "ergo_tree_sha256": "2690634924efb22359a776f89f5274d77e067bd8ad0619a6e358a2f96697a0c2" + }, + "token": { + "source_path": "contracts/offchain/basis-token-v2.es", + "source_blob": "48a00b612fe6f4345b322d22408c48100f2cd442", + "normalized_source_sha256": "c7e5a0cf6a12aaedefba79ecd012ac7839a48f683931d1d4e10371d287c58573", + "golden_path": "contracts/offchain/basis-token-v2.p2s", + "golden_blob": "debc24531f0adc26292f897fff6b53ed06ff4374", + "ergo_tree_bytes": 1963, + "ergo_tree_sha256": "ba1df64e7d95ecffc4f3d49fcada8baebe59a676eb617737cd010bdb52381cb3" + } + } +} diff --git a/crates/basis_store/contracts/basis-v2.p2s b/crates/basis_store/contracts/basis-v2.p2s new file mode 100644 index 0000000..f806b34 --- /dev/null +++ b/crates/basis_store/contracts/basis-v2.p2s @@ -0,0 +1 @@ +1b8f0d5001000e010004140414040004020400050204400440050005000440043001000400040004020100040201000e0842415349530200000400050004300400041004100420042004300500050005000400050004300500050005000500050004800104840104000442044201000101040001000400040204000400050201000440041004100100048001048401040004420442010004020404040001000100058084af5f0500053c040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d80bd6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e47202d609db6a01ddd60a9f72097b7301d60be4c6a70564d60c9d72037302d60d9e7203730395efededededededed9272037304ed93b172047305938cb2720473060002730793b17205730893b1e4c6a7090e7309917206730a927207730b947208720aededededed93db6403720b730ce6db6404720b93e4db6404720b730ddb6405720bdb6406720befdb6407720bd1730e9593720c730fd806d60ee30107d60fe3020ed610e30305d611e30405d612e3050ed613e3070e95ecefed92720d731091b1a59a720d7311efededededede6720ee6720fe67210e67211e67212e67213d17312d804d614b2a5720d00d615c672140407d616b2a59a720d731300d617c67216040e95ecefededededede67215e6c672140564e6c67214060ee6c672140705e6c672140805e6c67214090eefe67217d17314d818d618e4720ed619cd7218d61ac17214d61bc1a7d61c99721b721ad61ddb07027208d61ecbb3b3b3b373158cb27204731600017205721ddb07027218d61fdc640a720b02721ee47213d620e6721fd6217a7317d622b3b3722172217221d623e5721f7222d6249593b17223731872237222d6257cb472247319731ad6267cb47224731b731cd6277cb47224731d731ed628e47211d629e47210d62aed91721c731f90721c95ed9272277320927229722799722972277321d62b7a7228d62c7a7229d62de4720fd62eb1722dd62fb3b3721e722c722bea02d1edededededededededededededededed93c27214c2a793db63087214720493e47215720893e4c67214060e720593e4c672140705720793e4c672140805720693e4c67214090ec5a7947218720aeded93c27216d0721993b1db63087216732293e47217c5a7ededed8f721a721b91721c732393c17216721c939a721ac17216721becef722093b172237324ededed9272257325927226732692722773279072277226957220eced93722872259372297226ed91722872259272297226ed91722873289172297329722a93e4dc6410720b0283013c0e0e8602721eb3b3722b722c7a95722a9a7227721c7227e47212e4c67214056495ed92722e732a90722e732bd802d630b4722d732c732dd631ee7230ed947231720a939f72097bb4722d732e722ea072319f72087bcbb3b37230722f721d732f95927ea30572067330d803d630e3060ed631e3080ed632db6501fe95ececefe67230efe6723190b1723273317332d803d633b27232733300d634c672330407d635db6308723395ecefede67234e6c672330564efeded93b172357334938cb27235733500017205938cb272357336000273377338d805d636e47234d637e4c672330564d638dc640a723702721ee47231d639e47230d63ab17239ededed947236720aededededed93db640372377339e6db6404723793e4db64047237733adb64057237db64067237efdb6407723795e67238d801d63be47238ed93b1723b733b927c723b7229733c95ed92723a733d90723a733ed802d63bb47239733f7340d63cee723bed94723c720a939f72097bb472397341723aa0723c9f72367bcbb3b3723b722fdb0702723673427219d801d60e93720c734395ec720e93720c734495efed92720d734591b1a5720dd17346d802d60fb2a5720d00d610c6720f040795efededededede67210e6c6720f0564e6c6720f060ee6c6720f0705e6c6720f0805e6c6720f090ed17347d801d611edededededed93c2720fc2a793db6308720f720493e47210720893e4c6720f0564720b93e4c6720f060e720593e4c6720f0805720693e4c6720f090ec5a795720ed1eded721193e4c6720f070572079299c1720fc1a77348ea02d1edededed7211937207734992e4c6720f07057ea30590e4c6720f07059a7ea305734a92c1720fc1a7cd72089593720c734bea02d1eded917207734c927ea305734d907207997ea305734ecd7208d1734f diff --git a/crates/basis_store/src/basis_spec_tests.rs b/crates/basis_store/src/basis_spec_tests.rs index 8398ba2..617f46d 100644 --- a/crates/basis_store/src/basis_spec_tests.rs +++ b/crates/basis_store/src/basis_spec_tests.rs @@ -16,23 +16,15 @@ #[cfg(test)] mod tests { use crate::schnorr::{ - self, generate_keypair, pubkey_from_hex, pubkey_to_hex, schnorr_sign, schnorr_verify, + generate_keypair, pubkey_from_hex, pubkey_to_hex, schnorr_sign, schnorr_verify, signature_from_hex, signature_to_hex, }; - use crate::{IouNote, NoteKey, PubKey, TrackerStateManager}; + use crate::{IouNote, NoteKey, PubKey}; use basis_core::types::signing_message as core_signing_message; use blake2::{Blake2b, Digest}; use generic_array::typenum::U32; use secp256k1::{Secp256k1, SecretKey}; - // ========== Test Constants (matching Scala BasisSpec) ========== - - const BASIS_TOKEN_ID: &str = "4b2d8b7beb3eaac8234d9e61792d270898a43934d6a27275e4f3a044609c9f2a"; - const TRACKER_NFT: &str = "3c45f29a5165b030fdb5eaf5d81f8108f9d8f507b31487dd51f4ae08fe07cf4a"; - - const MIN_VALUE: u64 = 1_000_000_000; // 1 ERG - const FEE_VALUE: u64 = 1_000_000; // 0.001 ERG - // Standard test timestamp (in seconds, Sept 2001 - clearly in the past) // Note: The add_note check compares timestamp (ms) against current_time (secs), // so we use a value that is clearly in the past even when measured in seconds. @@ -46,14 +38,6 @@ mod tests { hasher.finalize().into() } - /// Create a debt record key: blake2b256(ownerKey || receiverKey) - fn debt_record_key(owner_key: &PubKey, receiver_key: &PubKey) -> [u8; 32] { - let mut input = [0u8; 66]; - input[..33].copy_from_slice(owner_key); - input[33..].copy_from_slice(receiver_key); - blake2b256(&input) - } - /// Generate a random keypair fn random_keypair() -> ([u8; 32], PubKey) { generate_keypair() @@ -784,103 +768,6 @@ mod tests { ); } - // ========== TRACKER STATE MANAGER TESTS ========== - - /// Test reserve tree value format: 8 bytes (already_redeemed) - #[test] - fn reserve_tree_value_format_8_bytes() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - - let (issuer_secret, issuer_pk) = random_keypair(); - let (_, recipient_pk) = random_keypair(); - - let total_debt: u64 = 1_000_000_000; - let timestamp: u64 = TEST_TIMESTAMP; - - let note = - IouNote::create_and_sign(recipient_pk, total_debt, timestamp, &issuer_secret).unwrap(); - - // Insert note into tracker - tracker.add_note(&issuer_pk, ¬e).unwrap(); - - // Generate reserve lookup proof - let lookup_proof = tracker - .generate_reserve_lookup_proof(&issuer_pk, &recipient_pk) - .unwrap(); - - // For first redemption, value should be 16 bytes: 0 timestamp || 0 redeemedAmount - assert_eq!( - lookup_proof.value.len(), - 16, - "Reserve tree value must be 16 bytes (timestamp || redeemedAmount)" - ); - - // For first redemption, value should be all zeros - assert_eq!( - lookup_proof.value, - vec![0u8; 16], - "First redemption value should be 16 zero bytes" - ); - } - - /// Test that get_already_redeemed returns 0 for first redemption - #[test] - fn first_redemption_returns_zero_already_redeemed() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - - let (issuer_secret, issuer_pk) = random_keypair(); - let (_, recipient_pk) = random_keypair(); - - let total_debt: u64 = 1_000_000_000; - let timestamp: u64 = TEST_TIMESTAMP; - - let note = - IouNote::create_and_sign(recipient_pk, total_debt, timestamp, &issuer_secret).unwrap(); - tracker.add_note(&issuer_pk, ¬e).unwrap(); - - let already_redeemed = tracker - .get_already_redeemed(&issuer_pk, &recipient_pk) - .unwrap(); - assert_eq!( - already_redeemed, 0, - "First redemption should have 0 already redeemed" - ); - } - - /// Test tracker tree lookup proof generation - #[test] - fn tracker_tree_lookup_proof() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - - let (issuer_secret, issuer_pk) = random_keypair(); - let (_, recipient_pk) = random_keypair(); - - let total_debt: u64 = 1_000_000_000; - let timestamp: u64 = TEST_TIMESTAMP; - - let note = - IouNote::create_and_sign(recipient_pk, total_debt, timestamp, &issuer_secret).unwrap(); - tracker.add_note(&issuer_pk, ¬e).unwrap(); - - let lookup_proof = tracker - .generate_tracker_lookup_proof(&issuer_pk, &recipient_pk) - .unwrap(); - - // Tracker tree value should be 8 bytes (totalDebt as big-endian u64) - assert_eq!( - lookup_proof.value.len(), - 8, - "Tracker tree value must be 8 bytes (totalDebt)" - ); - - // Value should match the note's totalDebt - let decoded_debt = u64::from_be_bytes(lookup_proof.value.try_into().unwrap()); - assert_eq!( - decoded_debt, total_debt, - "Tracker tree value must match note's totalDebt" - ); - } - // ========== PROPERTY-BASED STYLE TESTS ========== /// Test that signatures are always 65 bytes regardless of input diff --git a/crates/basis_store/src/basis_v2_builder.rs b/crates/basis_store/src/basis_v2_builder.rs new file mode 100644 index 0000000..301a455 --- /dev/null +++ b/crates/basis_store/src/basis_v2_builder.rs @@ -0,0 +1,2501 @@ +//! Fail-closed Basis v2 redemption manifest construction and validation. +//! +//! This module prepares unsigned bytes only. It has no signing, submission, +//! broadcast, or activation path. Confirmed input authority is intentionally +//! constructible only by this crate so a future scanner/reconciler must own the +//! chain-observation boundary before the builder can become reachable. +//! +//! ```compile_fail +//! use basis_store::basis_v2_builder::VerifiedSigningTipV2; +//! let _ = VerifiedSigningTipV2 { block_id: [0; 32], height: 1 }; +//! ``` +//! +//! Raw JSON must enter through the bounded parser; the manifest deliberately +//! does not implement `Deserialize` directly: +//! +//! ```compile_fail +//! use basis_store::basis_v2_builder::V2RedemptionManifest; +//! let _: V2RedemptionManifest = serde_json::from_slice(b"{}").unwrap(); +//! ``` + +use crate::basis_v2_state::{ + ReserveRedeemedStoreV2, ReserveRedemptionWitnessV2, TrackerClaimStoreV2, TrackerClaimWitnessV2, + V2StateError, +}; +use crate::contract_compiler::BasisV2ContractKind; +use basis_core::basis_v2::{ + verify_basis_v2_signature, ClaimDomainV2, ClaimV2, RedeemedStateV2, ReserveAssetV2, +}; +use basis_core::types::Signature; +use basis_offchain::ergo_tx::{ + scala_context_extension_order, serialize_coll_bytes, serialize_ergo_byte, serialize_ergo_long, +}; +use basis_trees::{ReserveAvlTree, TrackerAvlTree}; +use ergo_lib::ergo_chain_types::ADDigest; +use ergo_lib::ergotree_ir::chain::ergo_box::{ + box_value::BoxValue, ErgoBox, NonMandatoryRegisterId, +}; +use ergo_lib::ergotree_ir::mir::avl_tree_data::{AvlTreeData, AvlTreeFlags}; +use ergo_lib::ergotree_ir::mir::constant::{Constant, TryExtractInto}; +use ergo_lib::ergotree_ir::serialization::{SigmaSerializable, SigmaSerializationError}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, HashSet}; +use thiserror::Error; + +pub const BASIS_V2_MANIFEST_SCHEMA: &str = "basis-v2-redemption-manifest/1"; +pub const BASIS_V2_MIN_BOX_VALUE: u64 = 1_000_000; +pub const BASIS_V2_MAX_FUNDING_INPUTS: usize = 16; +pub const BASIS_V2_MAX_PROOF_BYTES: usize = 64 * 1024; +pub const BASIS_V2_MAX_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024; + +const REQUIRED_V2_MANIFEST_FIELDS: &[&str] = &[ + "schema", + "claim", + "amount", + "fee", + "current_height", + "minimum_confirmations", + "emergency", + "reserve_input", + "funding_inputs", + "tracker_data_input", + "tracker_signature", + "reserve_root", + "reserve_prior_state", + "reserve_prior_proof", + "reserve_next_state", + "reserve_update_proof", + "reserve_next_root", + "tracker_root", + "tracker_lookup_proof", + "tracker_committed_total_debt", + "context_extension", + "outputs", +]; + +/// Miner-fee proposition used by the canonical Scala transaction shape. +pub const BASIS_V2_FEE_ERGO_TREE: &str = "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a701730073011001020402d19683030193a38cc7b2a57300000193c2b2a57301007473027303830108cdeeac93b1a57304"; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum V2BuilderError { + #[error("v2 state evidence failed: {0}")] + State(String), + #[error("invalid exact box bytes: {0}")] + BoxBytes(String), + #[error("invalid v2 manifest bytes: {0}")] + ManifestBytes(String), + #[error("v2 manifest invariant failed: {0}")] + Invariant(String), +} + +impl From for V2BuilderError { + fn from(value: V2StateError) -> Self { + Self::State(value.to_string()) + } +} + +impl From for V2BuilderError { + fn from(value: SigmaSerializationError) -> Self { + Self::BoxBytes(value.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TokenManifestV2 { + token_id: String, + amount: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExactBoxManifestV2 { + box_id: String, + value: u64, + ergo_tree: String, + tokens: Vec, + registers: Vec, + creation_height: u32, + raw_sigma_hex: String, +} + +impl ExactBoxManifestV2 { + pub fn box_id(&self) -> &str { + &self.box_id + } + + pub fn raw_sigma_hex(&self) -> &str { + &self.raw_sigma_hex + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChainObservationManifestV2 { + inclusion_block_id: String, + inclusion_height: u32, + tip_block_id: String, + tip_height: u32, + confirmations: u32, +} + +/// Opaque evidence supplied only by a confirmed-chain scanner in this crate. +#[derive(Debug, Clone)] +pub struct ConfirmedChainBoxV2 { + exact: ExactBoxManifestV2, + observation: ChainObservationManifestV2, +} + +impl ConfirmedChainBoxV2 { + /// Test fixture only. Production deliberately has no constructor until a + /// sealed header-ancestry reconciler can supply this opaque authority. + #[cfg(test)] + fn from_test_observation( + raw_sigma_hex: &str, + inclusion_block_id: [u8; 32], + inclusion_height: u32, + tip_block_id: [u8; 32], + tip_height: u32, + ) -> Result { + let confirmations = tip_height + .checked_sub(inclusion_height) + .and_then(|distance| distance.checked_add(1)) + .ok_or_else(|| invariant("box inclusion height is ahead of the confirmed tip"))?; + Ok(Self { + exact: parse_exact_box(raw_sigma_hex)?, + observation: ChainObservationManifestV2 { + inclusion_block_id: hex::encode(inclusion_block_id), + inclusion_height, + tip_block_id: hex::encode(tip_block_id), + tip_height, + confirmations, + }, + }) + } +} + +#[derive(Debug, Clone)] +pub struct V2RedemptionBuildRequest { + claim: ClaimV2, + amount: u64, + fee: u64, + current_height: u32, + minimum_confirmations: u32, + reserve: ConfirmedChainBoxV2, + funding: Vec, + tracker: Option, + tracker_signature: Option, +} + +impl V2RedemptionBuildRequest { + /// Scanner/state-owned assembly boundary. Kept crate-private until a + /// confirmed v2 reconciler is wired; this prevents implicit activation. + #[allow(clippy::too_many_arguments)] + #[cfg(test)] + fn from_test_confirmed_state( + claim: ClaimV2, + amount: u64, + fee: u64, + current_height: u32, + minimum_confirmations: u32, + reserve: ConfirmedChainBoxV2, + funding: Vec, + tracker: Option, + tracker_signature: Option, + ) -> Self { + Self { + claim, + amount, + fee, + current_height, + minimum_confirmations, + reserve, + funding, + tracker, + tracker_signature, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReserveAssetManifestV2 { + Erg, + Token { token_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClaimManifestV2 { + reserve_nft_id: String, + tracker_nft_id: String, + owner_pubkey: String, + receiver_pubkey: String, + asset: ReserveAssetManifestV2, + total_debt: u64, + timestamp: u64, + owner_signature: String, +} + +impl ClaimManifestV2 { + fn from_claim(claim: &ClaimV2) -> Self { + let domain = claim.domain(); + Self { + reserve_nft_id: hex::encode(domain.reserve_nft_id()), + tracker_nft_id: hex::encode(domain.tracker_nft_id()), + owner_pubkey: hex::encode(domain.owner_pubkey()), + receiver_pubkey: hex::encode(domain.receiver_pubkey()), + asset: match domain.asset() { + ReserveAssetV2::Erg => ReserveAssetManifestV2::Erg, + ReserveAssetV2::Token { token_id } => ReserveAssetManifestV2::Token { + token_id: hex::encode(token_id), + }, + }, + total_debt: claim.total_debt(), + timestamp: claim.timestamp(), + owner_signature: hex::encode(claim.signature()), + } + } + + fn to_claim(&self) -> Result { + let reserve_nft_id = decode_array::<32>(&self.reserve_nft_id, "claim reserve NFT")?; + let tracker_nft_id = decode_array::<32>(&self.tracker_nft_id, "claim tracker NFT")?; + let owner = decode_array::<33>(&self.owner_pubkey, "claim owner key")?; + let receiver = decode_array::<33>(&self.receiver_pubkey, "claim receiver key")?; + let domain = match &self.asset { + ReserveAssetManifestV2::Erg => { + ClaimDomainV2::erg(reserve_nft_id, tracker_nft_id, owner, receiver) + } + ReserveAssetManifestV2::Token { token_id } => ClaimDomainV2::token( + reserve_nft_id, + decode_array::<32>(token_id, "claim reserve token")?, + tracker_nft_id, + owner, + receiver, + ), + } + .map_err(|error| invariant(format!("invalid claim domain: {error}")))?; + ClaimV2::from_signed( + domain, + self.total_debt, + self.timestamp, + decode_array::<65>(&self.owner_signature, "claim owner signature")?, + ) + .map_err(|error| invariant(format!("invalid signed claim: {error}"))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContextVariableManifestV2 { + index: u8, + serialized_value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OutputManifestV2 { + value: u64, + ergo_tree: String, + creation_height: u32, + tokens: Vec, + additional_registers: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthenticatedInputManifestV2 { + exact_box: ExactBoxManifestV2, + observation: ChainObservationManifestV2, +} + +impl From<&ConfirmedChainBoxV2> for AuthenticatedInputManifestV2 { + fn from(value: &ConfirmedChainBoxV2) -> Self { + Self { + exact_box: value.exact.clone(), + observation: value.observation.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct V2RedemptionManifest { + schema: String, + claim: ClaimManifestV2, + amount: u64, + fee: u64, + current_height: u32, + minimum_confirmations: u32, + emergency: bool, + reserve_input: AuthenticatedInputManifestV2, + funding_inputs: Vec, + tracker_data_input: Option, + tracker_signature: Option, + reserve_root: String, + reserve_prior_state: Option, + reserve_prior_proof: String, + reserve_next_state: String, + reserve_update_proof: String, + reserve_next_root: String, + tracker_root: Option, + tracker_lookup_proof: Option, + tracker_committed_total_debt: Option, + context_extension: Vec, + outputs: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct V2RedemptionManifestWire { + schema: String, + claim: ClaimManifestV2, + amount: u64, + fee: u64, + current_height: u32, + minimum_confirmations: u32, + emergency: bool, + reserve_input: AuthenticatedInputManifestV2, + funding_inputs: Vec, + tracker_data_input: Option, + tracker_signature: Option, + reserve_root: String, + reserve_prior_state: Option, + reserve_prior_proof: String, + reserve_next_state: String, + reserve_update_proof: String, + reserve_next_root: String, + tracker_root: Option, + tracker_lookup_proof: Option, + tracker_committed_total_debt: Option, + context_extension: Vec, + outputs: Vec, +} + +impl From for V2RedemptionManifest { + fn from(wire: V2RedemptionManifestWire) -> Self { + Self { + schema: wire.schema, + claim: wire.claim, + amount: wire.amount, + fee: wire.fee, + current_height: wire.current_height, + minimum_confirmations: wire.minimum_confirmations, + emergency: wire.emergency, + reserve_input: wire.reserve_input, + funding_inputs: wire.funding_inputs, + tracker_data_input: wire.tracker_data_input, + tracker_signature: wire.tracker_signature, + reserve_root: wire.reserve_root, + reserve_prior_state: wire.reserve_prior_state, + reserve_prior_proof: wire.reserve_prior_proof, + reserve_next_state: wire.reserve_next_state, + reserve_update_proof: wire.reserve_update_proof, + reserve_next_root: wire.reserve_next_root, + tracker_root: wire.tracker_root, + tracker_lookup_proof: wire.tracker_lookup_proof, + tracker_committed_total_debt: wire.tracker_committed_total_debt, + context_extension: wire.context_extension, + outputs: wire.outputs, + } + } +} + +impl V2RedemptionManifest { + /// Parse an untrusted manifest under one explicit allocation budget. + /// + /// Optional evidence fields remain required JSON keys and must be encoded + /// as `null` when absent. This keeps omission distinct from the pinned + /// emergency ABI while preventing direct unbounded deserialization. + pub fn from_json_bytes(encoded: &[u8]) -> Result { + if encoded.is_empty() { + return Err(V2BuilderError::ManifestBytes( + "manifest JSON is empty".to_string(), + )); + } + if encoded.len() > BASIS_V2_MAX_MANIFEST_JSON_BYTES { + return Err(V2BuilderError::ManifestBytes(format!( + "manifest JSON exceeds {BASIS_V2_MAX_MANIFEST_JSON_BYTES} bytes" + ))); + } + + // Deserialize directly into the wire struct first so serde rejects + // duplicate and unknown fields. A second bounded object pass makes + // nullable fields presence-sensitive instead of treating omission as + // an implicit `None`. + let wire: V2RedemptionManifestWire = serde_json::from_slice(encoded) + .map_err(|error| V2BuilderError::ManifestBytes(error.to_string()))?; + let value: Value = serde_json::from_slice(encoded) + .map_err(|error| V2BuilderError::ManifestBytes(error.to_string()))?; + let object = value.as_object().ok_or_else(|| { + V2BuilderError::ManifestBytes("manifest JSON must be an object".to_string()) + })?; + for field in REQUIRED_V2_MANIFEST_FIELDS { + if !object.contains_key(*field) { + return Err(V2BuilderError::ManifestBytes(format!( + "manifest JSON omits required field {field}" + ))); + } + } + Ok(wire.into()) + } + + pub fn claim(&self) -> &ClaimManifestV2 { + &self.claim + } + + pub const fn amount(&self) -> u64 { + self.amount + } + + pub const fn emergency(&self) -> bool { + self.emergency + } + + /// Node-style unsigned transaction JSON. This remains unsigned and has no + /// broadcast behavior; context variables retain Scala map iteration order. + pub fn unsigned_transaction_json(&self) -> Value { + let mut reserve_extension = Map::new(); + for variable in &self.context_extension { + reserve_extension.insert( + variable.index.to_string(), + Value::String(variable.serialized_value.clone()), + ); + } + let mut inputs = vec![json!({ + "boxId": self.reserve_input.exact_box.box_id, + "extension": reserve_extension, + })]; + inputs.extend( + self.funding_inputs + .iter() + .map(|input| json!({ "boxId": input.exact_box.box_id, "extension": {} })), + ); + let data_inputs: Vec = self + .tracker_data_input + .iter() + .map(|input| json!({ "boxId": input.exact_box.box_id })) + .collect(); + let outputs: Vec = self + .outputs + .iter() + .map(|output| { + json!({ + "value": output.value, + "ergoTree": output.ergo_tree, + "creationHeight": output.creation_height, + "assets": output.tokens.iter().map(|token| json!({ + "tokenId": token.token_id, + "amount": token.amount, + })).collect::>(), + "additionalRegisters": output.additional_registers, + }) + }) + .collect(); + json!({ "inputs": inputs, "dataInputs": data_inputs, "outputs": outputs }) + } +} + +/// Locally configured user intent against which a remote manifest is checked +/// before any signing callback may be entered. +#[derive(Debug, Clone)] +pub struct V2SigningIntent { + claim: ClaimV2, + amount: u64, + fee: u64, + signing_tip: VerifiedSigningTipV2, + minimum_confirmations: u32, + reserve_box_id: [u8; 32], + funding_owner_pubkey: [u8; 33], +} + +/// Opaque signer-side chain tip. Production deliberately has no constructor: +/// a future local header-ancestry verifier must be integrated here before a v2 +/// signing intent can exist. +#[derive(Debug, Clone)] +pub struct VerifiedSigningTipV2 { + block_id: [u8; 32], + height: u32, +} + +impl VerifiedSigningTipV2 { + #[cfg(test)] + fn from_test_tip(block_id: [u8; 32], height: u32) -> Self { + Self { block_id, height } + } +} + +impl V2SigningIntent { + #[allow(clippy::too_many_arguments)] + pub fn new( + claim: ClaimV2, + amount: u64, + fee: u64, + signing_tip: VerifiedSigningTipV2, + minimum_confirmations: u32, + reserve_box_id: [u8; 32], + funding_owner_pubkey: [u8; 33], + ) -> Result { + claim + .verify() + .map_err(|error| invariant(format!("invalid signing intent claim: {error}")))?; + if amount == 0 || fee == 0 || minimum_confirmations == 0 { + return Err(invariant( + "signing intent amount, fee, and confirmations must be non-zero", + )); + } + validate_output_box_value(fee, "miner fee output")?; + if matches!(claim.domain().asset(), ReserveAssetV2::Erg) { + validate_output_box_value(amount, "creditor payout output")?; + } + Ok(Self { + claim, + amount, + fee, + signing_tip, + minimum_confirmations, + reserve_box_id, + funding_owner_pubkey, + }) + } +} + +/// Proof that the exact manifest has passed all signer-side validations. +pub struct ValidatedV2RedemptionManifest<'a> { + manifest: &'a V2RedemptionManifest, +} + +impl<'a> ValidatedV2RedemptionManifest<'a> { + pub fn manifest(&self) -> &'a V2RedemptionManifest { + self.manifest + } +} + +/// Enter a proof or signing boundary only after the exact remote manifest has +/// passed every signer-side v2 check. +/// +/// The callback cannot be called with an unvalidated manifest: the proof type +/// is constructed only by [`validate_v2_redemption_manifest`]. Callers that +/// need a fallible callback can return a `Result` as `T` and flatten it after +/// this validation result has been handled. +pub fn with_validated_v2_redemption_manifest<'a, T>( + manifest: &'a V2RedemptionManifest, + intent: &V2SigningIntent, + callback: impl FnOnce(ValidatedV2RedemptionManifest<'a>) -> T, +) -> Result { + let validated = validate_v2_redemption_manifest(manifest, intent)?; + Ok(callback(validated)) +} + +/// Parse bounded raw JSON and enter the proof/signing callback only after the +/// resulting exact manifest passes signer-side validation. +pub fn with_validated_v2_redemption_manifest_bytes( + encoded: &[u8], + intent: &V2SigningIntent, + callback: impl FnOnce(ValidatedV2RedemptionManifest<'_>) -> T, +) -> Result { + let manifest = V2RedemptionManifest::from_json_bytes(encoded)?; + let validated = validate_v2_redemption_manifest(&manifest, intent)?; + Ok(callback(validated)) +} + +/// Build a v2 manifest from confirmed exact boxes and authoritative BNS2/BRS2 +/// roots. No signing material is requested or used. +pub fn build_v2_redemption_manifest( + request: V2RedemptionBuildRequest, + reserve_state: &mut ReserveRedeemedStoreV2, + tracker_state: Option<&mut TrackerClaimStoreV2>, +) -> Result { + request + .claim + .verify() + .map_err(|error| invariant(format!("invalid supplied ClaimV2: {error}")))?; + validate_scalar_request( + request.amount, + request.fee, + request.minimum_confirmations, + request.funding.len(), + )?; + validate_observation( + &request.reserve.observation, + request.current_height, + request.minimum_confirmations, + None, + )?; + for funding in &request.funding { + validate_observation( + &funding.observation, + request.current_height, + request.minimum_confirmations, + None, + )?; + } + + let reserve_root = reserve_state.root_digest()?; + let reserve_witness = reserve_state.redemption_witness(&request.claim, request.amount)?; + let reserve_view = + validate_reserve_input(&request.reserve.exact, &request.claim, reserve_root)?; + let emergency = i64::from(request.current_height) >= reserve_view.emergency_height; + + let (tracker_input, tracker_signature, tracker_root, tracker_witness) = if emergency { + if request.tracker.is_some() + || request.tracker_signature.is_some() + || tracker_state.is_some() + { + return Err(invariant( + "emergency mode is height-derived and must omit tracker-only evidence", + )); + } + (None, None, None, None) + } else { + let tracker = request + .tracker + .as_ref() + .ok_or_else(|| invariant("normal mode requires confirmed tracker data input"))?; + validate_observation( + &tracker.observation, + request.current_height, + request.minimum_confirmations, + None, + )?; + let signature = request + .tracker_signature + .ok_or_else(|| invariant("normal mode requires the tracker ClaimV2 signature"))?; + let state = tracker_state + .ok_or_else(|| invariant("normal mode requires authoritative BNS2 state"))?; + let root = state.root_digest()?; + let witness = state.claim_witness(&request.claim)?; + validate_tracker_input(&tracker.exact, &request.claim, root, &signature, &witness)?; + ( + Some(AuthenticatedInputManifestV2::from(tracker)), + Some(hex::encode(signature)), + Some(root), + Some(witness), + ) + }; + + let funding_views = validate_funding_inputs(&request.funding, request.fee, &request.claim)?; + let outputs = expected_outputs( + &request.claim, + request.amount, + request.fee, + request.current_height, + &request.reserve.exact, + &reserve_view, + &reserve_witness, + &funding_views, + )?; + let contexts = expected_context( + &request.claim, + &reserve_witness, + request.tracker_signature.as_ref(), + tracker_witness.as_ref(), + emergency, + )?; + + Ok(V2RedemptionManifest { + schema: BASIS_V2_MANIFEST_SCHEMA.to_string(), + claim: ClaimManifestV2::from_claim(&request.claim), + amount: request.amount, + fee: request.fee, + current_height: request.current_height, + minimum_confirmations: request.minimum_confirmations, + emergency, + reserve_input: AuthenticatedInputManifestV2::from(&request.reserve), + funding_inputs: request + .funding + .iter() + .map(AuthenticatedInputManifestV2::from) + .collect(), + tracker_data_input: tracker_input, + tracker_signature, + reserve_root: hex::encode(reserve_root), + reserve_prior_state: reserve_witness + .prior_state() + .map(|state| hex::encode(state.encode())), + reserve_prior_proof: hex::encode(reserve_witness.prior_proof()), + reserve_next_state: hex::encode(reserve_witness.next_state().encode()), + reserve_update_proof: hex::encode(reserve_witness.update_proof()), + reserve_next_root: hex::encode(reserve_witness.next_root()), + tracker_root: tracker_root.map(hex::encode), + tracker_lookup_proof: tracker_witness + .as_ref() + .map(|witness| hex::encode(witness.proof())), + tracker_committed_total_debt: tracker_witness + .as_ref() + .map(TrackerClaimWitnessV2::committed_total_debt), + context_extension: contexts, + outputs, + }) +} + +/// Recompute every deciding field from exact embedded input bytes and local +/// signing intent. This function does not trust the builder's summaries. +pub fn validate_v2_redemption_manifest<'a>( + manifest: &'a V2RedemptionManifest, + intent: &V2SigningIntent, +) -> Result, V2BuilderError> { + if manifest.schema != BASIS_V2_MANIFEST_SCHEMA { + return Err(invariant("unknown v2 manifest schema")); + } + validate_scalar_request( + manifest.amount, + manifest.fee, + manifest.minimum_confirmations, + manifest.funding_inputs.len(), + )?; + if !(3..=4).contains(&manifest.outputs.len()) { + return Err(invariant( + "manifest output count is outside the exact v2 shape", + )); + } + for (index, output) in manifest.outputs.iter().enumerate() { + validate_output_box_value(output.value, output_value_label(index))?; + } + let claim = manifest.claim.to_claim()?; + if claim != intent.claim + || manifest.amount != intent.amount + || manifest.fee != intent.fee + || manifest.current_height != intent.signing_tip.height + || manifest.minimum_confirmations != intent.minimum_confirmations + { + return Err(invariant("manifest does not match local signing intent")); + } + + let reserve_exact = reparse_manifest_box(&manifest.reserve_input.exact_box)?; + if decode_array::<32>(&reserve_exact.box_id, "reserve box id")? != intent.reserve_box_id { + return Err(invariant("unexpected reserve input box id")); + } + validate_observation( + &manifest.reserve_input.observation, + manifest.current_height, + manifest.minimum_confirmations, + Some(&intent.signing_tip), + )?; + let reserve_root = decode_array::<33>(&manifest.reserve_root, "reserve root")?; + let reserve_view = validate_reserve_input(&reserve_exact, &claim, reserve_root)?; + let emergency = i64::from(manifest.current_height) >= reserve_view.emergency_height; + if manifest.emergency != emergency { + return Err(invariant( + "emergency mode is not derived from HEIGHT and immutable R8", + )); + } + + let key = claim.domain().claim_key(); + let prior_state = manifest + .reserve_prior_state + .as_ref() + .map(|encoded| decode_array::<24>(encoded, "reserve prior state")) + .transpose()? + .map(|encoded| RedeemedStateV2::decode(&encoded)) + .transpose() + .map_err(|error| invariant(format!("invalid reserve prior state: {error}")))?; + let prior_proof = decode_proof(&manifest.reserve_prior_proof, "reserve prior proof")?; + if !ReserveAvlTree::verify_lookup_bytes( + &reserve_root, + &key, + &prior_proof, + prior_state.map(RedeemedStateV2::encode), + ) { + return Err(invariant( + "reserve prior membership/non-membership proof failed", + )); + } + let next_state = match prior_state { + Some(state) => state + .advance(claim.timestamp(), claim.total_debt(), manifest.amount) + .map_err(|error| invariant(format!("invalid reserve claim progress: {error}")))?, + None => RedeemedStateV2::new(claim.timestamp(), claim.total_debt(), manifest.amount) + .map_err(|error| invariant(format!("invalid initial reserve state: {error}")))?, + }; + if hex::encode(next_state.encode()) != manifest.reserve_next_state { + return Err(invariant("reserve successor R5 value is not exact")); + } + let next_root = decode_array::<33>(&manifest.reserve_next_root, "reserve next root")?; + let update_proof = decode_proof(&manifest.reserve_update_proof, "reserve update proof")?; + if !ReserveAvlTree::verify_transition_bytes( + &reserve_root, + &key, + &next_state.encode(), + &update_proof, + &next_root, + ) { + return Err(invariant( + "reserve insert-or-update proof or successor root failed", + )); + } + let reserve_witness = ManifestReserveWitness { + prior_proof, + update_proof, + next_root, + }; + + let tracker_signature = manifest + .tracker_signature + .as_ref() + .map(|signature| decode_array::<65>(signature, "tracker signature")) + .transpose()?; + let tracker_witness = if emergency { + if manifest.tracker_data_input.is_some() + || tracker_signature.is_some() + || manifest.tracker_root.is_some() + || manifest.tracker_lookup_proof.is_some() + || manifest.tracker_committed_total_debt.is_some() + { + return Err(invariant( + "height-derived emergency manifest carries tracker evidence", + )); + } + None + } else { + let tracker_input = manifest + .tracker_data_input + .as_ref() + .ok_or_else(|| invariant("normal manifest omits tracker data input"))?; + validate_observation( + &tracker_input.observation, + manifest.current_height, + manifest.minimum_confirmations, + Some(&intent.signing_tip), + )?; + let tracker_exact = reparse_manifest_box(&tracker_input.exact_box)?; + let root = decode_array::<33>( + manifest + .tracker_root + .as_ref() + .ok_or_else(|| invariant("normal manifest omits tracker root"))?, + "tracker root", + )?; + let proof = decode_proof( + manifest + .tracker_lookup_proof + .as_ref() + .ok_or_else(|| invariant("normal manifest omits tracker lookup proof"))?, + "tracker lookup proof", + )?; + let committed_total_debt = manifest + .tracker_committed_total_debt + .ok_or_else(|| invariant("normal manifest omits committed tracker debt"))?; + let signature = tracker_signature + .as_ref() + .ok_or_else(|| invariant("normal manifest omits tracker signature"))?; + let witness = ManifestTrackerWitness { + committed_total_debt, + proof, + }; + validate_tracker_input_raw(&tracker_exact, &claim, root, signature, &witness)?; + Some(witness) + }; + + let confirmed_funding: Vec = manifest + .funding_inputs + .iter() + .map(|input| { + validate_observation( + &input.observation, + manifest.current_height, + manifest.minimum_confirmations, + Some(&intent.signing_tip), + )?; + Ok(ConfirmedChainBoxV2 { + exact: reparse_manifest_box(&input.exact_box)?, + observation: input.observation.clone(), + }) + }) + .collect::>()?; + let funding_views = validate_funding_inputs(&confirmed_funding, manifest.fee, &claim)?; + if funding_views.owner_pubkey != intent.funding_owner_pubkey { + return Err(invariant( + "funding inputs do not belong to the intended owner", + )); + } + + let expected_outputs = expected_outputs_raw( + &claim, + manifest.amount, + manifest.fee, + manifest.current_height, + &reserve_exact, + &reserve_view, + &reserve_witness, + &funding_views, + )?; + if manifest.outputs != expected_outputs { + return Err(invariant( + "outputs do not preserve exact reserve payout, fee, and owner change", + )); + } + let expected_context = expected_context_raw( + &claim, + &reserve_witness, + tracker_signature.as_ref(), + tracker_witness.as_ref(), + emergency, + )?; + if manifest.context_extension != expected_context { + return Err(invariant("context extension fields or Scala order differ")); + } + + Ok(ValidatedV2RedemptionManifest { manifest }) +} + +#[derive(Debug)] +struct ReserveInputView { + emergency_height: i64, + owner_register: String, + tracker_register: String, + refund_register: String, + emergency_register: String, +} + +#[derive(Debug)] +struct FundingInputView { + total: u64, + owner_tree: String, + owner_pubkey: [u8; 33], +} + +trait ReserveWitnessView { + fn prior_proof(&self) -> &[u8]; + fn update_proof(&self) -> &[u8]; + fn next_root(&self) -> [u8; 33]; +} + +impl ReserveWitnessView for ReserveRedemptionWitnessV2 { + fn prior_proof(&self) -> &[u8] { + self.prior_proof() + } + fn update_proof(&self) -> &[u8] { + self.update_proof() + } + fn next_root(&self) -> [u8; 33] { + self.next_root() + } +} + +struct ManifestReserveWitness { + prior_proof: Vec, + update_proof: Vec, + next_root: [u8; 33], +} + +impl ReserveWitnessView for ManifestReserveWitness { + fn prior_proof(&self) -> &[u8] { + &self.prior_proof + } + fn update_proof(&self) -> &[u8] { + &self.update_proof + } + fn next_root(&self) -> [u8; 33] { + self.next_root + } +} + +trait TrackerWitnessView { + fn committed_total_debt(&self) -> u64; + fn proof(&self) -> &[u8]; +} + +impl TrackerWitnessView for TrackerClaimWitnessV2 { + fn committed_total_debt(&self) -> u64 { + self.committed_total_debt() + } + fn proof(&self) -> &[u8] { + self.proof() + } +} + +struct ManifestTrackerWitness { + committed_total_debt: u64, + proof: Vec, +} + +impl TrackerWitnessView for ManifestTrackerWitness { + fn committed_total_debt(&self) -> u64 { + self.committed_total_debt + } + fn proof(&self) -> &[u8] { + &self.proof + } +} + +fn parse_exact_box(raw_sigma_hex: &str) -> Result { + validate_hex_encoded_length(raw_sigma_hex, "raw Sigma box", ErgoBox::MAX_BOX_SIZE) + .map_err(V2BuilderError::BoxBytes)?; + let bytes = hex::decode(raw_sigma_hex) + .map_err(|error| V2BuilderError::BoxBytes(format!("invalid hex: {error}")))?; + let ergo_box = ErgoBox::sigma_parse_bytes(&bytes) + .map_err(|error| V2BuilderError::BoxBytes(format!("Sigma parse failed: {error:?}")))?; + let canonical = ergo_box.sigma_serialize_bytes()?; + if canonical != bytes { + return Err(V2BuilderError::BoxBytes( + "box bytes are not the exact canonical Sigma serialization".to_string(), + )); + } + let ergo_tree = hex::encode(ergo_box.ergo_tree.sigma_serialize_bytes()?); + let tokens = ergo_box + .tokens + .as_ref() + .map(|tokens| { + tokens + .iter() + .map(|token| TokenManifestV2 { + token_id: hex::encode(token.token_id.as_ref()), + amount: *token.amount.as_u64(), + }) + .collect() + }) + .unwrap_or_default(); + let mut registers = Vec::new(); + let mut absent_seen = false; + for register_id in NonMandatoryRegisterId::REG_IDS { + let value = ergo_box + .additional_registers + .get_constant(register_id) + .map_err(|error| V2BuilderError::BoxBytes(error.to_string()))?; + match value { + Some(_constant) if absent_seen => { + return Err(V2BuilderError::BoxBytes( + "non-mandatory registers are not densely packed".to_string(), + )) + } + Some(constant) => registers.push(hex::encode(constant.sigma_serialize_bytes()?)), + None => absent_seen = true, + } + } + Ok(ExactBoxManifestV2 { + box_id: ergo_box.box_id().to_string(), + value: *ergo_box.value.as_u64(), + ergo_tree, + tokens, + registers, + creation_height: ergo_box.creation_height, + raw_sigma_hex: hex::encode(bytes), + }) +} + +fn reparse_manifest_box( + claimed: &ExactBoxManifestV2, +) -> Result { + let exact = parse_exact_box(&claimed.raw_sigma_hex)?; + if &exact != claimed { + return Err(invariant( + "box manifest summary differs from embedded exact Sigma bytes", + )); + } + Ok(exact) +} + +fn validate_scalar_request( + amount: u64, + fee: u64, + minimum_confirmations: u32, + funding_count: usize, +) -> Result<(), V2BuilderError> { + if amount == 0 { + return Err(invariant("redemption amount must be non-zero")); + } + if fee == 0 { + return Err(invariant("miner fee must be non-zero")); + } + validate_output_box_value(fee, "miner fee output")?; + if minimum_confirmations == 0 { + return Err(invariant("minimum confirmations must be non-zero")); + } + if funding_count == 0 || funding_count > BASIS_V2_MAX_FUNDING_INPUTS { + return Err(invariant( + "funding input count is outside the bounded profile", + )); + } + Ok(()) +} + +fn validate_observation( + observation: &ChainObservationManifestV2, + current_height: u32, + minimum_confirmations: u32, + trusted_tip: Option<&VerifiedSigningTipV2>, +) -> Result<(), V2BuilderError> { + let inclusion_block_id = decode_array::<32>( + &observation.inclusion_block_id, + "confirmed inclusion block id", + )?; + let tip_block_id = decode_array::<32>(&observation.tip_block_id, "confirmed tip block id")?; + if inclusion_block_id == [0u8; 32] || tip_block_id == [0u8; 32] { + return Err(invariant("confirmed block ids cannot be zero")); + } + let expected = observation + .tip_height + .checked_sub(observation.inclusion_height) + .and_then(|distance| distance.checked_add(1)) + .ok_or_else(|| invariant("confirmed inclusion height exceeds tip"))?; + if observation.tip_height != current_height + || observation.confirmations != expected + || expected < minimum_confirmations + { + return Err(invariant( + "confirmed observation is stale, inconsistent, or insufficiently deep", + )); + } + if let Some(trusted_tip) = trusted_tip { + if observation.tip_height != trusted_tip.height || tip_block_id != trusted_tip.block_id { + return Err(invariant( + "manifest observation is not bound to the independently verified signer tip", + )); + } + } + Ok(()) +} + +fn validate_reserve_input( + exact: &ExactBoxManifestV2, + claim: &ClaimV2, + expected_root: [u8; 33], +) -> Result { + let domain = claim.domain(); + let expected_kind = match domain.asset() { + ReserveAssetV2::Erg => BasisV2ContractKind::Erg, + ReserveAssetV2::Token { .. } => BasisV2ContractKind::Token, + }; + if exact.ergo_tree != expected_kind.ergo_tree_hex() { + return Err(invariant( + "reserve does not use the exact v2 golden ErgoTree", + )); + } + validate_reserve_tokens(&exact.tokens, domain)?; + if exact.registers.len() != 6 { + return Err(invariant("reserve R4-R9 are not all present")); + } + let expected_owner = format!("07{}", hex::encode(domain.owner_pubkey())); + if exact.registers[0] != expected_owner { + return Err(invariant("reserve R4 owner differs from ClaimV2 domain")); + } + let avl = parse_avl_register(&exact.registers[1], "reserve R5")?; + validate_avl_shape(&avl, expected_root, 24, "reserve R5")?; + let tracker_id = parse_coll_register(&exact.registers[2], "reserve R6")?; + if tracker_id != domain.tracker_nft_id() { + return Err(invariant( + "reserve R6 tracker NFT differs from ClaimV2 domain", + )); + } + let refund_height = parse_long_register(&exact.registers[3], "reserve R7")?; + let emergency_height = parse_long_register(&exact.registers[4], "reserve R8")?; + let predecessor = parse_coll_register(&exact.registers[5], "reserve R9")?; + if refund_height < 0 || emergency_height <= 0 || predecessor.len() != 32 { + return Err(invariant("reserve R7/R8/R9 shape is invalid")); + } + Ok(ReserveInputView { + emergency_height, + owner_register: exact.registers[0].clone(), + tracker_register: exact.registers[2].clone(), + refund_register: exact.registers[3].clone(), + emergency_register: exact.registers[4].clone(), + }) +} + +fn validate_reserve_tokens( + tokens: &[TokenManifestV2], + domain: ClaimDomainV2, +) -> Result<(), V2BuilderError> { + let reserve_nft = hex::encode(domain.reserve_nft_id()); + match domain.asset() { + ReserveAssetV2::Erg => { + if tokens.len() != 1 || tokens[0].token_id != reserve_nft || tokens[0].amount != 1 { + return Err(invariant("ERG reserve singleton NFT shape is not exact")); + } + } + ReserveAssetV2::Token { token_id } => { + if tokens.len() != 2 + || tokens[0].token_id != reserve_nft + || tokens[0].amount != 1 + || tokens[1].token_id != hex::encode(token_id) + || tokens[1].amount == 0 + { + return Err(invariant("token reserve NFT/asset shape is not exact")); + } + } + } + Ok(()) +} + +fn validate_tracker_input( + exact: &ExactBoxManifestV2, + claim: &ClaimV2, + expected_root: [u8; 33], + signature: &Signature, + witness: &W, +) -> Result<(), V2BuilderError> { + validate_tracker_input_raw(exact, claim, expected_root, signature, witness) +} + +fn validate_tracker_input_raw( + exact: &ExactBoxManifestV2, + claim: &ClaimV2, + expected_root: [u8; 33], + signature: &Signature, + witness: &W, +) -> Result<(), V2BuilderError> { + if exact.tokens.len() != 1 + || exact.tokens[0].token_id != hex::encode(claim.domain().tracker_nft_id()) + || exact.tokens[0].amount != 1 + { + return Err(invariant("tracker singleton NFT shape is not exact")); + } + if exact.registers.len() < 2 { + return Err(invariant("tracker R4/R5 are absent")); + } + let tracker_key = parse_group_register(&exact.registers[0], "tracker R4")?; + let avl = parse_avl_register(&exact.registers[1], "tracker R5")?; + validate_avl_shape(&avl, expected_root, 8, "tracker R5")?; + if witness.committed_total_debt() < claim.total_debt() + || witness.committed_total_debt() > i64::MAX as u64 + { + return Err(invariant( + "tracker lookup debt does not cover the supplied signed ClaimV2", + )); + } + let proof = witness.proof(); + validate_proof_size(proof, "tracker lookup proof")?; + if !TrackerAvlTree::verify_lookup_bytes( + &expected_root, + &claim.domain().claim_key(), + proof, + Some(witness.committed_total_debt().to_be_bytes()), + ) { + return Err(invariant("tracker membership proof failed")); + } + let message = claim + .signing_message() + .map_err(|error| invariant(format!("claim message failed: {error}")))?; + verify_basis_v2_signature(signature, &message, &tracker_key) + .map_err(|error| invariant(format!("tracker ClaimV2 signature failed: {error}")))?; + Ok(()) +} + +fn validate_funding_inputs( + funding: &[ConfirmedChainBoxV2], + fee: u64, + claim: &ClaimV2, +) -> Result { + if funding.is_empty() || funding.len() > BASIS_V2_MAX_FUNDING_INPUTS { + return Err(invariant( + "funding input count is outside the bounded profile", + )); + } + let mut ids = HashSet::new(); + let first_tree = funding[0].exact.ergo_tree.clone(); + let owner_pubkey = parse_p2pk_tree(&first_tree)?; + let mut total = 0u64; + for input in funding { + if !ids.insert(input.exact.box_id.clone()) { + return Err(invariant("duplicate funding input")); + } + if !input.exact.tokens.is_empty() { + return Err(invariant("funding inputs must be token-free")); + } + if input.exact.ergo_tree != first_tree + || parse_p2pk_tree(&input.exact.ergo_tree)? != owner_pubkey + { + return Err(invariant( + "all fee/change inputs must share one exact P2PK owner", + )); + } + total = total + .checked_add(input.exact.value) + .ok_or_else(|| invariant("funding value overflow"))?; + } + let payout_erg = match claim.domain().asset() { + ReserveAssetV2::Erg => 0, + ReserveAssetV2::Token { .. } => BASIS_V2_MIN_BOX_VALUE, + }; + let required = fee + .checked_add(payout_erg) + .ok_or_else(|| invariant("required funding overflow"))?; + if total < required { + return Err(invariant( + "funding boxes do not cover fee and token payout ERG", + )); + } + let change = total - required; + if change > 0 && change < BASIS_V2_MIN_BOX_VALUE { + return Err(invariant("funding change would create a sub-minimum box")); + } + Ok(FundingInputView { + total, + owner_tree: first_tree, + owner_pubkey, + }) +} + +fn expected_outputs( + claim: &ClaimV2, + amount: u64, + fee: u64, + current_height: u32, + reserve: &ExactBoxManifestV2, + reserve_view: &ReserveInputView, + witness: &W, + funding: &FundingInputView, +) -> Result, V2BuilderError> { + expected_outputs_raw( + claim, + amount, + fee, + current_height, + reserve, + reserve_view, + witness, + funding, + ) +} + +fn expected_outputs_raw( + claim: &ClaimV2, + amount: u64, + fee: u64, + current_height: u32, + reserve: &ExactBoxManifestV2, + reserve_view: &ReserveInputView, + witness: &W, + funding: &FundingInputView, +) -> Result, V2BuilderError> { + validate_proof_size(witness.prior_proof(), "reserve prior proof")?; + validate_proof_size(witness.update_proof(), "reserve update proof")?; + let domain = claim.domain(); + let box_id = decode_array::<32>(&reserve.box_id, "reserve box id")?; + let successor_r5 = serialize_fixed_avl_register(witness.next_root(), 24)?; + let mut successor_registers = BTreeMap::new(); + successor_registers.insert("R4".to_string(), reserve_view.owner_register.clone()); + successor_registers.insert("R5".to_string(), successor_r5); + successor_registers.insert("R6".to_string(), reserve_view.tracker_register.clone()); + successor_registers.insert("R7".to_string(), reserve_view.refund_register.clone()); + successor_registers.insert("R8".to_string(), reserve_view.emergency_register.clone()); + successor_registers.insert("R9".to_string(), serialize_coll_bytes(&box_id)); + let mut payout_registers = BTreeMap::new(); + payout_registers.insert("R4".to_string(), serialize_coll_bytes(&box_id)); + + let (successor_value, successor_tokens, payout_value, payout_tokens, external_payout_erg) = + match domain.asset() { + ReserveAssetV2::Erg => { + let successor_value = reserve + .value + .checked_sub(amount) + .ok_or_else(|| invariant("ERG payout exceeds reserve value"))?; + if successor_value < BASIS_V2_MIN_BOX_VALUE { + return Err(invariant( + "ERG reserve successor is below minimum box value", + )); + } + ( + successor_value, + reserve.tokens.clone(), + amount, + Vec::new(), + 0, + ) + } + ReserveAssetV2::Token { token_id } => { + let reserve_amount = reserve.tokens[1].amount; + let successor_amount = reserve_amount + .checked_sub(amount) + .ok_or_else(|| invariant("token payout exceeds reserve token balance"))?; + if successor_amount == 0 { + return Err(invariant( + "token reserve successor must retain its indexed reserve asset", + )); + } + let mut tokens = reserve.tokens.clone(); + tokens[1].amount = successor_amount; + ( + reserve.value, + tokens, + BASIS_V2_MIN_BOX_VALUE, + vec![TokenManifestV2 { + token_id: hex::encode(token_id), + amount, + }], + BASIS_V2_MIN_BOX_VALUE, + ) + } + }; + let receiver_tree = format!("0008cd{}", hex::encode(domain.receiver_pubkey())); + let mut outputs = vec![ + OutputManifestV2 { + value: successor_value, + ergo_tree: reserve.ergo_tree.clone(), + creation_height: current_height, + tokens: successor_tokens, + additional_registers: successor_registers, + }, + OutputManifestV2 { + value: payout_value, + ergo_tree: receiver_tree, + creation_height: current_height, + tokens: payout_tokens, + additional_registers: payout_registers, + }, + OutputManifestV2 { + value: fee, + ergo_tree: BASIS_V2_FEE_ERGO_TREE.to_string(), + creation_height: current_height, + tokens: Vec::new(), + additional_registers: BTreeMap::new(), + }, + ]; + let change = funding + .total + .checked_sub(fee) + .and_then(|value| value.checked_sub(external_payout_erg)) + .ok_or_else(|| invariant("funding does not cover external outputs"))?; + if change > 0 { + if change < BASIS_V2_MIN_BOX_VALUE { + return Err(invariant("change output is below minimum box value")); + } + outputs.push(OutputManifestV2 { + value: change, + ergo_tree: funding.owner_tree.clone(), + creation_height: current_height, + tokens: Vec::new(), + additional_registers: BTreeMap::new(), + }); + } + for (index, output) in outputs.iter().enumerate() { + validate_output_box_value(output.value, output_value_label(index))?; + } + let input_total = reserve + .value + .checked_add(funding.total) + .ok_or_else(|| invariant("aggregate input value overflow"))?; + let output_total = outputs.iter().try_fold(0u64, |total, output| { + total + .checked_add(output.value) + .ok_or_else(|| invariant("aggregate output value overflow")) + })?; + if output_total != input_total { + return Err(invariant( + "aggregate input and output values do not balance exactly", + )); + } + Ok(outputs) +} + +fn validate_output_box_value(value: u64, label: &str) -> Result<(), V2BuilderError> { + if !(BASIS_V2_MIN_BOX_VALUE..=BoxValue::MAX_RAW).contains(&value) { + return Err(invariant(format!( + "{label} value is outside {BASIS_V2_MIN_BOX_VALUE}..={}", + BoxValue::MAX_RAW + ))); + } + Ok(()) +} + +fn output_value_label(index: usize) -> &'static str { + match index { + 0 => "reserve successor output", + 1 => "creditor payout output", + 2 => "miner fee output", + _ => "change output", + } +} + +fn expected_context( + claim: &ClaimV2, + reserve: &W, + tracker_signature: Option<&Signature>, + tracker: Option<&T>, + emergency: bool, +) -> Result, V2BuilderError> { + expected_context_raw(claim, reserve, tracker_signature, tracker, emergency) +} + +fn expected_context_raw( + claim: &ClaimV2, + reserve: &W, + tracker_signature: Option<&Signature>, + tracker: Option<&T>, + emergency: bool, +) -> Result, V2BuilderError> { + validate_proof_size(reserve.prior_proof(), "reserve prior proof")?; + validate_proof_size(reserve.update_proof(), "reserve update proof")?; + let mut values = BTreeMap::new(); + values.insert(0, serialize_ergo_byte(0)); + values.insert( + 1, + format!("07{}", hex::encode(claim.domain().receiver_pubkey())), + ); + values.insert(2, serialize_coll_bytes(claim.signature())); + values.insert(3, serialize_ergo_long(to_ergo_long(claim.total_debt())?)); + values.insert(4, serialize_ergo_long(to_ergo_long(claim.timestamp())?)); + values.insert(5, serialize_coll_bytes(reserve.update_proof())); + values.insert(7, serialize_coll_bytes(reserve.prior_proof())); + if emergency { + if tracker_signature.is_some() || tracker.is_some() { + return Err(invariant( + "emergency context carries tracker-only variables", + )); + } + } else { + let signature = tracker_signature + .ok_or_else(|| invariant("normal context omits tracker signature #6"))?; + let witness = tracker.ok_or_else(|| invariant("normal context omits tracker proof #8"))?; + validate_proof_size(witness.proof(), "tracker lookup proof")?; + values.insert(6, serialize_coll_bytes(signature)); + values.insert(8, serialize_coll_bytes(witness.proof())); + } + let keys: Vec = values.keys().copied().collect(); + let order = scala_context_extension_order(&keys); + Ok(order + .into_iter() + .map(|index| ContextVariableManifestV2 { + index, + serialized_value: values.remove(&index).expect("ordered key exists"), + }) + .collect()) +} + +fn parse_constant(encoded: &str, label: &str) -> Result { + let bytes = hex::decode(encoded).map_err(|error| invariant(format!("{label} hex: {error}")))?; + Constant::sigma_parse_bytes(&bytes) + .map_err(|error| invariant(format!("{label} constant parse: {error:?}"))) +} + +fn parse_avl_register(encoded: &str, label: &str) -> Result { + parse_constant(encoded, label)? + .try_extract_into::() + .map_err(|error| invariant(format!("{label} is not an AVL tree: {error}"))) +} + +fn parse_long_register(encoded: &str, label: &str) -> Result { + parse_constant(encoded, label)? + .try_extract_into::() + .map_err(|error| invariant(format!("{label} is not a Long: {error}"))) +} + +fn parse_coll_register(encoded: &str, label: &str) -> Result, V2BuilderError> { + parse_constant(encoded, label)? + .try_extract_into::>() + .map_err(|error| invariant(format!("{label} is not Coll[Byte]: {error}"))) +} + +fn parse_group_register(encoded: &str, label: &str) -> Result<[u8; 33], V2BuilderError> { + let bytes = hex::decode(encoded).map_err(|error| invariant(format!("{label} hex: {error}")))?; + if bytes.len() != 34 || bytes[0] != 0x07 { + return Err(invariant(format!( + "{label} is not a canonical GroupElement" + ))); + } + decode_array::<33>(&hex::encode(&bytes[1..]), label) +} + +fn validate_avl_shape( + avl: &AvlTreeData, + expected_root: [u8; 33], + value_length: u32, + label: &str, +) -> Result<(), V2BuilderError> { + if avl.digest.0 != expected_root + || avl.key_length != 32 + || avl.value_length_opt.as_deref() != Some(&value_length) + || !avl.tree_flags.insert_allowed() + || !avl.tree_flags.update_allowed() + || avl.tree_flags.remove_allowed() + { + return Err(invariant(format!( + "{label} fixed AVL shape or root differs" + ))); + } + Ok(()) +} + +fn serialize_fixed_avl_register( + root: [u8; 33], + value_length: u32, +) -> Result { + let constant: Constant = AvlTreeData { + digest: ADDigest::from(root), + tree_flags: AvlTreeFlags::new(true, true, false), + key_length: 32, + value_length_opt: Some(Box::new(value_length)), + } + .into(); + Ok(hex::encode(constant.sigma_serialize_bytes()?)) +} + +fn parse_p2pk_tree(tree: &str) -> Result<[u8; 33], V2BuilderError> { + let bytes = + hex::decode(tree).map_err(|error| invariant(format!("funding tree hex: {error}")))?; + if bytes.len() != 36 || bytes[..3] != [0x00, 0x08, 0xcd] { + return Err(invariant("funding input is not exact P2PK")); + } + bytes[3..] + .try_into() + .map_err(|_| invariant("funding P2PK key length differs")) +} + +fn decode_array(encoded: &str, label: &str) -> Result<[u8; N], V2BuilderError> { + if encoded.is_empty() { + return Err(invariant(format!( + "{label} must be non-empty before hex decoding" + ))); + } + if encoded.len() & 1 != 0 { + return Err(invariant(format!( + "{label} must contain an even number of hex characters before hex decoding" + ))); + } + if encoded.len() != N * 2 { + return Err(invariant(format!( + "{label} must encode exactly {N} bytes before hex decoding" + ))); + } + let bytes = hex::decode(encoded).map_err(|error| invariant(format!("{label} hex: {error}")))?; + bytes + .try_into() + .map_err(|_| invariant(format!("{label} must be exactly {N} bytes"))) +} + +fn decode_proof(encoded: &str, label: &str) -> Result, V2BuilderError> { + validate_hex_encoded_length(encoded, label, BASIS_V2_MAX_PROOF_BYTES).map_err(invariant)?; + let proof = hex::decode(encoded).map_err(|error| invariant(format!("{label} hex: {error}")))?; + validate_proof_size(&proof, label)?; + Ok(proof) +} + +fn validate_hex_encoded_length( + encoded: &str, + label: &str, + maximum_bytes: usize, +) -> Result<(), String> { + if encoded.is_empty() { + return Err(format!("{label} must be non-empty before hex decoding")); + } + if encoded.len() & 1 != 0 { + return Err(format!( + "{label} must contain an even number of hex characters before hex decoding" + )); + } + if encoded.len() > maximum_bytes * 2 { + return Err(format!( + "{label} exceeds {maximum_bytes} bytes before hex decoding" + )); + } + Ok(()) +} + +fn validate_proof_size(proof: &[u8], label: &str) -> Result<(), V2BuilderError> { + if proof.is_empty() || proof.len() > BASIS_V2_MAX_PROOF_BYTES { + return Err(invariant(format!( + "{label} is empty or exceeds the bounded profile" + ))); + } + Ok(()) +} + +fn to_ergo_long(value: u64) -> Result { + i64::try_from(value).map_err(|_| invariant("value exceeds the Ergo Long domain")) +} + +fn invariant(message: impl Into) -> V2BuilderError { + V2BuilderError::Invariant(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::basis_v2_state::{FreshV2StateApproval, ReserveStoreBindingV2}; + use basis_core::impls::schnorr_sign; + use ergo_lib::ergo_chain_types::{Digest32, EcPoint}; + use ergo_lib::ergotree_ir::chain::ergo_box::{ + box_value::BoxValue, BoxTokens, NonMandatoryRegisters, + }; + use ergo_lib::ergotree_ir::chain::token::{Token, TokenAmount, TokenId}; + use ergo_lib::ergotree_ir::chain::tx_id::TxId; + use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; + use secp256k1::{PublicKey, Secp256k1, SecretKey}; + use tempfile::TempDir; + + const CONTEXT_ABI_VECTOR: &str = include_str!("../tests/fixtures/basis_v2_context_abi.json"); + + struct Fixture { + manifest: V2RedemptionManifest, + intent: V2SigningIntent, + old_total: u64, + committed_total: u64, + } + + fn key(seed: u8) -> ([u8; 32], [u8; 33]) { + let mut secret = [0u8; 32]; + secret[31] = seed; + let key = SecretKey::from_slice(&secret).unwrap(); + let public = PublicKey::from_secret_key(&Secp256k1::new(), &key).serialize(); + (secret, public) + } + + fn avl_constant(root: [u8; 33], value_length: u32) -> Constant { + AvlTreeData { + digest: ADDigest::from(root), + tree_flags: AvlTreeFlags::new(true, true, false), + key_length: 32, + value_length_opt: Some(Box::new(value_length)), + } + .into() + } + + #[test] + fn successor_avl_register_uses_canonical_sigma_option_encoding() { + for first_byte in u8::MIN..=u8::MAX { + let mut root = [0u8; 33]; + root[0] = first_byte; + root[32] = first_byte.wrapping_add(1); + let encoded = serialize_fixed_avl_register(root, 24).unwrap(); + let parsed = parse_avl_register(&encoded, "successor R5").unwrap(); + validate_avl_shape(&parsed, root, 24, "successor R5").unwrap(); + } + } + + fn group_constant(key: [u8; 33]) -> Constant { + EcPoint::sigma_parse_bytes(&key).unwrap().into() + } + + fn token(id: [u8; 32], amount: u64) -> Token { + Token { + token_id: TokenId::from(Digest32::from(id)), + amount: TokenAmount::try_from(amount).unwrap(), + } + } + + fn exact_box( + tree_hex: &str, + value: u64, + tokens: Vec, + registers: Vec, + index: u16, + ) -> String { + let tree = ErgoTree::sigma_parse_bytes(&hex::decode(tree_hex).unwrap()).unwrap(); + let tokens = if tokens.is_empty() { + None + } else { + Some(BoxTokens::try_from(tokens).unwrap()) + }; + let registers = NonMandatoryRegisters::try_from(registers).unwrap(); + let ergo_box = ErgoBox::new( + BoxValue::try_from(value).unwrap(), + tree, + tokens, + registers, + 90, + TxId::zero(), + index, + ) + .unwrap(); + hex::encode(ergo_box.sigma_serialize_bytes().unwrap()) + } + + fn confirmed(raw: &str, current_height: u32) -> ConfirmedChainBoxV2 { + ConfirmedChainBoxV2::from_test_observation( + raw, + [0xabu8; 32], + current_height - 4, + [0xcdu8; 32], + current_height, + ) + .unwrap() + } + + fn make_fixture(token_reserve: bool, emergency: bool) -> Fixture { + let tracker_nft = [0x11u8; 32]; + let reserve_nft = [0x22u8; 32]; + let reserve_token = [0x33u8; 32]; + let (owner_secret, owner) = key(1); + let (_receiver_secret, receiver) = key(2); + let (tracker_secret, tracker_key) = key(3); + let (_funding_secret, funding_key) = key(4); + let old_total = if token_reserve { 100 } else { 100_000_000 }; + let committed_total = if token_reserve { 150 } else { 150_000_000 }; + let amount = if token_reserve { 20 } else { 20_000_000 }; + let fee = 1_000_000; + let current_height = if emergency { 110 } else { 100 }; + let domain = if token_reserve { + ClaimDomainV2::token(reserve_nft, reserve_token, tracker_nft, owner, receiver).unwrap() + } else { + ClaimDomainV2::erg(reserve_nft, tracker_nft, owner, receiver).unwrap() + }; + let claim = ClaimV2::sign(domain, old_total, 10, &owner_secret).unwrap(); + let newer_claim = ClaimV2::sign(domain, committed_total, 11, &owner_secret).unwrap(); + + let tracker_dir = TempDir::new().unwrap(); + let reserve_dir = TempDir::new().unwrap(); + let mut tracker_store = TrackerClaimStoreV2::open( + tracker_dir.path(), + tracker_nft, + FreshV2StateApproval::Approve, + ) + .unwrap(); + tracker_store.record_validated_claim(newer_claim).unwrap(); + let binding = if token_reserve { + ReserveStoreBindingV2::token(tracker_nft, reserve_nft, reserve_token).unwrap() + } else { + ReserveStoreBindingV2::erg(tracker_nft, reserve_nft) + }; + let mut reserve_store = ReserveRedeemedStoreV2::open( + reserve_dir.path(), + binding, + FreshV2StateApproval::Approve, + ) + .unwrap(); + + let reserve_root = reserve_store.root_digest().unwrap(); + let reserve_kind = if token_reserve { + BasisV2ContractKind::Token + } else { + BasisV2ContractKind::Erg + }; + let reserve_tokens = if token_reserve { + vec![token(reserve_nft, 1), token(reserve_token, 100)] + } else { + vec![token(reserve_nft, 1)] + }; + let reserve_raw = exact_box( + reserve_kind.ergo_tree_hex(), + if token_reserve { + 2_000_000 + } else { + 100_000_000 + }, + reserve_tokens, + vec![ + group_constant(owner), + avl_constant(reserve_root, 24), + Constant::from(tracker_nft.to_vec()), + Constant::from(0i64), + Constant::from(110i64), + Constant::from([0x44u8; 32].to_vec()), + ], + 0, + ); + let tracker_root = tracker_store.root_digest().unwrap(); + let tracker_raw = exact_box( + &format!("0008cd{}", hex::encode(tracker_key)), + 2_000_000, + vec![token(tracker_nft, 1)], + vec![group_constant(tracker_key), avl_constant(tracker_root, 8)], + 1, + ); + let funding_raw = exact_box( + &format!("0008cd{}", hex::encode(funding_key)), + 3_000_000, + Vec::new(), + Vec::new(), + 2, + ); + let reserve = confirmed(&reserve_raw, current_height); + let reserve_box_id = decode_array::<32>(&reserve.exact.box_id, "reserve id").unwrap(); + let funding = confirmed(&funding_raw, current_height); + let message = claim.signing_message().unwrap(); + let tracker_signature = schnorr_sign(&message, &tracker_secret, &tracker_key).unwrap(); + let (tracker, signature, tracker_state) = if emergency { + (None, None, None) + } else { + ( + Some(confirmed(&tracker_raw, current_height)), + Some(tracker_signature), + Some(&mut tracker_store), + ) + }; + let request = V2RedemptionBuildRequest::from_test_confirmed_state( + claim.clone(), + amount, + fee, + current_height, + 5, + reserve, + vec![funding], + tracker, + signature, + ); + let manifest = + build_v2_redemption_manifest(request, &mut reserve_store, tracker_state).unwrap(); + let intent = V2SigningIntent::new( + claim, + amount, + fee, + VerifiedSigningTipV2::from_test_tip([0xcdu8; 32], current_height), + 5, + reserve_box_id, + funding_key, + ) + .unwrap(); + Fixture { + manifest, + intent, + old_total, + committed_total, + } + } + + fn reject(manifest: &V2RedemptionManifest, intent: &V2SigningIntent) { + let _ = reject_error(manifest, intent); + } + + fn reject_error(manifest: &V2RedemptionManifest, intent: &V2SigningIntent) -> V2BuilderError { + let callback_calls = std::cell::Cell::new(0usize); + let result = with_validated_v2_redemption_manifest(manifest, intent, |_| { + callback_calls.set(callback_calls.get() + 1); + }); + assert_eq!( + callback_calls.get(), + 0, + "proof/signature callback ran for a rejected manifest" + ); + result.expect_err("mutant must be rejected") + } + + fn reject_bytes(encoded: &[u8], intent: &V2SigningIntent) -> V2BuilderError { + let callback_calls = std::cell::Cell::new(0usize); + let result = with_validated_v2_redemption_manifest_bytes(encoded, intent, |_| { + callback_calls.set(callback_calls.get() + 1); + }); + assert_eq!( + callback_calls.get(), + 0, + "proof/signature callback ran for rejected manifest bytes" + ); + result.expect_err("mutant bytes must be rejected") + } + + #[test] + fn older_signed_claim_remains_exact_when_tracker_commits_newer_total() { + let fixture = make_fixture(false, false); + let callback_calls = std::cell::Cell::new(0usize); + with_validated_v2_redemption_manifest(&fixture.manifest, &fixture.intent, |validated| { + callback_calls.set(callback_calls.get() + 1); + assert!(std::ptr::eq(validated.manifest(), &fixture.manifest)); + }) + .unwrap(); + assert_eq!(callback_calls.get(), 1); + assert_eq!( + fixture.manifest.tracker_committed_total_debt, + Some(fixture.committed_total) + ); + let claim_total = fixture + .manifest + .context_extension + .iter() + .find(|variable| variable.index == 3) + .unwrap(); + assert_eq!( + claim_total.serialized_value, + serialize_ergo_long(fixture.old_total as i64) + ); + assert_ne!(fixture.old_total, fixture.committed_total); + } + + #[test] + fn exact_payout_fee_and_owner_change_each_fail_as_single_faults() { + let fixture = make_fixture(false, false); + + let mut payout = fixture.manifest.clone(); + payout.outputs[1].value += 1; + reject(&payout, &fixture.intent); + + let mut fee = fixture.manifest.clone(); + fee.outputs[2].value += 1; + reject(&fee, &fixture.intent); + + let mut change = fixture.manifest.clone(); + change.outputs[3].value += 1; + reject(&change, &fixture.intent); + + let mut payout_script = fixture.manifest.clone(); + payout_script.outputs[1].ergo_tree.push_str("00"); + reject(&payout_script, &fixture.intent); + + let mut fee_script = fixture.manifest.clone(); + fee_script.outputs[2].ergo_tree.push_str("00"); + reject(&fee_script, &fixture.intent); + + let mut change_owner = fixture.manifest.clone(); + change_owner.outputs[3].ergo_tree.push_str("00"); + reject(&change_owner, &fixture.intent); + + let mut funding_owner = fixture.manifest.clone(); + funding_owner.funding_inputs[0] + .exact_box + .ergo_tree + .push_str("00"); + reject(&funding_owner, &fixture.intent); + + assert_eq!( + fixture.manifest.outputs[0].value + fixture.manifest.outputs[1].value, + fixture.manifest.reserve_input.exact_box.value + ); + } + + #[test] + fn reserve_successor_r5_r8_r9_and_prior_proof_are_individually_bound() { + let fixture = make_fixture(false, false); + for register in ["R5", "R8", "R9"] { + let mut mutant = fixture.manifest.clone(); + mutant.outputs[0] + .additional_registers + .get_mut(register) + .unwrap() + .push_str("00"); + reject(&mutant, &fixture.intent); + } + + let mut absent_prior = fixture.manifest.clone(); + absent_prior.reserve_prior_proof.clear(); + reject(&absent_prior, &fixture.intent); + } + + #[test] + fn amount_timestamp_and_context_fields_are_not_builder_controlled() { + let fixture = make_fixture(false, false); + + let mut amount = fixture.manifest.clone(); + amount.amount += 1; + reject(&amount, &fixture.intent); + + let mut timestamp = fixture.manifest.clone(); + timestamp.claim.timestamp += 1; + reject(×tamp, &fixture.intent); + + let mut receiver = fixture.manifest.clone(); + receiver + .context_extension + .iter_mut() + .find(|variable| variable.index == 1) + .unwrap() + .serialized_value + .push_str("00"); + reject(&receiver, &fixture.intent); + } + + #[test] + fn reserve_domain_nft_tracker_nft_and_tracker_proof_are_individually_bound() { + let fixture = make_fixture(false, false); + + let mut claim_domain = fixture.manifest.clone(); + claim_domain.claim.reserve_nft_id = "44".repeat(32); + reject(&claim_domain, &fixture.intent); + + let mut reserve_nft = fixture.manifest.clone(); + reserve_nft.reserve_input.exact_box.tokens[0].token_id = "55".repeat(32); + reject(&reserve_nft, &fixture.intent); + + let mut tracker_nft = fixture.manifest.clone(); + tracker_nft + .tracker_data_input + .as_mut() + .unwrap() + .exact_box + .tokens[0] + .token_id = "66".repeat(32); + reject(&tracker_nft, &fixture.intent); + + let mut proof = fixture.manifest.clone(); + let mut proof_bytes = hex::decode(proof.tracker_lookup_proof.as_ref().unwrap()).unwrap(); + *proof_bytes.last_mut().unwrap() ^= 1; + proof.tracker_lookup_proof = Some(hex::encode(proof_bytes)); + reject(&proof, &fixture.intent); + } + + #[test] + fn emergency_is_derived_only_from_height_and_immutable_r8() { + let normal = make_fixture(false, false); + assert!(!normal.manifest.emergency); + assert!(normal.manifest.tracker_data_input.is_some()); + validate_v2_redemption_manifest(&normal.manifest, &normal.intent).unwrap(); + + let emergency = make_fixture(false, true); + assert!(emergency.manifest.emergency); + assert!(emergency.manifest.tracker_data_input.is_none()); + assert!(emergency.manifest.tracker_signature.is_none()); + validate_v2_redemption_manifest(&emergency.manifest, &emergency.intent).unwrap(); + let encoded = serde_json::to_vec(&emergency.manifest).unwrap(); + let callback_calls = std::cell::Cell::new(0usize); + with_validated_v2_redemption_manifest_bytes(&encoded, &emergency.intent, |_| { + callback_calls.set(callback_calls.get() + 1); + }) + .unwrap(); + assert_eq!(callback_calls.get(), 1); + + let mut caller_boolean = normal.manifest.clone(); + caller_boolean.emergency = true; + reject(&caller_boolean, &normal.intent); + } + + #[test] + fn emergency_context_omission_matches_the_pinned_scala_source_vector() { + let vector: Value = serde_json::from_str(CONTEXT_ABI_VECTOR).unwrap(); + assert_eq!( + vector["source_commit"], + "9a274396d5f78f7be5ed76bacee5329c42570317" + ); + + let normal = make_fixture(false, false); + let normal_ids: Vec = normal + .manifest + .context_extension + .iter() + .map(|variable| variable.index) + .collect(); + assert_eq!( + normal_ids, + serde_json::from_value::>( + vector["normal"]["scala_serialization_order"].clone() + ) + .unwrap() + ); + assert_eq!( + normal.manifest.unsigned_transaction_json()["dataInputs"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let emergency = make_fixture(false, true); + let emergency_ids: Vec = emergency + .manifest + .context_extension + .iter() + .map(|variable| variable.index) + .collect(); + assert_eq!( + emergency_ids, + serde_json::from_value::>( + vector["emergency"]["scala_serialization_order"].clone() + ) + .unwrap() + ); + assert!(!emergency_ids.contains(&6)); + assert!(!emergency_ids.contains(&8)); + assert_eq!( + emergency.manifest.unsigned_transaction_json()["dataInputs"] + .as_array() + .unwrap() + .len(), + 0 + ); + + let successor_r5 = emergency.manifest.outputs[0] + .additional_registers + .get("R5") + .unwrap(); + let parsed = parse_avl_register(successor_r5, "successor R5").unwrap(); + assert_eq!(parsed.key_length, 32); + assert_eq!(parsed.value_length_opt.as_deref(), Some(&24)); + assert_eq!(parsed.tree_flags.serialize(), 0x03); + } + + #[test] + fn token_reserve_uses_exact_token_payout_and_external_erg_funding() { + let fixture = make_fixture(true, false); + validate_v2_redemption_manifest(&fixture.manifest, &fixture.intent).unwrap(); + assert_eq!(fixture.manifest.outputs[0].value, 2_000_000); + assert_eq!(fixture.manifest.outputs[0].tokens[1].amount, 80); + assert_eq!(fixture.manifest.outputs[1].value, BASIS_V2_MIN_BOX_VALUE); + assert_eq!(fixture.manifest.outputs[1].tokens.len(), 1); + assert_eq!(fixture.manifest.outputs[1].tokens[0].amount, 20); + + let mut token_amount = fixture.manifest.clone(); + token_amount.outputs[1].tokens[0].amount += 1; + reject(&token_amount, &fixture.intent); + + let mut token_id = fixture.manifest.clone(); + token_id.reserve_input.exact_box.tokens[1].token_id = "88".repeat(32); + reject(&token_id, &fixture.intent); + } + + #[test] + fn opaque_authority_binds_the_independent_signer_tip_and_depth() { + let fixture = make_fixture(false, false); + + let mut fork_tip = fixture.manifest.clone(); + fork_tip.reserve_input.observation.tip_block_id = "ee".repeat(32); + reject(&fork_tip, &fixture.intent); + + let mut shallow = fixture.manifest.clone(); + shallow.reserve_input.observation.inclusion_height = 100; + shallow.reserve_input.observation.confirmations = 1; + reject(&shallow, &fixture.intent); + } + + #[test] + fn bounded_manifest_bytes_accept_the_exact_cap_and_reject_one_byte_over() { + let fixture = make_fixture(false, false); + let mut encoded = serde_json::to_vec(&fixture.manifest).unwrap(); + assert!(encoded.len() < BASIS_V2_MAX_MANIFEST_JSON_BYTES); + encoded.resize(BASIS_V2_MAX_MANIFEST_JSON_BYTES, b' '); + + let callback_calls = std::cell::Cell::new(0usize); + with_validated_v2_redemption_manifest_bytes(&encoded, &fixture.intent, |validated| { + callback_calls.set(callback_calls.get() + 1); + assert_eq!(validated.manifest().amount(), fixture.manifest.amount()); + }) + .unwrap(); + assert_eq!(callback_calls.get(), 1); + + encoded.push(b' '); + let error = reject_bytes(&encoded, &fixture.intent); + assert!(error.to_string().contains("manifest JSON exceeds")); + } + + #[test] + fn every_top_level_manifest_field_is_required_before_callback_admission() { + let fixture = make_fixture(false, false); + let value = serde_json::to_value(&fixture.manifest).unwrap(); + let object = value.as_object().unwrap(); + let mut actual_fields: Vec<&str> = object.keys().map(String::as_str).collect(); + actual_fields.sort_unstable(); + let mut required_fields = REQUIRED_V2_MANIFEST_FIELDS.to_vec(); + required_fields.sort_unstable(); + assert_eq!(actual_fields, required_fields); + + for field in REQUIRED_V2_MANIFEST_FIELDS { + let mut mutant = value.clone(); + mutant.as_object_mut().unwrap().remove(*field); + let encoded = serde_json::to_vec(&mutant).unwrap(); + let error = reject_bytes(&encoded, &fixture.intent); + assert!( + error.to_string().contains(field), + "missing {field} was not named by {error}" + ); + } + + let mut unknown = value.clone(); + unknown + .as_object_mut() + .unwrap() + .insert("unexpected".to_string(), Value::Null); + reject_bytes(&serde_json::to_vec(&unknown).unwrap(), &fixture.intent); + + let valid = serde_json::to_string(&value).unwrap(); + let duplicate = format!( + "{{\"schema\":\"{BASIS_V2_MANIFEST_SCHEMA}\",{}", + &valid[1..] + ); + reject_bytes(duplicate.as_bytes(), &fixture.intent); + } + + #[test] + fn hex_lengths_are_checked_before_decode_with_exact_caps_admitted() { + assert!(validate_hex_encoded_length( + &"00".repeat(ErgoBox::MAX_BOX_SIZE), + "raw Sigma box", + ErgoBox::MAX_BOX_SIZE, + ) + .is_ok()); + assert_eq!( + decode_proof(&"00".repeat(BASIS_V2_MAX_PROOF_BYTES), "bounded proof") + .unwrap() + .len(), + BASIS_V2_MAX_PROOF_BYTES + ); + assert_eq!( + decode_array::<32>(&"00".repeat(32), "fixed field").unwrap(), + [0u8; 32] + ); + + let fixture = make_fixture(false, false); + + let mut raw_empty = fixture.manifest.clone(); + raw_empty.reserve_input.exact_box.raw_sigma_hex.clear(); + assert!(reject_error(&raw_empty, &fixture.intent) + .to_string() + .contains("non-empty before hex decoding")); + + let mut proof_empty = fixture.manifest.clone(); + proof_empty.reserve_prior_proof.clear(); + assert!(reject_error(&proof_empty, &fixture.intent) + .to_string() + .contains("non-empty before hex decoding")); + + let mut fixed_empty = fixture.manifest.clone(); + fixed_empty.claim.reserve_nft_id.clear(); + assert!(reject_error(&fixed_empty, &fixture.intent) + .to_string() + .contains("non-empty before hex decoding")); + + let mut raw_oversized = fixture.manifest.clone(); + raw_oversized.reserve_input.exact_box.raw_sigma_hex = + "zz".repeat(ErgoBox::MAX_BOX_SIZE + 1); + let error = reject_error(&raw_oversized, &fixture.intent); + assert!(error + .to_string() + .contains("exceeds 4096 bytes before hex decoding")); + + let mut proof_oversized = fixture.manifest.clone(); + proof_oversized.reserve_prior_proof = "zz".repeat(BASIS_V2_MAX_PROOF_BYTES + 1); + let error = reject_error(&proof_oversized, &fixture.intent); + assert!(error + .to_string() + .contains("exceeds 65536 bytes before hex decoding")); + + let mut fixed_oversized = fixture.manifest.clone(); + fixed_oversized.claim.reserve_nft_id = "zz".repeat(33); + let error = reject_error(&fixed_oversized, &fixture.intent); + assert!(error + .to_string() + .contains("must encode exactly 32 bytes before hex decoding")); + + let mut raw_odd = fixture.manifest.clone(); + raw_odd.reserve_input.exact_box.raw_sigma_hex = "0".to_string(); + assert!(reject_error(&raw_odd, &fixture.intent) + .to_string() + .contains("even number of hex characters")); + + let mut proof_odd = fixture.manifest.clone(); + proof_odd.reserve_prior_proof = "0".to_string(); + assert!(reject_error(&proof_odd, &fixture.intent) + .to_string() + .contains("even number of hex characters")); + + let mut fixed_odd = fixture.manifest.clone(); + fixed_odd.claim.reserve_nft_id = "0".to_string(); + assert!(reject_error(&fixed_odd, &fixture.intent) + .to_string() + .contains("even number of hex characters")); + } + + #[test] + fn reserve_update_proof_mutants_and_next_root_never_reach_callback() { + let fixture = make_fixture(false, false); + + let mut truncated = fixture.manifest.clone(); + let mut proof = hex::decode(&truncated.reserve_update_proof).unwrap(); + proof.pop(); + truncated.reserve_update_proof = hex::encode(proof); + reject(&truncated, &fixture.intent); + + let mut bit_flipped = fixture.manifest.clone(); + let mut proof = hex::decode(&bit_flipped.reserve_update_proof).unwrap(); + *proof.last_mut().unwrap() ^= 1; + bit_flipped.reserve_update_proof = hex::encode(proof); + reject(&bit_flipped, &fixture.intent); + + let mut oversized = fixture.manifest.clone(); + oversized.reserve_update_proof = "zz".repeat(BASIS_V2_MAX_PROOF_BYTES + 1); + let error = reject_error(&oversized, &fixture.intent); + assert!(error + .to_string() + .contains("exceeds 65536 bytes before hex decoding")); + + let mut next_root = fixture.manifest.clone(); + let mut root = hex::decode(&next_root.reserve_next_root).unwrap(); + root[0] ^= 1; + next_root.reserve_next_root = hex::encode(root); + reject(&next_root, &fixture.intent); + } + + #[test] + fn payout_fee_change_and_aggregate_values_are_bounded() { + assert!(validate_output_box_value(BASIS_V2_MIN_BOX_VALUE - 1, "creditor payout").is_err()); + assert!(validate_output_box_value(BASIS_V2_MIN_BOX_VALUE - 1, "miner fee").is_err()); + assert!(validate_output_box_value(BASIS_V2_MIN_BOX_VALUE, "exact minimum").is_ok()); + assert!(validate_output_box_value(BoxValue::MAX_RAW, "exact maximum").is_ok()); + assert!(validate_output_box_value(BoxValue::MAX_RAW + 1, "over maximum").is_err()); + + let admission = make_fixture(false, false); + let payout_error = V2SigningIntent::new( + admission.intent.claim.clone(), + BASIS_V2_MIN_BOX_VALUE - 1, + BASIS_V2_MIN_BOX_VALUE, + admission.intent.signing_tip.clone(), + admission.intent.minimum_confirmations, + admission.intent.reserve_box_id, + admission.intent.funding_owner_pubkey, + ) + .unwrap_err(); + assert!(payout_error + .to_string() + .contains("creditor payout output value")); + let fee_error = V2SigningIntent::new( + admission.intent.claim.clone(), + admission.intent.amount, + BASIS_V2_MIN_BOX_VALUE - 1, + admission.intent.signing_tip.clone(), + admission.intent.minimum_confirmations, + admission.intent.reserve_box_id, + admission.intent.funding_owner_pubkey, + ) + .unwrap_err(); + assert!(fee_error.to_string().contains("miner fee output value")); + + let mut exact_maximum = make_fixture(false, false); + let owner_tree = exact_maximum.manifest.funding_inputs[0] + .exact_box + .ergo_tree + .clone(); + let funding = confirmed( + &exact_box(&owner_tree, BoxValue::MAX_RAW, Vec::new(), Vec::new(), 20), + exact_maximum.manifest.current_height, + ); + exact_maximum.manifest.funding_inputs = vec![AuthenticatedInputManifestV2::from(&funding)]; + exact_maximum.manifest.fee = BoxValue::MAX_RAW; + exact_maximum.intent.fee = BoxValue::MAX_RAW; + exact_maximum.manifest.outputs[2].value = BoxValue::MAX_RAW; + exact_maximum.manifest.outputs.truncate(3); + validate_v2_redemption_manifest(&exact_maximum.manifest, &exact_maximum.intent).unwrap(); + + let fixture = make_fixture(false, false); + let owner_tree = fixture.manifest.funding_inputs[0] + .exact_box + .ergo_tree + .clone(); + let first = confirmed( + &exact_box(&owner_tree, BoxValue::MAX_RAW, Vec::new(), Vec::new(), 21), + fixture.manifest.current_height, + ); + let exact_boundary_second = confirmed( + &exact_box( + &owner_tree, + BASIS_V2_MIN_BOX_VALUE, + Vec::new(), + Vec::new(), + 22, + ), + fixture.manifest.current_height, + ); + let mut exact_change = fixture.manifest.clone(); + exact_change.funding_inputs = vec![ + AuthenticatedInputManifestV2::from(&first), + AuthenticatedInputManifestV2::from(&exact_boundary_second), + ]; + exact_change.outputs[3].value = BoxValue::MAX_RAW; + validate_v2_redemption_manifest(&exact_change, &fixture.intent).unwrap(); + + let overlong_second = confirmed( + &exact_box( + &owner_tree, + 2 * BASIS_V2_MIN_BOX_VALUE, + Vec::new(), + Vec::new(), + 23, + ), + fixture.manifest.current_height, + ); + let mut overlong_change = fixture.manifest.clone(); + overlong_change.funding_inputs = vec![ + AuthenticatedInputManifestV2::from(&first), + AuthenticatedInputManifestV2::from(&overlong_second), + ]; + let error = reject_error(&overlong_change, &fixture.intent); + assert!(error.to_string().contains("change output value")); + + let third = confirmed( + &exact_box(&owner_tree, BoxValue::MAX_RAW, Vec::new(), Vec::new(), 24), + fixture.manifest.current_height, + ); + let mut aggregate_overflow = fixture.manifest.clone(); + aggregate_overflow.funding_inputs = vec![ + AuthenticatedInputManifestV2::from(&first), + AuthenticatedInputManifestV2::from(&third), + AuthenticatedInputManifestV2::from(&overlong_second), + ]; + let error = reject_error(&aggregate_overflow, &fixture.intent); + assert!(error.to_string().contains("funding value overflow")); + } +} diff --git a/crates/basis_store/src/basis_v2_state.rs b/crates/basis_store/src/basis_v2_state.rs new file mode 100644 index 0000000..f6a82eb --- /dev/null +++ b/crates/basis_store/src/basis_v2_state.rs @@ -0,0 +1,2135 @@ +//! Bounded, versioned persistence for the inactive Basis v2 state generation. +//! +//! This module deliberately does not activate v2 routes, scanners, transaction +//! builders, or network behavior. It only owns the authoritative local bytes +//! needed to reconstruct the fixed-shape tracker and per-reserve AVL roots. + +use basis_core::basis_v2::{ + BasisV2Error, ClaimDomainV2, ClaimV2, RedeemedStateV2, ReserveAssetV2, BASIS_V2_ABI_GENERATION, + BASIS_V2_ERG_ASSET_KIND, BASIS_V2_TOKEN_ASSET_KIND, +}; +use basis_trees::{ReserveAvlTree, TrackerAvlTree, TreeError}; +use blake2::{Blake2b, Digest}; +use fjall::{Config, Keyspace, Partition, PartitionCreateOptions, PersistMode}; +use fs2::FileExt; +use generic_array::typenum::U32; +use std::collections::{HashMap, HashSet}; +use std::fs::{File, OpenOptions}; +use std::path::Path; +use thiserror::Error; + +const TRACKER_MAGIC: [u8; 4] = *b"BNS2"; +const LEGACY_TRACKER_MAGIC: [u8; 4] = *b"BNS1"; +const RESERVE_MAGIC: [u8; 4] = *b"BRS2"; +const LEGACY_RESERVE_MAGIC: [u8; 4] = *b"BRS1"; + +const TRACKER_PARTITION: &str = "basis_v2_tracker_claims"; +const TRACKER_SNAPSHOT_KEY: &[u8] = b"bns2_snapshot"; +const RESERVE_PARTITION: &str = "basis_v2_reserve_redeemed"; +const RESERVE_SNAPSHOT_KEY: &[u8] = b"brs2_snapshot"; +const WRITER_LOCK_FILE: &str = ".basis-v2-writer.lock"; + +const TRACKER_CHECKSUM_DOMAIN: &[u8] = b"basis-v2-tracker-claims-bns2"; +const RESERVE_CHECKSUM_DOMAIN: &[u8] = b"basis-v2-reserve-redeemed-brs2"; + +// reserve NFT + tracker NFT + owner + receiver + kind + token/zero + debt + timestamp + signature +const CLAIM_RECORD_LEN: usize = 32 + 32 + 33 + 33 + 1 + 32 + 8 + 8 + 65; +const TRACKER_HEADER_LEN: usize = 4 + 1 + 32 + 4 + 33; +const RESERVE_HEADER_LEN: usize = 4 + 1 + 32 + 32 + 1 + 32 + 4 + 33; +const CHECKSUM_LEN: usize = 32; +const RESERVE_RECORD_LEN: usize = CLAIM_RECORD_LEN + RedeemedStateV2::ENCODED_LEN; +const MAX_V2_ENTRY_COUNT: usize = 50_000; +const MAX_TRACKER_SNAPSHOT_LEN: usize = + TRACKER_HEADER_LEN + MAX_V2_ENTRY_COUNT * CLAIM_RECORD_LEN + CHECKSUM_LEN; +const MAX_RESERVE_SNAPSHOT_LEN: usize = + RESERVE_HEADER_LEN + MAX_V2_ENTRY_COUNT * RESERVE_RECORD_LEN + CHECKSUM_LEN; + +/// Explicit consent required before an empty v2 generation is created. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FreshV2StateApproval { + #[default] + Reject, + Approve, +} + +/// Immutable lineage and asset binding for one per-reserve BRS2 directory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReserveStoreBindingV2 { + tracker_nft_id: [u8; 32], + reserve_nft_id: [u8; 32], + asset: ReserveAssetV2, +} + +impl ReserveStoreBindingV2 { + pub const fn erg(tracker_nft_id: [u8; 32], reserve_nft_id: [u8; 32]) -> Self { + Self { + tracker_nft_id, + reserve_nft_id, + asset: ReserveAssetV2::Erg, + } + } + + pub fn token( + tracker_nft_id: [u8; 32], + reserve_nft_id: [u8; 32], + token_id: [u8; 32], + ) -> Result { + if token_id == reserve_nft_id { + return Err(V2StateError::Claim(BasisV2Error::DuplicateReserveAssetId)); + } + Ok(Self { + tracker_nft_id, + reserve_nft_id, + asset: ReserveAssetV2::Token { token_id }, + }) + } + + pub const fn tracker_nft_id(&self) -> [u8; 32] { + self.tracker_nft_id + } + + pub const fn reserve_nft_id(&self) -> [u8; 32] { + self.reserve_nft_id + } + + pub const fn asset(&self) -> ReserveAssetV2 { + self.asset + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum V2StateError { + #[error("a new v2 data directory requires explicit fresh-generation approval")] + FreshGenerationRequired, + #[error("explicit migration or reset is required: {0}")] + MigrationRequired(String), + #[error("configured v2 lineage or asset does not match the stored binding")] + BindingMismatch, + #[error("v2 state is corrupt: {0}")] + Corrupt(String), + #[error("v2 state capacity exceeded (limit {limit})")] + CapacityExceeded { limit: usize }, + #[error("v2 state writer is already active for this path")] + WriterAlreadyActive, + #[error("v2 state is terminally poisoned after an unknown write outcome")] + Poisoned, + #[error("confirmed transition does not start from the current reserve root")] + StaleRoot, + #[error("v2 storage outcome is unknown: {0}")] + StorageOutcomeUnknown(String), + #[error("v2 storage error: {0}")] + Storage(String), + #[error("invalid v2 claim/state transition: {0}")] + Claim(#[from] BasisV2Error), + #[error("v2 AVL reconstruction failed: {0}")] + Tree(String), +} + +impl From for V2StateError { + fn from(value: TreeError) -> Self { + Self::Tree(value.to_string()) + } +} + +/// One authoritative BNS2 snapshot for all claims bound to one tracker NFT. +pub struct TrackerClaimStoreV2 { + keyspace: Keyspace, + partition: Partition, + _writer_lock: File, + tracker_nft_id: [u8; 32], + claims: Vec, + positions: HashMap<[u8; 32], usize>, + tree: TrackerAvlTree, + poisoned: bool, + capacity_limit: usize, + #[cfg(test)] + fail_next_persist: bool, +} + +/// Read-only evidence that a cumulative debt is committed by the current +/// fixed-shape tracker root. Generating this witness never advances durable +/// state and deliberately returns the tree value, not a replacement ClaimV2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackerClaimWitnessV2 { + committed_total_debt: u64, + proof: Vec, +} + +impl TrackerClaimWitnessV2 { + pub const fn committed_total_debt(&self) -> u64 { + self.committed_total_debt + } + + pub fn proof(&self) -> &[u8] { + &self.proof + } +} + +impl TrackerClaimStoreV2 { + pub fn open>( + path: P, + tracker_nft_id: [u8; 32], + fresh: FreshV2StateApproval, + ) -> Result { + let opened = open_exact_partition(path.as_ref(), TRACKER_PARTITION, fresh)?; + let (claims, tree) = if opened.is_fresh { + let claims = Vec::new(); + let tree = TrackerAvlTree::new(); + let root = tree.root_digest()?; + let bytes = encode_tracker_snapshot(tracker_nft_id, root, &claims)?; + initialize_snapshot( + &opened.keyspace, + &opened.partition, + TRACKER_SNAPSHOT_KEY, + bytes, + )?; + (claims, tree) + } else { + read_only_snapshot( + &opened.partition, + TRACKER_SNAPSHOT_KEY, + MAX_TRACKER_SNAPSHOT_LEN, + ) + .and_then(|bytes| decode_tracker_snapshot(&bytes, tracker_nft_id))? + }; + let positions = index_claims(&claims)?; + Ok(Self { + keyspace: opened.keyspace, + partition: opened.partition, + _writer_lock: opened.writer_lock, + tracker_nft_id, + claims, + positions, + tree, + poisoned: false, + capacity_limit: MAX_V2_ENTRY_COUNT, + #[cfg(test)] + fail_next_persist: false, + }) + } + + pub fn tracker_nft_id(&self) -> [u8; 32] { + self.tracker_nft_id + } + + pub fn len(&self) -> Result { + self.ensure_healthy()?; + Ok(self.claims.len()) + } + + pub fn is_empty(&self) -> Result { + self.ensure_healthy()?; + Ok(self.claims.is_empty()) + } + + pub fn root_digest(&self) -> Result<[u8; 33], V2StateError> { + self.ensure_healthy()?; + self.tree.root_digest().map_err(Into::into) + } + + pub fn claim(&self, claim_key: &[u8; 32]) -> Result, V2StateError> { + self.ensure_healthy()?; + Ok(self + .positions + .get(claim_key) + .map(|position| &self.claims[*position])) + } + + pub fn ordered_claim_keys(&self) -> Result, V2StateError> { + self.ensure_healthy()?; + Ok(self + .claims + .iter() + .map(|claim| claim.domain().claim_key()) + .collect()) + } + + /// Produce the mandatory tracker membership proof for a supplied signed + /// claim. A newer committed tracker value may cover the older claim, but + /// the caller must continue to use the supplied claim's authenticated + /// total and timestamp in the transaction context extension. + pub fn claim_witness( + &mut self, + claim: &ClaimV2, + ) -> Result { + self.ensure_healthy()?; + revalidate_claim(claim)?; + if claim.domain().tracker_nft_id() != self.tracker_nft_id { + return Err(V2StateError::BindingMismatch); + } + let key = claim.domain().claim_key(); + let witness = self.tree.lookup_witness(key)?; + let encoded = witness.value().ok_or_else(|| { + V2StateError::Corrupt("signed claim is absent from the tracker root".to_string()) + })?; + let committed_total_debt = u64::from_be_bytes(encoded); + if committed_total_debt > i64::MAX as u64 { + return Err(V2StateError::Corrupt( + "tracker debt exceeds the Ergo Long domain".to_string(), + )); + } + if committed_total_debt < claim.total_debt() { + return Err(V2StateError::Claim(BasisV2Error::ClaimRegression)); + } + Ok(TrackerClaimWitnessV2 { + committed_total_debt, + proof: witness.proof().to_vec(), + }) + } + + /// Revalidate the complete signed claim and durably replace the snapshot. + /// Existing keys keep their first-insertion position. + pub fn record_validated_claim(&mut self, claim: ClaimV2) -> Result<[u8; 33], V2StateError> { + self.ensure_healthy()?; + revalidate_claim(&claim)?; + if claim.domain().tracker_nft_id() != self.tracker_nft_id { + return Err(V2StateError::BindingMismatch); + } + + let key = claim.domain().claim_key(); + let mut candidate = self.claims.clone(); + if let Some(position) = self.positions.get(&key).copied() { + validate_claim_successor(&candidate[position], &claim)?; + candidate[position] = claim; + } else { + if candidate.len() >= self.capacity_limit { + return Err(V2StateError::CapacityExceeded { + limit: self.capacity_limit, + }); + } + candidate.push(claim); + } + + let candidate_positions = index_claims(&candidate)?; + let candidate_tree = build_tracker_tree(&candidate)?; + let candidate_root = candidate_tree.root_digest()?; + let bytes = encode_tracker_snapshot(self.tracker_nft_id, candidate_root, &candidate)?; + self.replace_snapshot(bytes)?; + + self.claims = candidate; + self.positions = candidate_positions; + self.tree = candidate_tree; + Ok(candidate_root) + } + + pub fn is_poisoned(&self) -> bool { + self.poisoned + } + + fn ensure_healthy(&self) -> Result<(), V2StateError> { + if self.poisoned { + Err(V2StateError::Poisoned) + } else { + Ok(()) + } + } + + fn replace_snapshot(&mut self, bytes: Vec) -> Result<(), V2StateError> { + if let Err(error) = self.partition.insert(TRACKER_SNAPSHOT_KEY, bytes) { + self.poisoned = true; + return Err(V2StateError::StorageOutcomeUnknown(error.to_string())); + } + #[cfg(test)] + if std::mem::take(&mut self.fail_next_persist) { + self.poisoned = true; + return Err(V2StateError::StorageOutcomeUnknown( + "injected post-insert durability failure".to_string(), + )); + } + if let Err(error) = self.keyspace.persist(PersistMode::SyncData) { + self.poisoned = true; + return Err(V2StateError::StorageOutcomeUnknown(error.to_string())); + } + Ok(()) + } +} + +/// One authoritative BRS2 snapshot for exactly one reserve NFT lineage. +// The mutation half stays dormant until a confirmed-chain scanner can own its +// call boundary. Keeping it compiled here prevents a route-facing raw setter. +#[allow(dead_code)] +pub struct ReserveRedeemedStoreV2 { + keyspace: Keyspace, + partition: Partition, + _writer_lock: File, + binding: ReserveStoreBindingV2, + records: Vec<(ClaimV2, RedeemedStateV2)>, + positions: HashMap<[u8; 32], usize>, + tree: ReserveAvlTree, + poisoned: bool, + capacity_limit: usize, + #[cfg(test)] + fail_next_persist: bool, +} + +/// Pure proof preparation for one reserve redemption. Both the prior lookup +/// proof (membership or non-membership) and the insert-or-update proof are +/// mandatory contract inputs; durable state remains unchanged until a later +/// confirmed-chain reconciler commits the observed successor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReserveRedemptionWitnessV2 { + prior_state: Option, + prior_proof: Vec, + next_state: RedeemedStateV2, + update_proof: Vec, + next_root: [u8; 33], +} + +impl ReserveRedemptionWitnessV2 { + pub const fn prior_state(&self) -> Option { + self.prior_state + } + + pub fn prior_proof(&self) -> &[u8] { + &self.prior_proof + } + + pub const fn next_state(&self) -> RedeemedStateV2 { + self.next_state + } + + pub fn update_proof(&self) -> &[u8] { + &self.update_proof + } + + pub const fn next_root(&self) -> [u8; 33] { + self.next_root + } +} + +#[allow(dead_code)] +impl ReserveRedeemedStoreV2 { + pub fn open>( + path: P, + binding: ReserveStoreBindingV2, + fresh: FreshV2StateApproval, + ) -> Result { + let opened = open_exact_partition(path.as_ref(), RESERVE_PARTITION, fresh)?; + let (records, tree) = if opened.is_fresh { + let records = Vec::new(); + let tree = ReserveAvlTree::new(); + let root = tree.root_digest()?; + let bytes = encode_reserve_snapshot(binding, root, &records)?; + initialize_snapshot( + &opened.keyspace, + &opened.partition, + RESERVE_SNAPSHOT_KEY, + bytes, + )?; + (records, tree) + } else { + read_only_snapshot( + &opened.partition, + RESERVE_SNAPSHOT_KEY, + MAX_RESERVE_SNAPSHOT_LEN, + ) + .and_then(|bytes| decode_reserve_snapshot(&bytes, binding))? + }; + let positions = index_reserve_records(&records)?; + Ok(Self { + keyspace: opened.keyspace, + partition: opened.partition, + _writer_lock: opened.writer_lock, + binding, + records, + positions, + tree, + poisoned: false, + capacity_limit: MAX_V2_ENTRY_COUNT, + #[cfg(test)] + fail_next_persist: false, + }) + } + + pub const fn binding(&self) -> ReserveStoreBindingV2 { + self.binding + } + + pub fn len(&self) -> Result { + self.ensure_healthy()?; + Ok(self.records.len()) + } + + pub fn is_empty(&self) -> Result { + self.ensure_healthy()?; + Ok(self.records.is_empty()) + } + + pub fn root_digest(&self) -> Result<[u8; 33], V2StateError> { + self.ensure_healthy()?; + self.tree.root_digest().map_err(Into::into) + } + + pub fn redeemed_state( + &self, + claim_key: &[u8; 32], + ) -> Result, V2StateError> { + self.ensure_healthy()?; + Ok(self + .positions + .get(claim_key) + .map(|position| self.records[*position].1)) + } + + pub fn claim(&self, claim_key: &[u8; 32]) -> Result, V2StateError> { + self.ensure_healthy()?; + Ok(self + .positions + .get(claim_key) + .map(|position| &self.records[*position].0)) + } + + pub fn ordered_claim_keys(&self) -> Result, V2StateError> { + self.ensure_healthy()?; + Ok(self + .records + .iter() + .map(|(claim, _)| claim.domain().claim_key()) + .collect()) + } + + /// Prepare all reserve proofs against the current root without mutating + /// BRS2. Non-membership is represented by `prior_state == None`, but its + /// proof bytes remain mandatory. + pub fn redemption_witness( + &mut self, + claim: &ClaimV2, + amount: u64, + ) -> Result { + self.ensure_healthy()?; + revalidate_claim(claim)?; + validate_reserve_binding(claim, self.binding)?; + + let key = claim.domain().claim_key(); + let lookup = self.tree.lookup_witness(key)?; + let prior_state = lookup + .value() + .as_ref() + .map(|encoded| RedeemedStateV2::decode(encoded)) + .transpose()?; + let recorded_state = self + .positions + .get(&key) + .map(|position| self.records[*position].1); + if prior_state != recorded_state { + return Err(V2StateError::Corrupt( + "reserve record and authenticated tree value disagree".to_string(), + )); + } + let next_state = match prior_state { + Some(state) => state.advance(claim.timestamp(), claim.total_debt(), amount)?, + None => RedeemedStateV2::new(claim.timestamp(), claim.total_debt(), amount)?, + }; + let transition = self.tree.transition_witness(key, next_state.encode())?; + + Ok(ReserveRedemptionWitnessV2 { + prior_state, + prior_proof: lookup.proof().to_vec(), + next_state, + update_proof: transition.proof().to_vec(), + next_root: transition.new_digest(), + }) + } + + pub fn is_poisoned(&self) -> bool { + self.poisoned + } + + fn ensure_healthy(&self) -> Result<(), V2StateError> { + if self.poisoned { + Err(V2StateError::Poisoned) + } else { + Ok(()) + } + } + + // Deliberately private. A later confirmed-chain scanner must own the only + // call boundary; no route or builder can forge redeemed progress today. + fn commit_confirmed_redemption( + &mut self, + expected_root: [u8; 33], + claim: ClaimV2, + amount: u64, + ) -> Result<[u8; 33], V2StateError> { + self.ensure_healthy()?; + if self.tree.root_digest()? != expected_root { + return Err(V2StateError::StaleRoot); + } + revalidate_claim(&claim)?; + validate_reserve_binding(&claim, self.binding)?; + + let key = claim.domain().claim_key(); + let mut candidate = self.records.clone(); + if let Some(position) = self.positions.get(&key).copied() { + if candidate[position].0.domain() != claim.domain() { + return Err(V2StateError::Corrupt( + "claim successor changed its authenticated domain".to_string(), + )); + } + let next = + candidate[position] + .1 + .advance(claim.timestamp(), claim.total_debt(), amount)?; + candidate[position] = (claim, next); + } else { + if candidate.len() >= self.capacity_limit { + return Err(V2StateError::CapacityExceeded { + limit: self.capacity_limit, + }); + } + if amount == 0 { + return Err(V2StateError::Claim(BasisV2Error::InvalidRedemptionAmount)); + } + if amount > claim.total_debt() { + return Err(V2StateError::Claim(BasisV2Error::RedemptionExceedsClaim)); + } + let state = RedeemedStateV2::new(claim.timestamp(), claim.total_debt(), amount)?; + candidate.push((claim, state)); + } + + let candidate_positions = index_reserve_records(&candidate)?; + let candidate_tree = build_reserve_tree(&candidate)?; + let candidate_root = candidate_tree.root_digest()?; + let bytes = encode_reserve_snapshot(self.binding, candidate_root, &candidate)?; + self.replace_snapshot(bytes)?; + + self.records = candidate; + self.positions = candidate_positions; + self.tree = candidate_tree; + Ok(candidate_root) + } + + fn replace_snapshot(&mut self, bytes: Vec) -> Result<(), V2StateError> { + if let Err(error) = self.partition.insert(RESERVE_SNAPSHOT_KEY, bytes) { + self.poisoned = true; + return Err(V2StateError::StorageOutcomeUnknown(error.to_string())); + } + #[cfg(test)] + if std::mem::take(&mut self.fail_next_persist) { + self.poisoned = true; + return Err(V2StateError::StorageOutcomeUnknown( + "injected post-insert durability failure".to_string(), + )); + } + if let Err(error) = self.keyspace.persist(PersistMode::SyncData) { + self.poisoned = true; + return Err(V2StateError::StorageOutcomeUnknown(error.to_string())); + } + Ok(()) + } +} + +struct OpenedPartition { + keyspace: Keyspace, + partition: Partition, + writer_lock: File, + is_fresh: bool, +} + +fn open_exact_partition( + path: &Path, + expected_partition: &str, + fresh: FreshV2StateApproval, +) -> Result { + let existed = path.exists(); + let had_payload = if existed { + if !path.is_dir() { + return Err(V2StateError::MigrationRequired( + "v2 state path exists but is not a directory".to_string(), + )); + } + let mut has_payload = false; + for entry in + std::fs::read_dir(path).map_err(|error| V2StateError::Storage(error.to_string()))? + { + let entry = entry.map_err(|error| V2StateError::Storage(error.to_string()))?; + if entry.file_name() != std::ffi::OsStr::new(WRITER_LOCK_FILE) { + has_payload = true; + break; + } + } + has_payload + } else { + false + }; + + if !had_payload && fresh != FreshV2StateApproval::Approve { + return Err(V2StateError::FreshGenerationRequired); + } + if !existed { + std::fs::create_dir_all(path).map_err(|error| V2StateError::Storage(error.to_string()))?; + } + + let writer_lock_path = path.join(WRITER_LOCK_FILE); + let writer_lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&writer_lock_path) + .map_err(|error| V2StateError::Storage(error.to_string()))?; + writer_lock + .try_lock_exclusive() + .map_err(|_| V2StateError::WriterAlreadyActive)?; + + let keyspace = Config::new(path) + .open() + .map_err(|error| V2StateError::Storage(error.to_string()))?; + let partitions = keyspace.list_partitions(); + let is_fresh = partitions.is_empty() && !had_payload; + + if is_fresh { + if fresh != FreshV2StateApproval::Approve { + return Err(V2StateError::FreshGenerationRequired); + } + } else if partitions.is_empty() { + return Err(V2StateError::MigrationRequired( + "existing directory has no unambiguous v2 partition".to_string(), + )); + } else if partitions.len() != 1 || partitions[0].as_ref() != expected_partition { + return Err(V2StateError::MigrationRequired(format!( + "expected only partition {expected_partition}; found {}", + partitions + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ))); + } + + let partition = keyspace + .open_partition(expected_partition, PartitionCreateOptions::default()) + .map_err(|error| V2StateError::Storage(error.to_string()))?; + Ok(OpenedPartition { + keyspace, + partition, + writer_lock, + is_fresh, + }) +} + +fn initialize_snapshot( + keyspace: &Keyspace, + partition: &Partition, + key: &[u8], + bytes: Vec, +) -> Result<(), V2StateError> { + partition + .insert(key, bytes) + .map_err(|error| V2StateError::StorageOutcomeUnknown(error.to_string()))?; + keyspace + .persist(PersistMode::SyncData) + .map_err(|error| V2StateError::StorageOutcomeUnknown(error.to_string())) +} + +fn read_only_snapshot( + partition: &Partition, + key: &[u8], + maximum_len: usize, +) -> Result, V2StateError> { + // Probe at most two keys. `Partition::len()` exhausts the full iterator in + // Fjall 2.11.2, which would make rejection time attacker/corruption-sized. + let mut keys = partition.keys(); + let first_key = keys + .next() + .transpose() + .map_err(|error| V2StateError::Storage(error.to_string()))? + .ok_or_else(|| { + V2StateError::Corrupt("authoritative partition contains no rows".to_string()) + })?; + if keys + .next() + .transpose() + .map_err(|error| V2StateError::Storage(error.to_string()))? + .is_some() + { + return Err(V2StateError::Corrupt( + "authoritative partition contains more than one row".to_string(), + )); + } + if first_key.as_ref() != key { + return Err(V2StateError::Corrupt( + "authoritative snapshot row is missing".to_string(), + )); + } + let bytes = partition + .get(key) + .map_err(|error| V2StateError::Storage(error.to_string()))? + .ok_or_else(|| { + V2StateError::Corrupt("authoritative snapshot row is missing".to_string()) + })?; + if bytes.len() > maximum_len { + return Err(V2StateError::Corrupt( + "authoritative snapshot exceeds its byte bound".to_string(), + )); + } + Ok(bytes.to_vec()) +} + +fn revalidate_claim(claim: &ClaimV2) -> Result<(), V2StateError> { + claim.verify()?; + let reconstructed = ClaimV2::from_signed( + claim.domain(), + claim.total_debt(), + claim.timestamp(), + *claim.signature(), + )?; + if reconstructed != *claim { + return Err(V2StateError::Corrupt( + "claim differs from its fully revalidated signed form".to_string(), + )); + } + Ok(()) +} + +fn validate_claim_successor(previous: &ClaimV2, next: &ClaimV2) -> Result<(), V2StateError> { + if previous.domain() != next.domain() + || previous.domain().claim_key() != next.domain().claim_key() + { + return Err(V2StateError::Corrupt( + "claim successor changed its authenticated domain or authoritative key".to_string(), + )); + } + let same = + next.timestamp() == previous.timestamp() && next.total_debt() == previous.total_debt(); + let monotone = + next.timestamp() > previous.timestamp() && next.total_debt() >= previous.total_debt(); + if same || monotone { + Ok(()) + } else { + Err(V2StateError::Claim(BasisV2Error::ClaimRegression)) + } +} + +fn validate_reserve_binding( + claim: &ClaimV2, + binding: ReserveStoreBindingV2, +) -> Result<(), V2StateError> { + let domain = claim.domain(); + if domain.tracker_nft_id() != binding.tracker_nft_id + || domain.reserve_nft_id() != binding.reserve_nft_id + || domain.asset() != binding.asset + { + return Err(V2StateError::BindingMismatch); + } + Ok(()) +} + +fn index_claims(claims: &[ClaimV2]) -> Result, V2StateError> { + let mut positions = HashMap::with_capacity(claims.len()); + for (position, claim) in claims.iter().enumerate() { + let key = claim.domain().claim_key(); + if positions.insert(key, position).is_some() { + return Err(V2StateError::Corrupt( + "duplicate authoritative claim key".to_string(), + )); + } + } + Ok(positions) +} + +fn index_reserve_records( + records: &[(ClaimV2, RedeemedStateV2)], +) -> Result, V2StateError> { + let mut positions = HashMap::with_capacity(records.len()); + for (position, (claim, _)) in records.iter().enumerate() { + let key = claim.domain().claim_key(); + if positions.insert(key, position).is_some() { + return Err(V2StateError::Corrupt( + "duplicate authoritative claim key".to_string(), + )); + } + } + Ok(positions) +} + +fn build_tracker_tree(claims: &[ClaimV2]) -> Result { + if claims.len() > MAX_V2_ENTRY_COUNT { + return Err(V2StateError::CapacityExceeded { + limit: MAX_V2_ENTRY_COUNT, + }); + } + let mut seen = HashSet::with_capacity(claims.len()); + let mut entries = Vec::with_capacity(claims.len()); + for claim in claims { + revalidate_claim(claim)?; + let key = claim.domain().claim_key(); + if !seen.insert(key) { + return Err(V2StateError::Corrupt( + "duplicate authoritative claim key".to_string(), + )); + } + entries.push((key, claim.total_debt().to_be_bytes())); + } + TrackerAvlTree::from_ordered_entries(entries).map_err(Into::into) +} + +fn build_reserve_tree( + records: &[(ClaimV2, RedeemedStateV2)], +) -> Result { + if records.len() > MAX_V2_ENTRY_COUNT { + return Err(V2StateError::CapacityExceeded { + limit: MAX_V2_ENTRY_COUNT, + }); + } + let mut seen = HashSet::with_capacity(records.len()); + let mut entries = Vec::with_capacity(records.len()); + for (claim, state) in records { + revalidate_claim(claim)?; + if state.timestamp() != claim.timestamp() || state.total_debt() != claim.total_debt() { + return Err(V2StateError::Corrupt( + "redeemed state does not match its authenticated claim".to_string(), + )); + } + let key = claim.domain().claim_key(); + if !seen.insert(key) { + return Err(V2StateError::Corrupt( + "duplicate authoritative claim key".to_string(), + )); + } + entries.push((key, state.encode())); + } + ReserveAvlTree::from_ordered_entries(entries).map_err(Into::into) +} + +fn encode_tracker_snapshot( + tracker_nft_id: [u8; 32], + root: [u8; 33], + claims: &[ClaimV2], +) -> Result, V2StateError> { + if claims.len() > MAX_V2_ENTRY_COUNT { + return Err(V2StateError::CapacityExceeded { + limit: MAX_V2_ENTRY_COUNT, + }); + } + let records_len = checked_records_len(claims.len(), CLAIM_RECORD_LEN)?; + let capacity = TRACKER_HEADER_LEN + .checked_add(records_len) + .and_then(|len| len.checked_add(CHECKSUM_LEN)) + .ok_or_else(|| V2StateError::Corrupt("tracker snapshot length overflow".to_string()))?; + let mut bytes = Vec::with_capacity(capacity); + bytes.extend_from_slice(&TRACKER_MAGIC); + bytes.push(BASIS_V2_ABI_GENERATION); + bytes.extend_from_slice(&tracker_nft_id); + bytes.extend_from_slice(&(claims.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&root); + for claim in claims { + if claim.domain().tracker_nft_id() != tracker_nft_id { + return Err(V2StateError::BindingMismatch); + } + encode_claim(&mut bytes, claim)?; + } + append_checksum(&mut bytes, TRACKER_CHECKSUM_DOMAIN); + debug_assert_eq!(bytes.len(), capacity); + Ok(bytes) +} + +fn decode_tracker_snapshot( + bytes: &[u8], + expected_tracker_nft_id: [u8; 32], +) -> Result<(Vec, TrackerAvlTree), V2StateError> { + reject_legacy_or_malformed( + bytes, + TRACKER_MAGIC, + LEGACY_TRACKER_MAGIC, + TRACKER_HEADER_LEN + CHECKSUM_LEN, + MAX_TRACKER_SNAPSHOT_LEN, + "tracker", + )?; + let count = read_u32_at(bytes, 4 + 1 + 32)? as usize; + validate_snapshot_len( + bytes.len(), + count, + TRACKER_HEADER_LEN, + CLAIM_RECORD_LEN, + MAX_TRACKER_SNAPSHOT_LEN, + "tracker", + )?; + verify_checksum(bytes, TRACKER_CHECKSUM_DOMAIN)?; + + let payload_len = bytes.len() - CHECKSUM_LEN; + let mut decoder = Decoder::new(&bytes[..payload_len]); + if decoder.array::<4>()? != TRACKER_MAGIC || decoder.byte()? != BASIS_V2_ABI_GENERATION { + return Err(V2StateError::MigrationRequired( + "unsupported tracker snapshot generation".to_string(), + )); + } + let tracker_nft_id = decoder.array::<32>()?; + if tracker_nft_id != expected_tracker_nft_id { + return Err(V2StateError::BindingMismatch); + } + let decoded_count = decoder.u32()? as usize; + let stored_root = decoder.array::<33>()?; + let mut claims = Vec::with_capacity(decoded_count); + for _ in 0..decoded_count { + let claim = decode_claim(&mut decoder)?; + if claim.domain().tracker_nft_id() != tracker_nft_id { + return Err(V2StateError::Corrupt( + "claim is bound to a different tracker NFT".to_string(), + )); + } + claims.push(claim); + } + decoder.finish()?; + index_claims(&claims)?; + let tree = build_tracker_tree(&claims)?; + if tree.root_digest()? != stored_root { + return Err(V2StateError::Corrupt( + "stored tracker root does not match ordered claims".to_string(), + )); + } + Ok((claims, tree)) +} + +fn encode_reserve_snapshot( + binding: ReserveStoreBindingV2, + root: [u8; 33], + records: &[(ClaimV2, RedeemedStateV2)], +) -> Result, V2StateError> { + if records.len() > MAX_V2_ENTRY_COUNT { + return Err(V2StateError::CapacityExceeded { + limit: MAX_V2_ENTRY_COUNT, + }); + } + let records_len = checked_records_len(records.len(), RESERVE_RECORD_LEN)?; + let capacity = RESERVE_HEADER_LEN + .checked_add(records_len) + .and_then(|len| len.checked_add(CHECKSUM_LEN)) + .ok_or_else(|| V2StateError::Corrupt("reserve snapshot length overflow".to_string()))?; + let mut bytes = Vec::with_capacity(capacity); + bytes.extend_from_slice(&RESERVE_MAGIC); + bytes.push(BASIS_V2_ABI_GENERATION); + bytes.extend_from_slice(&binding.tracker_nft_id); + bytes.extend_from_slice(&binding.reserve_nft_id); + encode_asset_binding(&mut bytes, binding.asset); + bytes.extend_from_slice(&(records.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&root); + for (claim, state) in records { + validate_reserve_binding(claim, binding)?; + if state.timestamp() != claim.timestamp() || state.total_debt() != claim.total_debt() { + return Err(V2StateError::Corrupt( + "redeemed state does not match its authenticated claim".to_string(), + )); + } + encode_claim(&mut bytes, claim)?; + bytes.extend_from_slice(&state.encode()); + } + append_checksum(&mut bytes, RESERVE_CHECKSUM_DOMAIN); + debug_assert_eq!(bytes.len(), capacity); + Ok(bytes) +} + +fn decode_reserve_snapshot( + bytes: &[u8], + expected_binding: ReserveStoreBindingV2, +) -> Result<(Vec<(ClaimV2, RedeemedStateV2)>, ReserveAvlTree), V2StateError> { + reject_legacy_or_malformed( + bytes, + RESERVE_MAGIC, + LEGACY_RESERVE_MAGIC, + RESERVE_HEADER_LEN + CHECKSUM_LEN, + MAX_RESERVE_SNAPSHOT_LEN, + "reserve", + )?; + let count_offset = 4 + 1 + 32 + 32 + 1 + 32; + let count = read_u32_at(bytes, count_offset)? as usize; + validate_snapshot_len( + bytes.len(), + count, + RESERVE_HEADER_LEN, + RESERVE_RECORD_LEN, + MAX_RESERVE_SNAPSHOT_LEN, + "reserve", + )?; + verify_checksum(bytes, RESERVE_CHECKSUM_DOMAIN)?; + + let payload_len = bytes.len() - CHECKSUM_LEN; + let mut decoder = Decoder::new(&bytes[..payload_len]); + if decoder.array::<4>()? != RESERVE_MAGIC || decoder.byte()? != BASIS_V2_ABI_GENERATION { + return Err(V2StateError::MigrationRequired( + "unsupported reserve snapshot generation".to_string(), + )); + } + let tracker_nft_id = decoder.array::<32>()?; + let reserve_nft_id = decoder.array::<32>()?; + let asset = decode_asset_binding(&mut decoder, reserve_nft_id)?; + let stored_binding = ReserveStoreBindingV2 { + tracker_nft_id, + reserve_nft_id, + asset, + }; + if stored_binding != expected_binding { + return Err(V2StateError::BindingMismatch); + } + let decoded_count = decoder.u32()? as usize; + let stored_root = decoder.array::<33>()?; + let mut records = Vec::with_capacity(decoded_count); + for _ in 0..decoded_count { + let claim = decode_claim(&mut decoder)?; + validate_reserve_binding(&claim, stored_binding).map_err(|_| { + V2StateError::Corrupt("claim does not match reserve lineage and asset".to_string()) + })?; + let state = RedeemedStateV2::decode(&decoder.array::<24>()?) + .map_err(|error| V2StateError::Corrupt(error.to_string()))?; + if state.timestamp() != claim.timestamp() || state.total_debt() != claim.total_debt() { + return Err(V2StateError::Corrupt( + "redeemed state does not match its authenticated claim".to_string(), + )); + } + records.push((claim, state)); + } + decoder.finish()?; + index_reserve_records(&records)?; + let tree = build_reserve_tree(&records)?; + if tree.root_digest()? != stored_root { + return Err(V2StateError::Corrupt( + "stored reserve root does not match ordered redeemed states".to_string(), + )); + } + Ok((records, tree)) +} + +fn encode_claim(bytes: &mut Vec, claim: &ClaimV2) -> Result<(), V2StateError> { + revalidate_claim(claim)?; + let domain = claim.domain(); + bytes.extend_from_slice(&domain.reserve_nft_id()); + bytes.extend_from_slice(&domain.tracker_nft_id()); + bytes.extend_from_slice(&domain.owner_pubkey()); + bytes.extend_from_slice(&domain.receiver_pubkey()); + encode_asset_binding(bytes, domain.asset()); + bytes.extend_from_slice(&claim.total_debt().to_be_bytes()); + bytes.extend_from_slice(&claim.timestamp().to_be_bytes()); + bytes.extend_from_slice(claim.signature()); + Ok(()) +} + +fn decode_claim(decoder: &mut Decoder<'_>) -> Result { + let reserve_nft_id = decoder.array::<32>()?; + let tracker_nft_id = decoder.array::<32>()?; + let owner_pubkey = decoder.array::<33>()?; + let receiver_pubkey = decoder.array::<33>()?; + let asset = decode_asset_binding(decoder, reserve_nft_id)?; + let domain = match asset { + ReserveAssetV2::Erg => ClaimDomainV2::erg( + reserve_nft_id, + tracker_nft_id, + owner_pubkey, + receiver_pubkey, + ), + ReserveAssetV2::Token { token_id } => ClaimDomainV2::token( + reserve_nft_id, + token_id, + tracker_nft_id, + owner_pubkey, + receiver_pubkey, + ), + } + .map_err(|error| V2StateError::Corrupt(error.to_string()))?; + let total_debt = decoder.u64()?; + let timestamp = decoder.u64()?; + let signature = decoder.array::<65>()?; + ClaimV2::from_signed(domain, total_debt, timestamp, signature) + .map_err(|error| V2StateError::Corrupt(error.to_string())) +} + +fn encode_asset_binding(bytes: &mut Vec, asset: ReserveAssetV2) { + match asset { + ReserveAssetV2::Erg => { + bytes.push(BASIS_V2_ERG_ASSET_KIND); + bytes.extend_from_slice(&[0u8; 32]); + } + ReserveAssetV2::Token { token_id } => { + bytes.push(BASIS_V2_TOKEN_ASSET_KIND); + bytes.extend_from_slice(&token_id); + } + } +} + +fn decode_asset_binding( + decoder: &mut Decoder<'_>, + reserve_nft_id: [u8; 32], +) -> Result { + let kind = decoder.byte()?; + let token_or_zero = decoder.array::<32>()?; + match kind { + BASIS_V2_ERG_ASSET_KIND if token_or_zero == [0u8; 32] => Ok(ReserveAssetV2::Erg), + BASIS_V2_ERG_ASSET_KIND => Err(V2StateError::Corrupt( + "ERG binding contains a non-zero token id".to_string(), + )), + BASIS_V2_TOKEN_ASSET_KIND if token_or_zero != reserve_nft_id => Ok(ReserveAssetV2::Token { + token_id: token_or_zero, + }), + BASIS_V2_TOKEN_ASSET_KIND => Err(V2StateError::Corrupt( + "reserve NFT and reserve token ids are identical".to_string(), + )), + _ => Err(V2StateError::MigrationRequired( + "unsupported reserve asset discriminator".to_string(), + )), + } +} + +fn checked_records_len(count: usize, record_len: usize) -> Result { + count + .checked_mul(record_len) + .ok_or_else(|| V2StateError::Corrupt("snapshot length overflow".to_string())) +} + +fn read_u32_at(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(4) + .ok_or_else(|| V2StateError::Corrupt("snapshot offset overflow".to_string()))?; + let source = bytes.get(offset..end).ok_or_else(|| { + V2StateError::Corrupt("snapshot ended inside its entry count".to_string()) + })?; + let mut encoded = [0u8; 4]; + encoded.copy_from_slice(source); + Ok(u32::from_be_bytes(encoded)) +} + +fn validate_snapshot_len( + actual_len: usize, + count: usize, + header_len: usize, + record_len: usize, + max_len: usize, + label: &str, +) -> Result<(), V2StateError> { + if count > MAX_V2_ENTRY_COUNT { + return Err(V2StateError::Corrupt(format!( + "stored {label} count exceeds {MAX_V2_ENTRY_COUNT}" + ))); + } + let expected_len = header_len + .checked_add(checked_records_len(count, record_len)?) + .and_then(|len| len.checked_add(CHECKSUM_LEN)) + .ok_or_else(|| V2StateError::Corrupt(format!("{label} snapshot length overflow")))?; + if actual_len > max_len || actual_len != expected_len { + return Err(V2StateError::Corrupt(format!( + "stored {label} length does not match its bounded count" + ))); + } + Ok(()) +} + +fn reject_legacy_or_malformed( + bytes: &[u8], + expected_magic: [u8; 4], + legacy_magic: [u8; 4], + minimum_len: usize, + maximum_len: usize, + label: &str, +) -> Result<(), V2StateError> { + if bytes.get(..4) == Some(legacy_magic.as_slice()) { + return Err(V2StateError::MigrationRequired(format!( + "{label} v1 state cannot be converted implicitly" + ))); + } + if bytes.len() < minimum_len + || bytes.len() > maximum_len + || bytes.get(..4) != Some(expected_magic.as_slice()) + { + return Err(V2StateError::MigrationRequired(format!( + "unsupported or malformed {label} snapshot" + ))); + } + if bytes[4] != BASIS_V2_ABI_GENERATION { + return Err(V2StateError::MigrationRequired(format!( + "unsupported {label} ABI generation" + ))); + } + Ok(()) +} + +fn append_checksum(bytes: &mut Vec, domain: &[u8]) { + let mut hasher = Blake2b::::new(); + hasher.update(domain); + hasher.update(bytes.as_slice()); + let checksum: [u8; 32] = hasher.finalize().into(); + bytes.extend_from_slice(&checksum); +} + +fn verify_checksum(bytes: &[u8], domain: &[u8]) -> Result<(), V2StateError> { + if bytes.len() < CHECKSUM_LEN { + return Err(V2StateError::Corrupt( + "snapshot is shorter than its checksum".to_string(), + )); + } + let payload_len = bytes.len() - CHECKSUM_LEN; + let mut hasher = Blake2b::::new(); + hasher.update(domain); + hasher.update(&bytes[..payload_len]); + let expected: [u8; 32] = hasher.finalize().into(); + if bytes[payload_len..] != expected[..] { + return Err(V2StateError::Corrupt( + "authoritative snapshot checksum mismatch".to_string(), + )); + } + Ok(()) +} + +struct Decoder<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Decoder<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn array(&mut self) -> Result<[u8; N], V2StateError> { + let end = self + .offset + .checked_add(N) + .ok_or_else(|| V2StateError::Corrupt("decoder offset overflow".to_string()))?; + let slice = self.bytes.get(self.offset..end).ok_or_else(|| { + V2StateError::Corrupt("snapshot ended inside a fixed-width field".to_string()) + })?; + self.offset = end; + let mut output = [0u8; N]; + output.copy_from_slice(slice); + Ok(output) + } + + fn byte(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.array::<4>()?)) + } + + fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.array::<8>()?)) + } + + fn finish(self) -> Result<(), V2StateError> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err(V2StateError::Corrupt( + "snapshot contains trailing payload bytes".to_string(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use basis_core::types::PubKey; + use secp256k1::{PublicKey, Secp256k1, SecretKey}; + use tempfile::TempDir; + + fn key(seed: u8) -> ([u8; 32], PubKey) { + let mut secret = [0u8; 32]; + secret[31] = seed; + let secret_key = SecretKey::from_slice(&secret).unwrap(); + let public = PublicKey::from_secret_key(&Secp256k1::new(), &secret_key).serialize(); + (secret, public) + } + + fn erg_claim( + tracker_nft_id: [u8; 32], + reserve_nft_id: [u8; 32], + owner_seed: u8, + receiver_seed: u8, + total_debt: u64, + timestamp: u64, + ) -> ClaimV2 { + let (owner_secret, owner) = key(owner_seed); + let (_, receiver) = key(receiver_seed); + let domain = ClaimDomainV2::erg(reserve_nft_id, tracker_nft_id, owner, receiver).unwrap(); + ClaimV2::sign(domain, total_debt, timestamp, &owner_secret).unwrap() + } + + fn token_claim( + tracker_nft_id: [u8; 32], + reserve_nft_id: [u8; 32], + token_id: [u8; 32], + owner_seed: u8, + receiver_seed: u8, + total_debt: u64, + timestamp: u64, + ) -> ClaimV2 { + let (owner_secret, owner) = key(owner_seed); + let (_, receiver) = key(receiver_seed); + let domain = + ClaimDomainV2::token(reserve_nft_id, token_id, tracker_nft_id, owner, receiver) + .unwrap(); + ClaimV2::sign(domain, total_debt, timestamp, &owner_secret).unwrap() + } + + #[test] + fn proof_preparation_keeps_signed_claim_values_and_never_mutates_state() { + let tracker_dir = TempDir::new().unwrap(); + let reserve_dir = TempDir::new().unwrap(); + let tracker = [0x71u8; 32]; + let reserve = [0x72u8; 32]; + let old_claim = erg_claim(tracker, reserve, 1, 2, 100, 10); + let newer_claim = erg_claim(tracker, reserve, 1, 2, 150, 11); + + let mut tracker_store = + TrackerClaimStoreV2::open(tracker_dir.path(), tracker, FreshV2StateApproval::Approve) + .unwrap(); + tracker_store.record_validated_claim(newer_claim).unwrap(); + let tracker_root = tracker_store.root_digest().unwrap(); + let tracker_witness = tracker_store.claim_witness(&old_claim).unwrap(); + assert_eq!(tracker_witness.committed_total_debt(), 150); + assert_eq!(old_claim.total_debt(), 100); + assert!(TrackerAvlTree::verify_lookup_bytes( + &tracker_root, + &old_claim.domain().claim_key(), + tracker_witness.proof(), + Some(150u64.to_be_bytes()), + )); + assert_eq!(tracker_store.root_digest().unwrap(), tracker_root); + + let mut reserve_store = ReserveRedeemedStoreV2::open( + reserve_dir.path(), + ReserveStoreBindingV2::erg(tracker, reserve), + FreshV2StateApproval::Approve, + ) + .unwrap(); + let reserve_root = reserve_store.root_digest().unwrap(); + let witness = reserve_store.redemption_witness(&old_claim, 25).unwrap(); + assert_eq!(witness.prior_state(), None); + assert!(!witness.prior_proof().is_empty()); + assert_eq!(witness.next_state().redeemed(), 25); + assert!(ReserveAvlTree::verify_lookup_bytes( + &reserve_root, + &old_claim.domain().claim_key(), + witness.prior_proof(), + None, + )); + assert!(ReserveAvlTree::verify_transition_bytes( + &reserve_root, + &old_claim.domain().claim_key(), + &witness.next_state().encode(), + witness.update_proof(), + &witness.next_root(), + )); + assert_eq!(reserve_store.root_digest().unwrap(), reserve_root); + assert!(reserve_store.is_empty().unwrap()); + } + + fn rewrite_checksum(bytes: &mut Vec, domain: &[u8]) { + bytes.truncate(bytes.len() - CHECKSUM_LEN); + append_checksum(bytes, domain); + } + + fn replace_and_sync(partition: &Partition, keyspace: &Keyspace, key: &[u8], bytes: Vec) { + partition.insert(key, bytes).unwrap(); + keyspace.persist(PersistMode::SyncData).unwrap(); + } + + const LOCK_HELPER_PATH_ENV: &str = "BASIS_V2_STATE_LOCK_HELPER_PATH"; + const LOCK_HELPER_EXPECT_ENV: &str = "BASIS_V2_STATE_LOCK_HELPER_EXPECT"; + const LOCK_HELPER_TEST: &str = + "basis_v2_state::tests::tracker_writer_lease_blocks_another_process"; + + fn run_lock_helper(path: &Path, expected: &str) { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg(LOCK_HELPER_TEST) + .arg("--nocapture") + .env(LOCK_HELPER_PATH_ENV, path) + .env(LOCK_HELPER_EXPECT_ENV, expected) + .output() + .unwrap(); + assert!( + output.status.success(), + "lock helper failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn tracker_writer_lease_blocks_another_process() { + if let Some(path) = std::env::var_os(LOCK_HELPER_PATH_ENV) { + let expected = std::env::var(LOCK_HELPER_EXPECT_ENV).unwrap(); + let result = TrackerClaimStoreV2::open( + Path::new(&path), + [23u8; 32], + FreshV2StateApproval::Reject, + ); + match expected.as_str() { + "blocked" => { + assert_eq!(result.err().unwrap(), V2StateError::WriterAlreadyActive) + } + "open" => assert!(result.unwrap().is_empty().unwrap()), + other => panic!("unsupported lock-helper expectation: {other}"), + } + return; + } + + let temp = TempDir::new().unwrap(); + let store = + TrackerClaimStoreV2::open(temp.path(), [23u8; 32], FreshV2StateApproval::Approve) + .unwrap(); + + run_lock_helper(temp.path(), "blocked"); + drop(store); + run_lock_helper(temp.path(), "open"); + } + + #[test] + fn tracker_requires_fresh_approval_enforces_binding_and_single_writer() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("tracker"); + let tracker = [7u8; 32]; + + assert_eq!( + TrackerClaimStoreV2::open(&path, tracker, FreshV2StateApproval::Reject) + .err() + .unwrap(), + V2StateError::FreshGenerationRequired + ); + assert!(!path.exists()); + + let store = + TrackerClaimStoreV2::open(&path, tracker, FreshV2StateApproval::Approve).unwrap(); + let empty_root = store.root_digest().unwrap(); + assert_eq!(store.len().unwrap(), 0); + assert_eq!( + TrackerClaimStoreV2::open(&path, tracker, FreshV2StateApproval::Reject) + .err() + .unwrap(), + V2StateError::WriterAlreadyActive + ); + drop(store); + + let reopened = + TrackerClaimStoreV2::open(&path, tracker, FreshV2StateApproval::Reject).unwrap(); + assert_eq!(reopened.root_digest().unwrap(), empty_root); + drop(reopened); + assert_eq!( + TrackerClaimStoreV2::open(&path, [8u8; 32], FreshV2StateApproval::Reject) + .err() + .unwrap(), + V2StateError::BindingMismatch + ); + } + + #[test] + fn tracker_preserves_first_insertion_order_and_root_across_restart() { + let temp = TempDir::new().unwrap(); + let tracker = [10u8; 32]; + let reserve = [11u8; 32]; + let first = erg_claim(tracker, reserve, 1, 2, 100, 10); + let second = erg_claim(tracker, reserve, 3, 4, 200, 20); + let first_key = first.domain().claim_key(); + let second_key = second.domain().claim_key(); + let first_update = erg_claim(tracker, reserve, 1, 2, 175, 30); + + let mut store = + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Approve).unwrap(); + store.record_validated_claim(first).unwrap(); + store.record_validated_claim(second).unwrap(); + let root = store.record_validated_claim(first_update).unwrap(); + assert_eq!( + store.ordered_claim_keys().unwrap(), + vec![first_key, second_key] + ); + assert_eq!(store.claim(&first_key).unwrap().unwrap().total_debt(), 175); + + let expected = TrackerAvlTree::from_ordered_entries([ + (first_key, 175u64.to_be_bytes()), + (second_key, 200u64.to_be_bytes()), + ]) + .unwrap() + .root_digest() + .unwrap(); + assert_eq!(root, expected); + drop(store); + + let reopened = + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Reject).unwrap(); + assert_eq!(reopened.root_digest().unwrap(), root); + assert_eq!( + reopened.ordered_claim_keys().unwrap(), + vec![first_key, second_key] + ); + } + + #[test] + fn tracker_rejects_binding_regression_and_capacity_without_poisoning() { + let temp = TempDir::new().unwrap(); + let tracker = [12u8; 32]; + let reserve = [13u8; 32]; + let mut store = + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Approve).unwrap(); + store + .record_validated_claim(erg_claim(tracker, reserve, 1, 2, 100, 10)) + .unwrap(); + assert_eq!( + store + .record_validated_claim(erg_claim(tracker, reserve, 1, 2, 99, 11)) + .unwrap_err(), + V2StateError::Claim(BasisV2Error::ClaimRegression) + ); + assert_eq!( + store + .record_validated_claim(erg_claim([99u8; 32], reserve, 3, 4, 5, 5)) + .unwrap_err(), + V2StateError::BindingMismatch + ); + store.capacity_limit = 1; + assert_eq!( + store + .record_validated_claim(erg_claim(tracker, reserve, 3, 4, 5, 20)) + .unwrap_err(), + V2StateError::CapacityExceeded { limit: 1 } + ); + assert!(!store.is_poisoned()); + assert_eq!(store.len().unwrap(), 1); + } + + #[test] + fn tracker_unknown_write_outcome_is_terminal_and_restart_is_self_consistent() { + let temp = TempDir::new().unwrap(); + let tracker = [14u8; 32]; + let reserve = [15u8; 32]; + let mut store = + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Approve).unwrap(); + let old_root = store.root_digest().unwrap(); + let claim = erg_claim(tracker, reserve, 1, 2, 100, 10); + let key = claim.domain().claim_key(); + let new_root = TrackerAvlTree::from_ordered_entries([(key, 100u64.to_be_bytes())]) + .unwrap() + .root_digest() + .unwrap(); + store.fail_next_persist = true; + assert!(matches!( + store.record_validated_claim(claim), + Err(V2StateError::StorageOutcomeUnknown(_)) + )); + assert!(store.is_poisoned()); + assert_eq!(store.root_digest().unwrap_err(), V2StateError::Poisoned); + assert_eq!( + store + .record_validated_claim(erg_claim(tracker, reserve, 3, 4, 5, 20)) + .unwrap_err(), + V2StateError::Poisoned + ); + drop(store); + + let reopened = + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Reject).unwrap(); + assert!([old_root, new_root].contains(&reopened.root_digest().unwrap())); + } + + fn tracker_corruption_case(mutate: F) -> V2StateError + where + F: FnOnce(&mut Vec), + { + let temp = TempDir::new().unwrap(); + let tracker = [20u8; 32]; + let reserve = [21u8; 32]; + let mut store = + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Approve).unwrap(); + store + .record_validated_claim(erg_claim(tracker, reserve, 1, 2, 100, 10)) + .unwrap(); + store + .record_validated_claim(erg_claim(tracker, reserve, 3, 4, 200, 20)) + .unwrap(); + let mut bytes = store + .partition + .get(TRACKER_SNAPSHOT_KEY) + .unwrap() + .unwrap() + .to_vec(); + mutate(&mut bytes); + replace_and_sync( + &store.partition, + &store.keyspace, + TRACKER_SNAPSHOT_KEY, + bytes, + ); + drop(store); + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Reject) + .err() + .expect("corruption must reject restart") + } + + #[test] + fn tracker_restart_rejects_checksum_generation_signature_root_order_duplicate_and_bounds_corruption( + ) { + assert!(matches!( + tracker_corruption_case(|bytes| *bytes.last_mut().unwrap() ^= 1), + V2StateError::Corrupt(_) + )); + + assert!(matches!( + tracker_corruption_case(|bytes| { + bytes[4] = BASIS_V2_ABI_GENERATION + 1; + rewrite_checksum(bytes, TRACKER_CHECKSUM_DOMAIN); + }), + V2StateError::MigrationRequired(_) + )); + + assert!(matches!( + tracker_corruption_case(|bytes| { + let debt_offset = TRACKER_HEADER_LEN + 163; + bytes[debt_offset + 7] ^= 1; + rewrite_checksum(bytes, TRACKER_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + + assert!(matches!( + tracker_corruption_case(|bytes| { + let root_offset = 4 + 1 + 32 + 4; + bytes[root_offset] ^= 1; + rewrite_checksum(bytes, TRACKER_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + + assert!(matches!( + tracker_corruption_case(|bytes| { + let first = + bytes[TRACKER_HEADER_LEN..TRACKER_HEADER_LEN + CLAIM_RECORD_LEN].to_vec(); + let second = bytes[TRACKER_HEADER_LEN + CLAIM_RECORD_LEN + ..TRACKER_HEADER_LEN + 2 * CLAIM_RECORD_LEN] + .to_vec(); + bytes[TRACKER_HEADER_LEN..TRACKER_HEADER_LEN + CLAIM_RECORD_LEN] + .copy_from_slice(&second); + bytes[TRACKER_HEADER_LEN + CLAIM_RECORD_LEN + ..TRACKER_HEADER_LEN + 2 * CLAIM_RECORD_LEN] + .copy_from_slice(&first); + rewrite_checksum(bytes, TRACKER_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + + assert!(matches!( + tracker_corruption_case(|bytes| { + let first = + bytes[TRACKER_HEADER_LEN..TRACKER_HEADER_LEN + CLAIM_RECORD_LEN].to_vec(); + bytes[TRACKER_HEADER_LEN + CLAIM_RECORD_LEN + ..TRACKER_HEADER_LEN + 2 * CLAIM_RECORD_LEN] + .copy_from_slice(&first); + rewrite_checksum(bytes, TRACKER_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + + assert!(matches!( + tracker_corruption_case(|bytes| { + bytes[37..41].copy_from_slice(&((MAX_V2_ENTRY_COUNT as u32) + 1).to_be_bytes()); + }), + V2StateError::Corrupt(_) + )); + + assert!(matches!( + tracker_corruption_case(|bytes| bytes[..4].copy_from_slice(&LEGACY_TRACKER_MAGIC)), + V2StateError::MigrationRequired(_) + )); + } + + #[test] + fn tracker_rejects_extra_rows_and_ambiguous_existing_directories() { + let temp = TempDir::new().unwrap(); + let tracker = [22u8; 32]; + let store = + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Approve).unwrap(); + store.partition.insert(b"unexpected", [1u8]).unwrap(); + store.keyspace.persist(PersistMode::SyncData).unwrap(); + drop(store); + assert!(matches!( + TrackerClaimStoreV2::open(temp.path(), tracker, FreshV2StateApproval::Reject), + Err(V2StateError::Corrupt(_)) + )); + + let ambiguous = TempDir::new().unwrap(); + std::fs::write(ambiguous.path().join("legacy.data"), b"not a v2 database").unwrap(); + assert!(matches!( + TrackerClaimStoreV2::open(ambiguous.path(), tracker, FreshV2StateApproval::Approve), + Err(V2StateError::MigrationRequired(_)) + )); + } + + #[test] + fn reserve_roots_are_independent_bound_and_restart_deterministically() { + let temp = TempDir::new().unwrap(); + let tracker = [30u8; 32]; + let first_reserve = [31u8; 32]; + let second_reserve = [32u8; 32]; + let first_binding = ReserveStoreBindingV2::erg(tracker, first_reserve); + let second_binding = ReserveStoreBindingV2::erg(tracker, second_reserve); + let first_path = temp.path().join("reserve-a"); + let second_path = temp.path().join("reserve-b"); + let mut first = + ReserveRedeemedStoreV2::open(&first_path, first_binding, FreshV2StateApproval::Approve) + .unwrap(); + let mut second = ReserveRedeemedStoreV2::open( + &second_path, + second_binding, + FreshV2StateApproval::Approve, + ) + .unwrap(); + assert!(first.is_empty().unwrap()); + assert_eq!( + ReserveRedeemedStoreV2::open(&first_path, first_binding, FreshV2StateApproval::Reject) + .err() + .unwrap(), + V2StateError::WriterAlreadyActive + ); + + let first_claim = erg_claim(tracker, first_reserve, 1, 2, 100, 10); + let other_claim = erg_claim(tracker, first_reserve, 3, 4, 200, 20); + let first_key = first_claim.domain().claim_key(); + let other_key = other_claim.domain().claim_key(); + let initial_root = first.root_digest().unwrap(); + let first_root = first + .commit_confirmed_redemption(initial_root, first_claim, 25) + .unwrap(); + let first_root = first + .commit_confirmed_redemption(first_root, other_claim, 50) + .unwrap(); + let first_update = erg_claim(tracker, first_reserve, 1, 2, 150, 30); + let first_root = first + .commit_confirmed_redemption(first_root, first_update, 20) + .unwrap(); + assert_eq!( + first.ordered_claim_keys().unwrap(), + vec![first_key, other_key] + ); + assert_eq!( + first + .redeemed_state(&first_key) + .unwrap() + .unwrap() + .redeemed(), + 45 + ); + + let second_claim = erg_claim(tracker, second_reserve, 1, 2, 100, 10); + let second_root = second + .commit_confirmed_redemption(second.root_digest().unwrap(), second_claim, 25) + .unwrap(); + assert_ne!(first_root, second_root); + drop(first); + drop(second); + + let reopened = + ReserveRedeemedStoreV2::open(&first_path, first_binding, FreshV2StateApproval::Reject) + .unwrap(); + assert_eq!(reopened.root_digest().unwrap(), first_root); + assert_eq!( + reopened.ordered_claim_keys().unwrap(), + vec![first_key, other_key] + ); + drop(reopened); + assert_eq!( + ReserveRedeemedStoreV2::open(&first_path, second_binding, FreshV2StateApproval::Reject) + .err() + .unwrap(), + V2StateError::BindingMismatch + ); + let wrong_tracker_binding = ReserveStoreBindingV2::erg([99u8; 32], first_reserve); + assert_eq!( + ReserveRedeemedStoreV2::open( + &first_path, + wrong_tracker_binding, + FreshV2StateApproval::Reject, + ) + .err() + .unwrap(), + V2StateError::BindingMismatch + ); + let wrong_asset_binding = + ReserveStoreBindingV2::token(tracker, first_reserve, [33u8; 32]).unwrap(); + assert_eq!( + ReserveRedeemedStoreV2::open( + &first_path, + wrong_asset_binding, + FreshV2StateApproval::Reject, + ) + .err() + .unwrap(), + V2StateError::BindingMismatch + ); + } + + #[test] + fn reserve_rejects_unconfirmed_inputs_and_asset_or_lineage_mismatch_without_poisoning() { + let temp = TempDir::new().unwrap(); + let tracker = [40u8; 32]; + let reserve = [41u8; 32]; + let token = [42u8; 32]; + let binding = ReserveStoreBindingV2::erg(tracker, reserve); + let mut store = + ReserveRedeemedStoreV2::open(temp.path(), binding, FreshV2StateApproval::Approve) + .unwrap(); + let root = store.root_digest().unwrap(); + + assert_eq!( + store + .commit_confirmed_redemption( + [0u8; 33], + erg_claim(tracker, reserve, 1, 2, 100, 10), + 1, + ) + .unwrap_err(), + V2StateError::StaleRoot + ); + assert_eq!( + store + .commit_confirmed_redemption( + root, + token_claim(tracker, reserve, token, 1, 2, 100, 10), + 1, + ) + .unwrap_err(), + V2StateError::BindingMismatch + ); + assert_eq!( + store + .commit_confirmed_redemption( + root, + erg_claim([99u8; 32], reserve, 1, 2, 100, 10), + 1, + ) + .unwrap_err(), + V2StateError::BindingMismatch + ); + assert_eq!( + store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 1, 2, 100, 10), 0,) + .unwrap_err(), + V2StateError::Claim(BasisV2Error::InvalidRedemptionAmount) + ); + assert_eq!( + store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 1, 2, 100, 10), 101,) + .unwrap_err(), + V2StateError::Claim(BasisV2Error::RedemptionExceedsClaim) + ); + + store.capacity_limit = 0; + assert_eq!( + store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 1, 2, 100, 10), 1,) + .unwrap_err(), + V2StateError::CapacityExceeded { limit: 0 } + ); + assert!(!store.is_poisoned()); + assert_eq!(store.len().unwrap(), 0); + } + + #[test] + fn reserve_enforces_cumulative_redemption_and_claim_successor_rules() { + let temp = TempDir::new().unwrap(); + let tracker = [43u8; 32]; + let reserve = [44u8; 32]; + let binding = ReserveStoreBindingV2::erg(tracker, reserve); + let mut store = + ReserveRedeemedStoreV2::open(temp.path(), binding, FreshV2StateApproval::Approve) + .unwrap(); + let root = store.root_digest().unwrap(); + let root = store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 1, 2, 100, 10), 90) + .unwrap(); + assert_eq!( + store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 1, 2, 100, 10), 11,) + .unwrap_err(), + V2StateError::Claim(BasisV2Error::RedemptionExceedsClaim) + ); + assert_eq!( + store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 1, 2, 99, 11), 1,) + .unwrap_err(), + V2StateError::Claim(BasisV2Error::ClaimRegression) + ); + assert!(!store.is_poisoned()); + } + + #[test] + fn token_reserve_accepts_only_its_exact_token_binding() { + let temp = TempDir::new().unwrap(); + let tracker = [45u8; 32]; + let reserve = [46u8; 32]; + let token = [47u8; 32]; + assert_eq!( + ReserveStoreBindingV2::token(tracker, reserve, reserve) + .err() + .unwrap(), + V2StateError::Claim(BasisV2Error::DuplicateReserveAssetId) + ); + let binding = ReserveStoreBindingV2::token(tracker, reserve, token).unwrap(); + let mut store = + ReserveRedeemedStoreV2::open(temp.path(), binding, FreshV2StateApproval::Approve) + .unwrap(); + let root = store.root_digest().unwrap(); + store + .commit_confirmed_redemption( + root, + token_claim(tracker, reserve, token, 1, 2, 100, 10), + 1, + ) + .unwrap(); + assert_eq!(store.len().unwrap(), 1); + } + + #[test] + fn reserve_unknown_write_outcome_is_terminal_and_restart_is_self_consistent() { + let temp = TempDir::new().unwrap(); + let tracker = [48u8; 32]; + let reserve = [49u8; 32]; + let binding = ReserveStoreBindingV2::erg(tracker, reserve); + let claim = erg_claim(tracker, reserve, 1, 2, 100, 10); + let key = claim.domain().claim_key(); + let state = RedeemedStateV2::new(10, 100, 25).unwrap(); + let new_root = ReserveAvlTree::from_ordered_entries([(key, state.encode())]) + .unwrap() + .root_digest() + .unwrap(); + let mut store = + ReserveRedeemedStoreV2::open(temp.path(), binding, FreshV2StateApproval::Approve) + .unwrap(); + let old_root = store.root_digest().unwrap(); + store.fail_next_persist = true; + assert!(matches!( + store.commit_confirmed_redemption(old_root, claim, 25), + Err(V2StateError::StorageOutcomeUnknown(_)) + )); + assert!(store.is_poisoned()); + assert_eq!(store.len().unwrap_err(), V2StateError::Poisoned); + drop(store); + + let reopened = + ReserveRedeemedStoreV2::open(temp.path(), binding, FreshV2StateApproval::Reject) + .unwrap(); + assert!([old_root, new_root].contains(&reopened.root_digest().unwrap())); + } + + fn reserve_corruption_case(mutate: F) -> V2StateError + where + F: FnOnce(&mut Vec), + { + let temp = TempDir::new().unwrap(); + let tracker = [50u8; 32]; + let reserve = [51u8; 32]; + let binding = ReserveStoreBindingV2::erg(tracker, reserve); + let mut store = + ReserveRedeemedStoreV2::open(temp.path(), binding, FreshV2StateApproval::Approve) + .unwrap(); + let root = store.root_digest().unwrap(); + store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 1, 2, 100, 10), 25) + .unwrap(); + let root = store.root_digest().unwrap(); + store + .commit_confirmed_redemption(root, erg_claim(tracker, reserve, 3, 4, 200, 20), 50) + .unwrap(); + let mut bytes = store + .partition + .get(RESERVE_SNAPSHOT_KEY) + .unwrap() + .unwrap() + .to_vec(); + mutate(&mut bytes); + replace_and_sync( + &store.partition, + &store.keyspace, + RESERVE_SNAPSHOT_KEY, + bytes, + ); + drop(store); + ReserveRedeemedStoreV2::open(temp.path(), binding, FreshV2StateApproval::Reject) + .err() + .expect("corruption must reject restart") + } + + #[test] + fn reserve_restart_rejects_checksum_generation_signature_state_root_order_and_legacy_corruption( + ) { + assert!(matches!( + reserve_corruption_case(|bytes| *bytes.last_mut().unwrap() ^= 1), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + bytes[4] = BASIS_V2_ABI_GENERATION + 1; + rewrite_checksum(bytes, RESERVE_CHECKSUM_DOMAIN); + }), + V2StateError::MigrationRequired(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + let signature_offset = RESERVE_HEADER_LEN + 179; + bytes[signature_offset + 10] ^= 1; + rewrite_checksum(bytes, RESERVE_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + let state_timestamp_offset = RESERVE_HEADER_LEN + CLAIM_RECORD_LEN; + bytes[state_timestamp_offset + 7] ^= 1; + rewrite_checksum(bytes, RESERVE_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + let state_total_debt_offset = RESERVE_HEADER_LEN + CLAIM_RECORD_LEN + 8; + bytes[state_total_debt_offset + 7] ^= 1; + rewrite_checksum(bytes, RESERVE_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + let root_offset = 4 + 1 + 32 + 32 + 1 + 32 + 4; + bytes[root_offset] ^= 1; + rewrite_checksum(bytes, RESERVE_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + let first = + bytes[RESERVE_HEADER_LEN..RESERVE_HEADER_LEN + RESERVE_RECORD_LEN].to_vec(); + bytes[RESERVE_HEADER_LEN + RESERVE_RECORD_LEN + ..RESERVE_HEADER_LEN + 2 * RESERVE_RECORD_LEN] + .copy_from_slice(&first); + rewrite_checksum(bytes, RESERVE_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + let first = + bytes[RESERVE_HEADER_LEN..RESERVE_HEADER_LEN + RESERVE_RECORD_LEN].to_vec(); + let second = bytes[RESERVE_HEADER_LEN + RESERVE_RECORD_LEN + ..RESERVE_HEADER_LEN + 2 * RESERVE_RECORD_LEN] + .to_vec(); + bytes[RESERVE_HEADER_LEN..RESERVE_HEADER_LEN + RESERVE_RECORD_LEN] + .copy_from_slice(&second); + bytes[RESERVE_HEADER_LEN + RESERVE_RECORD_LEN + ..RESERVE_HEADER_LEN + 2 * RESERVE_RECORD_LEN] + .copy_from_slice(&first); + rewrite_checksum(bytes, RESERVE_CHECKSUM_DOMAIN); + }), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| { + let count_offset = 4 + 1 + 32 + 32 + 1 + 32; + bytes[count_offset..count_offset + 4] + .copy_from_slice(&((MAX_V2_ENTRY_COUNT as u32) + 1).to_be_bytes()); + }), + V2StateError::Corrupt(_) + )); + assert!(matches!( + reserve_corruption_case(|bytes| bytes[..4].copy_from_slice(&LEGACY_RESERVE_MAGIC)), + V2StateError::MigrationRequired(_) + )); + } +} diff --git a/crates/basis_store/src/chain_reconciliation.rs b/crates/basis_store/src/chain_reconciliation.rs new file mode 100644 index 0000000..91f687a --- /dev/null +++ b/crates/basis_store/src/chain_reconciliation.rs @@ -0,0 +1,4145 @@ +//! Durable, fail-closed reconciliation of signed protocol transactions. +//! +//! Transaction presence is not confirmation. Acceptance requires one exact +//! signed transaction, canonical predecessor and successor boxes, and a +//! coherent selected-chain header segment whose first block contains the +//! transaction and whose last block is the unchanged node tip. + +use crate::{blake2b256_hash, ConfirmedProjectionAnchor}; +use ergo_lib::{ + chain::{block::FullBlock, transaction::Transaction}, + ergo_chain_types::{blake2b256_hash as header_hash, BlockId, Digest32, Header}, + ergo_merkle_tree::{MerkleNode, MerkleTree}, + ergotree_ir::{ + chain::ergo_box::{ErgoBox, NonMandatoryRegisterId}, + serialization::SigmaSerializable, + }, +}; +use fjall::{Config, Keyspace, PartitionCreateOptions, PersistMode}; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use std::{ + cell::Cell, + collections::BTreeSet, + fs::{File, OpenOptions}, + io::Write, + path::Path, + sync::Mutex, +}; + +const JOURNAL_MAGIC: &[u8; 4] = b"BCJ1"; +const JOURNAL_KEY: &[u8] = b"confirmed_chain_journal_v1"; +const JOURNAL_CHECKSUM_DOMAIN: &[u8] = b"basis-confirmed-chain-journal-v1"; +const JOURNAL_MANIFEST_FILE: &str = "confirmed-chain.manifest"; +const JOURNAL_MANIFEST_MAGIC: &[u8; 4] = b"BCM1"; +const JOURNAL_MANIFEST_CHECKSUM_DOMAIN: &[u8] = b"basis-confirmed-chain-manifest-v1"; +const MAX_SIGNED_TRANSACTION_BYTES: usize = 2 * 1024 * 1024; +const MAX_CHAIN_HEADERS: usize = 4096; +const MAX_HISTORY_ENTRIES: usize = 256; +const FINALITY_POLICY_ID: &str = "basis.tracker.confirmed-chain"; +const FINALITY_POLICY_VERSION: u16 = 1; +const EVIDENCE_HASH_DOMAIN: &[u8] = b"basis-confirmed-chain-evidence-v1"; +const EFFECT_DECISION_DOMAIN: &[u8] = b"basis-confirmed-chain-effect-v1"; +const ROLLBACK_DECISION_DOMAIN: &[u8] = b"basis-confirmed-chain-rollback-v1"; +const RETIREMENT_DECISION_DOMAIN: &[u8] = b"basis-confirmed-chain-retirement-v1"; +const NODE_SOURCE_DOMAIN: &[u8] = b"basis-confirmed-chain-node-source-v1"; + +/// Largest bounded reorg-monitoring horizon accepted by this implementation. +/// The inclusive chain window contains `depth + 1` headers. +pub const MAX_REORG_MONITOR_DEPTH: u64 = (MAX_CHAIN_HEADERS - 1) as u64; + +/// Stable identity shared by the BNS1 tracker state and this reconciliation +/// journal. The state identity is derived, rather than caller-selected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReconciliationJournalBinding { + tracker_nft_id: [u8; 32], + protocol_generation: u8, + state_identity: [u8; 32], +} + +impl ReconciliationJournalBinding { + pub fn tracker_v1(tracker_nft_id: [u8; 32]) -> Self { + let mut material = Vec::with_capacity(24 + tracker_nft_id.len()); + material.extend_from_slice(b"basis-tracker-state-v1"); + material.extend_from_slice(&tracker_nft_id); + Self { + tracker_nft_id, + protocol_generation: 1, + state_identity: blake2b256_hash(&material), + } + } + + pub fn tracker_nft_id(&self) -> &[u8; 32] { + &self.tracker_nft_id + } +} + +/// Whether startup may initialize a journal manifest. Existing BNS1 chain or +/// accounting history must use `ExistingRequired` so a lost/misdirected +/// journal fails before creating any replacement state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JournalBootstrap { + FreshAllowed, + ExistingRequired, +} + +thread_local! { + /// Validated tickets may be reconstructed only while reading this module's + /// checksummed single-writer journal. A generic serde caller cannot mint a + /// policy ticket from arbitrary ids. + static JOURNAL_DESERIALIZATION_DEPTH: Cell = const { Cell::new(0) }; +} + +struct JournalDeserializationGuard; + +impl JournalDeserializationGuard { + fn enter() -> Self { + JOURNAL_DESERIALIZATION_DEPTH.with(|depth| depth.set(depth.get().saturating_add(1))); + Self + } + + fn is_active() -> bool { + JOURNAL_DESERIALIZATION_DEPTH.with(|depth| depth.get() > 0) + } +} + +impl Drop for JournalDeserializationGuard { + fn drop(&mut self) { + JOURNAL_DESERIALIZATION_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1))); + } +} + +/// One ordered token entry. Token order is part of the protocol ABI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtocolAsset { + token_id: String, + amount: u64, +} + +impl ProtocolAsset { + pub fn token_id(&self) -> &str { + &self.token_id + } + + pub fn amount(&self) -> u64 { + self.amount + } +} + +/// Canonical protocol box. Every field, including R4-R9 and token order, is +/// re-derived from `canonical_bytes`; callers cannot bind arbitrary values to +/// a real box id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtocolBox { + canonical_bytes: Vec, + box_id: String, + value: u64, + ergo_tree: Vec, + assets: Vec, + registers: [Option>; 6], + creation_height: u32, + transaction_id: String, + index: u16, +} + +impl ProtocolBox { + /// Parse a node box response. `ergo-lib` recomputes and verifies `boxId` + /// from value/tree/assets/registers/creation reference. + pub fn from_json_bytes(bytes: &[u8]) -> Result { + let ergo_box: ErgoBox = serde_json::from_slice(bytes) + .map_err(|error| ReconciliationError::MalformedBox(error.to_string()))?; + Self::from_ergo_box(&ergo_box) + } + + /// Parse canonical binary box bytes. The embedded creation reference is + /// included in the recomputed box id. + pub fn from_serialized_bytes(bytes: &[u8]) -> Result { + let ergo_box = ErgoBox::sigma_parse_bytes(bytes) + .map_err(|error| ReconciliationError::MalformedBox(error.to_string()))?; + let result = Self::from_ergo_box(&ergo_box)?; + if result.canonical_bytes != bytes { + return Err(ReconciliationError::MalformedBox( + "box bytes are not canonical".to_string(), + )); + } + Ok(result) + } + + fn from_ergo_box(ergo_box: &ErgoBox) -> Result { + let canonical_bytes = ergo_box + .sigma_serialize_bytes() + .map_err(|error| ReconciliationError::MalformedBox(error.to_string()))?; + if canonical_bytes.len() > ErgoBox::MAX_BOX_SIZE { + return Err(ReconciliationError::MalformedBox( + "box exceeds the protocol size bound".to_string(), + )); + } + let ergo_tree = ergo_box + .ergo_tree + .sigma_serialize_bytes() + .map_err(|error| ReconciliationError::MalformedBox(error.to_string()))?; + let assets = ergo_box + .tokens + .as_ref() + .map(|tokens| { + tokens + .iter() + .map(|token| ProtocolAsset { + token_id: String::from(token.token_id), + amount: *token.amount.as_u64(), + }) + .collect() + }) + .unwrap_or_default(); + let mut registers: [Option>; 6] = Default::default(); + for (offset, register_id) in NonMandatoryRegisterId::REG_IDS.iter().enumerate() { + registers[offset] = ergo_box + .additional_registers + .get_constant(*register_id) + .map_err(|error| ReconciliationError::MalformedBox(error.to_string()))? + .map(|constant| { + constant + .sigma_serialize_bytes() + .map_err(|error| ReconciliationError::MalformedBox(error.to_string())) + }) + .transpose()?; + } + Ok(Self { + canonical_bytes, + box_id: ergo_box.box_id().to_string(), + value: *ergo_box.value.as_u64(), + ergo_tree, + assets, + registers, + creation_height: ergo_box.creation_height, + transaction_id: ergo_box.transaction_id.to_string(), + index: ergo_box.index, + }) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + let rebuilt = Self::from_serialized_bytes(&self.canonical_bytes)?; + if rebuilt != *self { + return Err(ReconciliationError::MalformedBox( + "stored box fields differ from canonical bytes".to_string(), + )); + } + Ok(()) + } + + fn singleton_index(&self, token_id: &str) -> Result { + let matches: Vec = self + .assets + .iter() + .enumerate() + .filter(|(_, asset)| asset.token_id == token_id && asset.amount == 1) + .map(|(index, _)| index) + .collect(); + if matches.len() != 1 + || self + .assets + .iter() + .filter(|asset| asset.token_id == token_id) + .count() + != 1 + { + return Err(ReconciliationError::SingletonMismatch); + } + u16::try_from(matches[0]).map_err(|_| ReconciliationError::SingletonMismatch) + } + + pub fn box_id(&self) -> &str { + &self.box_id + } + + pub fn value(&self) -> u64 { + self.value + } + + pub fn ergo_tree(&self) -> &[u8] { + &self.ergo_tree + } + + pub fn assets(&self) -> &[ProtocolAsset] { + &self.assets + } + + /// Exact serialized R4-R9 values in ABI order. + pub fn registers(&self) -> &[Option>; 6] { + &self.registers + } + + pub fn creation_height(&self) -> u32 { + self.creation_height + } + + pub fn transaction_id(&self) -> &str { + &self.transaction_id + } + + pub fn index(&self) -> u16 { + self.index + } +} + +/// Full private manifest derived from the signed transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "effect", rename_all = "snake_case")] +enum ReconciliationEffect { + TrackerPublication { + committed_root: Vec, + protocol_nft_id: String, + protocol_nft_index: u16, + }, +} + +/// Private signed intent. Its successor and all payout manifests are derived +/// from the parsed transaction; none is accepted as a caller-supplied box. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReconciliationIntent { + tx_id: String, + signed_transaction_json: Vec, + predecessor: ProtocolBox, + successor_output_index: u16, + successor: ProtocolBox, + effect: ReconciliationEffect, + intent_id: String, +} + +impl ReconciliationIntent { + pub fn tracker_publication( + signed_transaction_json: Vec, + predecessor_box_json: Vec, + committed_root: [u8; 33], + ) -> Result { + let predecessor = ProtocolBox::from_json_bytes(&predecessor_box_json)?; + let first_asset = predecessor + .assets + .first() + .filter(|asset| asset.amount == 1) + .ok_or(ReconciliationError::SingletonMismatch)?; + let protocol_nft_id = normalize_id(first_asset.token_id.clone(), "protocol NFT id")?; + let protocol_nft_index = predecessor.singleton_index(&protocol_nft_id)?; + if protocol_nft_index != 0 { + return Err(ReconciliationError::SingletonMismatch); + } + let transaction = parse_transaction(&signed_transaction_json)?; + let (successor_output_index, successor) = + unique_tracker_successor(&transaction, &predecessor, &protocol_nft_id)?; + let effect = ReconciliationEffect::TrackerPublication { + committed_root: committed_root.to_vec(), + protocol_nft_id, + protocol_nft_index, + }; + Self::new( + signed_transaction_json, + predecessor, + successor_output_index, + successor, + effect, + ) + } + + fn new( + signed_transaction_json: Vec, + predecessor: ProtocolBox, + successor_output_index: u16, + successor: ProtocolBox, + effect: ReconciliationEffect, + ) -> Result { + let tx_id = parse_transaction(&signed_transaction_json)? + .id() + .to_string(); + let mut intent = Self { + tx_id, + signed_transaction_json, + predecessor, + successor_output_index, + successor, + effect, + intent_id: String::new(), + }; + intent.validate_semantics()?; + intent.intent_id = intent.compute_intent_id()?; + Ok(intent) + } + + fn compute_intent_id(&self) -> Result { + let mut clone = self.clone(); + clone.intent_id.clear(); + let encoded = serde_json::to_vec(&clone) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + Ok(hex::encode(blake2b256_hash(&encoded))) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + if normalize_id(self.tx_id.clone(), "transaction id")? != self.tx_id + || normalize_id(self.intent_id.clone(), "intent id")? != self.intent_id + || self.intent_id != self.compute_intent_id()? + { + return Err(ReconciliationError::MalformedIntent( + "intent identity is inconsistent".to_string(), + )); + } + self.validate_semantics() + } + + fn validate_semantics(&self) -> Result<(), ReconciliationError> { + self.predecessor.validate()?; + self.successor.validate()?; + let transaction = parse_transaction(&self.signed_transaction_json)?; + if transaction.id().to_string() != self.tx_id { + return Err(ReconciliationError::TransactionMismatch); + } + if transaction + .inputs + .iter() + .filter(|input| input.box_id.to_string() == self.predecessor.box_id) + .count() + != 1 + { + return Err(ReconciliationError::LineageMismatch); + } + if transaction_output(&transaction, self.successor_output_index)? != self.successor { + return Err(ReconciliationError::SuccessorMismatch); + } + match &self.effect { + ReconciliationEffect::TrackerPublication { + committed_root, + protocol_nft_id, + protocol_nft_index, + } => { + ensure_singleton_lineage( + &transaction, + &self.predecessor, + &self.successor, + protocol_nft_id, + *protocol_nft_index, + self.successor_output_index, + )?; + if self.successor.value != self.predecessor.value + || self.successor.ergo_tree != self.predecessor.ergo_tree + || self.successor.assets != self.predecessor.assets + || self.successor.registers[0] != self.predecessor.registers[0] + || self.successor.registers[2..] != self.predecessor.registers[2..] + { + return Err(ReconciliationError::SuccessorMismatch); + } + let predecessor_r5 = self.predecessor.registers[1] + .as_ref() + .ok_or(ReconciliationError::RootMismatch)?; + validate_tracker_avl_register(predecessor_r5)?; + let r5 = self.successor.registers[1] + .as_ref() + .ok_or(ReconciliationError::RootMismatch)?; + let derived_root = validate_tracker_avl_register(r5)?; + if committed_root.len() != 33 || derived_root != committed_root.as_slice() { + return Err(ReconciliationError::RootMismatch); + } + } + } + Ok(()) + } + + pub fn tx_id(&self) -> &str { + &self.tx_id + } + + pub fn intent_id(&self) -> &str { + &self.intent_id + } + + pub fn signed_transaction_json(&self) -> &[u8] { + &self.signed_transaction_json + } + + pub fn predecessor(&self) -> &ProtocolBox { + &self.predecessor + } + + pub fn successor(&self) -> &ProtocolBox { + &self.successor + } + + pub fn tracker_root(&self) -> Option<[u8; 33]> { + match &self.effect { + ReconciliationEffect::TrackerPublication { committed_root, .. } + if committed_root.len() == 33 => + { + let mut root = [0u8; 33]; + root.copy_from_slice(committed_root); + Some(root) + } + _ => None, + } + } + + pub fn protocol_nft_id(&self) -> Option<&str> { + match &self.effect { + ReconciliationEffect::TrackerPublication { + protocol_nft_id, .. + } => Some(protocol_nft_id), + } + } +} + +fn parse_transaction(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_SIGNED_TRANSACTION_BYTES { + return Err(ReconciliationError::MalformedIntent( + "signed transaction bytes are outside the journal bound".to_string(), + )); + } + serde_json::from_slice(bytes) + .map_err(|error| ReconciliationError::MalformedIntent(error.to_string())) +} + +fn transaction_output( + transaction: &Transaction, + output_index: u16, +) -> Result { + transaction + .outputs + .get(output_index as usize) + .ok_or(ReconciliationError::SuccessorMismatch) + .and_then(ProtocolBox::from_ergo_box) +} + +fn unique_tracker_successor( + transaction: &Transaction, + predecessor: &ProtocolBox, + protocol_nft_id: &str, +) -> Result<(u16, ProtocolBox), ReconciliationError> { + let candidates = transaction + .outputs + .iter() + .enumerate() + .filter_map(|(index, output)| { + let candidate = ProtocolBox::from_ergo_box(output).ok()?; + (candidate.ergo_tree == predecessor.ergo_tree + && candidate + .assets + .first() + .is_some_and(|asset| asset.token_id == protocol_nft_id && asset.amount == 1)) + .then_some((index, candidate)) + }) + .collect::>(); + if candidates.len() != 1 { + return Err(ReconciliationError::SuccessorMismatch); + } + let (index, successor) = candidates + .into_iter() + .next() + .ok_or(ReconciliationError::SuccessorMismatch)?; + Ok(( + u16::try_from(index).map_err(|_| ReconciliationError::SuccessorMismatch)?, + successor, + )) +} + +fn validate_tracker_avl_register(bytes: &[u8]) -> Result<&[u8], ReconciliationError> { + if bytes.len() != 37 + || bytes[0] != 0x64 + || bytes[34] != 0x03 + || bytes[35] != 0x20 + || bytes[36] != 0x00 + { + return Err(ReconciliationError::RootMismatch); + } + Ok(&bytes[1..34]) +} + +fn ensure_singleton_lineage( + transaction: &Transaction, + predecessor: &ProtocolBox, + successor: &ProtocolBox, + token_id: &str, + token_index: u16, + successor_output_index: u16, +) -> Result<(), ReconciliationError> { + if predecessor.singleton_index(token_id)? != token_index + || successor.singleton_index(token_id)? != token_index + { + return Err(ReconciliationError::SingletonMismatch); + } + let carrying_outputs = transaction + .outputs + .iter() + .enumerate() + .filter(|(_, output)| { + output.tokens.as_ref().is_some_and(|tokens| { + tokens + .iter() + .any(|token| String::from(token.token_id) == token_id) + }) + }) + .map(|(index, _)| index) + .collect::>(); + if carrying_outputs != vec![successor_output_index as usize] { + return Err(ReconciliationError::SingletonMismatch); + } + Ok(()) +} + +/// A header whose id was recomputed from its canonical Scorex bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CanonicalHeader { + bytes: Vec, + id: String, + parent_id: String, + height: u64, +} + +impl CanonicalHeader { + fn from_header(header: &Header) -> Result { + let mut json = serde_json::to_value(header) + .map_err(|error| ReconciliationError::MalformedChainProof(error.to_string()))?; + // sigma-rust serializes absent Autolykos-v1-only fields as null, while + // its own v2 deserializer accepts them only when omitted. + if header.version > 1 { + if let Some(solution) = json + .get_mut("powSolutions") + .and_then(serde_json::Value::as_object_mut) + { + solution.remove("w"); + solution.remove("d"); + } + } + let bytes = serde_json::to_vec(&json) + .map_err(|error| ReconciliationError::MalformedChainProof(error.to_string()))?; + let derived = derived_header_id(header)?; + if derived != header.id.to_string() { + return Err(ReconciliationError::HeaderIdMismatch); + } + Ok(Self { + bytes, + id: derived, + parent_id: header.parent_id.to_string(), + height: header.height as u64, + }) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + let header: Header = serde_json::from_slice(&self.bytes) + .map_err(|error| ReconciliationError::MalformedChainProof(error.to_string()))?; + let rebuilt = Self::from_header(&header)?; + if rebuilt != *self { + return Err(ReconciliationError::HeaderIdMismatch); + } + Ok(()) + } +} + +fn derived_header_id(header: &Header) -> Result { + let mut bytes = header + .serialize_without_pow() + .map_err(|error| ReconciliationError::MalformedChainProof(error.to_string()))?; + header + .autolykos_solution + .serialize_bytes(header.version, &mut bytes) + .map_err(|error| ReconciliationError::MalformedChainProof(error.to_string()))?; + Ok(BlockId(header_hash(&bytes)).to_string()) +} + +/// Coherent selected-chain path returned between two identical node tip +/// snapshots. Every header id is recomputed and every parent link is checked. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActiveChainProof { + inclusion_height: u64, + selected_through_height: u64, + tip_id: String, + tip_height: u64, + observed_at_unix_ms: u64, + headers: Vec, +} + +impl ActiveChainProof { + /// Seal raw `/blocks/chainSlice` JSON with the `/info` tip observed before + /// and after the request. `toHeight` must have been `tip + 1`. + #[allow(clippy::too_many_arguments)] + pub fn from_node_responses( + before_tip_id: impl Into, + before_tip_height: u64, + after_tip_id: impl Into, + after_tip_height: u64, + inclusion_height: u64, + chain_slice_json: &[u8], + observed_at_unix_ms: u64, + ) -> Result { + Self::from_bounded_node_responses( + before_tip_id, + before_tip_height, + after_tip_id, + after_tip_height, + inclusion_height, + before_tip_height, + chain_slice_json, + observed_at_unix_ms, + ) + } + + /// Seal a bounded historical window returned by `/blocks/chainSlice` + /// between two identical node-tip observations. This is used only to make + /// the explicit reorg-monitoring horizon decision; transaction acceptance + /// and ordinary rollback still require a path through the current tip. + #[allow(clippy::too_many_arguments)] + pub fn from_bounded_node_responses( + before_tip_id: impl Into, + before_tip_height: u64, + after_tip_id: impl Into, + after_tip_height: u64, + inclusion_height: u64, + selected_through_height: u64, + chain_slice_json: &[u8], + observed_at_unix_ms: u64, + ) -> Result { + let before_tip_id = normalize_id(before_tip_id.into(), "before tip id")?; + let after_tip_id = normalize_id(after_tip_id.into(), "after tip id")?; + if before_tip_id != after_tip_id || before_tip_height != after_tip_height { + return Err(ReconciliationError::IncoherentSnapshot); + } + let parsed: Vec
= serde_json::from_slice(chain_slice_json) + .map_err(|error| ReconciliationError::MalformedChainProof(error.to_string()))?; + let headers = parsed + .iter() + .map(CanonicalHeader::from_header) + .collect::, _>>()?; + let proof = Self { + inclusion_height, + selected_through_height, + tip_id: before_tip_id, + tip_height: before_tip_height, + observed_at_unix_ms, + headers, + }; + proof.validate()?; + Ok(proof) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + if self.headers.is_empty() || self.headers.len() > MAX_CHAIN_HEADERS { + return Err(ReconciliationError::MalformedChainProof( + "chain segment length is outside the bound".to_string(), + )); + } + normalize_id(self.tip_id.clone(), "tip id")?; + if self.selected_through_height > self.tip_height { + return Err(ReconciliationError::DepthMismatch); + } + let expected_len = self + .selected_through_height + .checked_sub(self.inclusion_height) + .and_then(|depth| depth.checked_add(1)) + .and_then(|length| usize::try_from(length).ok()) + .ok_or(ReconciliationError::DepthMismatch)?; + if self.headers.len() != expected_len { + return Err(ReconciliationError::DepthMismatch); + } + for (offset, header) in self.headers.iter().enumerate() { + header.validate()?; + if header.height != self.inclusion_height + offset as u64 { + return Err(ReconciliationError::DepthMismatch); + } + if offset > 0 && header.parent_id != self.headers[offset - 1].id { + return Err(ReconciliationError::AncestryMismatch); + } + } + let tip = self.headers.last().ok_or_else(|| { + ReconciliationError::MalformedChainProof("empty chain segment".to_string()) + })?; + if tip.height != self.selected_through_height { + return Err(ReconciliationError::DepthMismatch); + } + if self.selected_through_height == self.tip_height && tip.id != self.tip_id { + return Err(ReconciliationError::IncoherentSnapshot); + } + Ok(()) + } + + pub fn inclusion_height(&self) -> u64 { + self.inclusion_height + } + + pub fn first_block_id(&self) -> &str { + &self.headers[0].id + } + + pub fn tip_id(&self) -> &str { + &self.tip_id + } + + pub fn tip_height(&self) -> u64 { + self.tip_height + } + + pub fn selected_through_height(&self) -> u64 { + self.selected_through_height + } + + pub fn covers_tip(&self) -> bool { + self.selected_through_height == self.tip_height + } + + pub fn successor_depth(&self) -> u64 { + self.tip_height - self.inclusion_height + } + + pub fn observed_at_unix_ms(&self) -> u64 { + self.observed_at_unix_ms + } +} + +/// Exact node transaction plus canonical predecessor boxes and its coherent +/// selected-chain proof. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransactionChainEvidence { + transaction_json: Vec, + full_block_json: Vec, + tx_id: String, + reported_block_id: String, + reported_inclusion_height: u64, + predecessor_boxes: Vec, + chain: ActiveChainProof, +} + +impl TransactionChainEvidence { + pub fn from_node_snapshot( + transaction_json: Vec, + full_block_json: Vec, + reported_block_id: impl Into, + reported_inclusion_height: u64, + predecessor_box_json: Vec>, + chain: ActiveChainProof, + ) -> Result { + let transaction = parse_transaction(&transaction_json)?; + let predecessor_boxes = predecessor_box_json + .iter() + .map(|bytes| ProtocolBox::from_json_bytes(bytes)) + .collect::, _>>()?; + let evidence = Self { + transaction_json, + full_block_json, + tx_id: transaction.id().to_string(), + reported_block_id: normalize_id(reported_block_id.into(), "transaction block id")?, + reported_inclusion_height, + predecessor_boxes, + chain, + }; + evidence.validate()?; + Ok(evidence) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + self.chain.validate()?; + let transaction = parse_transaction(&self.transaction_json)?; + if transaction.id().to_string() != self.tx_id { + return Err(ReconciliationError::TransactionMismatch); + } + if self.reported_block_id != self.chain.first_block_id() + || self.reported_inclusion_height != self.chain.inclusion_height() + { + return Err(ReconciliationError::InactiveBlock); + } + validate_full_block_inclusion( + &self.full_block_json, + &transaction, + self.chain.first_block_id(), + self.chain.inclusion_height(), + )?; + let mut ids = BTreeSet::new(); + for predecessor in &self.predecessor_boxes { + predecessor.validate()?; + if !ids.insert(predecessor.box_id.clone()) + || transaction + .inputs + .iter() + .filter(|input| input.box_id.to_string() == predecessor.box_id) + .count() + != 1 + { + return Err(ReconciliationError::LineageMismatch); + } + } + Ok(()) + } + + pub fn tx_id(&self) -> &str { + &self.tx_id + } + + pub fn block_id(&self) -> &str { + &self.reported_block_id + } + + pub fn inclusion_height(&self) -> u64 { + self.reported_inclusion_height + } + + pub fn chain(&self) -> &ActiveChainProof { + &self.chain + } +} + +fn validate_full_block_inclusion( + full_block_json: &[u8], + expected_transaction: &Transaction, + expected_block_id: &str, + expected_height: u64, +) -> Result<(), ReconciliationError> { + let block: FullBlock = serde_json::from_slice(full_block_json) + .map_err(|error| ReconciliationError::MalformedBlock(error.to_string()))?; + let canonical_header = CanonicalHeader::from_header(&block.header)?; + if canonical_header.id != expected_block_id || canonical_header.height != expected_height { + return Err(ReconciliationError::InactiveBlock); + } + let transactions = block.block_transactions.transactions.as_vec(); + if transactions + .iter() + .filter(|transaction| *transaction == expected_transaction) + .count() + != 1 + { + return Err(ReconciliationError::TransactionNotInBlock); + } + if transaction_merkle_root(transactions, block.header.version)? != block.header.transaction_root + { + return Err(ReconciliationError::TransactionRootMismatch); + } + Ok(()) +} + +/// Exact Ergo node `BlockTransactions.transactionsRoot` construction. +/// Version 1 commits to transaction serialized ids only. Later block versions +/// append a second, grouped run of witness serialized ids after every +/// transaction id. A witness id is `Blake2b256(concat(input proofs)).tail`, so +/// it is a 31-byte Merkle leaf; it is never raw, full-width, interleaved, or +/// appended to its transaction-id leaf. +fn transaction_merkle_root( + transactions: &[Transaction], + block_version: u8, +) -> Result { + let mut leaves = Vec::with_capacity(if block_version == 1 { + transactions.len() + } else { + transactions.len().saturating_mul(2) + }); + for transaction in transactions { + let unsigned_bytes = transaction.bytes_to_sign().map_err(|error| { + ReconciliationError::MalformedBlock(format!( + "cannot serialize transaction bytes-to-sign: {error}" + )) + })?; + let transaction_id = blake2b256_hash(&unsigned_bytes); + if transaction.id().as_ref() != transaction_id.as_slice() { + return Err(ReconciliationError::TransactionMismatch); + } + leaves.push(MerkleNode::from_bytes(transaction_id.to_vec())); + } + if block_version != 1 { + for transaction in transactions { + let proof_bytes = transaction + .inputs + .iter() + .flat_map(|input| input.spending_proof.proof.as_ref().iter().copied()) + .collect::>(); + let witness_hash = blake2b256_hash(&proof_bytes); + leaves.push(MerkleNode::from_bytes(witness_hash[1..].to_vec())); + } + } + Ok(MerkleTree::new(leaves).root_hash_special()) +} + +/// Application finality policy. Depth counts successors, so the tip block has +/// depth zero. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReconciliationPolicy { + policy_id: String, + policy_version: u16, + min_successor_depth: u64, + max_evidence_age_ms: u64, + reorg_monitor_depth: u64, + network_id: String, + source_id: [u8; 32], +} + +impl ReconciliationPolicy { + pub fn new( + min_successor_depth: u64, + max_evidence_age_ms: u64, + reorg_monitor_depth: u64, + network_id: impl Into, + source_id: [u8; 32], + ) -> Self { + Self { + policy_id: FINALITY_POLICY_ID.to_string(), + policy_version: FINALITY_POLICY_VERSION, + min_successor_depth, + max_evidence_age_ms, + reorg_monitor_depth, + network_id: network_id.into(), + source_id, + } + } + + /// Bind policy decisions to one named network and one configured node + /// endpoint without persisting the endpoint itself. + pub fn source_id_for(network_id: &str, node_url: &str) -> [u8; 32] { + let endpoint = node_url.trim_end_matches('/'); + let mut material = + Vec::with_capacity(NODE_SOURCE_DOMAIN.len() + network_id.len() + endpoint.len() + 16); + material.extend_from_slice(NODE_SOURCE_DOMAIN); + material.extend_from_slice(&(network_id.len() as u64).to_be_bytes()); + material.extend_from_slice(network_id.as_bytes()); + material.extend_from_slice(&(endpoint.len() as u64).to_be_bytes()); + material.extend_from_slice(endpoint.as_bytes()); + blake2b256_hash(&material) + } + + pub fn min_successor_depth(&self) -> u64 { + self.min_successor_depth + } + + pub fn reorg_monitor_depth(&self) -> u64 { + self.reorg_monitor_depth + } + + fn validate(&self) -> Result<(), ReconciliationError> { + if self.policy_id != FINALITY_POLICY_ID + || self.policy_version != FINALITY_POLICY_VERSION + || self.network_id.is_empty() + || self.network_id.len() > 64 + || !self + .network_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + || self.source_id == [0u8; 32] + || self.max_evidence_age_ms == 0 + || self.reorg_monitor_depth == 0 + || self.reorg_monitor_depth < self.min_successor_depth + || self.reorg_monitor_depth > MAX_REORG_MONITOR_DEPTH + { + return Err(ReconciliationError::InvalidPolicy); + } + Ok(()) + } + + fn validate_freshness( + &self, + observed_at_unix_ms: u64, + now_unix_ms: u64, + ) -> Result<(), ReconciliationError> { + self.validate()?; + if now_unix_ms + .checked_sub(observed_at_unix_ms) + .is_none_or(|age| age > self.max_evidence_age_ms) + { + return Err(ReconciliationError::StaleEvidence); + } + Ok(()) + } +} + +/// Effect sealed by signed bytes, exact boxes, active-chain ancestry, coherent +/// tip and policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ValidatedChainEffect { + intent_id: String, + tx_id: String, + block_id: String, + inclusion_height: u64, + successor_depth: u64, + tip_id: String, + tip_height: u64, + successor_box_id: String, + observed_at_unix_ms: u64, + effect: ReconciliationEffect, + policy: ReconciliationPolicy, + evidence_hash: [u8; 32], + decision_id: [u8; 32], +} + +#[derive(Deserialize)] +struct StoredValidatedChainEffect { + intent_id: String, + tx_id: String, + block_id: String, + inclusion_height: u64, + successor_depth: u64, + tip_id: String, + tip_height: u64, + successor_box_id: String, + observed_at_unix_ms: u64, + effect: ReconciliationEffect, + policy: ReconciliationPolicy, + evidence_hash: [u8; 32], + decision_id: [u8; 32], +} + +impl<'de> Deserialize<'de> for ValidatedChainEffect { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + if !JournalDeserializationGuard::is_active() { + return Err(serde::de::Error::custom( + "validated chain tickets are journal-private", + )); + } + let stored = StoredValidatedChainEffect::deserialize(deserializer)?; + Ok(Self { + intent_id: stored.intent_id, + tx_id: stored.tx_id, + block_id: stored.block_id, + inclusion_height: stored.inclusion_height, + successor_depth: stored.successor_depth, + tip_id: stored.tip_id, + tip_height: stored.tip_height, + successor_box_id: stored.successor_box_id, + observed_at_unix_ms: stored.observed_at_unix_ms, + effect: stored.effect, + policy: stored.policy, + evidence_hash: stored.evidence_hash, + decision_id: stored.decision_id, + }) + } +} + +impl ValidatedChainEffect { + fn compute_decision_id(&self) -> Result<[u8; 32], ReconciliationError> { + hash_serialized( + EFFECT_DECISION_DOMAIN, + &( + &self.intent_id, + &self.tx_id, + &self.block_id, + self.inclusion_height, + self.successor_depth, + &self.tip_id, + self.tip_height, + &self.successor_box_id, + self.observed_at_unix_ms, + &self.effect, + &self.policy, + self.evidence_hash, + ), + ) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + self.policy.validate()?; + if normalize_id(self.intent_id.clone(), "effect intent id")? != self.intent_id + || normalize_id(self.tx_id.clone(), "effect transaction id")? != self.tx_id + || normalize_id(self.block_id.clone(), "effect block id")? != self.block_id + || normalize_id(self.tip_id.clone(), "effect tip id")? != self.tip_id + || normalize_id(self.successor_box_id.clone(), "effect successor box id")? + != self.successor_box_id + || self.evidence_hash == [0u8; 32] + || self.decision_id != self.compute_decision_id()? + || self.inclusion_height.checked_add(self.successor_depth) != Some(self.tip_height) + || self.successor_depth < self.policy.min_successor_depth + { + return Err(ReconciliationError::MalformedDecision( + "validated effect fields or decision digest are inconsistent".to_string(), + )); + } + match &self.effect { + ReconciliationEffect::TrackerPublication { + committed_root, + protocol_nft_id, + protocol_nft_index, + } => { + if committed_root.len() != 33 + || normalize_id(protocol_nft_id.clone(), "effect protocol NFT")? + != *protocol_nft_id + || *protocol_nft_index != 0 + { + return Err(ReconciliationError::MalformedDecision( + "validated tracker effect manifest is inconsistent".to_string(), + )); + } + } + } + Ok(()) + } + + fn validate_against_intent( + &self, + intent: &ReconciliationIntent, + ) -> Result<(), ReconciliationError> { + self.validate()?; + intent.validate()?; + if self.intent_id != intent.intent_id + || self.tx_id != intent.tx_id + || self.successor_box_id != intent.successor.box_id + || self.effect != intent.effect + { + return Err(ReconciliationError::IntentMismatch); + } + Ok(()) + } + + fn validate_runtime_policy( + &self, + policy: &ReconciliationPolicy, + ) -> Result<(), ReconciliationError> { + self.validate()?; + policy.validate()?; + if &self.policy != policy { + return Err(ReconciliationError::PolicyMismatch); + } + Ok(()) + } + + pub fn intent_id(&self) -> &str { + &self.intent_id + } + + pub fn tx_id(&self) -> &str { + &self.tx_id + } + + pub fn block_id(&self) -> &str { + &self.block_id + } + + pub fn inclusion_height(&self) -> u64 { + self.inclusion_height + } + + pub fn successor_depth(&self) -> u64 { + self.successor_depth + } + + pub fn successor_box_id(&self) -> &str { + &self.successor_box_id + } + + pub fn policy(&self) -> &ReconciliationPolicy { + &self.policy + } + + pub fn tracker_root(&self) -> Option<[u8; 33]> { + match &self.effect { + ReconciliationEffect::TrackerPublication { committed_root, .. } + if committed_root.len() == 33 => + { + let mut root = [0u8; 33]; + root.copy_from_slice(committed_root); + Some(root) + } + _ => None, + } + } + + pub fn protocol_nft_id(&self) -> Option<&str> { + match &self.effect { + ReconciliationEffect::TrackerPublication { + protocol_nft_id, .. + } => Some(protocol_nft_id), + } + } +} + +#[cfg(test)] +pub(crate) fn validated_tracker_effect_for_test( + intent_id: String, + tx_id: String, + block_id: String, + successor_box_id: String, + inclusion_height: u64, + successor_depth: u64, + root: [u8; 33], +) -> ValidatedChainEffect { + let mut effect = ValidatedChainEffect { + intent_id, + tx_id, + block_id, + inclusion_height, + successor_depth, + tip_id: "cc".repeat(32), + tip_height: inclusion_height + successor_depth, + successor_box_id, + observed_at_unix_ms: 1_000, + effect: ReconciliationEffect::TrackerPublication { + committed_root: root.to_vec(), + protocol_nft_id: "dd".repeat(32), + protocol_nft_index: 0, + }, + policy: ReconciliationPolicy::new(6, 100, 12, "ergo-test", [0xee; 32]), + evidence_hash: [0xef; 32], + decision_id: [0u8; 32], + }; + effect.decision_id = effect.compute_decision_id().unwrap(); + effect +} + +pub fn validate_chain_effect( + intent: &ReconciliationIntent, + evidence: &TransactionChainEvidence, + policy: ReconciliationPolicy, + now_unix_ms: u64, +) -> Result { + intent.validate()?; + evidence.validate()?; + if !evidence.chain.covers_tip() { + return Err(ReconciliationError::IncompleteAncestry); + } + policy.validate_freshness(evidence.chain.observed_at_unix_ms, now_unix_ms)?; + if evidence.tx_id != intent.tx_id { + return Err(ReconciliationError::TransactionMismatch); + } + let signed = parse_transaction(&intent.signed_transaction_json)?; + let observed = parse_transaction(&evidence.transaction_json)?; + if signed != observed { + return Err(ReconciliationError::TransactionMismatch); + } + if evidence + .predecessor_boxes + .iter() + .filter(|predecessor| predecessor.box_id == intent.predecessor.box_id) + .count() + != 1 + || !evidence + .predecessor_boxes + .iter() + .any(|predecessor| predecessor == &intent.predecessor) + { + return Err(ReconciliationError::LineageMismatch); + } + let observed_successor = transaction_output(&observed, intent.successor_output_index)?; + if observed_successor != intent.successor { + return Err(ReconciliationError::SuccessorMismatch); + } + let successor_depth = evidence.chain.successor_depth(); + if successor_depth < policy.min_successor_depth { + return Err(ReconciliationError::DepthTooShallow { + observed: successor_depth, + required: policy.min_successor_depth, + }); + } + let evidence_hash = hash_serialized(EVIDENCE_HASH_DOMAIN, evidence)?; + let mut effect = ValidatedChainEffect { + intent_id: intent.intent_id.clone(), + tx_id: intent.tx_id.clone(), + block_id: evidence.reported_block_id.clone(), + inclusion_height: evidence.reported_inclusion_height, + successor_depth, + tip_id: evidence.chain.tip_id.clone(), + tip_height: evidence.chain.tip_height, + successor_box_id: intent.successor.box_id.clone(), + observed_at_unix_ms: evidence.chain.observed_at_unix_ms, + effect: intent.effect.clone(), + policy, + evidence_hash, + decision_id: [0u8; 32], + }; + effect.decision_id = effect.compute_decision_id()?; + effect.validate_against_intent(intent)?; + Ok(effect) +} + +/// Evidence that an earlier accepted block is no longer the first block in a +/// fresh coherent selected-chain segment at the exact inclusion height. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ValidatedRollback { + intent_id: String, + tx_id: String, + removed_block_id: String, + replacement_block_id: String, + inclusion_height: u64, + observed_tip_id: String, + observed_tip_height: u64, + observed_at_unix_ms: u64, + policy: ReconciliationPolicy, + evidence_hash: [u8; 32], + decision_id: [u8; 32], +} + +#[derive(Deserialize)] +struct StoredValidatedRollback { + intent_id: String, + tx_id: String, + removed_block_id: String, + replacement_block_id: String, + inclusion_height: u64, + observed_tip_id: String, + observed_tip_height: u64, + observed_at_unix_ms: u64, + policy: ReconciliationPolicy, + evidence_hash: [u8; 32], + decision_id: [u8; 32], +} + +impl<'de> Deserialize<'de> for ValidatedRollback { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + if !JournalDeserializationGuard::is_active() { + return Err(serde::de::Error::custom( + "validated rollback tickets are journal-private", + )); + } + let stored = StoredValidatedRollback::deserialize(deserializer)?; + Ok(Self { + intent_id: stored.intent_id, + tx_id: stored.tx_id, + removed_block_id: stored.removed_block_id, + replacement_block_id: stored.replacement_block_id, + inclusion_height: stored.inclusion_height, + observed_tip_id: stored.observed_tip_id, + observed_tip_height: stored.observed_tip_height, + observed_at_unix_ms: stored.observed_at_unix_ms, + policy: stored.policy, + evidence_hash: stored.evidence_hash, + decision_id: stored.decision_id, + }) + } +} + +impl ValidatedRollback { + fn compute_decision_id(&self) -> Result<[u8; 32], ReconciliationError> { + hash_serialized( + ROLLBACK_DECISION_DOMAIN, + &( + &self.intent_id, + &self.tx_id, + &self.removed_block_id, + &self.replacement_block_id, + self.inclusion_height, + &self.observed_tip_id, + self.observed_tip_height, + self.observed_at_unix_ms, + &self.policy, + self.evidence_hash, + ), + ) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + self.policy.validate()?; + if normalize_id(self.intent_id.clone(), "rollback intent id")? != self.intent_id + || normalize_id(self.tx_id.clone(), "rollback transaction id")? != self.tx_id + || normalize_id(self.removed_block_id.clone(), "rollback removed block id")? + != self.removed_block_id + || normalize_id( + self.replacement_block_id.clone(), + "rollback replacement block id", + )? != self.replacement_block_id + || normalize_id(self.observed_tip_id.clone(), "rollback observed tip id")? + != self.observed_tip_id + || self.removed_block_id == self.replacement_block_id + || self.observed_tip_height < self.inclusion_height + || self.evidence_hash == [0u8; 32] + || self.decision_id != self.compute_decision_id()? + { + return Err(ReconciliationError::MalformedDecision( + "validated rollback fields or decision digest are inconsistent".to_string(), + )); + } + Ok(()) + } + + fn validate_against_effect( + &self, + effect: &ValidatedChainEffect, + ) -> Result<(), ReconciliationError> { + self.validate()?; + effect.validate()?; + if self.intent_id != effect.intent_id + || self.tx_id != effect.tx_id + || self.removed_block_id != effect.block_id + || self.inclusion_height != effect.inclusion_height + || self.policy != effect.policy + { + return Err(ReconciliationError::IntentMismatch); + } + Ok(()) + } + + pub fn intent_id(&self) -> &str { + &self.intent_id + } + + pub fn tx_id(&self) -> &str { + &self.tx_id + } + + pub fn removed_block_id(&self) -> &str { + &self.removed_block_id + } +} + +#[cfg(test)] +pub(crate) fn validated_rollback_for_test(effect: &ValidatedChainEffect) -> ValidatedRollback { + let mut rollback = ValidatedRollback { + intent_id: effect.intent_id.clone(), + tx_id: effect.tx_id.clone(), + removed_block_id: effect.block_id.clone(), + replacement_block_id: "ee".repeat(32), + inclusion_height: effect.inclusion_height, + observed_tip_id: "ff".repeat(32), + observed_tip_height: effect.tip_height + 1, + observed_at_unix_ms: 1_001, + policy: effect.policy.clone(), + evidence_hash: [0xfa; 32], + decision_id: [0u8; 32], + }; + rollback.decision_id = rollback.compute_decision_id().unwrap(); + rollback +} + +pub fn validate_rollback( + accepted: &ValidatedChainEffect, + selected_chain: &ActiveChainProof, + policy: &ReconciliationPolicy, + now_unix_ms: u64, +) -> Result { + accepted.validate_runtime_policy(policy)?; + selected_chain.validate()?; + if !selected_chain.covers_tip() { + return Err(ReconciliationError::IncompleteAncestry); + } + policy.validate_freshness(selected_chain.observed_at_unix_ms, now_unix_ms)?; + if selected_chain.inclusion_height != accepted.inclusion_height { + return Err(ReconciliationError::RollbackNotProven); + } + let replacement = selected_chain.first_block_id(); + if replacement == accepted.block_id { + return Err(ReconciliationError::RollbackNotProven); + } + let evidence_hash = hash_serialized(EVIDENCE_HASH_DOMAIN, selected_chain)?; + let mut rollback = ValidatedRollback { + intent_id: accepted.intent_id.clone(), + tx_id: accepted.tx_id.clone(), + removed_block_id: accepted.block_id.clone(), + replacement_block_id: replacement.to_string(), + inclusion_height: accepted.inclusion_height, + observed_tip_id: selected_chain.tip_id.clone(), + observed_tip_height: selected_chain.tip_height, + observed_at_unix_ms: selected_chain.observed_at_unix_ms, + policy: policy.clone(), + evidence_hash, + decision_id: [0u8; 32], + }; + rollback.decision_id = rollback.compute_decision_id()?; + rollback.validate_against_effect(accepted)?; + Ok(rollback) +} + +/// Validate an accepted anchor against the same coherent-chain authority used +/// for rollback detection. +pub fn validate_anchor_still_active( + accepted: &ValidatedChainEffect, + selected_chain: &ActiveChainProof, + policy: &ReconciliationPolicy, + now_unix_ms: u64, +) -> Result<(), ReconciliationError> { + accepted.validate_runtime_policy(policy)?; + selected_chain.validate()?; + if !selected_chain.covers_tip() { + return Err(ReconciliationError::IncompleteAncestry); + } + policy.validate_freshness(selected_chain.observed_at_unix_ms, now_unix_ms)?; + if selected_chain.inclusion_height != accepted.inclusion_height + || selected_chain.first_block_id() != accepted.block_id + { + return Err(ReconciliationError::InactiveBlock); + } + Ok(()) +} + +/// Private authorization to stop polling an accepted anchor after it has +/// remained on the selected chain for the complete configured reorg horizon. +/// Generic serde callers cannot mint this ticket. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ValidatedRetirement { + intent_id: String, + tx_id: String, + block_id: String, + inclusion_height: u64, + monitor_depth: u64, + observed_tip_id: String, + observed_tip_height: u64, + observed_at_unix_ms: u64, + policy: ReconciliationPolicy, + evidence_hash: [u8; 32], + decision_id: [u8; 32], +} + +#[derive(Deserialize)] +struct StoredValidatedRetirement { + intent_id: String, + tx_id: String, + block_id: String, + inclusion_height: u64, + monitor_depth: u64, + observed_tip_id: String, + observed_tip_height: u64, + observed_at_unix_ms: u64, + policy: ReconciliationPolicy, + evidence_hash: [u8; 32], + decision_id: [u8; 32], +} + +impl<'de> Deserialize<'de> for ValidatedRetirement { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + if !JournalDeserializationGuard::is_active() { + return Err(serde::de::Error::custom( + "validated retirement tickets are journal-private", + )); + } + let stored = StoredValidatedRetirement::deserialize(deserializer)?; + Ok(Self { + intent_id: stored.intent_id, + tx_id: stored.tx_id, + block_id: stored.block_id, + inclusion_height: stored.inclusion_height, + monitor_depth: stored.monitor_depth, + observed_tip_id: stored.observed_tip_id, + observed_tip_height: stored.observed_tip_height, + observed_at_unix_ms: stored.observed_at_unix_ms, + policy: stored.policy, + evidence_hash: stored.evidence_hash, + decision_id: stored.decision_id, + }) + } +} + +impl ValidatedRetirement { + fn compute_decision_id(&self) -> Result<[u8; 32], ReconciliationError> { + hash_serialized( + RETIREMENT_DECISION_DOMAIN, + &( + &self.intent_id, + &self.tx_id, + &self.block_id, + self.inclusion_height, + self.monitor_depth, + &self.observed_tip_id, + self.observed_tip_height, + self.observed_at_unix_ms, + &self.policy, + self.evidence_hash, + ), + ) + } + + fn validate(&self) -> Result<(), ReconciliationError> { + self.policy.validate()?; + if normalize_id(self.intent_id.clone(), "retirement intent id")? != self.intent_id + || normalize_id(self.tx_id.clone(), "retirement transaction id")? != self.tx_id + || normalize_id(self.block_id.clone(), "retirement block id")? != self.block_id + || normalize_id(self.observed_tip_id.clone(), "retirement observed tip id")? + != self.observed_tip_id + || self.monitor_depth != self.policy.reorg_monitor_depth + || self.observed_tip_height + < self + .inclusion_height + .checked_add(self.monitor_depth) + .ok_or(ReconciliationError::DepthMismatch)? + || self.evidence_hash == [0u8; 32] + || self.decision_id != self.compute_decision_id()? + { + return Err(ReconciliationError::MalformedDecision( + "validated retirement fields or decision digest are inconsistent".to_string(), + )); + } + Ok(()) + } + + fn validate_against_effect( + &self, + effect: &ValidatedChainEffect, + ) -> Result<(), ReconciliationError> { + self.validate()?; + effect.validate()?; + if self.intent_id != effect.intent_id + || self.tx_id != effect.tx_id + || self.block_id != effect.block_id + || self.inclusion_height != effect.inclusion_height + || self.policy != effect.policy + { + return Err(ReconciliationError::IntentMismatch); + } + Ok(()) + } +} + +/// Result of checking the bounded selected-chain window at the configured +/// monitoring horizon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReorgHorizonDecision { + Retire(ValidatedRetirement), + Rollback(ValidatedRollback), +} + +/// Decide an old anchor using an inclusive, bounded selected-chain window. +/// +/// This is deliberately distinct from acceptance: it cannot confirm a new +/// transaction. The window must cover exactly `inclusion..=inclusion+horizon` +/// and must have been returned between two identical node-tip observations. +/// A matching first header authorizes durable retirement; a different first +/// header authorizes rollback before any newer ticket is processed. +pub fn validate_reorg_horizon( + accepted: &ValidatedChainEffect, + selected_window: &ActiveChainProof, + policy: &ReconciliationPolicy, + now_unix_ms: u64, +) -> Result { + accepted.validate_runtime_policy(policy)?; + selected_window.validate()?; + policy.validate_freshness(selected_window.observed_at_unix_ms, now_unix_ms)?; + if selected_window.inclusion_height != accepted.inclusion_height { + return Err(ReconciliationError::RollbackNotProven); + } + let expected_through = accepted + .inclusion_height + .checked_add(policy.reorg_monitor_depth) + .ok_or(ReconciliationError::DepthMismatch)?; + if selected_window.selected_through_height != expected_through + || selected_window.tip_height < expected_through + { + return Err(ReconciliationError::IncompleteAncestry); + } + if selected_window.first_block_id() != accepted.block_id { + let evidence_hash = hash_serialized(EVIDENCE_HASH_DOMAIN, selected_window)?; + let mut rollback = ValidatedRollback { + intent_id: accepted.intent_id.clone(), + tx_id: accepted.tx_id.clone(), + removed_block_id: accepted.block_id.clone(), + replacement_block_id: selected_window.first_block_id().to_string(), + inclusion_height: accepted.inclusion_height, + observed_tip_id: selected_window.tip_id.clone(), + observed_tip_height: selected_window.tip_height, + observed_at_unix_ms: selected_window.observed_at_unix_ms, + policy: policy.clone(), + evidence_hash, + decision_id: [0u8; 32], + }; + rollback.decision_id = rollback.compute_decision_id()?; + rollback.validate_against_effect(accepted)?; + return Ok(ReorgHorizonDecision::Rollback(rollback)); + } + let evidence_hash = hash_serialized(EVIDENCE_HASH_DOMAIN, selected_window)?; + let mut retirement = ValidatedRetirement { + intent_id: accepted.intent_id.clone(), + tx_id: accepted.tx_id.clone(), + block_id: accepted.block_id.clone(), + inclusion_height: accepted.inclusion_height, + monitor_depth: policy.reorg_monitor_depth, + observed_tip_id: selected_window.tip_id.clone(), + observed_tip_height: selected_window.tip_height, + observed_at_unix_ms: selected_window.observed_at_unix_ms, + policy: policy.clone(), + evidence_hash, + decision_id: [0u8; 32], + }; + retirement.decision_id = retirement.compute_decision_id()?; + retirement.validate_against_effect(accepted)?; + Ok(ReorgHorizonDecision::Retire(retirement)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum PendingPhase { + Prepared, + SubmissionArmed, + AcceptanceReady, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PendingTicket { + intent: ReconciliationIntent, + phase: PendingPhase, + accepted_effect: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct AcceptedAnchor { + intent: ReconciliationIntent, + effect: ValidatedChainEffect, + rollback: Option, + applied: bool, + #[serde(default)] + retirement: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct JournalEvent { + sequence: u64, + event_id: String, + kind: String, + tx_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +struct JournalState { + sequence: u64, + pending: Option, + accepted: Option, + history: Vec, +} + +/// Recovery action chosen exclusively from durable journal state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecoveryAction { + Idle, + SubmitPrepared(ReconciliationIntent), + QueryExactTransaction(ReconciliationIntent), + ApplyAccepted(ValidatedChainEffect), + RevalidateAccepted(ValidatedChainEffect), + RestoreRetired(ValidatedChainEffect), + ApplyRollback(ValidatedRollback), +} + +/// Single-writer durable journal for publication and settlement tickets. +pub struct ReconciliationJournal { + keyspace: Keyspace, + partition: fjall::Partition, + binding: ReconciliationJournalBinding, + write_lock: Mutex<()>, + _writer_file_lock: File, +} + +impl ReconciliationJournal { + pub fn open( + path: impl AsRef, + binding: ReconciliationJournalBinding, + bootstrap: JournalBootstrap, + ) -> Result { + let path = path.as_ref(); + ensure_journal_manifest(path, &binding, bootstrap)?; + let lock_path = path.join(".confirmed-chain-writer.lock"); + let writer_file_lock = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + writer_file_lock.try_lock_exclusive().map_err(|error| { + ReconciliationError::Journal(format!( + "confirmed-chain journal already has an active writer ({}): {}", + lock_path.display(), + error + )) + })?; + let keyspace = Config::new(path) + .open() + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + let partition = keyspace + .open_partition("confirmed_chain", PartitionCreateOptions::default()) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + let journal = Self { + keyspace, + partition, + binding, + write_lock: Mutex::new(()), + _writer_file_lock: writer_file_lock, + }; + journal.read_state()?; + Ok(journal) + } + + pub fn recovery_action(&self) -> Result { + let state = self.read_state()?; + Self::recovery_action_from_state(&state) + } + + fn recovery_action_from_state( + state: &JournalState, + ) -> Result { + // A validated reorg must demote the old accounting projection before + // any newer signed ticket is submitted or queried. The pending ticket + // remains durable and resumes after `mark_rollback_applied`. + if let Some(rollback) = state + .accepted + .as_ref() + .and_then(|anchor| anchor.rollback.clone()) + { + return Ok(RecoveryAction::ApplyRollback(rollback)); + } + if let Some(pending) = &state.pending { + return Ok(match (pending.phase, pending.accepted_effect.clone()) { + (PendingPhase::Prepared, _) => { + RecoveryAction::SubmitPrepared(pending.intent.clone()) + } + (PendingPhase::SubmissionArmed, _) => { + RecoveryAction::QueryExactTransaction(pending.intent.clone()) + } + (PendingPhase::AcceptanceReady, Some(effect)) => { + RecoveryAction::ApplyAccepted(effect) + } + (PendingPhase::AcceptanceReady, None) => { + return Err(ReconciliationError::Journal( + "acceptance-ready ticket has no validated effect".to_string(), + )); + } + }); + } + if let Some(anchor) = &state.accepted { + return Ok( + match ( + anchor.applied, + anchor.rollback.as_ref(), + anchor.retirement.as_ref(), + ) { + (_, Some(_), _) => { + return Err(ReconciliationError::Journal( + "rollback priority invariant was bypassed".to_string(), + )); + } + (false, None, _) => RecoveryAction::ApplyAccepted(anchor.effect.clone()), + (true, None, Some(_)) => RecoveryAction::RestoreRetired(anchor.effect.clone()), + (true, None, None) => RecoveryAction::RevalidateAccepted(anchor.effect.clone()), + }, + ); + } + Ok(RecoveryAction::Idle) + } + + /// Validate the complete crash-recovery join between BNS1 accounting + /// state, its exact BPA1 in-flight receipt, and this sealed journal before + /// any node request is permitted. + pub fn validate_tracker_startup_join( + &self, + restored_pending: Option<&(String, [u8; 33])>, + historical_confirmation: Option<&ConfirmedProjectionAnchor>, + confirmation_history_present: bool, + runtime_policy: &ReconciliationPolicy, + ) -> Result<(), ReconciliationError> { + let state = self.read_state()?; + let action = Self::recovery_action_from_state(&state)?; + runtime_policy.validate()?; + let receipt_matches = |tx_id: &str, root: [u8; 33]| { + restored_pending.is_some_and(|(restored_tx_id, restored_root)| { + restored_tx_id.eq_ignore_ascii_case(tx_id) && *restored_root == root + }) + }; + let intent_receipt_matches = |intent: &ReconciliationIntent| { + intent + .tracker_root() + .is_some_and(|root| receipt_matches(intent.tx_id(), root)) + }; + let effect_receipt_matches = |effect: &ValidatedChainEffect| { + effect + .tracker_root() + .is_some_and(|root| receipt_matches(effect.tx_id(), root)) + }; + + match &action { + RecoveryAction::SubmitPrepared(intent) + | RecoveryAction::QueryExactTransaction(intent) => { + if !intent_receipt_matches(intent) { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + } + RecoveryAction::ApplyAccepted(effect) => { + let receipt_is_exact = effect_receipt_matches(effect); + if restored_pending.is_some() && !receipt_is_exact { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + let projection_is_already_applied = historical_confirmation + .is_some_and(|anchor| anchor.matches_validated_effect(effect)); + if !receipt_is_exact && !projection_is_already_applied { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + } + RecoveryAction::ApplyRollback(_) => match &state.pending { + Some(pending) if !intent_receipt_matches(&pending.intent) => { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + None if restored_pending.is_some() => { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + _ => {} + }, + RecoveryAction::RevalidateAccepted(_) + | RecoveryAction::RestoreRetired(_) + | RecoveryAction::Idle => { + if restored_pending.is_some() { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + } + } + + let mut candidates = Vec::with_capacity(2); + if let Some(accepted) = &state.accepted { + accepted.effect.validate_runtime_policy(runtime_policy)?; + candidates.push(&accepted.effect); + } + if let Some(effect) = state.pending.as_ref().and_then(|pending| { + (pending.phase == PendingPhase::AcceptanceReady) + .then_some(pending.accepted_effect.as_ref()) + .flatten() + }) { + effect.validate_runtime_policy(runtime_policy)?; + if !candidates.contains(&effect) { + candidates.push(effect); + } + } + if historical_confirmation.is_some() && !confirmation_history_present { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + if let Some(anchor) = historical_confirmation { + if !candidates + .iter() + .any(|effect| anchor.matches_validated_effect(effect)) + { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + } else { + let accepted_was_applied = state + .accepted + .as_ref() + .is_some_and(|accepted| accepted.applied); + let safe_absence = matches!(&action, RecoveryAction::ApplyRollback(_)) + || matches!(&action, RecoveryAction::ApplyAccepted(effect) if effect_receipt_matches(effect)); + if (accepted_was_applied || confirmation_history_present) && !safe_absence { + return Err(ReconciliationError::AccountingProjectionMismatch); + } + } + Ok(()) + } + + pub fn pending_intent(&self) -> Result, ReconciliationError> { + Ok(self.read_state()?.pending.map(|ticket| ticket.intent)) + } + + pub fn accepted_effect(&self) -> Result, ReconciliationError> { + Ok(self + .read_state()? + .accepted + .filter(|anchor| anchor.retirement.is_none()) + .map(|anchor| anchor.effect)) + } + + /// Effects which may legitimately describe the actor's persisted + /// accounting projection at startup. A crash may leave either the older + /// accepted anchor or the newer acceptance-ready effect applied locally. + pub fn accounting_effect_candidates( + &self, + ) -> Result, ReconciliationError> { + let state = self.read_state()?; + let mut candidates = Vec::with_capacity(2); + if let Some(accepted) = state.accepted { + candidates.push(accepted.effect); + } + if let Some(effect) = state.pending.and_then(|pending| { + (pending.phase == PendingPhase::AcceptanceReady) + .then_some(pending.accepted_effect) + .flatten() + }) { + if !candidates.contains(&effect) { + candidates.push(effect); + } + } + Ok(candidates) + } + + pub fn record_prepared(&self, intent: ReconciliationIntent) -> Result<(), ReconciliationError> { + intent.validate()?; + self.ensure_bound_nft(intent.protocol_nft_id())?; + self.mutate(|state| { + if let Some(existing) = &state.pending { + return if existing.intent == intent { + Ok(false) + } else if existing.intent.tx_id == intent.tx_id { + Err(ReconciliationError::DuplicateTransactionConflict) + } else { + Err(ReconciliationError::TicketInProgress) + }; + } + if state + .accepted + .as_ref() + .is_some_and(|accepted| accepted.effect.tx_id == intent.tx_id) + { + return Err(ReconciliationError::DuplicateTransactionConflict); + } + state.pending = Some(PendingTicket { + intent: intent.clone(), + phase: PendingPhase::Prepared, + accepted_effect: None, + }); + append_event(state, "prepared", intent.tx_id())?; + Ok(true) + }) + } + + /// Persist immediately before a request may cross the node boundary. + pub fn arm_submission(&self, intent_id: &str) -> Result<(), ReconciliationError> { + self.mutate(|state| { + let pending = state + .pending + .as_mut() + .ok_or(ReconciliationError::NoTicket)?; + if pending.intent.intent_id != intent_id { + return Err(ReconciliationError::IntentMismatch); + } + match pending.phase { + PendingPhase::Prepared => { + pending.phase = PendingPhase::SubmissionArmed; + let tx_id = pending.intent.tx_id.clone(); + append_event(state, "submission_armed", &tx_id)?; + Ok(true) + } + PendingPhase::SubmissionArmed => Ok(false), + PendingPhase::AcceptanceReady => Err(ReconciliationError::InvalidPhase), + } + }) + } + + pub fn record_validated_effect( + &self, + effect: ValidatedChainEffect, + ) -> Result<(), ReconciliationError> { + self.ensure_bound_nft(effect.protocol_nft_id())?; + self.mutate(|state| { + let pending = state + .pending + .as_mut() + .ok_or(ReconciliationError::NoTicket)?; + effect.validate_against_intent(&pending.intent)?; + if pending.phase == PendingPhase::AcceptanceReady { + return if pending.accepted_effect.as_ref() == Some(&effect) { + Ok(false) + } else { + Err(ReconciliationError::DuplicateTransactionConflict) + }; + } + if pending.phase != PendingPhase::SubmissionArmed { + return Err(ReconciliationError::InvalidPhase); + } + pending.phase = PendingPhase::AcceptanceReady; + pending.accepted_effect = Some(effect.clone()); + append_event(state, "policy_accepted", &effect.tx_id)?; + Ok(true) + }) + } + + pub fn mark_applied(&self, effect: &ValidatedChainEffect) -> Result<(), ReconciliationError> { + self.mutate(|state| { + if state.accepted.as_ref().is_some_and(|anchor| { + anchor.applied && anchor.effect == *effect && anchor.rollback.is_none() + }) && state.pending.is_none() + { + return Ok(false); + } + let pending = state + .pending + .as_ref() + .ok_or(ReconciliationError::NoTicket)?; + if pending.phase != PendingPhase::AcceptanceReady + || pending.accepted_effect.as_ref() != Some(effect) + { + return Err(ReconciliationError::InvalidPhase); + } + let accepted_intent = pending.intent.clone(); + state.accepted = Some(AcceptedAnchor { + intent: accepted_intent, + effect: effect.clone(), + rollback: None, + applied: true, + retirement: None, + }); + state.pending = None; + append_event(state, "applied", &effect.tx_id)?; + Ok(true) + }) + } + + pub fn record_rollback(&self, rollback: ValidatedRollback) -> Result<(), ReconciliationError> { + self.mutate(|state| { + let anchor = state + .accepted + .as_mut() + .ok_or(ReconciliationError::NoTicket)?; + rollback.validate_against_effect(&anchor.effect)?; + if anchor.retirement.is_some() { + return Err(ReconciliationError::InvalidPhase); + } + if anchor.effect.intent_id != rollback.intent_id + || anchor.effect.tx_id != rollback.tx_id + || anchor.effect.block_id != rollback.removed_block_id + { + return Err(ReconciliationError::IntentMismatch); + } + if anchor.rollback.as_ref() == Some(&rollback) { + return Ok(false); + } + if anchor.rollback.is_some() { + return Err(ReconciliationError::DuplicateTransactionConflict); + } + anchor.rollback = Some(rollback.clone()); + append_event(state, "rollback_detected", &rollback.tx_id)?; + Ok(true) + }) + } + + /// Stop active reorg monitoring only after a bounded selected-chain window + /// proved that this anchor survived the configured application horizon. + pub fn retire_accepted( + &self, + retirement: &ValidatedRetirement, + ) -> Result<(), ReconciliationError> { + self.mutate(|state| { + let anchor = state + .accepted + .as_mut() + .ok_or(ReconciliationError::NoTicket)?; + retirement.validate_against_effect(&anchor.effect)?; + if anchor.effect.intent_id != retirement.intent_id + || anchor.effect.tx_id != retirement.tx_id + || anchor.effect.block_id != retirement.block_id + || anchor.effect.inclusion_height != retirement.inclusion_height + || !anchor.applied + || anchor.rollback.is_some() + { + return Err(ReconciliationError::IntentMismatch); + } + if let Some(existing) = &anchor.retirement { + return if existing == retirement { + Ok(false) + } else { + Err(ReconciliationError::DuplicateTransactionConflict) + }; + } + anchor.retirement = Some(retirement.clone()); + append_event(state, "reorg_horizon_retired", &retirement.tx_id)?; + Ok(true) + }) + } + + pub fn mark_rollback_applied( + &self, + rollback: &ValidatedRollback, + ) -> Result<(), ReconciliationError> { + self.mutate(|state| { + let anchor = state + .accepted + .as_ref() + .ok_or(ReconciliationError::NoTicket)?; + if anchor.rollback.as_ref() != Some(rollback) { + return Err(ReconciliationError::IntentMismatch); + } + let tx_id = rollback.tx_id.clone(); + state.accepted = None; + append_event(state, "rollback_applied", &tx_id)?; + Ok(true) + }) + } + + fn read_state(&self) -> Result { + let state = match self + .partition + .get(JOURNAL_KEY) + .map_err(|error| ReconciliationError::Journal(error.to_string()))? + { + Some(bytes) => deserialize_state(bytes.as_ref()), + None => Ok(JournalState::default()), + }?; + self.validate_bound_state(&state)?; + Ok(state) + } + + fn mutate( + &self, + change: impl FnOnce(&mut JournalState) -> Result, + ) -> Result<(), ReconciliationError> { + let _guard = self + .write_lock + .lock() + .map_err(|_| ReconciliationError::Journal("journal lock is poisoned".to_string()))?; + let mut state = self.read_state()?; + if !change(&mut state)? { + return Ok(()); + } + validate_state(&state)?; + self.validate_bound_state(&state)?; + let bytes = serialize_state(&state)?; + self.partition + .insert(JOURNAL_KEY, bytes) + .map_err(|error| ReconciliationError::OutcomeUnknown(error.to_string()))?; + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|error| ReconciliationError::OutcomeUnknown(error.to_string())) + } + + fn ensure_bound_nft(&self, nft_id: Option<&str>) -> Result<(), ReconciliationError> { + let expected = hex::encode(self.binding.tracker_nft_id); + if nft_id != Some(expected.as_str()) { + return Err(ReconciliationError::JournalBindingMismatch); + } + Ok(()) + } + + fn validate_bound_state(&self, state: &JournalState) -> Result<(), ReconciliationError> { + if let Some(pending) = &state.pending { + self.ensure_bound_nft(pending.intent.protocol_nft_id())?; + if let Some(effect) = &pending.accepted_effect { + self.ensure_bound_nft(effect.protocol_nft_id())?; + } + } + if let Some(accepted) = &state.accepted { + self.ensure_bound_nft(accepted.intent.protocol_nft_id())?; + self.ensure_bound_nft(accepted.effect.protocol_nft_id())?; + } + Ok(()) + } +} + +fn ensure_journal_manifest( + path: &Path, + expected: &ReconciliationJournalBinding, + bootstrap: JournalBootstrap, +) -> Result<(), ReconciliationError> { + let manifest_path = path.join(JOURNAL_MANIFEST_FILE); + if manifest_path.exists() { + let bytes = std::fs::read(&manifest_path) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + let observed = deserialize_manifest(&bytes)?; + return if observed == *expected { + Ok(()) + } else { + Err(ReconciliationError::JournalBindingMismatch) + }; + } + if bootstrap == JournalBootstrap::ExistingRequired { + return Err(ReconciliationError::JournalBindingRequired); + } + if path.exists() { + let mut entries = std::fs::read_dir(path) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + if entries + .next() + .transpose() + .map_err(|error| ReconciliationError::Journal(error.to_string()))? + .is_some() + { + return Err(ReconciliationError::JournalBindingRequired); + } + } else { + std::fs::create_dir_all(path) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + } + let bytes = serialize_manifest(expected)?; + match OpenOptions::new() + .create_new(true) + .write(true) + .open(&manifest_path) + { + Ok(mut file) => { + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| ReconciliationError::OutcomeUnknown(error.to_string()))?; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let observed = std::fs::read(&manifest_path) + .map_err(|read_error| ReconciliationError::Journal(read_error.to_string()))?; + if deserialize_manifest(&observed)? == *expected { + Ok(()) + } else { + Err(ReconciliationError::JournalBindingMismatch) + } + } + Err(error) => Err(ReconciliationError::Journal(error.to_string())), + } +} + +fn serialize_manifest( + binding: &ReconciliationJournalBinding, +) -> Result, ReconciliationError> { + let payload = serde_json::to_vec(binding) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + let length = u32::try_from(payload.len()) + .map_err(|_| ReconciliationError::Journal("manifest is too large".to_string()))?; + let mut bytes = Vec::with_capacity(8 + payload.len() + 32); + bytes.extend_from_slice(JOURNAL_MANIFEST_MAGIC); + bytes.extend_from_slice(&length.to_be_bytes()); + bytes.extend_from_slice(&payload); + let mut checksum_input = + Vec::with_capacity(JOURNAL_MANIFEST_CHECKSUM_DOMAIN.len() + bytes.len()); + checksum_input.extend_from_slice(JOURNAL_MANIFEST_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes); + bytes.extend_from_slice(&blake2b256_hash(&checksum_input)); + Ok(bytes) +} + +fn deserialize_manifest(bytes: &[u8]) -> Result { + if bytes.len() < 8 + 32 || &bytes[..4] != JOURNAL_MANIFEST_MAGIC { + return Err(ReconciliationError::Journal( + "journal manifest envelope is malformed".to_string(), + )); + } + let length = u32::from_be_bytes( + bytes[4..8] + .try_into() + .map_err(|_| ReconciliationError::Journal("invalid manifest length".to_string()))?, + ) as usize; + if bytes.len() != 8 + length + 32 { + return Err(ReconciliationError::Journal( + "journal manifest envelope length mismatch".to_string(), + )); + } + let checksum_offset = 8 + length; + let mut checksum_input = + Vec::with_capacity(JOURNAL_MANIFEST_CHECKSUM_DOMAIN.len() + checksum_offset); + checksum_input.extend_from_slice(JOURNAL_MANIFEST_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes[..checksum_offset]); + if blake2b256_hash(&checksum_input) != bytes[checksum_offset..] { + return Err(ReconciliationError::Journal( + "journal manifest checksum mismatch".to_string(), + )); + } + serde_json::from_slice(&bytes[8..checksum_offset]) + .map_err(|error| ReconciliationError::Journal(error.to_string())) +} + +fn append_event( + state: &mut JournalState, + kind: &str, + tx_id: &str, +) -> Result<(), ReconciliationError> { + state.sequence = state + .sequence + .checked_add(1) + .ok_or_else(|| ReconciliationError::Journal("journal sequence overflow".to_string()))?; + let event_material = format!("{}:{}:{}", state.sequence, kind, tx_id); + state.history.push(JournalEvent { + sequence: state.sequence, + event_id: hex::encode(blake2b256_hash(event_material.as_bytes())), + kind: kind.to_string(), + tx_id: tx_id.to_string(), + }); + if state.history.len() > MAX_HISTORY_ENTRIES { + let remove = state.history.len() - MAX_HISTORY_ENTRIES; + state.history.drain(..remove); + } + Ok(()) +} + +fn validate_state(state: &JournalState) -> Result<(), ReconciliationError> { + let has_event = |kind: &str, tx_id: &str| { + state + .history + .iter() + .any(|event| event.kind == kind && event.tx_id == tx_id) + }; + let mut prior = 0u64; + for event in &state.history { + let expected_event_id = hex::encode(blake2b256_hash( + format!("{}:{}:{}", event.sequence, event.kind, event.tx_id).as_bytes(), + )); + if event.sequence <= prior + || event.sequence > state.sequence + || normalize_id(event.event_id.clone(), "event id")? != event.event_id + || normalize_id(event.tx_id.clone(), "event tx id")? != event.tx_id + || event.event_id != expected_event_id + || !matches!( + event.kind.as_str(), + "prepared" + | "submission_armed" + | "policy_accepted" + | "applied" + | "rollback_detected" + | "rollback_applied" + | "reorg_horizon_retired" + ) + { + return Err(ReconciliationError::Journal( + "journal history is malformed".to_string(), + )); + } + prior = event.sequence; + } + if prior != state.sequence { + return Err(ReconciliationError::Journal( + "journal sequence is not represented by its final retained event".to_string(), + )); + } + if let Some(pending) = &state.pending { + pending.intent.validate()?; + if !has_event("prepared", pending.intent.tx_id()) { + return Err(ReconciliationError::Journal( + "pending ticket has no matching prepared event".to_string(), + )); + } + match (pending.phase, pending.accepted_effect.as_ref()) { + (PendingPhase::AcceptanceReady, Some(effect)) => { + if !has_event("submission_armed", pending.intent.tx_id()) + || !has_event("policy_accepted", pending.intent.tx_id()) + { + return Err(ReconciliationError::Journal( + "acceptance-ready ticket lacks its transition events".to_string(), + )); + } + effect.validate_against_intent(&pending.intent)?; + } + (PendingPhase::AcceptanceReady, None) => { + return Err(ReconciliationError::Journal( + "acceptance-ready ticket lacks evidence".to_string(), + )); + } + (PendingPhase::Prepared, None) => {} + (PendingPhase::SubmissionArmed, None) => { + if !has_event("submission_armed", pending.intent.tx_id()) { + return Err(ReconciliationError::Journal( + "armed ticket lacks its transition event".to_string(), + )); + } + } + (PendingPhase::Prepared | PendingPhase::SubmissionArmed, Some(_)) => { + return Err(ReconciliationError::Journal( + "pre-acceptance ticket contains a validated effect".to_string(), + )); + } + } + } + if let Some(accepted) = &state.accepted { + accepted.effect.validate_against_intent(&accepted.intent)?; + if !accepted.applied + || !has_event("applied", accepted.effect.tx_id()) + || (accepted.rollback.is_some() && accepted.retirement.is_some()) + { + return Err(ReconciliationError::Journal( + "accepted anchor phase is inconsistent".to_string(), + )); + } + if let Some(rollback) = &accepted.rollback { + if !has_event("rollback_detected", rollback.tx_id()) { + return Err(ReconciliationError::Journal( + "rollback ticket lacks its transition event".to_string(), + )); + } + rollback.validate_against_effect(&accepted.effect)?; + } + if let Some(retirement) = &accepted.retirement { + if !has_event("reorg_horizon_retired", &retirement.tx_id) { + return Err(ReconciliationError::Journal( + "retirement ticket lacks its transition event".to_string(), + )); + } + retirement.validate_against_effect(&accepted.effect)?; + } + if state.pending.as_ref().is_some_and(|pending| { + pending.intent.tx_id == accepted.effect.tx_id + || pending.intent.intent_id == accepted.effect.intent_id + }) { + return Err(ReconciliationError::DuplicateTransactionConflict); + } + } + Ok(()) +} + +fn serialize_state(state: &JournalState) -> Result, ReconciliationError> { + let payload = serde_json::to_vec(state) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + let length = u32::try_from(payload.len()) + .map_err(|_| ReconciliationError::Journal("journal payload is too large".to_string()))?; + let mut bytes = Vec::with_capacity(8 + payload.len() + 32); + bytes.extend_from_slice(JOURNAL_MAGIC); + bytes.extend_from_slice(&length.to_be_bytes()); + bytes.extend_from_slice(&payload); + let mut checksum_input = Vec::with_capacity(JOURNAL_CHECKSUM_DOMAIN.len() + bytes.len()); + checksum_input.extend_from_slice(JOURNAL_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes); + bytes.extend_from_slice(&blake2b256_hash(&checksum_input)); + Ok(bytes) +} + +fn deserialize_state(bytes: &[u8]) -> Result { + if bytes.len() < 8 + 32 || &bytes[..4] != JOURNAL_MAGIC { + return Err(ReconciliationError::Journal( + "journal envelope is malformed".to_string(), + )); + } + let length = u32::from_be_bytes( + bytes[4..8] + .try_into() + .map_err(|_| ReconciliationError::Journal("invalid journal length".to_string()))?, + ) as usize; + if bytes.len() != 8 + length + 32 { + return Err(ReconciliationError::Journal( + "journal envelope length mismatch".to_string(), + )); + } + let checksum_offset = 8 + length; + let mut checksum_input = Vec::with_capacity(JOURNAL_CHECKSUM_DOMAIN.len() + checksum_offset); + checksum_input.extend_from_slice(JOURNAL_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes[..checksum_offset]); + if blake2b256_hash(&checksum_input) != bytes[checksum_offset..] { + return Err(ReconciliationError::Journal( + "journal checksum mismatch".to_string(), + )); + } + let _guard = JournalDeserializationGuard::enter(); + let state: JournalState = serde_json::from_slice(&bytes[8..checksum_offset]) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + validate_state(&state)?; + Ok(state) +} + +fn hash_serialized( + domain: &[u8], + value: &T, +) -> Result<[u8; 32], ReconciliationError> { + let encoded = serde_json::to_vec(value) + .map_err(|error| ReconciliationError::Journal(error.to_string()))?; + let mut material = Vec::with_capacity(domain.len() + 8 + encoded.len()); + material.extend_from_slice(domain); + material.extend_from_slice(&(encoded.len() as u64).to_be_bytes()); + material.extend_from_slice(&encoded); + Ok(blake2b256_hash(&material)) +} + +fn normalize_id(value: String, label: &str) -> Result { + let normalized = value.to_ascii_lowercase(); + if normalized.len() != 64 + || hex::decode(&normalized) + .map(|bytes| bytes.len() != 32) + .unwrap_or(true) + { + return Err(ReconciliationError::MalformedIntent(format!( + "{label} is not a 32-byte hex id" + ))); + } + Ok(normalized) +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum ReconciliationError { + #[error("malformed reconciliation intent: {0}")] + MalformedIntent(String), + #[error("malformed canonical protocol box: {0}")] + MalformedBox(String), + #[error("malformed active-chain proof: {0}")] + MalformedChainProof(String), + #[error("malformed full block: {0}")] + MalformedBlock(String), + #[error("transaction identity or exact body does not match the signed intent")] + TransactionMismatch, + #[error("exact transaction is absent from the selected full block")] + TransactionNotInBlock, + #[error("full block transaction list does not match the header transactions root")] + TransactionRootMismatch, + #[error("transaction does not consume the exact canonical predecessor")] + LineageMismatch, + #[error("transaction has no exact signed protocol successor")] + SuccessorMismatch, + #[error("protocol singleton lineage or token position is invalid")] + SingletonMismatch, + #[error("successor root does not match the signed intent")] + RootMismatch, + #[error("settlement amount does not match the signed chain-derived delta")] + AmountMismatch, + #[error("ordered asset manifest changed outside the settlement delta")] + AssetManifestMismatch, + #[error("signed payout outputs do not exactly equal the reserve delta")] + PayoutMismatch, + #[error("transaction block is not selected at its inclusion height")] + InactiveBlock, + #[error("header id does not match canonical header bytes")] + HeaderIdMismatch, + #[error("selected header chain has a broken parent link")] + AncestryMismatch, + #[error("node tip changed while chain evidence was collected")] + IncoherentSnapshot, + #[error("transaction depth evidence is inconsistent")] + DepthMismatch, + #[error("selected-chain evidence does not cover the required ancestry boundary")] + IncompleteAncestry, + #[error("reconciliation policy has an invalid finality or monitoring horizon")] + InvalidPolicy, + #[error("runtime finality policy/source differs from the policy that accepted this effect")] + PolicyMismatch, + #[error("validated reconciliation decision is malformed: {0}")] + MalformedDecision(String), + #[error("successor depth {observed} is below policy depth {required}")] + DepthTooShallow { observed: u64, required: u64 }, + #[error("chain evidence is stale")] + StaleEvidence, + #[error("v2 reconciliation is disabled until explicit activation")] + GenerationDisabled, + #[error("rollback was not proven by a coherent replacement chain")] + RollbackNotProven, + #[error("a different reconciliation ticket is already pending")] + TicketInProgress, + #[error("the same transaction id is bound to a different ticket")] + DuplicateTransactionConflict, + #[error("no reconciliation ticket exists")] + NoTicket, + #[error("reconciliation ticket identity mismatch")] + IntentMismatch, + #[error("reconciliation ticket is in the wrong phase")] + InvalidPhase, + #[error("durable reconciliation journal error: {0}")] + Journal(String), + #[error("confirmed-chain journal manifest is required for existing tracker history")] + JournalBindingRequired, + #[error("confirmed-chain journal is bound to a different tracker generation")] + JournalBindingMismatch, + #[error("BNS1 accounting projection and confirmed-chain journal do not join exactly")] + AccountingProjectionMismatch, + #[error("durable reconciliation outcome is unknown: {0}")] + OutcomeUnknown(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use ergo_lib::{ + chain::transaction::{Input, UnsignedInput}, + ergotree_interpreter::sigma_protocol::prover::ProofBytes, + ergotree_ir::{ + chain::{ + context_extension::ContextExtension, + ergo_box::{ + box_value::BoxValue, BoxTokens, ErgoBoxCandidate, NonMandatoryRegisters, + RegisterValue, + }, + token::{Token, TokenAmount, TokenId}, + tx_id::TxId, + }, + ergo_tree::ErgoTree, + }, + }; + + const HEADER_JSON: &str = r#"{ + "extensionId":"d16f25b14457186df4c5f6355579cc769261ce1aebc8209949ca6feadbac5a3f", + "votes":"040000","timestamp":1618929697400, + "stateRoot":"8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713", + "height":471746,"nBits":117586360,"version":2, + "id":"4caa17e62fe66ba7bd69597afdc996ae35b1ff12e0ba90c22ff288a4de10e91b", + "adProofsRoot":"d882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c39", + "transactionsRoot":"63eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b", + "extensionHash":"3f91f3c680beb26615fdec251aee3f81aaf5a02740806c167c0f3c929471df44", + "powSolutions":{"pk":"02b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f669","n":"5939ecfee6b0d7f4"}, + "parentId":"6481752bace5fa5acba5d5ef7124d48826664742d46c974c98a2d60ace229a34" + }"#; + + fn id(byte: u8) -> String { + hex::encode([byte; 32]) + } + + fn tracker_registers( + root: [u8; 33], + r4_hex: &str, + avl_shape: [u8; 3], + ) -> NonMandatoryRegisters { + let r4 = hex::decode(r4_hex).unwrap(); + let mut r5 = vec![0x64]; + r5.extend_from_slice(&root); + r5.extend_from_slice(&avl_shape); + NonMandatoryRegisters::try_from(vec![ + RegisterValue::sigma_parse_bytes(&r4), + RegisterValue::sigma_parse_bytes(&r5), + ]) + .unwrap() + } + + fn nft() -> (String, BoxTokens) { + let token_id = id(0xaa); + let token = Token { + token_id: token_id.parse::().unwrap(), + amount: TokenAmount::try_from(1u64).unwrap(), + }; + (token_id, vec![token].try_into().unwrap()) + } + + fn tracker_transaction( + successor_r4: &str, + successor_avl_shape: [u8; 3], + leading_output: bool, + duplicate_successor: bool, + ) -> (Vec, Vec) { + let (_, tokens) = nft(); + tracker_transaction_with_tokens( + tokens, + successor_r4, + successor_avl_shape, + leading_output, + duplicate_successor, + ) + } + + fn tracker_transaction_with_tokens( + tokens: BoxTokens, + successor_r4: &str, + successor_avl_shape: [u8; 3], + leading_output: bool, + duplicate_successor: bool, + ) -> (Vec, Vec) { + tracker_transaction_with_tokens_and_value( + tokens, + None, + successor_r4, + successor_avl_shape, + leading_output, + duplicate_successor, + ) + } + + fn tracker_transaction_with_tokens_and_value( + tokens: BoxTokens, + successor_value: Option, + successor_r4: &str, + successor_avl_shape: [u8; 3], + leading_output: bool, + duplicate_successor: bool, + ) -> (Vec, Vec) { + const TRACKER_R4: &str = + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7"; + let tree = ErgoTree::sigma_parse_bytes( + &hex::decode( + "0008cd02dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + ) + .unwrap(), + ) + .unwrap(); + let predecessor = ErgoBox::new( + BoxValue::try_from(10_000_000u64).unwrap(), + tree.clone(), + Some(tokens.clone()), + tracker_registers([0x11; 33], TRACKER_R4, [0x03, 0x20, 0x00]), + 100, + TxId::zero(), + 0, + ) + .unwrap(); + let successor = ErgoBoxCandidate { + value: BoxValue::try_from(successor_value.unwrap_or(*predecessor.value.as_u64())) + .unwrap(), + ergo_tree: tree.clone(), + tokens: Some(tokens), + additional_registers: tracker_registers([0x44; 33], successor_r4, successor_avl_shape), + creation_height: 101, + }; + let fee_output = ErgoBoxCandidate { + value: BoxValue::try_from(1_000_000u64).unwrap(), + ergo_tree: tree, + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: 101, + }; + let mut outputs = Vec::new(); + if leading_output { + outputs.push(fee_output); + } + outputs.push(successor.clone()); + if duplicate_successor { + outputs.push(successor); + } + let input = Input::from_unsigned_input( + UnsignedInput::new(predecessor.box_id(), ContextExtension::empty()), + ProofBytes::Some(vec![1, 2, 3].into()), + ); + let transaction = Transaction::new_from_vec(vec![input], vec![], outputs).unwrap(); + let signed = serde_json::to_vec(&transaction).unwrap(); + let predecessor_json = serde_json::to_vec(&predecessor).unwrap(); + (signed, predecessor_json) + } + + fn tracker_fixture() -> (ReconciliationIntent, Vec, Vec) { + let (signed, predecessor_json) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + false, + false, + ); + let intent = ReconciliationIntent::tracker_publication( + signed.clone(), + predecessor_json.clone(), + [0x44; 33], + ) + .unwrap(); + (intent, signed, predecessor_json) + } + + fn recompute_header_id(header: &mut Header) { + header.id = derived_header_id(header).unwrap().parse().unwrap(); + } + + fn chain_with_first_root( + start_height: u64, + depth: u64, + fork: u8, + first_root: Option, + ) -> ActiveChainProof { + let template: Header = serde_json::from_str(HEADER_JSON).unwrap(); + let mut headers = Vec::new(); + let mut parent = template.parent_id; + for offset in 0..=depth { + let mut header = template.clone(); + header.height = (start_height + offset) as u32; + header.parent_id = parent; + header.timestamp += offset + fork as u64; + header.autolykos_solution.nonce[0] ^= fork.wrapping_add(offset as u8); + if offset == 0 { + if let Some(root) = first_root { + header.transaction_root = root; + } + } + recompute_header_id(&mut header); + parent = header.id; + headers.push(header); + } + let json_headers = headers + .iter() + .map(|header| { + let mut value = serde_json::to_value(header).unwrap(); + if header.version > 1 { + if let Some(solution) = value + .get_mut("powSolutions") + .and_then(serde_json::Value::as_object_mut) + { + solution.remove("w"); + solution.remove("d"); + } + } + value + }) + .collect::>(); + let json = serde_json::to_vec(&json_headers).unwrap(); + let tip = headers.last().unwrap(); + ActiveChainProof::from_node_responses( + tip.id.to_string(), + tip.height as u64, + tip.id.to_string(), + tip.height as u64, + start_height, + &json, + 1_000, + ) + .unwrap() + } + + fn chain(start_height: u64, depth: u64, fork: u8) -> ActiveChainProof { + chain_with_first_root(start_height, depth, fork, None) + } + + fn chain_for_transaction( + start_height: u64, + depth: u64, + fork: u8, + signed: &[u8], + ) -> ActiveChainProof { + let transaction = parse_transaction(signed).unwrap(); + let root = transaction_merkle_root(&[transaction], 2).unwrap(); + chain_with_first_root(start_height, depth, fork, Some(root)) + } + + fn bounded_chain_with_first_root( + start_height: u64, + selected_depth: u64, + observed_tip_depth: u64, + fork: u8, + first_root: Option, + ) -> ActiveChainProof { + assert!(observed_tip_depth >= selected_depth); + let selected = chain_with_first_root(start_height, selected_depth, fork, first_root); + let json = serde_json::to_vec( + &selected + .headers + .iter() + .map(|header| serde_json::from_slice::(&header.bytes).unwrap()) + .collect::>(), + ) + .unwrap(); + let observed_tip_id = if observed_tip_depth == selected_depth { + selected.headers.last().unwrap().id.clone() + } else { + id(0xf0u8.wrapping_add(fork)) + }; + ActiveChainProof::from_bounded_node_responses( + observed_tip_id.clone(), + start_height + observed_tip_depth, + observed_tip_id, + start_height + observed_tip_depth, + start_height, + start_height + selected_depth, + &json, + 1_000, + ) + .unwrap() + } + + fn bounded_chain_for_transaction( + start_height: u64, + selected_depth: u64, + observed_tip_depth: u64, + fork: u8, + signed: &[u8], + ) -> ActiveChainProof { + let transaction = parse_transaction(signed).unwrap(); + let root = transaction_merkle_root(&[transaction], 2).unwrap(); + bounded_chain_with_first_root( + start_height, + selected_depth, + observed_tip_depth, + fork, + Some(root), + ) + } + + fn full_block_json(chain: &ActiveChainProof, transactions: &[Vec]) -> Vec { + let header: serde_json::Value = serde_json::from_slice(&chain.headers[0].bytes).unwrap(); + let transactions = transactions + .iter() + .map(|bytes| serde_json::from_slice::(bytes).unwrap()) + .collect::>(); + serde_json::to_vec(&serde_json::json!({ + "header": header, + "blockTransactions": { "transactions": transactions } + })) + .unwrap() + } + + fn evidence( + signed: Vec, + predecessor: Vec, + chain: ActiveChainProof, + ) -> TransactionChainEvidence { + TransactionChainEvidence::from_node_snapshot( + signed.clone(), + full_block_json(&chain, &[signed]), + chain.first_block_id(), + chain.inclusion_height(), + vec![predecessor], + chain.clone(), + ) + .unwrap() + } + + fn policy() -> ReconciliationPolicy { + ReconciliationPolicy::new(6, 100, 12, "ergo-test", [0x42; 32]) + } + + fn journal_binding() -> ReconciliationJournalBinding { + ReconciliationJournalBinding::tracker_v1([0xaa; 32]) + } + + fn open_test_journal(path: &Path) -> ReconciliationJournal { + ReconciliationJournal::open(path, journal_binding(), JournalBootstrap::FreshAllowed) + .unwrap() + } + + fn write_unvalidated_state(journal: &ReconciliationJournal, state: &JournalState) { + journal + .partition + .insert(JOURNAL_KEY, serialize_state(state).unwrap()) + .unwrap(); + journal.keyspace.persist(PersistMode::SyncData).unwrap(); + } + + #[test] + fn signed_transaction_derives_exact_successor_and_full_register_manifest() { + let (signed, predecessor) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + true, + false, + ); + let intent = + ReconciliationIntent::tracker_publication(signed, predecessor.clone(), [0x44; 33]) + .unwrap(); + let predecessor_json: serde_json::Value = serde_json::from_slice(&predecessor).unwrap(); + assert!(predecessor_json.get("boxId").is_some()); + assert_eq!(intent.successor_output_index, 1); + assert_eq!(intent.successor().index(), 1); + assert!(intent.successor().registers()[0].is_some()); + assert!(intent.successor().registers()[1].is_some()); + assert_eq!( + intent.successor().registers()[2..], + [None, None, None, None] + ); + let durable_json = serde_json::to_value(&intent).unwrap(); + assert!(durable_json.get("generation").is_none()); + + let mut forged = intent.clone(); + forged.successor.value -= 1; + forged.intent_id = forged.compute_intent_id().unwrap(); + assert_eq!( + forged.validate(), + Err(ReconciliationError::MalformedBox( + "stored box fields differ from canonical bytes".to_string() + )) + ); + } + + #[test] + fn tracker_receiver_avl_root_and_unique_successor_fail_independently() { + let wrong_receiver = tracker_transaction( + "070279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + [0x03, 0x20, 0x00], + false, + false, + ); + assert_eq!( + ReconciliationIntent::tracker_publication( + wrong_receiver.0, + wrong_receiver.1, + [0x44; 33], + ), + Err(ReconciliationError::SuccessorMismatch) + ); + + let wrong_avl_shape = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x01, 0x20, 0x00], + false, + false, + ); + assert_eq!( + ReconciliationIntent::tracker_publication( + wrong_avl_shape.0, + wrong_avl_shape.1, + [0x44; 33], + ), + Err(ReconciliationError::RootMismatch) + ); + + let duplicate = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + false, + true, + ); + assert_eq!( + ReconciliationIntent::tracker_publication(duplicate.0, duplicate.1, [0x44; 33]), + Err(ReconciliationError::SuccessorMismatch) + ); + + let (_, signed, predecessor) = tracker_fixture(); + assert_eq!( + ReconciliationIntent::tracker_publication(signed, predecessor, [0x45; 33]), + Err(ReconciliationError::RootMismatch) + ); + } + + #[test] + fn configured_tracker_nft_cannot_be_reinterpreted_at_a_different_token_index() { + let (tracker_nft_id, _) = nft(); + let tracker_token = Token { + token_id: tracker_nft_id.parse::().unwrap(), + amount: TokenAmount::try_from(1u64).unwrap(), + }; + let leading_token = Token { + token_id: id(0xbb).parse::().unwrap(), + amount: TokenAmount::try_from(1u64).unwrap(), + }; + let tokens: BoxTokens = vec![leading_token, tracker_token].try_into().unwrap(); + let (signed, predecessor) = tracker_transaction_with_tokens( + tokens, + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + false, + false, + ); + let predecessor_box = ProtocolBox::from_json_bytes(&predecessor).unwrap(); + assert_eq!(predecessor_box.singleton_index(&tracker_nft_id).unwrap(), 1); + + // The intent derives index zero from signed bytes, but the journal is + // bound independently to the configured tracker NFT and rejects this + // attempted reinterpretation before any submission can be armed. + let intent = + ReconciliationIntent::tracker_publication(signed, predecessor, [0x44; 33]).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let journal = ReconciliationJournal::open( + temp.path(), + ReconciliationJournalBinding::tracker_v1( + hex::decode(tracker_nft_id).unwrap().try_into().unwrap(), + ), + JournalBootstrap::FreshAllowed, + ) + .unwrap(); + assert_eq!( + journal.record_prepared(intent), + Err(ReconciliationError::JournalBindingMismatch) + ); + } + + #[test] + fn signed_successor_value_must_equal_the_exact_predecessor() { + let (_, tokens) = nft(); + let (signed, predecessor) = tracker_transaction_with_tokens_and_value( + tokens, + Some(9_000_000), + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + false, + false, + ); + assert_eq!( + ReconciliationIntent::tracker_publication(signed, predecessor, [0x44; 33]), + Err(ReconciliationError::SuccessorMismatch) + ); + } + + #[test] + fn transaction_root_matches_node_v6_reference_and_rejects_layout_mutants() { + let (_, signed_a, _) = tracker_fixture(); + let (signed_b, _) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + true, + false, + ); + let transactions = vec![ + parse_transaction(&signed_a).unwrap(), + parse_transaction(&signed_b).unwrap(), + ]; + let root_v2 = transaction_merkle_root(&transactions, 2).unwrap(); + // Independent JVM golden reproduced with Ergo node v6.0.3 commit + // 28ebb184: Algos.hash(010203).tail for both witnesses, then the + // node's Algos.merkleTreeRoot over the two ids followed by two witness + // ids. This is not computed through the Rust helper under test. + assert_eq!( + hex::encode(root_v2.as_ref()), + "c392f72d35ee256968ae2d2426280d38f4f89d4f722aea8b7e0c0b864b1dce4f" + ); + + let transaction_ids = transactions + .iter() + .map(|transaction| { + let id = blake2b256_hash(&transaction.bytes_to_sign().unwrap()); + assert_eq!(transaction.id().as_ref(), id.as_slice()); + id + }) + .collect::>(); + let witnesses = transactions + .iter() + .map(|transaction| { + transaction + .inputs + .iter() + .flat_map(|input| input.spending_proof.proof.as_ref().iter().copied()) + .collect::>() + }) + .collect::>(); + let witness_hashes = witnesses + .iter() + .map(|witness| blake2b256_hash(witness)) + .collect::>(); + + let root_v1 = transaction_merkle_root(&transactions, 1).unwrap(); + assert_eq!( + root_v1, + MerkleTree::new( + transaction_ids + .iter() + .map(|id| MerkleNode::from_bytes(id.to_vec())) + .collect::>() + ) + .root_hash_special() + ); + + let appended_raw = MerkleTree::new( + transaction_ids + .iter() + .zip(&witnesses) + .map(|(id, witness)| { + let mut leaf = id.to_vec(); + leaf.extend_from_slice(witness); + MerkleNode::from_bytes(leaf) + }) + .collect::>(), + ) + .root_hash_special(); + let grouped_raw = MerkleTree::new( + transaction_ids + .iter() + .map(|id| MerkleNode::from_bytes(id.to_vec())) + .chain( + witnesses + .iter() + .map(|witness| MerkleNode::from_bytes(witness.clone())), + ) + .collect::>(), + ) + .root_hash_special(); + let grouped_full_hash = MerkleTree::new( + transaction_ids + .iter() + .map(|id| MerkleNode::from_bytes(id.to_vec())) + .chain( + witness_hashes + .iter() + .map(|hash| MerkleNode::from_bytes(hash.to_vec())), + ) + .collect::>(), + ) + .root_hash_special(); + let interleaved_tail = MerkleTree::new( + transaction_ids + .iter() + .zip(&witness_hashes) + .flat_map(|(id, hash)| { + [ + MerkleNode::from_bytes(id.to_vec()), + MerkleNode::from_bytes(hash[1..].to_vec()), + ] + }) + .collect::>(), + ) + .root_hash_special(); + assert_ne!(root_v2, appended_raw); + assert_ne!(root_v2, grouped_raw); + assert_ne!(root_v2, grouped_full_hash); + assert_ne!(root_v2, interleaved_tail); + } + + #[test] + fn wrong_block_ancestor_tip_and_depth_fail_independently() { + let (_, signed, predecessor) = tracker_fixture(); + let good = chain_for_transaction(100, 6, 0, &signed); + assert_eq!( + TransactionChainEvidence::from_node_snapshot( + signed.clone(), + full_block_json(&good, std::slice::from_ref(&signed)), + id(9), + 100, + vec![predecessor.clone()], + good.clone(), + ), + Err(ReconciliationError::InactiveBlock) + ); + + let mut broken = good.clone(); + let mut broken_header: Header = serde_json::from_slice(&broken.headers[1].bytes).unwrap(); + broken_header.parent_id = id(8).parse().unwrap(); + recompute_header_id(&mut broken_header); + broken.headers[1] = CanonicalHeader::from_header(&broken_header).unwrap(); + assert_eq!( + broken.validate(), + Err(ReconciliationError::AncestryMismatch) + ); + + let headers_json = serde_json::to_vec( + &good + .headers + .iter() + .map(|header| serde_json::from_slice::(&header.bytes).unwrap()) + .collect::>(), + ) + .unwrap(); + assert_eq!( + ActiveChainProof::from_node_responses( + good.tip_id(), + good.tip_height(), + id(7), + good.tip_height(), + 100, + &headers_json, + 1_000, + ), + Err(ReconciliationError::IncoherentSnapshot) + ); + + let shallow = chain_for_transaction(100, 5, 0, &signed); + let (intent, _, _) = tracker_fixture(); + assert_eq!( + validate_chain_effect( + &intent, + &evidence(signed, predecessor, shallow), + policy(), + 1_000, + ), + Err(ReconciliationError::DepthTooShallow { + observed: 5, + required: 6, + }) + ); + } + + #[test] + fn transaction_and_predecessor_fields_cannot_be_fabricated_behind_real_ids() { + let (intent, signed, predecessor) = tracker_fixture(); + let selected = chain_for_transaction(100, 6, 0, &signed); + let mut predecessor_json: serde_json::Value = serde_json::from_slice(&predecessor).unwrap(); + predecessor_json["value"] = serde_json::json!(9_000_000u64); + assert!(matches!( + TransactionChainEvidence::from_node_snapshot( + signed.clone(), + full_block_json(&selected, std::slice::from_ref(&signed)), + selected.first_block_id(), + 100, + vec![serde_json::to_vec(&predecessor_json).unwrap()], + selected.clone(), + ), + Err(ReconciliationError::MalformedBox(_)) + )); + + let mut tx_json: serde_json::Value = serde_json::from_slice(&signed).unwrap(); + tx_json["outputs"][0]["additionalRegisters"]["R5"] = + serde_json::Value::String("0e0100".to_string()); + let selected_block_id = selected.first_block_id().to_string(); + assert!(matches!( + TransactionChainEvidence::from_node_snapshot( + serde_json::to_vec(&tx_json).unwrap(), + full_block_json(&selected, std::slice::from_ref(&signed)), + selected_block_id, + 100, + vec![predecessor], + selected, + ), + Err(ReconciliationError::MalformedIntent(_)) + )); + assert!(intent.validate().is_ok()); + } + + #[test] + fn full_block_inclusion_is_bound_to_header_chain_and_transaction_root() { + let (_, signed, predecessor) = tracker_fixture(); + let selected = chain_for_transaction(100, 6, 0, &signed); + assert!(TransactionChainEvidence::from_node_snapshot( + signed.clone(), + full_block_json(&selected, std::slice::from_ref(&signed)), + selected.first_block_id(), + 100, + vec![predecessor.clone()], + selected.clone(), + ) + .is_ok()); + + let (unrelated_signed, _) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + true, + false, + ); + assert_eq!( + TransactionChainEvidence::from_node_snapshot( + signed.clone(), + full_block_json(&selected, &[unrelated_signed]), + selected.first_block_id(), + 100, + vec![predecessor.clone()], + selected.clone(), + ), + Err(ReconciliationError::TransactionNotInBlock) + ); + + let unrelated_block = chain_for_transaction(100, 6, 9, &signed); + assert_eq!( + TransactionChainEvidence::from_node_snapshot( + signed.clone(), + full_block_json(&unrelated_block, std::slice::from_ref(&signed)), + selected.first_block_id(), + 100, + vec![predecessor.clone()], + selected.clone(), + ), + Err(ReconciliationError::InactiveBlock) + ); + + let wrong_root: Digest32 = id(0xdd).parse().unwrap(); + let wrong_root_chain = chain_with_first_root(100, 6, 4, Some(wrong_root)); + let wrong_root_block_id = wrong_root_chain.first_block_id().to_string(); + assert_eq!( + TransactionChainEvidence::from_node_snapshot( + signed.clone(), + full_block_json(&wrong_root_chain, std::slice::from_ref(&signed)), + wrong_root_block_id, + 100, + vec![predecessor.clone()], + wrong_root_chain, + ), + Err(ReconciliationError::TransactionRootMismatch) + ); + + let mut forged_block: serde_json::Value = + serde_json::from_slice(&full_block_json(&selected, std::slice::from_ref(&signed))) + .unwrap(); + forged_block["header"]["transactionsRoot"] = serde_json::json!(id(0xee)); + let selected_block_id = selected.first_block_id().to_string(); + assert_eq!( + TransactionChainEvidence::from_node_snapshot( + signed, + serde_json::to_vec(&forged_block).unwrap(), + selected_block_id, + 100, + vec![predecessor], + selected, + ), + Err(ReconciliationError::HeaderIdMismatch) + ); + } + + #[test] + fn rollback_uses_fresh_coherent_replacement_path() { + let (intent, signed, predecessor) = tracker_fixture(); + let original_chain = chain_for_transaction(100, 6, 0, &signed); + let effect = validate_chain_effect( + &intent, + &evidence(signed, predecessor, original_chain.clone()), + policy(), + 1_000, + ) + .unwrap(); + assert_eq!( + validate_rollback(&effect, &original_chain, &policy(), 1_000), + Err(ReconciliationError::RollbackNotProven) + ); + let replacement = chain(100, 7, 9); + let rollback = validate_rollback(&effect, &replacement, &policy(), 1_000).unwrap(); + assert_eq!(rollback.removed_block_id(), effect.block_id()); + assert!(serde_json::from_slice::( + &serde_json::to_vec(&effect).unwrap() + ) + .is_err()); + assert!(serde_json::from_slice::( + &serde_json::to_vec(&rollback).unwrap() + ) + .is_err()); + + let mut forged = replacement; + forged.headers[1].parent_id = id(3); + assert!(validate_rollback(&effect, &forged, &policy(), 1_000).is_err()); + } + + #[test] + fn bounded_reorg_horizon_retires_or_rolls_back_one_fault_at_a_time() { + let (intent, signed, predecessor) = tracker_fixture(); + let effect = validate_chain_effect( + &intent, + &evidence( + signed.clone(), + predecessor, + chain_for_transaction(100, 6, 0, &signed), + ), + policy(), + 1_000, + ) + .unwrap(); + + // An arbitrarily old anchor needs only the configured 13-header + // inclusive window, not inclusion..tip history. + let old_but_active = bounded_chain_for_transaction( + 100, + policy().reorg_monitor_depth(), + MAX_CHAIN_HEADERS as u64 + 100, + 0, + &signed, + ); + let retirement = + match validate_reorg_horizon(&effect, &old_but_active, &policy(), 1_000).unwrap() { + ReorgHorizonDecision::Retire(retirement) => retirement, + ReorgHorizonDecision::Rollback(_) => panic!("unchanged anchor must retire"), + }; + assert!(serde_json::from_slice::( + &serde_json::to_vec(&retirement).unwrap() + ) + .is_err()); + + let replacement = bounded_chain_with_first_root( + 100, + policy().reorg_monitor_depth(), + MAX_CHAIN_HEADERS as u64 + 100, + 9, + None, + ); + assert!(matches!( + validate_reorg_horizon(&effect, &replacement, &policy(), 1_000).unwrap(), + ReorgHorizonDecision::Rollback(rollback) + if rollback.removed_block_id() == effect.block_id() + )); + + let too_short = bounded_chain_for_transaction( + 100, + policy().reorg_monitor_depth() - 1, + MAX_CHAIN_HEADERS as u64 + 100, + 0, + &signed, + ); + assert_eq!( + validate_reorg_horizon(&effect, &too_short, &policy(), 1_000), + Err(ReconciliationError::IncompleteAncestry) + ); + let too_long = bounded_chain_for_transaction( + 100, + policy().reorg_monitor_depth() + 1, + MAX_CHAIN_HEADERS as u64 + 100, + 0, + &signed, + ); + assert_eq!( + validate_reorg_horizon(&effect, &too_long, &policy(), 1_000), + Err(ReconciliationError::IncompleteAncestry) + ); + + let mut truncated_json = serde_json::to_value( + old_but_active + .headers + .iter() + .map(|header| serde_json::from_slice::(&header.bytes).unwrap()) + .collect::>(), + ) + .unwrap(); + truncated_json.as_array_mut().unwrap().pop(); + assert_eq!( + ActiveChainProof::from_bounded_node_responses( + old_but_active.tip_id(), + old_but_active.tip_height(), + old_but_active.tip_id(), + old_but_active.tip_height(), + 100, + 100 + policy().reorg_monitor_depth(), + &serde_json::to_vec(&truncated_json).unwrap(), + 1_000, + ), + Err(ReconciliationError::DepthMismatch) + ); + + let invalid = + ReconciliationPolicy::new(6, 100, MAX_REORG_MONITOR_DEPTH + 1, "ergo-test", [0x42; 32]); + assert_eq!( + validate_reorg_horizon(&effect, &old_but_active, &invalid, 1_000), + Err(ReconciliationError::InvalidPolicy) + ); + } + + #[test] + fn retired_anchor_survives_restart_and_does_not_block_newer_ticket() { + let temp = tempfile::tempdir().unwrap(); + let (intent_a, signed_a, predecessor_a) = tracker_fixture(); + let effect_a = validate_chain_effect( + &intent_a, + &evidence( + signed_a.clone(), + predecessor_a, + chain_for_transaction(100, 6, 0, &signed_a), + ), + policy(), + 1_000, + ) + .unwrap(); + let selected = bounded_chain_for_transaction( + 100, + policy().reorg_monitor_depth(), + MAX_CHAIN_HEADERS as u64 + 100, + 0, + &signed_a, + ); + let retirement = + match validate_reorg_horizon(&effect_a, &selected, &policy(), 1_000).unwrap() { + ReorgHorizonDecision::Retire(retirement) => retirement, + ReorgHorizonDecision::Rollback(_) => panic!("same anchor"), + }; + + { + let journal = open_test_journal(temp.path()); + journal.record_prepared(intent_a).unwrap(); + journal.arm_submission(effect_a.intent_id()).unwrap(); + journal.record_validated_effect(effect_a.clone()).unwrap(); + journal.mark_applied(&effect_a).unwrap(); + journal.retire_accepted(&retirement).unwrap(); + } + let journal = ReconciliationJournal::open( + temp.path(), + journal_binding(), + JournalBootstrap::ExistingRequired, + ) + .unwrap(); + assert!(matches!( + journal.recovery_action().unwrap(), + RecoveryAction::RestoreRetired(found) if found == effect_a + )); + assert!(journal.accepted_effect().unwrap().is_none()); + + let (signed_b, predecessor_b) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + true, + false, + ); + let intent_b = + ReconciliationIntent::tracker_publication(signed_b, predecessor_b, [0x44; 33]).unwrap(); + journal.record_prepared(intent_b.clone()).unwrap(); + journal.arm_submission(intent_b.intent_id()).unwrap(); + assert!(matches!( + journal.recovery_action().unwrap(), + RecoveryAction::QueryExactTransaction(found) if found == intent_b + )); + } + + #[test] + fn journal_manifest_requires_explicit_fresh_approval_and_never_rebinds() { + let parent = tempfile::tempdir().unwrap(); + let missing = parent.path().join("missing"); + assert!(matches!( + ReconciliationJournal::open( + &missing, + journal_binding(), + JournalBootstrap::ExistingRequired, + ), + Err(ReconciliationError::JournalBindingRequired) + )); + assert!(!missing.exists()); + + let journal_path = parent.path().join("journal"); + { + let _journal = ReconciliationJournal::open( + &journal_path, + journal_binding(), + JournalBootstrap::FreshAllowed, + ) + .unwrap(); + } + let manifest_path = journal_path.join(JOURNAL_MANIFEST_FILE); + let before = std::fs::read(&manifest_path).unwrap(); + let wrong = ReconciliationJournalBinding::tracker_v1([0xbb; 32]); + assert!(matches!( + ReconciliationJournal::open(&journal_path, wrong, JournalBootstrap::ExistingRequired,), + Err(ReconciliationError::JournalBindingMismatch) + )); + assert_eq!(std::fs::read(&manifest_path).unwrap(), before); + assert!(ReconciliationJournal::open( + &journal_path, + journal_binding(), + JournalBootstrap::ExistingRequired, + ) + .is_ok()); + + let orphan = parent.path().join("orphan"); + std::fs::create_dir(&orphan).unwrap(); + let sentinel = orphan.join("old-state"); + std::fs::write(&sentinel, b"do-not-rewrite").unwrap(); + assert!(matches!( + ReconciliationJournal::open(&orphan, journal_binding(), JournalBootstrap::FreshAllowed,), + Err(ReconciliationError::JournalBindingRequired) + )); + assert_eq!(std::fs::read(sentinel).unwrap(), b"do-not-rewrite"); + assert!(!orphan.join(JOURNAL_MANIFEST_FILE).exists()); + } + + #[test] + fn same_nft_journal_with_a_different_anchor_cannot_join_accounting_history() { + let (intent_a, signed_a, predecessor_a) = tracker_fixture(); + let effect_a = validate_chain_effect( + &intent_a, + &evidence( + signed_a.clone(), + predecessor_a, + chain_for_transaction(100, 6, 0, &signed_a), + ), + policy(), + 1_000, + ) + .unwrap(); + let (signed_b, predecessor_b) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + true, + false, + ); + let intent_b = ReconciliationIntent::tracker_publication( + signed_b.clone(), + predecessor_b.clone(), + [0x44; 33], + ) + .unwrap(); + let effect_b = validate_chain_effect( + &intent_b, + &evidence( + signed_b.clone(), + predecessor_b, + chain_for_transaction(200, 6, 4, &signed_b), + ), + policy(), + 1_000, + ) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let journal = open_test_journal(temp.path()); + journal.record_prepared(intent_b).unwrap(); + journal.arm_submission(effect_b.intent_id()).unwrap(); + journal.record_validated_effect(effect_b.clone()).unwrap(); + journal.mark_applied(&effect_b).unwrap(); + + let history_a = crate::ConfirmedProjectionAnchor::from_parts( + effect_a.tx_id().to_string(), + effect_a.successor_box_id().to_string(), + effect_a.block_id().to_string(), + effect_a.inclusion_height(), + effect_a.successor_depth(), + effect_a.intent_id().to_string(), + effect_a.tracker_root().unwrap(), + ); + let candidates = journal.accounting_effect_candidates().unwrap(); + assert_eq!(candidates, vec![effect_b]); + assert!(!candidates + .iter() + .any(|candidate| history_a.matches_validated_effect(candidate))); + assert_eq!( + journal.validate_tracker_startup_join(None, Some(&history_a), true, &policy()), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + } + + fn projection_anchor(effect: &ValidatedChainEffect) -> ConfirmedProjectionAnchor { + ConfirmedProjectionAnchor::from_parts( + effect.tx_id().to_string(), + effect.successor_box_id().to_string(), + effect.block_id().to_string(), + effect.inclusion_height(), + effect.successor_depth(), + effect.intent_id().to_string(), + effect.tracker_root().unwrap(), + ) + } + + #[test] + fn startup_join_is_reciprocal_and_acceptance_crash_window_is_exact() { + let (intent, signed, predecessor) = tracker_fixture(); + let effect = validate_chain_effect( + &intent, + &evidence( + signed.clone(), + predecessor, + chain_for_transaction(100, 6, 0, &signed), + ), + policy(), + 1_000, + ) + .unwrap(); + let pending = (effect.tx_id().to_string(), effect.tracker_root().unwrap()); + let projection = projection_anchor(&effect); + + let acceptance_dir = tempfile::tempdir().unwrap(); + let acceptance = open_test_journal(acceptance_dir.path()); + acceptance.record_prepared(intent.clone()).unwrap(); + acceptance.arm_submission(intent.intent_id()).unwrap(); + acceptance.record_validated_effect(effect.clone()).unwrap(); + assert!(acceptance + .validate_tracker_startup_join(Some(&pending), None, false, &policy()) + .is_ok()); + assert_eq!( + acceptance.validate_tracker_startup_join(None, None, false, &policy()), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert!(acceptance + .validate_tracker_startup_join(None, Some(&projection), true, &policy()) + .is_ok()); + assert_eq!( + acceptance.validate_tracker_startup_join( + Some(&("aa".repeat(32), pending.1)), + None, + false, + &policy(), + ), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert_eq!( + acceptance.validate_tracker_startup_join( + Some(&(pending.0.clone(), [0x99; 33])), + None, + false, + &policy(), + ), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + + acceptance.mark_applied(&effect).unwrap(); + assert_eq!( + acceptance.validate_tracker_startup_join(None, None, false, &policy()), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert!(acceptance + .validate_tracker_startup_join(None, Some(&projection), true, &policy()) + .is_ok()); + } + + #[test] + fn checksummed_journal_mutants_cannot_mint_effects_or_retirement() { + let (intent, signed, predecessor) = tracker_fixture(); + let effect = validate_chain_effect( + &intent, + &evidence( + signed.clone(), + predecessor, + chain_for_transaction(100, 6, 0, &signed), + ), + policy(), + 1_000, + ) + .unwrap(); + let pending = (effect.tx_id().to_string(), effect.tracker_root().unwrap()); + let temp = tempfile::tempdir().unwrap(); + let journal = open_test_journal(temp.path()); + journal.record_prepared(intent).unwrap(); + journal.arm_submission(effect.intent_id()).unwrap(); + journal.record_validated_effect(effect.clone()).unwrap(); + let original = journal.read_state().unwrap(); + + let mut mutants = Vec::new(); + for mutate in 0..11 { + let mut state = original.clone(); + let accepted_effect = state + .pending + .as_mut() + .and_then(|pending| pending.accepted_effect.as_mut()) + .unwrap(); + match mutate { + 0 => accepted_effect.successor_box_id = id(0x71), + 1 => accepted_effect.block_id = id(0x72), + 2 => accepted_effect.inclusion_height += 1, + 3 => accepted_effect.successor_depth += 1, + 4 => accepted_effect.intent_id = id(0x73), + 5 => accepted_effect.tip_id = id(0x74), + 6 => accepted_effect.policy.reorg_monitor_depth += 1, + 7 => accepted_effect.evidence_hash = [0x75; 32], + 8 => accepted_effect.decision_id = [0x76; 32], + 9 => accepted_effect.policy.policy_id = "basis.invalid-policy".to_string(), + 10 => accepted_effect.policy.policy_version += 1, + _ => unreachable!(), + } + mutants.push(state); + } + let mut bad_event = original.clone(); + bad_event.history.last_mut().unwrap().event_id = id(0x77); + mutants.push(bad_event); + + for mutant in mutants { + // `serialize_state` recomputes the outer BCJ1 checksum. Each + // mutation must still fail its inner authority/transition join. + write_unvalidated_state(&journal, &mutant); + assert!(journal + .validate_tracker_startup_join(Some(&pending), None, false, &policy()) + .is_err()); + } + + write_unvalidated_state(&journal, &original); + journal.mark_applied(&effect).unwrap(); + let applied = journal.read_state().unwrap(); + let mut accepted_successor_mutant = applied.clone(); + let mutated_effect = &mut accepted_successor_mutant.accepted.as_mut().unwrap().effect; + mutated_effect.successor_box_id = id(0x78); + // Even a coordinated recomputation of the inner decision digest cannot + // detach an applied effect from the signed intent retained by BCJ1. + mutated_effect.decision_id = mutated_effect.compute_decision_id().unwrap(); + write_unvalidated_state(&journal, &accepted_successor_mutant); + assert!(journal.recovery_action().is_err()); + + write_unvalidated_state(&journal, &applied); + let selected = bounded_chain_for_transaction(100, 12, 12, 0, &signed); + let retirement = match validate_reorg_horizon(&effect, &selected, &policy(), 1_000).unwrap() + { + ReorgHorizonDecision::Retire(retirement) => retirement, + ReorgHorizonDecision::Rollback(_) => panic!("matching chain must retire"), + }; + let mut fake_retired = applied; + fake_retired.accepted.as_mut().unwrap().retirement = Some(retirement); + write_unvalidated_state(&journal, &fake_retired); + assert!(journal.recovery_action().is_err()); + } + + #[test] + fn startup_rejects_orphan_history_and_policy_drift() { + let empty_dir = tempfile::tempdir().unwrap(); + let empty = open_test_journal(empty_dir.path()); + assert_eq!( + empty.validate_tracker_startup_join(None, None, true, &policy()), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + + let (intent, signed, predecessor) = tracker_fixture(); + let effect = validate_chain_effect( + &intent, + &evidence( + signed.clone(), + predecessor, + chain_for_transaction(100, 6, 0, &signed), + ), + policy(), + 1_000, + ) + .unwrap(); + let projection = projection_anchor(&effect); + let dir = tempfile::tempdir().unwrap(); + let journal = open_test_journal(dir.path()); + journal.record_prepared(intent).unwrap(); + journal.arm_submission(effect.intent_id()).unwrap(); + journal.record_validated_effect(effect.clone()).unwrap(); + journal.mark_applied(&effect).unwrap(); + + for horizon in [6, 13] { + let changed = ReconciliationPolicy::new(6, 100, horizon, "ergo-test", [0x42; 32]); + assert_eq!( + journal.validate_tracker_startup_join(None, Some(&projection), true, &changed,), + Err(ReconciliationError::PolicyMismatch) + ); + } + for changed in [ + ReconciliationPolicy::new(7, 100, 12, "ergo-test", [0x42; 32]), + ReconciliationPolicy::new(6, 101, 12, "ergo-test", [0x42; 32]), + ReconciliationPolicy::new(6, 100, 12, "ergo-testnet", [0x42; 32]), + ReconciliationPolicy::new(6, 100, 12, "ergo-test", [0x43; 32]), + ] { + assert_eq!( + journal.validate_tracker_startup_join(None, Some(&projection), true, &changed,), + Err(ReconciliationError::PolicyMismatch) + ); + } + } + + #[test] + fn rollback_startup_demotes_before_resuming_the_exact_newer_receipt() { + let (intent_a, signed_a, predecessor_a) = tracker_fixture(); + let effect_a = validate_chain_effect( + &intent_a, + &evidence( + signed_a.clone(), + predecessor_a, + chain_for_transaction(100, 6, 0, &signed_a), + ), + policy(), + 1_000, + ) + .unwrap(); + let (signed_b, predecessor_b) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + true, + false, + ); + let intent_b = + ReconciliationIntent::tracker_publication(signed_b, predecessor_b, [0x44; 33]).unwrap(); + let pending_b = ( + intent_b.tx_id().to_string(), + intent_b.tracker_root().unwrap(), + ); + let rollback = validated_rollback_for_test(&effect_a); + let temp = tempfile::tempdir().unwrap(); + let journal = open_test_journal(temp.path()); + journal.record_prepared(intent_a).unwrap(); + journal.arm_submission(effect_a.intent_id()).unwrap(); + journal.record_validated_effect(effect_a.clone()).unwrap(); + journal.mark_applied(&effect_a).unwrap(); + journal.record_prepared(intent_b).unwrap(); + journal.record_rollback(rollback.clone()).unwrap(); + + assert!(matches!( + journal.recovery_action().unwrap(), + RecoveryAction::ApplyRollback(found) if found == rollback + )); + assert!(journal + .validate_tracker_startup_join(Some(&pending_b), None, true, &policy()) + .is_ok()); + assert_eq!( + journal.validate_tracker_startup_join(None, None, true, &policy()), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + assert_eq!( + journal.validate_tracker_startup_join( + Some(&("aa".repeat(32), pending_b.1)), + None, + true, + &policy(), + ), + Err(ReconciliationError::AccountingProjectionMismatch) + ); + } + + #[test] + fn crash_windows_restart_and_duplicate_tx_are_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let (intent, signed, predecessor) = tracker_fixture(); + { + let journal = open_test_journal(temp.path()); + journal.record_prepared(intent.clone()).unwrap(); + } + { + let journal = open_test_journal(temp.path()); + assert!(matches!( + journal.recovery_action().unwrap(), + RecoveryAction::SubmitPrepared(found) if found == intent + )); + journal.arm_submission(intent.intent_id()).unwrap(); + } + let effect = validate_chain_effect( + &intent, + &evidence( + signed.clone(), + predecessor, + chain_for_transaction(100, 6, 0, &signed), + ), + policy(), + 1_000, + ) + .unwrap(); + { + let journal = open_test_journal(temp.path()); + assert!(matches!( + journal.recovery_action().unwrap(), + RecoveryAction::QueryExactTransaction(found) if found == intent + )); + journal.record_validated_effect(effect.clone()).unwrap(); + } + { + let journal = open_test_journal(temp.path()); + assert!( + matches!(journal.recovery_action().unwrap(), RecoveryAction::ApplyAccepted(found) if found == effect) + ); + journal.mark_applied(&effect).unwrap(); + journal.mark_applied(&effect).unwrap(); + } + { + let journal = open_test_journal(temp.path()); + assert!( + matches!(journal.recovery_action().unwrap(), RecoveryAction::RevalidateAccepted(found) if found == effect) + ); + assert_eq!( + journal.record_prepared(intent), + Err(ReconciliationError::DuplicateTransactionConflict) + ); + } + } + + #[test] + fn validated_reorg_preempts_a_newer_pending_ticket_then_resumes_it() { + let temp = tempfile::tempdir().unwrap(); + let (intent_a, signed_a, predecessor_a) = tracker_fixture(); + let original_chain = chain_for_transaction(100, 6, 0, &signed_a); + let effect_a = validate_chain_effect( + &intent_a, + &evidence(signed_a, predecessor_a, original_chain), + policy(), + 1_000, + ) + .unwrap(); + let (signed_b, predecessor_b) = tracker_transaction( + "0702dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7", + [0x03, 0x20, 0x00], + true, + false, + ); + let intent_b = + ReconciliationIntent::tracker_publication(signed_b, predecessor_b, [0x44; 33]).unwrap(); + assert_ne!(intent_a.tx_id(), intent_b.tx_id()); + + let journal = open_test_journal(temp.path()); + journal.record_prepared(intent_a.clone()).unwrap(); + journal.arm_submission(intent_a.intent_id()).unwrap(); + journal.record_validated_effect(effect_a.clone()).unwrap(); + journal.mark_applied(&effect_a).unwrap(); + journal.record_prepared(intent_b.clone()).unwrap(); + journal.arm_submission(intent_b.intent_id()).unwrap(); + + let replacement = chain(100, 7, 9); + let rollback = validate_rollback(&effect_a, &replacement, &policy(), 1_000).unwrap(); + journal.record_rollback(rollback.clone()).unwrap(); + assert!(matches!( + journal.recovery_action().unwrap(), + RecoveryAction::ApplyRollback(found) if found == rollback + )); + journal.mark_rollback_applied(&rollback).unwrap(); + assert!(matches!( + journal.recovery_action().unwrap(), + RecoveryAction::QueryExactTransaction(found) if found == intent_b + )); + } +} diff --git a/crates/basis_store/src/contract_compiler.rs b/crates/basis_store/src/contract_compiler.rs index d201242..f748e33 100644 --- a/crates/basis_store/src/contract_compiler.rs +++ b/crates/basis_store/src/contract_compiler.rs @@ -2,6 +2,23 @@ use thiserror::Error; +use ergo_lib::ergotree_ir::chain::address::{Address, AddressEncoder, NetworkPrefix}; +use ergo_lib::ergotree_ir::serialization::SigmaSerializable; + +/// Full ErgoTree bytes pinned by the ChainCash Basis v2 source receipt. +pub const BASIS_V2_ERG_ERGO_TREE_HEX: &str = include_str!("../contracts/basis-v2.p2s"); +/// Full token-reserve ErgoTree bytes pinned by the ChainCash Basis v2 source receipt. +pub const BASIS_V2_TOKEN_ERGO_TREE_HEX: &str = include_str!("../contracts/basis-token-v2.p2s"); +/// Machine-readable source-to-byte provenance copied from the pinned +/// ChainCash candidate receipt. It is evidence metadata, not an activation flag. +pub const BASIS_V2_PROVENANCE_JSON: &str = include_str!("../contracts/basis-v2-provenance.json"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BasisV2ContractKind { + Erg, + Token, +} + #[derive(Error, Debug)] pub enum CompilerError { #[error("File not found: {0}")] @@ -10,11 +27,64 @@ pub enum CompilerError { CompilationFailed(String), #[error("Ergo-lib not available: {0}")] ErgoLibUnavailable(String), + #[error("invalid Basis contract address: {0}")] + InvalidAddress(String), + #[error("configured contract does not match the exact Basis v2 {0} ErgoTree")] + ContractIdentityMismatch(&'static str), +} + +impl BasisV2ContractKind { + fn label(self) -> &'static str { + match self { + Self::Erg => "ERG", + Self::Token => "token", + } + } + + pub fn ergo_tree_hex(self) -> &'static str { + match self { + Self::Erg => BASIS_V2_ERG_ERGO_TREE_HEX.trim(), + Self::Token => BASIS_V2_TOKEN_ERGO_TREE_HEX.trim(), + } + } } -/// Get the Basis reserve contract P2S address +/// Derive the mainnet P2S address from the exact committed v2 ErgoTree bytes. +pub fn get_basis_v2_contract_p2s(kind: BasisV2ContractKind) -> Result { + let tree_bytes = hex::decode(kind.ergo_tree_hex()) + .map_err(|e| CompilerError::ErgoLibUnavailable(format!("invalid golden hex: {e}")))?; + Ok(AddressEncoder::new(NetworkPrefix::Mainnet).address_to_str(&Address::P2S(tree_bytes))) +} + +/// Fail closed unless `configured_p2s` resolves to the exact v2 tree selected by `kind`. +pub fn validate_basis_v2_contract_p2s( + configured_p2s: &str, + kind: BasisV2ContractKind, +) -> Result<(), CompilerError> { + let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); + let address = encoder + .parse_address_from_str(configured_p2s) + .map_err(|e| CompilerError::InvalidAddress(e.to_string()))?; + let actual = address + .script() + .map_err(|e| CompilerError::InvalidAddress(e.to_string()))? + .sigma_serialize_bytes() + .map_err(|e| CompilerError::ErgoLibUnavailable(e.to_string()))?; + let expected = hex::decode(kind.ergo_tree_hex()) + .map_err(|e| CompilerError::ErgoLibUnavailable(format!("invalid golden hex: {e}")))?; + if actual != expected { + return Err(CompilerError::ContractIdentityMismatch(kind.label())); + } + Ok(()) +} + +/// Get the historical strict-insert Basis reserve contract P2S address. +/// +/// This identity is retained for lineage checks and compatibility detection. +/// It must not be used as the default for builders that emit insert-or-update +/// reserve state. pub fn get_basis_reserve_contract_p2s() -> Result { - // Return the compiled Basis reserve contract P2S address + // Exact compiled identity of the historical strict-insert generation. Ok("3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT".to_string()) } @@ -31,10 +101,78 @@ mod tests { use ergo_lib::ergotree_ir::chain::address::AddressEncoder; use ergo_lib::ergotree_ir::chain::address::NetworkPrefix; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; + use sha2::{Digest, Sha256}; + + #[test] + fn v2_golden_lengths_and_addresses_are_exact() { + let erg = hex::decode(BASIS_V2_ERG_ERGO_TREE_HEX.trim()).unwrap(); + let token = hex::decode(BASIS_V2_TOKEN_ERGO_TREE_HEX.trim()).unwrap(); + assert_eq!(erg.len(), 1682); + assert_eq!(token.len(), 1963); + assert_eq!( + hex::encode(Sha256::digest(&erg)), + "2690634924efb22359a776f89f5274d77e067bd8ad0619a6e358a2f96697a0c2" + ); + assert_eq!( + hex::encode(Sha256::digest(&token)), + "ba1df64e7d95ecffc4f3d49fcada8baebe59a676eb617737cd010bdb52381cb3" + ); + + let receipt: serde_json::Value = serde_json::from_str(BASIS_V2_PROVENANCE_JSON).unwrap(); + assert_eq!(receipt["abi_generation"], 2); + assert_eq!(receipt["source_repository"], "BetterMoneyLabs/chaincash"); + assert_eq!( + receipt["contract_source_commit"], + "9a274396d5f78f7be5ed76bacee5329c42570317" + ); + assert_eq!( + receipt["claim_key_vector_commit"], + "04031626f09c6590a20ad20d5583c6eccc14412d" + ); + assert_eq!( + receipt["claim_key_vector_test_blob"], + "52cf88c3f692e0f0c23903562f603fae700b2ebe" + ); + assert_eq!(receipt["contracts"]["erg"]["ergo_tree_bytes"], 1682); + assert_eq!(receipt["contracts"]["token"]["ergo_tree_bytes"], 1963); + assert_eq!( + receipt["contracts"]["erg"]["ergo_tree_sha256"], + hex::encode(Sha256::digest(&erg)) + ); + assert_eq!( + receipt["contracts"]["token"]["ergo_tree_sha256"], + hex::encode(Sha256::digest(&token)) + ); + + for kind in [BasisV2ContractKind::Erg, BasisV2ContractKind::Token] { + let p2s = get_basis_v2_contract_p2s(kind).unwrap(); + validate_basis_v2_contract_p2s(&p2s, kind).unwrap(); + } + } + + #[test] + fn v2_identity_rejects_wrong_family_and_legacy_bytes() { + let erg = get_basis_v2_contract_p2s(BasisV2ContractKind::Erg).unwrap(); + let token = get_basis_v2_contract_p2s(BasisV2ContractKind::Token).unwrap(); + assert!(validate_basis_v2_contract_p2s(&erg, BasisV2ContractKind::Token).is_err()); + assert!(validate_basis_v2_contract_p2s(&token, BasisV2ContractKind::Erg).is_err()); + assert!(validate_basis_v2_contract_p2s( + &get_basis_reserve_contract_p2s().unwrap(), + BasisV2ContractKind::Erg + ) + .is_err()); + + let mut mutated = hex::decode(BASIS_V2_ERG_ERGO_TREE_HEX.trim()).unwrap(); + let last = mutated.len() - 1; + mutated[last] ^= 1; + let mutated_p2s = + AddressEncoder::new(NetworkPrefix::Mainnet).address_to_str(&Address::P2S(mutated)); + assert!(validate_basis_v2_contract_p2s(&mutated_p2s, BasisV2ContractKind::Erg).is_err()); + } #[test] - fn test_contract_compilation_placeholder() { - // Test that we can get the Basis reserve contract P2S + fn historical_contract_identity_is_a_valid_p2s() { + // Test that the retained historical identity remains parseable. let p2s = get_basis_reserve_contract_p2s().unwrap(); assert!(!p2s.is_empty()); // The P2S should be a valid P2S address @@ -64,7 +202,7 @@ mod tests { !ergo_tree_hex.is_empty(), "ErgoTree bytes should not be empty" ); - // Updated expected ErgoTree bytes for current P2S address + // Expected ErgoTree bytes for the historical strict-insert address. assert_eq!(ergo_tree_hex, "102004140414050004000400041004200500040004420400040004000410050004420500040004420442010104e021050004020500058084af5f04040500040605000480a3050100d806d6017ee4e3000204d6029d72017300d603b2a59e7201730100d604e4c6a70407d605ededed93c27203c2a793db63087203db6308a793e4c672030407720493e4c67203060ee4c6a7060ed606e5c6a707057302959372027303d813d607b2db6501fe730400d608db07027204d609e4e30107d60acbb37208db07027209d60be4e30305d60ce4e30405d60de3070ed60ee6720dd60f7a720cd61095720e7cb4e4dc640ae4c6a7056402720ae4720d730573067307d61199c1a7c17203d612db6a01ddd613e4e3020ed614b4721373087309d615b3b3720a7a720b720fd616e4e3060ed617b17216d618917217730ad619e4c672070407ea02d1ededededededed7205938cb2db63087207730b0001e4c6a7060e937ce4dc640ae4c67207056402720ae4e3080e720b91720c95720e7cb4e4dc640ae4c6a7056402720ae4720d730c730d730e93e4dc640ce4c6a705640283013c0e0e8602720ab3720f7a9a72107211e4e3050ee4c672030564939f72127bb47213730fb17213a0ee72149f72047bcbb3b3721472157208eded917211731090721199720b7210957218957218d801d61ab4721673117312939f72127bb4721673137217a0ee721a9f72197bcbb3b3721a7215db0702721973149199a38cc7720701731593e5c67203070573167206cd7209959372027317d1ededed720593e4c672030564e4c6a7056493e5c672030705731872069299c17203c1a7731995937202731aea02d1edededed720592c17203c1a793e4c672030564e4c6a7056492e4c6720307057ea305937206731bcd720495937202731cea02d1ed917206731d927ea3059a72067e731e05cd7204d1731f", "ErgoTree bytes don't match expected raw bytes"); @@ -101,7 +239,7 @@ mod tests { let serialized_hex = hex::encode(&serialized_bytes); // The expected ByteArrayConstant-wrapped bytes that the Ergo node expects for scan registration - // Updated for current P2S address: starts with 0eaa04 (ByteArrayConstant prefix with length) + // Historical P2S scan constant (ByteArrayConstant prefix plus tree bytes). let expected_bytes_hex = "0ead05102004140414050004000400041004200500040004420400040004000410050004420500040004420442010104e021050004020500058084af5f04040500040605000480a3050100d806d6017ee4e3000204d6029d72017300d603b2a59e7201730100d604e4c6a70407d605ededed93c27203c2a793db63087203db6308a793e4c672030407720493e4c67203060ee4c6a7060ed606e5c6a707057302959372027303d813d607b2db6501fe730400d608db07027204d609e4e30107d60acbb37208db07027209d60be4e30305d60ce4e30405d60de3070ed60ee6720dd60f7a720cd61095720e7cb4e4dc640ae4c6a7056402720ae4720d730573067307d61199c1a7c17203d612db6a01ddd613e4e3020ed614b4721373087309d615b3b3720a7a720b720fd616e4e3060ed617b17216d618917217730ad619e4c672070407ea02d1ededededededed7205938cb2db63087207730b0001e4c6a7060e937ce4dc640ae4c67207056402720ae4e3080e720b91720c95720e7cb4e4dc640ae4c6a7056402720ae4720d730c730d730e93e4dc640ce4c6a705640283013c0e0e8602720ab3720f7a9a72107211e4e3050ee4c672030564939f72127bb47213730fb17213a0ee72149f72047bcbb3b3721472157208eded917211731090721199720b7210957218957218d801d61ab4721673117312939f72127bb4721673137217a0ee721a9f72197bcbb3b3721a7215db0702721973149199a38cc7720701731593e5c67203070573167206cd7209959372027317d1ededed720593e4c672030564e4c6a7056493e5c672030705731872069299c17203c1a7731995937202731aea02d1edededed720592c17203c1a793e4c672030564e4c6a7056492e4c6720307057ea305937206731bcd720495937202731cea02d1ed917206731d927ea3059a72067e731e05cd7204d1731f"; // Verify the reserve scan contains exactly the expected ByteArrayConstant-wrapped bytes diff --git a/crates/basis_store/src/ergo_scanner.rs b/crates/basis_store/src/ergo_scanner.rs index 933b3af..505091b 100644 --- a/crates/basis_store/src/ergo_scanner.rs +++ b/crates/basis_store/src/ergo_scanner.rs @@ -2,17 +2,214 @@ //! This module provides blockchain integration using /blockchain endpoints (no node scans). use std::{ + collections::HashSet, path::Path, - sync::Arc, + sync::{Arc, LazyLock}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, Semaphore}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, error, info, warn}; -use reqwest::Client; +use reqwest::{RequestBuilder, StatusCode}; +use serde::de::DeserializeOwned; + +pub(crate) const SCAN_PAGE_SIZE: usize = 100; +pub(crate) const MAX_SCAN_PAGES: usize = 1_024; +pub(crate) const MAX_SCAN_BOXES: usize = 100_000; +#[cfg(test)] +pub(crate) const MAX_RESPONSE_BODY_BYTES: usize = NODE_HTTP_MAX_BODY_BYTES; +pub(crate) const MAX_CONCURRENT_SCANNER_REQUESTS: usize = 4; +const MAX_ERROR_BODY_CHARS: usize = 1_024; + +/// Total deadline applied to every outbound Ergo node request in this process. +pub const NODE_HTTP_TIMEOUT: Duration = Duration::from_secs(15); +/// Maximum response body accepted from the Ergo node. +pub const NODE_HTTP_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; +/// Maximum number of concurrent Ergo node requests across scanners and server. +pub const NODE_HTTP_MAX_IN_FLIGHT: usize = 16; + +/// Failure returned by the process-wide bounded Ergo node HTTP client. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum BoundedHttpError { + #[error("failed to initialize bounded HTTP client: {0}")] + ClientInitialization(String), + #[error("outbound node request limit reached")] + Overloaded, + #[error("outbound node request timed out")] + Timeout, + #[error("outbound node request failed: {0}")] + Request(String), + #[error("outbound node response body exceeds {limit} bytes")] + BodyTooLarge { limit: usize }, + #[error("failed to read outbound node response: {0}")] + Body(String), + #[error("outbound node response is not valid JSON: {0}")] + Json(String), +} + +fn map_reqwest_error(error: reqwest::Error) -> BoundedHttpError { + if error.is_timeout() { + BoundedHttpError::Timeout + } else { + BoundedHttpError::Request(error.to_string()) + } +} + +/// Fully buffered response whose body has already passed the configured cap. +#[derive(Debug)] +pub struct BoundedResponse { + pub status: StatusCode, + body: Vec, +} + +impl BoundedResponse { + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn json(&self) -> Result { + serde_json::from_slice(&self.body) + .map_err(|error| BoundedHttpError::Json(error.to_string())) + } + + pub fn text_lossy(&self) -> std::borrow::Cow<'_, str> { + String::from_utf8_lossy(&self.body) + } + + /// Return the already-buffered, size-checked response body. + pub fn bytes(&self) -> &[u8] { + &self.body + } + + pub(crate) fn into_parts(self) -> (StatusCode, Vec) { + (self.status, self.body) + } +} + +/// HTTP executor that applies one admission budget, total deadline, and body cap. +#[derive(Clone)] +pub struct BoundedHttpClient { + client: reqwest::Client, + permits: Arc, + timeout: Duration, + max_body_bytes: usize, +} + +impl BoundedHttpClient { + pub fn new( + timeout: Duration, + max_body_bytes: usize, + max_in_flight: usize, + ) -> Result { + if timeout.is_zero() || max_body_bytes == 0 || max_in_flight == 0 { + return Err(BoundedHttpError::ClientInitialization( + "timeout, body cap, and concurrency limit must be non-zero".to_string(), + )); + } + let client = reqwest::Client::builder() + .connect_timeout(timeout.min(Duration::from_secs(3))) + .timeout(timeout) + .pool_max_idle_per_host(max_in_flight) + .build() + .map_err(|error| BoundedHttpError::ClientInitialization(error.to_string()))?; + Ok(Self { + client, + permits: Arc::new(Semaphore::new(max_in_flight)), + timeout, + max_body_bytes, + }) + } + + pub fn get(&self, url: &str) -> RequestBuilder { + self.request(reqwest::Method::GET, url) + } + + pub fn post(&self, url: &str) -> RequestBuilder { + self.request(reqwest::Method::POST, url) + } + + pub fn request(&self, method: reqwest::Method, url: &str) -> RequestBuilder { + self.client.request(method, url) + } + + /// Execute a request through this client's shared policy. + pub async fn execute( + &self, + request: RequestBuilder, + ) -> Result { + let _permit = self + .permits + .try_acquire() + .map_err(|_| BoundedHttpError::Overloaded)?; + let max_body_bytes = self.max_body_bytes; + + tokio::time::timeout(self.timeout, async move { + let mut response = request.send().await.map_err(map_reqwest_error)?; + if response + .content_length() + .is_some_and(|length| length > max_body_bytes as u64) + { + return Err(BoundedHttpError::BodyTooLarge { + limit: max_body_bytes, + }); + } + + let status = response.status(); + let mut body = Vec::with_capacity( + response + .content_length() + .unwrap_or(0) + .min(max_body_bytes as u64) as usize, + ); + while let Some(chunk) = response.chunk().await.map_err(|error| { + if error.is_timeout() { + BoundedHttpError::Timeout + } else { + BoundedHttpError::Body(error.to_string()) + } + })? { + let new_len = body + .len() + .checked_add(chunk.len()) + .filter(|length| *length <= max_body_bytes) + .ok_or(BoundedHttpError::BodyTooLarge { + limit: max_body_bytes, + })?; + body.reserve(new_len.saturating_sub(body.capacity())); + body.extend_from_slice(&chunk); + } + Ok(BoundedResponse { status, body }) + }) + .await + .map_err(|_| BoundedHttpError::Timeout)? + } +} + +static NODE_HTTP_CLIENT: LazyLock> = + LazyLock::new(|| { + BoundedHttpClient::new( + NODE_HTTP_TIMEOUT, + NODE_HTTP_MAX_BODY_BYTES, + NODE_HTTP_MAX_IN_FLIGHT, + ) + }); + +/// Return the one process-wide client used by server and scanner node calls. +pub fn node_http() -> Result<&'static BoundedHttpClient, BoundedHttpError> { + NODE_HTTP_CLIENT + .as_ref() + .map_err(|error| BoundedHttpError::ClientInitialization(error.to_string())) +} + +pub(crate) fn summarize_error_body(body: &[u8]) -> String { + String::from_utf8_lossy(body) + .chars() + .take(MAX_ERROR_BODY_CHARS) + .collect() +} /// Response from `POST /blockchain/box/unspent/byAddress` is a JSON array of IndexedErgoBox. pub(crate) type ByAddressResponse = Vec; @@ -21,7 +218,7 @@ pub(crate) type ByAddressResponse = Vec; #[derive(Debug, Clone, Deserialize)] pub(crate) struct IndexedErgoBox { #[serde(rename = "boxId")] - box_id: String, + pub(crate) box_id: String, value: u64, #[serde(rename = "ergoTree")] ergo_tree: String, @@ -45,6 +242,14 @@ pub(crate) struct IndexedBoxAsset { amount: u64, } +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +pub(crate) struct IndexedHeightResponse { + #[serde(rename = "indexedHeight")] + pub(crate) indexed_height: u64, + #[serde(rename = "fullHeight")] + pub(crate) full_height: u64, +} + impl From for ScanBox { fn from(box_: IndexedErgoBox) -> Self { Self { @@ -91,6 +296,21 @@ pub enum ScannerError { HttpError(String), #[error("JSON parse error: {0}")] JsonError(String), + #[error("Scanner response exceeds {max_bytes} bytes")] + ResponseTooLarge { max_bytes: usize }, + #[error("Scanner request concurrency gate is closed")] + RequestGateClosed, + #[error("Scanner request capacity is exhausted")] + RequestCapacityExceeded, + #[error("Indexed node is behind: indexed height {indexed_height}, full height {full_height}")] + IndexLag { + indexed_height: u64, + full_height: u64, + }, + #[error("Incoherent scanner snapshot: {0}")] + IncoherentSnapshot(String), + #[error("Scanner resource limit exceeded: {0}")] + ScanLimitExceeded(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,7 +327,7 @@ impl ScanType { } /// Configuration for scanner -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct NodeConfig { /// Starting block height for scanning pub start_height: Option, @@ -121,6 +341,18 @@ pub struct NodeConfig { pub api_key: Option, } +impl std::fmt::Debug for NodeConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NodeConfig") + .field("start_height", &self.start_height) + .field("reserve_contract_p2s", &self.reserve_contract_p2s) + .field("node_url", &self.node_url) + .field("scan_name", &self.scan_name) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .finish() + } +} + /// Inner state for scanner that requires synchronization #[derive(Clone)] pub struct ServerStateInner { @@ -135,42 +367,85 @@ pub struct ServerStateInner { pub struct ServerState { pub config: NodeConfig, pub inner: Arc>, - pub client: Client, + pub(crate) request_permits: Arc, pub reserve_tracker: ReserveTracker, pub metadata_storage: ScannerMetadataStorage, pub reserve_storage: ReserveStorage, } impl ServerState { + async fn request_bytes( + &self, + request: reqwest::RequestBuilder, + context: &str, + ) -> Result<(StatusCode, Vec), ScannerError> { + let _permit = self + .request_permits + .try_acquire() + .map_err(|error| match error { + tokio::sync::TryAcquireError::Closed => ScannerError::RequestGateClosed, + tokio::sync::TryAcquireError::NoPermits => ScannerError::RequestCapacityExceeded, + })?; + let response = node_http() + .map_err(|error| ScannerError::HttpError(format!("{}: {}", context, error)))? + .execute(request) + .await + .map_err(|error| match error { + BoundedHttpError::BodyTooLarge { limit } => { + ScannerError::ResponseTooLarge { max_bytes: limit } + } + BoundedHttpError::Overloaded => ScannerError::RequestCapacityExceeded, + other => ScannerError::HttpError(format!("{}: {}", context, other)), + })?; + Ok(response.into_parts()) + } + /// Create HTTP request builder with API key header if configured - fn request_builder(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder { + fn request_builder( + &self, + method: reqwest::Method, + url: &str, + ) -> Result { debug!("Request method: {}, URL: {}", method, url); - let mut request = self.client.request(method, url); + let client = node_http().map_err(|error| ScannerError::HttpError(error.to_string()))?; + let mut request = client.request(method, url); // Add API key header if configured if let Some(api_key) = &self.config.api_key { - debug!("Using API key '{}' for request to: {}", api_key, url); - info!("Adding HTTP header: api_key: {}", api_key); + debug!("Using configured API key for request to: {}", url); request = request.header("api_key", api_key); } else { debug!("No API key configured for request to: {}", url); info!("No API key header added to HTTP request"); } - request + Ok(request) } /// Create a server state that uses real Ergo scanner pub fn new(config: NodeConfig, data_dir: impl AsRef) -> Result { + let configured = config.reserve_contract_p2s.as_deref().ok_or_else(|| { + ScannerError::Generic( + "reserve scanner contract generation is required before storage can be opened" + .to_string(), + ) + })?; + let historical = crate::contract_compiler::get_basis_reserve_contract_p2s() + .map_err(|error| ScannerError::Generic(error.to_string()))?; + if configured != historical { + return Err(ScannerError::Generic( + "reserve scanner generation is unsupported; v2 requires the BNS2/BRS2 scanner and unknown identities are rejected" + .to_string(), + )); + } let start_height = config.start_height.unwrap_or(0); - let client = Client::new(); let data_dir = data_dir.as_ref(); // Log which Ergo node is being used (INFO level) info!("Initializing Ergo scanner with node: {}", config.node_url); - if let Some(api_key) = &config.api_key { - info!("Using API key: {}", api_key); + if config.api_key.is_some() { + info!("Ergo node API key is configured"); } else { warn!("No API key configured for Ergo node"); } @@ -233,7 +508,7 @@ impl ServerState { Ok(Self { config, inner, - client, + request_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_SCANNER_REQUESTS)), reserve_tracker, metadata_storage, reserve_storage, @@ -247,22 +522,21 @@ impl ServerState { let url = format!("{}/info", self.config.node_url); info!("Fetching current blockchain height from: {}", url); - let response = self - .request_builder(reqwest::Method::GET, &url) - .send() - .await - .map_err(|e| ScannerError::HttpError(format!("Failed to connect to node: {}", e)))?; + let (status, body) = self + .request_bytes( + self.request_builder(reqwest::Method::GET, &url)?, + "Failed to fetch node height", + ) + .await?; - if !response.status().is_success() { + if !status.is_success() { return Err(ScannerError::NodeError(format!( "Node returned status: {}", - response.status() + status ))); } - let info: serde_json::Value = response - .json() - .await + let info: serde_json::Value = serde_json::from_slice(&body) .map_err(|e| ScannerError::JsonError(format!("Failed to parse node info: {}", e)))?; let height = info["fullHeight"].as_u64().ok_or_else(|| { @@ -312,22 +586,21 @@ impl ServerState { // Fetch from node let url = format!("{}/info", self.config.node_url); - let response = self - .request_builder(reqwest::Method::GET, &url) - .send() - .await - .map_err(|e| ScannerError::HttpError(format!("Failed to connect to node: {}", e)))?; + let (status, body) = self + .request_bytes( + self.request_builder(reqwest::Method::GET, &url)?, + "Failed to fetch node height", + ) + .await?; - if !response.status().is_success() { + if !status.is_success() { return Err(ScannerError::NodeError(format!( "Node returned status: {}", - response.status() + status ))); } - let info: serde_json::Value = response - .json() - .await + let info: serde_json::Value = serde_json::from_slice(&body) .map_err(|e| ScannerError::JsonError(format!("Failed to parse node info: {}", e)))?; let height = info["fullHeight"].as_u64().ok_or_else(|| { @@ -347,45 +620,145 @@ impl ServerState { Ok(height) } - /// Get unspent reserve boxes via `POST /blockchain/box/unspent/byAddress`. + async fn fetch_indexed_height(&self) -> Result { + let url = format!("{}/blockchain/indexedHeight", self.config.node_url); + let (status, body) = self + .request_bytes( + self.request_builder(reqwest::Method::GET, &url)?, + "Failed to fetch indexed height", + ) + .await?; + if !status.is_success() { + return Err(ScannerError::NodeError(format!( + "Failed to get indexed height with status {}: {}", + status, + summarize_error_body(&body) + ))); + } + serde_json::from_slice(&body).map_err(|e| { + ScannerError::JsonError(format!("Failed to parse indexed height response: {}", e)) + }) + } + + fn require_caught_up(height: IndexedHeightResponse) -> Result<(), ScannerError> { + if height.indexed_height < height.full_height { + return Err(ScannerError::IndexLag { + indexed_height: height.indexed_height, + full_height: height.full_height, + }); + } + Ok(()) + } + + async fn fetch_unspent_reserve_page( + &self, + reserve_contract_p2s: &str, + offset: usize, + ) -> Result, ScannerError> { + let url = format!("{}/blockchain/box/unspent/byAddress", self.config.node_url); + let request = self + .request_builder(reqwest::Method::POST, &url)? + .query(&[ + ("offset", offset.to_string()), + ("limit", SCAN_PAGE_SIZE.to_string()), + ("sortDirection", "asc".to_string()), + ("includeUnconfirmed", "false".to_string()), + ("excludeMempoolSpent", "false".to_string()), + ]) + .json(reserve_contract_p2s); + let (status, body) = self + .request_bytes(request, "Failed to fetch reserve boxes") + .await?; + + // The pinned v6.0.3 route returns a JSON sequence. Only an HTTP 200 + // carrying that sequence can prove page contents; in particular, a + // 404 is an ambiguous source failure rather than an exhausted page. + if status != StatusCode::OK { + return Err(ScannerError::NodeError(format!( + "Failed to get reserve boxes with status {}: {}", + status, + summarize_error_body(&body) + ))); + } + serde_json::from_slice(&body).map_err(|e| { + ScannerError::JsonError(format!("Failed to parse reserve boxes response: {}", e)) + }) + } + + /// Get a complete, height-coherent set of unspent reserve boxes via + /// `POST /blockchain/box/unspent/byAddress`. pub async fn get_unspent_reserve_boxes(&self) -> Result, ScannerError> { let reserve_contract_p2s = self.config.reserve_contract_p2s.as_ref().ok_or_else(|| { ScannerError::Generic("Reserve contract P2S not configured".to_string()) })?; - let url = format!("{}/blockchain/box/unspent/byAddress", self.config.node_url); - info!("Fetching unspent reserve boxes from: {}", url); + let before = self.fetch_indexed_height().await?; + Self::require_caught_up(before)?; - let response = self - .request_builder(reqwest::Method::POST, &url) - .json(reserve_contract_p2s) - .send() - .await - .map_err(|e| { - ScannerError::HttpError(format!("Failed to fetch reserve boxes: {}", e)) + let mut parsed: ByAddressResponse = Vec::new(); + let mut seen_box_ids = HashSet::new(); + let mut exhausted = false; + + for page_index in 0..MAX_SCAN_PAGES { + let offset = page_index.checked_mul(SCAN_PAGE_SIZE).ok_or_else(|| { + ScannerError::ScanLimitExceeded("page offset overflow".to_string()) })?; + let page = self + .fetch_unspent_reserve_page(reserve_contract_p2s, offset) + .await?; + if page.len() > SCAN_PAGE_SIZE { + return Err(ScannerError::IncoherentSnapshot(format!( + "node returned {} boxes for requested page size {} at offset {}", + page.len(), + SCAN_PAGE_SIZE, + offset + ))); + } - let status = response.status(); - if !status.is_success() { - let response_text = response - .text() - .await - .unwrap_or_else(|_| "Unable to read response".to_string()); - error!( - "Failed to get reserve boxes with status {}: {}", - status, response_text - ); - return Err(ScannerError::NodeError(format!( - "Failed to get reserve boxes with status: {}", - status + let page_len = page.len(); + for box_ in page { + if !seen_box_ids.insert(box_.box_id.clone()) { + return Err(ScannerError::IncoherentSnapshot(format!( + "duplicate box id {} across paginated response", + box_.box_id + ))); + } + if parsed.len() >= MAX_SCAN_BOXES { + return Err(ScannerError::ScanLimitExceeded(format!( + "more than {} reserve boxes", + MAX_SCAN_BOXES + ))); + } + parsed.push(box_); + } + + if page_len < SCAN_PAGE_SIZE { + exhausted = true; + break; + } + } + + if !exhausted { + return Err(ScannerError::ScanLimitExceeded(format!( + "scan did not exhaust within {} pages", + MAX_SCAN_PAGES ))); } - let parsed: ByAddressResponse = response.json().await.map_err(|e| { - ScannerError::JsonError(format!("Failed to parse reserve boxes response: {}", e)) - })?; + let after = self.fetch_indexed_height().await?; + Self::require_caught_up(after)?; + if before != after { + return Err(ScannerError::IncoherentSnapshot(format!( + "indexed/full height changed from {}/{} to {}/{} during pagination", + before.indexed_height, before.full_height, after.indexed_height, after.full_height + ))); + } - info!("Found {} reserve boxes", parsed.len()); + info!( + "Found {} reserve boxes in a complete snapshot at indexed height {}", + parsed.len(), + after.indexed_height + ); Ok(parsed.into_iter().map(Into::into).collect()) } @@ -435,7 +808,7 @@ impl ServerState { } /// Parse reserve box into ExtendedReserveInfo - pub fn parse_reserve_box( + pub(crate) fn parse_reserve_box( &self, scan_box: &ScanBox, ) -> Result { @@ -443,6 +816,21 @@ impl ServerState { let value = scan_box.value; let creation_height = scan_box.creation_height; + let expected_tree = crate::contract_compiler::get_basis_reserve_ergo_tree_hex() + .map_err(|error| ScannerError::Generic(error.to_string()))?; + let actual_tree = hex::decode(&scan_box.ergo_tree).map_err(|_| { + ScannerError::InvalidReserveBox(format!("Invalid ErgoTree encoding in box {}", box_id)) + })?; + let expected_tree = hex::decode(expected_tree).map_err(|_| { + ScannerError::Generic("embedded historical ErgoTree is invalid".to_string()) + })?; + if actual_tree != expected_tree { + return Err(ScannerError::InvalidReserveBox(format!( + "Reserve contract generation mismatch in box {}", + box_id + ))); + } + // Extract owner public key from R4 register let owner_pubkey_raw = scan_box .additional_registers @@ -525,51 +913,29 @@ impl ServerState { let scan_boxes = self.get_scan_boxes().await?; info!("Retrieved {} scan boxes to process", scan_boxes.len()); - let mut current_box_ids = Vec::new(); - + // Validate the entire coherent snapshot before the first mutation. A + // malformed candidate must not turn an incomplete observation into a + // destructive reconciliation. + let mut parsed_reserves = Vec::with_capacity(scan_boxes.len()); for scan_box in &scan_boxes { debug!( "Processing scan box: ID={}, value={}, registers={:?}", scan_box.box_id, scan_box.value, scan_box.additional_registers ); - - match self.parse_reserve_box(scan_box) { - Ok(reserve_info) => { - debug!( - "Successfully parsed reserve box: box_id={}, owner={}, collateral={}", - reserve_info.box_id, - reserve_info.owner_pubkey, - reserve_info.base_info.collateral_amount - ); - current_box_ids.push(reserve_info.box_id.clone()); - - // Update in-memory tracker - if let Err(e) = self.reserve_tracker.update_reserve(reserve_info.clone()) { - warn!("Failed to update reserve {}: {}", scan_box.box_id, e); - } else { - // Persist to database - if let Err(e) = self.reserve_storage.store_reserve(&reserve_info) { - warn!( - "Failed to persist reserve {} to database: {:?}", - scan_box.box_id, e - ); - } else { - info!("Updated and persisted reserve: {}", scan_box.box_id); - } - } - } - Err(e) => { - warn!( - "Failed to parse reserve box {}: {} - registers: {:?}", - scan_box.box_id, e, scan_box.additional_registers - ); - } - } + let reserve_info = self.parse_reserve_box(scan_box)?; + debug!( + "Successfully parsed reserve box: box_id={}, owner={}, collateral={}", + reserve_info.box_id, + reserve_info.owner_pubkey, + reserve_info.base_info.collateral_amount + ); + parsed_reserves.push(reserve_info); } - // Remove reserves that are no longer in the scan - // NOTE: Disabled for testing to prevent manually-inserted reserves from being deleted - // when they don't exist on the local test node. + let current_box_ids: HashSet = parsed_reserves + .iter() + .map(|reserve| reserve.box_id.clone()) + .collect(); let all_reserves = self.reserve_tracker.get_all_reserves(); info!( "Current tracker has {} reserves, {} are still active in scan", @@ -577,33 +943,54 @@ impl ServerState { current_box_ids.len() ); - // Only remove reserves if we actually found VALID boxes in the scan. - // If no valid reserves were parsed (e.g., all failed validation), don't remove manually-inserted reserves. - if !current_box_ids.is_empty() { - for reserve in all_reserves { - if !current_box_ids.contains(&reserve.box_id) { - info!( - "Removing spent reserve: {} (not found in current scan)", - reserve.box_id - ); - // Remove from in-memory tracker - if let Err(e) = self.reserve_tracker.remove_reserve(&reserve.box_id) { - warn!("Failed to remove reserve {}: {}", reserve.box_id, e); - } else { - // Remove from database - if let Err(e) = self.reserve_storage.remove_reserve(&reserve.box_id) { - warn!( - "Failed to remove reserve {} from database: {:?}", - reserve.box_id, e - ); - } else { - info!("Removed spent reserve: {}", reserve.box_id); - } - } - } + // Apply upserts only after collection and validation completed. Persist + // before publishing the value to the in-memory reader. + for reserve_info in &parsed_reserves { + self.reserve_storage + .store_reserve(reserve_info) + .map_err(|e| { + ScannerError::StoreError(format!( + "Failed to persist reserve {}: {:?}", + reserve_info.box_id, e + )) + })?; + self.reserve_tracker + .update_reserve(reserve_info.clone()) + .map_err(|e| { + ScannerError::StoreError(format!( + "Failed to update reserve {} in memory: {}", + reserve_info.box_id, e + )) + })?; + } + + // A successfully exhausted empty set is meaningful and removes every + // stale reserve. Fetch, height, duplicate, bound, and parse failures + // returned above before this destructive phase. + for reserve in all_reserves { + if current_box_ids.contains(&reserve.box_id) { + continue; } - } else { - info!("Scan returned 0 boxes, skipping reserve removal to preserve manually-inserted reserves"); + info!( + "Removing spent reserve: {} (not found in complete snapshot)", + reserve.box_id + ); + self.reserve_storage + .remove_reserve(&reserve.box_id) + .map_err(|e| { + ScannerError::StoreError(format!( + "Failed to remove reserve {} from database: {:?}", + reserve.box_id, e + )) + })?; + self.reserve_tracker + .remove_reserve(&reserve.box_id) + .map_err(|e| { + ScannerError::StoreError(format!( + "Failed to remove reserve {} from memory: {}", + reserve.box_id, e + )) + })?; } debug!( @@ -831,6 +1218,68 @@ fn decode_vlq_long(bytes: &[u8]) -> Result { mod tests { use super::*; use std::collections::HashMap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn oversized_declared_response_server() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + NODE_HTTP_MAX_BODY_BYTES + 1 + ); + let _ = socket.write_all(response.as_bytes()).await; + }); + format!("http://{address}") + } + + #[tokio::test] + async fn reserve_scanner_rejects_oversized_declared_node_body() { + let temp_dir = tempfile::tempdir().unwrap(); + let config = NodeConfig { + node_url: oversized_declared_response_server().await, + ..historical_config() + }; + let state = ServerState::new(config, temp_dir.path()).unwrap(); + + let error = state.fetch_current_height().await.unwrap_err(); + + assert!(matches!( + error, + ScannerError::ResponseTooLarge { + max_bytes: NODE_HTTP_MAX_BODY_BYTES + } + )); + } + + fn historical_tree() -> String { + crate::contract_compiler::get_basis_reserve_ergo_tree_hex().unwrap() + } + + fn historical_config() -> NodeConfig { + NodeConfig { + reserve_contract_p2s: Some( + crate::contract_compiler::get_basis_reserve_contract_p2s().unwrap(), + ), + ..NodeConfig::default() + } + } + + #[test] + fn node_config_debug_redacts_api_key() { + let sentinel = "sentinel-node-api-key-do-not-log"; + let config = NodeConfig { + api_key: Some(sentinel.to_string()), + ..NodeConfig::default() + }; + + let rendered = format!("{config:?}"); + assert!(!rendered.contains(sentinel)); + assert!(rendered.contains("")); + } #[test] fn test_parse_reserve_box_with_r6_register() { @@ -849,14 +1298,14 @@ mod tests { box_id: "test_box_id".to_string(), value: 1000000000, // 1 ERG creation_height: 1000, - ergo_tree: "test_ergo_tree".to_string(), + ergo_tree: historical_tree(), transaction_id: "test_tx_id".to_string(), additional_registers: registers, assets: vec![], }; // Create a dummy server state for testing - let config = NodeConfig::default(); + let config = historical_config(); let data_dir = std::env::temp_dir().join(format!( "basis_scanner_test_{}_{}", std::time::SystemTime::now() @@ -908,14 +1357,14 @@ mod tests { box_id: "test_box_id_2".to_string(), value: 1000000000, // 1 ERG creation_height: 1000, - ergo_tree: "test_ergo_tree".to_string(), + ergo_tree: historical_tree(), transaction_id: "test_tx_id".to_string(), additional_registers: registers, assets: vec![], }; // Create a dummy server state for testing - let config = NodeConfig::default(); + let config = historical_config(); let data_dir = std::env::temp_dir().join(format!( "basis_scanner_test_{}_{}", std::time::SystemTime::now() @@ -961,14 +1410,14 @@ mod tests { box_id: "test_box_id_3".to_string(), value: 1000000000, // 1 ERG creation_height: 1000, - ergo_tree: "test_ergo_tree".to_string(), + ergo_tree: historical_tree(), transaction_id: "test_tx_id".to_string(), additional_registers: registers, assets: vec![], }; // Create a dummy server state for testing - let config = NodeConfig::default(); + let config = historical_config(); let data_dir = std::env::temp_dir().join(format!( "basis_scanner_test_{}_{}", std::time::SystemTime::now() @@ -998,4 +1447,126 @@ mod tests { } } } + + #[test] + fn scanner_rejects_missing_v2_and_unknown_generations_before_opening_storage() { + let root = std::env::temp_dir().join(format!( + "basis_scanner_generation_guard_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let exact_v2 = crate::contract_compiler::get_basis_v2_contract_p2s( + crate::contract_compiler::BasisV2ContractKind::Erg, + ) + .unwrap(); + let configurations = [ + (None, "contract generation is required"), + (Some(exact_v2), "generation is unsupported"), + ( + Some("unknown-generation".to_string()), + "generation is unsupported", + ), + ]; + for (configured, expected) in configurations { + let config = NodeConfig { + reserve_contract_p2s: configured, + ..NodeConfig::default() + }; + assert!(matches!( + ServerState::new(config, &root), + Err(ScannerError::Generic(message)) if message.contains(expected) + )); + assert!(!root.exists()); + } + } + + #[test] + fn unsupported_generation_never_loads_a_seeded_unversioned_reserve() { + let exact_v2 = crate::contract_compiler::get_basis_v2_contract_p2s( + crate::contract_compiler::BasisV2ContractKind::Erg, + ) + .unwrap(); + for (marker, configured) in [ + (1u8, None), + (2u8, Some(exact_v2)), + (3u8, Some("unknown-generation".to_string())), + ] { + let root = std::env::temp_dir().join(format!( + "basis_scanner_seeded_generation_guard_{}_{}_{}", + std::process::id(), + marker, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let storage = ReserveStorage::open(root.join("reserves")).unwrap(); + let mut reserve = ExtendedReserveInfo::new( + &[marker; 32], + &[2u8; 33], + 1_000_000, + Some(&[3u8; 32]), + 1, + 0, + ); + reserve.set_contract_address("unversioned-or-wrong-generation".to_string()); + storage.store_reserve(&reserve).unwrap(); + drop(storage); + + let config = NodeConfig { + reserve_contract_p2s: configured, + ..NodeConfig::default() + }; + assert!(ServerState::new(config, &root).is_err()); + + let storage = ReserveStorage::open(root.join("reserves")).unwrap(); + let persisted = storage.get_all_reserves().unwrap(); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].box_id, reserve.box_id); + assert_eq!( + persisted[0].base_info.contract_address, + reserve.base_info.contract_address + ); + } + } + + #[test] + fn parser_rejects_a_box_from_another_contract_generation_first() { + let mut registers = HashMap::new(); + registers.insert( + "R4".to_string(), + "02c5b4b2f6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3".to_string(), + ); + registers.insert("R6".to_string(), format!("0e20{}", "11".repeat(32))); + let scan_box = ScanBox { + box_id: "wrong-generation".to_string(), + value: 1_000_000, + ergo_tree: crate::contract_compiler::BASIS_V2_ERG_ERGO_TREE_HEX + .trim() + .to_string(), + creation_height: 1, + transaction_id: "tx".to_string(), + additional_registers: registers, + assets: Vec::new(), + }; + let root = std::env::temp_dir().join(format!( + "basis_scanner_parser_guard_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let state = ServerState::new(historical_config(), &root).unwrap(); + assert!(matches!( + state.parse_reserve_box(&scan_box), + Err(ScannerError::InvalidReserveBox(message)) if message.contains("generation mismatch") + )); + } } diff --git a/crates/basis_store/src/lib.rs b/crates/basis_store/src/lib.rs index 9c0c136..3a9a13f 100644 --- a/crates/basis_store/src/lib.rs +++ b/crates/basis_store/src/lib.rs @@ -1,6 +1,88 @@ -//! Core data structures for Basis tracker +//! Core data structures for Basis tracker. +//! +//! The retired v1 redemption manager and transaction builder are removed. They +//! are not part of either the production or test API; v2 remains fail-closed at +//! its confirmed-authority boundary. +//! +//! ```compile_fail +//! use basis_store::RedemptionManager; +//! ``` +//! +//! ```compile_fail +//! use basis_store::redemption::RedemptionManager; +//! ``` +//! +//! ```compile_fail +//! use basis_store::RedemptionRequest; +//! ``` +//! +//! ```compile_fail +//! use basis_store::RedemptionData; +//! ``` +//! +//! ```compile_fail +//! use basis_store::RedemptionError; +//! ``` +//! +//! ```compile_fail +//! use basis_store::transaction_builder::RedemptionTransactionBuilder; +//! ``` +//! +//! The pre-v2 `TrackerStateManager` proof surface and its process-global +//! reserve AVL mirror are removed as well: +//! +//! ```compile_fail +//! use basis_store::NoteProof; +//! ``` +//! +//! ```compile_fail +//! use basis_store::TrackerLookupProof; +//! ``` +//! +//! ```compile_fail +//! use basis_store::ReserveLookupProof; +//! ``` +//! +//! ```compile_fail +//! fn removed(manager: &mut basis_store::TrackerStateManager, key: &[u8; 33]) { +//! let _ = manager.generate_proof(key, key); +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(manager: &mut basis_store::TrackerStateManager, key: &[u8; 33]) { +//! let _ = manager.generate_tracker_lookup_proof(key, key); +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(manager: &mut basis_store::TrackerStateManager, key: &[u8; 33]) { +//! let _ = manager.generate_reserve_lookup_proof(key, key); +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(manager: &basis_store::TrackerStateManager, key: &[u8; 33]) { +//! let _ = manager.generate_reserve_insert_proof(key, key, 0, 0); +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(manager: &basis_store::TrackerStateManager) { +//! let _ = manager.reserve_state_digest(); +//! } +//! ``` +//! +//! ```compile_fail +//! fn removed(manager: &mut basis_store::TrackerStateManager, key: &[u8; 33]) { +//! let _ = manager.update_already_redeemed(key, key, 0, 0); +//! } +//! ``` pub mod avl_tree; +pub mod basis_v2_builder; +pub mod basis_v2_state; +pub mod chain_reconciliation; pub mod contract_compiler; #[cfg(test)] @@ -8,11 +90,6 @@ pub mod cross_validation_tests; pub mod cross_verification; pub mod ergo_scanner; pub mod persistence; -pub mod redemption; -#[cfg(test)] -pub mod redemption_blockchain_tests; -#[cfg(test)] -pub mod redemption_simple_tests; pub mod reserve_tracker; pub mod scala_test_vectors; pub mod schnorr; @@ -22,7 +99,6 @@ pub mod schnorr_tests; pub mod simple_integration_tests; pub mod tests; pub mod tracker_scanner; -pub mod transaction_builder; // Test modules #[cfg(test)] @@ -36,7 +112,7 @@ pub mod real_scanner_integration_tests; #[cfg(test)] pub mod reserve_tracking_test; #[cfg(test)] -pub mod test_helpers; +mod scanner_hardening_tests; #[cfg(test)] pub mod tracker_scanner_test; @@ -44,7 +120,13 @@ use basis_core; use basis_core::impls::SchnorrVerifier; use basis_core::traits::SignatureVerifier; use secp256k1; -use std::path::Path; +use std::{ + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; /// Public key type (Secp256k1) pub type PubKey = [u8; 33]; @@ -117,6 +199,21 @@ pub struct NoteConfirmation { pub confirmed_box_id: Option, /// Height at which the confirmed tracker box was observed. pub confirmed_height: Option, + /// Exact transaction whose active-chain successor committed the value. + #[serde(default)] + pub confirmed_tx_id: Option, + /// Exact active-chain block containing `confirmed_tx_id`. + #[serde(default)] + pub confirmed_block_id: Option, + /// Successor depth accepted by the versioned application policy. + #[serde(default)] + pub confirmed_successor_depth: Option, + /// Private reconciliation-intent identity which produced this projection. + #[serde(default)] + pub confirmed_intent_id: Option, + /// Exact tracker AVL root authenticated by the accepted successor. + #[serde(default)] + pub confirmed_root: Option>, /// Transaction ID of the in-flight tracker box update that covers this note. pub pending_tx_id: Option, } @@ -130,6 +227,11 @@ impl NoteConfirmation { pending_total_debt: None, confirmed_box_id: None, confirmed_height: None, + confirmed_tx_id: None, + confirmed_block_id: None, + confirmed_successor_depth: None, + confirmed_intent_id: None, + confirmed_root: None, pending_tx_id: None, } } @@ -137,17 +239,41 @@ impl NoteConfirmation { /// Returns true when the note has a confirmed value that exceeds the /// `already_redeemed` amount, i.e. there is something left to redeem. pub fn is_redeemable(&self, already_redeemed: u64) -> bool { - self.confirmed_total_debt - .map(|debt| debt > already_redeemed) - .unwrap_or(false) + self.status == NoteConfirmationStatus::Confirmed + && self.confirmed_tx_id.is_some() + && self.confirmed_box_id.is_some() + && self.confirmed_block_id.is_some() + && self.confirmed_height.is_some() + && self.confirmed_successor_depth.is_some() + && self.confirmed_intent_id.is_some() + && self.confirmed_root.is_some() + && self + .confirmed_total_debt + .map(|debt| debt > already_redeemed) + .unwrap_or(false) } /// Returns the amount that can be redeemed right now: /// `max(0, confirmed_total_debt - already_redeemed)`. pub fn redeemable_amount(&self, already_redeemed: u64) -> u64 { - self.confirmed_total_debt - .map(|debt| debt.saturating_sub(already_redeemed)) - .unwrap_or(0) + if self.is_redeemable(already_redeemed) { + self.confirmed_total_debt + .map(|debt| debt.saturating_sub(already_redeemed)) + .unwrap_or(0) + } else { + 0 + } + } + + fn has_confirmed_history(&self) -> bool { + self.confirmed_total_debt.is_some() + || self.confirmed_box_id.is_some() + || self.confirmed_height.is_some() + || self.confirmed_tx_id.is_some() + || self.confirmed_block_id.is_some() + || self.confirmed_successor_depth.is_some() + || self.confirmed_intent_id.is_some() + || self.confirmed_root.is_some() } } @@ -157,9 +283,112 @@ impl Default for NoteConfirmation { } } +/// One complete historical tracker projection reconstructed from persisted +/// per-note confirmation values and insertion order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfirmedProjectionAnchor { + tx_id: String, + box_id: String, + block_id: String, + height: u64, + successor_depth: u64, + intent_id: String, + root: [u8; 33], +} + +impl ConfirmedProjectionAnchor { + /// Construct a data-only startup projection. This value is not an + /// authorization ticket: the confirmed-chain journal must independently + /// contain an exact private validated effect before it is accepted. + #[allow(clippy::too_many_arguments)] + pub fn from_parts( + tx_id: String, + box_id: String, + block_id: String, + height: u64, + successor_depth: u64, + intent_id: String, + root: [u8; 33], + ) -> Self { + Self { + tx_id, + box_id, + block_id, + height, + successor_depth, + intent_id, + root, + } + } + + pub fn matches_validated_effect( + &self, + effect: &chain_reconciliation::ValidatedChainEffect, + ) -> bool { + effect.tracker_root() == Some(self.root) + && effect.tx_id() == self.tx_id + && effect.successor_box_id() == self.box_id + && effect.block_id() == self.block_id + && effect.inclusion_height() == self.height + && effect.successor_depth() == self.successor_depth + && effect.intent_id() == self.intent_id + } + + pub fn root(&self) -> [u8; 33] { + self.root + } + + pub fn tx_id(&self) -> &str { + &self.tx_id + } + + pub fn box_id(&self) -> &str { + &self.box_id + } + + pub fn block_id(&self) -> &str { + &self.block_id + } + + pub fn height(&self) -> u64 { + self.height + } + + pub fn successor_depth(&self) -> u64 { + self.successor_depth + } + + pub fn intent_id(&self) -> &str { + &self.intent_id + } +} + /// Note key (32 bytes) used to index confirmation records. pub type NoteKeyBytes = [u8; 32]; +/// Durable identity of one tracker-root publication that may have crossed the +/// node admission boundary but is not yet confirmed on the active chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingTrackerPublication { + digest: [u8; 33], + tx_id: String, + submitted_height: u64, +} + +impl PendingTrackerPublication { + pub fn digest(&self) -> [u8; 33] { + self.digest + } + + pub fn tx_id(&self) -> &str { + &self.tx_id + } + + pub fn submitted_height(&self) -> u64 { + self.submitted_height + } +} + /// Reserve information for a public key #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ReserveInfo { @@ -195,41 +424,6 @@ pub struct TrackerBoxInfo { pub tracker_nft_id: String, } -/// Proof for a specific note against tracker state -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NoteProof { - /// The IOU note being proven - pub note: IouNote, - /// AVL tree proof bytes - pub avl_proof: Vec, - /// Operations performed to generate the proof - pub operations: Vec, -} - -/// Tracker lookup proof for context var #8 in redemption transactions -/// Proves that totalDebt exists in the tracker's AVL tree at key hash(ownerKey||receiverKey) -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TrackerLookupProof { - /// The AVL tree key: blake2b256(ownerKey || receiverKey) (32 bytes) - pub key: Vec, - /// The value: totalDebt as 8-byte big-endian - pub value: Vec, - /// AVL proof bytes for the lookup - pub proof: Vec, -} - -/// Reserve lookup proof for context var #7 in redemption transactions -/// Proves that already_redeemed exists in the reserve's AVL tree at key hash(ownerKey||receiverKey) -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReserveLookupProof { - /// The AVL tree key: blake2b256(ownerKey || receiverKey) (32 bytes) - pub key: Vec, - /// The value: already_redeemed (8 bytes BE) - pub value: Vec, - /// AVL proof bytes for the lookup (None for first redemption) - pub proof: Option>, -} - /// Key for note lookup: blake2b256(issuer_pubkey || recipient_pubkey) #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct NoteKey { @@ -281,12 +475,79 @@ pub enum NoteError { AmountOverflow, FutureTimestamp, PastTimestamp, + DebtRegression, RedemptionTooEarly, InsufficientCollateral, + /// Existing note data uses a persistence schema that this binary will not + /// rewrite implicitly. An explicit export/migration or approved reset is required. + MigrationRequired(String), + /// The configured tracker NFT does not match the generation bound to this + /// data directory, or the first observed on-chain root does not match the + /// explicitly approved fresh-generation root. + GenerationMismatch(String), + /// A new data directory cannot be initialized until the operator explicitly + /// approves creation of a fresh tracker generation. + GenerationBindingRequired(String), + /// The bounded live-note set is full. No state was mutated and the manager + /// remains healthy. + CapacityExceeded { + limit: usize, + }, StorageError(String), + /// The storage engine reported a durability failure after beginning a WAL + /// commit. The operation may become visible after restart, so the current + /// manager must be quarantined rather than treating this as a rollback. + StorageOutcomeUnknown(String), + /// The sole tracker actor is fenced across an external commitment effect. + PublicationInProgress, + /// A publication completion/abort did not present the actor's active lease. + PublicationLeaseMismatch, + /// A node transaction identity is not exactly 32 bytes of hexadecimal. + InvalidTransactionId, UnsupportedOperation, } +/// Explicit startup policy for a tracker generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FreshGenerationApproval { + /// Open only an already-bound generation. + Deny, + /// Permit creation of a new, empty generation manifest for this NFT. + Approve, +} + +/// Persistent tracker-generation identity supplied by the server at startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrackerGenerationConfig { + pub tracker_nft_id: [u8; 32], + pub fresh_generation: FreshGenerationApproval, +} + +/// One-way health signal shared with every component capable of publishing a +/// tracker root. Once quarantined it cannot be reset in-process. +#[derive(Debug, Clone)] +pub struct PublicationHealth(Arc); + +impl PublicationHealth { + pub fn new() -> Self { + Self(Arc::new(AtomicBool::new(true))) + } + + pub fn is_healthy(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + + pub fn quarantine(&self) { + self.0.store(false, Ordering::SeqCst); + } +} + +impl Default for PublicationHealth { + fn default() -> Self { + Self::new() + } +} + impl From for NoteError { fn from(_: secp256k1::Error) -> Self { NoteError::InvalidSignature @@ -298,62 +559,108 @@ pub struct TrackerStateManager { avl_state: basis_trees::BasisAvlTree, current_state: TrackerState, storage: persistence::NoteStorage, - /// Reserve AVL tree tracking hash(ownerKey || receiverKey) -> already_redeemed (8 bytes BE) - reserve_avl_state: basis_trees::BasisAvlTree, /// Per-note confirmation records, keyed by note key (32 bytes). confirmations: std::collections::HashMap, + poisoned: AtomicBool, + publication_health: PublicationHealth, } impl TrackerStateManager { + fn poison(&self) { + self.poisoned.store(true, Ordering::SeqCst); + self.publication_health.quarantine(); + } + + fn ensure_healthy(&self) -> Result<(), NoteError> { + if self.poisoned.load(Ordering::SeqCst) || !self.publication_health.is_healthy() { + Err(NoteError::StorageOutcomeUnknown( + "Tracker state manager or confirmed-chain publisher is quarantined; restart and reconcile before exposing commitment effects" + .to_string(), + )) + } else { + Ok(()) + } + } + + fn quarantine_on_storage_failure( + &self, + result: Result, + ) -> Result { + if matches!( + result, + Err(NoteError::InvalidSignature) + | Err(NoteError::StorageError(_)) + | Err(NoteError::StorageOutcomeUnknown(_)) + | Err(NoteError::MigrationRequired(_)) + | Err(NoteError::GenerationMismatch(_)) + | Err(NoteError::GenerationBindingRequired(_)) + ) { + self.poison(); + } + result + } + + /// Returns whether this manager and every publisher sharing its one-way + /// health signal may still expose a tracker commitment. + pub fn is_healthy(&self) -> bool { + !self.poisoned.load(Ordering::SeqCst) && self.publication_health.is_healthy() + } + + /// Validate the configured NFT and first observed on-chain root against the + /// persisted generation manifest. A mismatch permanently quarantines this + /// process so a wrong data directory can never publish over that NFT. + pub fn validate_observed_generation( + &self, + tracker_nft_id: &[u8; 32], + observed_root: [u8; 33], + ) -> Result<(), NoteError> { + self.ensure_healthy()?; + // Publication is itself a state transition. Revalidate the complete + // durable snapshot against the live tree before authorizing the updater + // to spend the tracker box, even when no new note was admitted in this + // process cycle. + self.validate_complete_snapshot_against_live()?; + self.quarantine_on_storage_failure( + self.storage + .validate_or_anchor_generation(tracker_nft_id, observed_root), + ) + } + /// Create a new tracker state manager with the configured storage location. - pub fn new(data_dir: impl AsRef) -> Self { + pub fn new(data_dir: impl AsRef, generation: TrackerGenerationConfig) -> Self { + Self::try_new(data_dir, generation) + .unwrap_or_else(|e| panic!("Failed to initialize tracker state manager: {:?}", e)) + } + + /// Try to create the sole writer for a tracker state directory. + /// + /// A second in-process or cross-process writer is rejected by the storage + /// lock, and any legacy/malformed persistence state is returned as a typed + /// error instead of being silently reordered or repaired. + pub fn try_new( + data_dir: impl AsRef, + generation: TrackerGenerationConfig, + ) -> Result { + Self::try_new_with_publication_health(data_dir, generation, PublicationHealth::new()) + } + + /// Open a generation while sharing its terminal health state with the + /// component that can publish tracker commitments. + pub fn try_new_with_publication_health( + data_dir: impl AsRef, + generation: TrackerGenerationConfig, + publication_health: PublicationHealth, + ) -> Result { tracing::debug!("Creating TrackerStateManager..."); - // Use the configured storage location tracing::debug!("Opening note storage..."); let storage_path = data_dir.as_ref().join("notes"); - let storage = match persistence::NoteStorage::open(&storage_path) { - Ok(storage) => { - tracing::debug!("Note storage opened successfully at: {:?}", storage_path); - // Rebuild indices to ensure all existing notes are indexed - // (especially important after upgrading to indexed storage) - match storage.rebuild_indices() { - Ok(count) => tracing::info!("Note indices rebuilt: {} notes indexed", count), - Err(e) => tracing::warn!("Failed to rebuild note indices: {:?}", e), - } - storage - } - Err(e) => { - tracing::error!("Failed to initialize note storage: {:?}", e); - // Fallback to in-memory storage if file storage fails - // In production, this should handle errors properly - panic!("Failed to initialize note storage: {:?}", e); - } - }; + let storage = persistence::NoteStorage::open(&storage_path, generation)?; + tracing::debug!("Note storage opened successfully at: {:?}", storage_path); - // Create in-memory AVL tree - let avl_state = match basis_trees::BasisAvlTree::new() { - Ok(tree) => { - tracing::debug!("In-memory AVL tree created successfully"); - tree - } - Err(e) => { - tracing::error!("Failed to initialize AVL tree: {:?}", e); - panic!("Failed to initialize AVL tree: {:?}", e); - } - }; - - // Create reserve AVL tree for tracking already_redeemed - let reserve_avl_state = match basis_trees::BasisAvlTree::new() { - Ok(tree) => { - tracing::debug!("Reserve AVL tree created successfully"); - tree - } - Err(e) => { - tracing::error!("Failed to initialize reserve AVL tree: {:?}", e); - panic!("Failed to initialize reserve AVL tree: {:?}", e); - } - }; + let avl_state = basis_trees::BasisAvlTree::new().map_err(|e| { + NoteError::StorageError(format!("Failed to initialize AVL tree: {:?}", e)) + })?; // Rebuild AVL tree from all stored notes to ensure consistency after restart let mut manager = Self { @@ -364,67 +671,147 @@ impl TrackerStateManager { last_update_timestamp: 0, }, storage, - reserve_avl_state, confirmations: std::collections::HashMap::new(), + poisoned: AtomicBool::new(false), + publication_health, }; - if let Err(e) = manager.rebuild_avl_tree() { - tracing::warn!("Failed to rebuild AVL tree from storage: {:?}", e); - } + manager.rebuild_avl_tree()?; // Rebuild confirmation records from storage and mark every stored note as // LocalOnly until the updater confirms otherwise. - manager.rebuild_confirmations(); + manager.rebuild_confirmations()?; tracing::debug!("TrackerStateManager created successfully"); - manager + Ok(manager) } - /// Rebuild the AVL tree from all notes stored in the database. - /// This is critical after server restart to ensure the AVL tree matches - /// the on-chain commitment. AVL trees are insertion-order sensitive, - /// so notes must be inserted in chronological order (by timestamp). + /// Rebuild the AVL tree from the authoritative first-insertion order. + /// + /// AVL tree roots are insertion-order sensitive. A final note snapshot or + /// business timestamp cannot reproduce the original key insertion order, so + /// a non-empty legacy store without that order is rejected rather than + /// synthesizing a potentially different root. Repeated value updates do not + /// append history: only the final snapshot and each key's first position are + /// required to rebuild the same bounded tree state. pub fn rebuild_avl_tree(&mut self) -> Result<(), NoteError> { - tracing::info!("Rebuilding AVL tree from stored notes..."); + self.ensure_healthy()?; + tracing::info!("Rebuilding AVL tree from persistent note insertion order..."); + + let rebuilt_tree = match self.build_validated_avl_tree() { + Ok(tree) => tree, + Err(error) => { + self.poison(); + return Err(error); + } + }; - let mut notes_with_issuer = self - .storage - .get_all_notes_with_issuer() - .map_err(|e| NoteError::StorageError(format!("Failed to get all notes: {:?}", e)))?; + self.avl_state = rebuilt_tree; + self.update_state(); + let root_digest = self.current_state.avl_root_digest; + tracing::info!( + "AVL tree rebuilt successfully with root digest: {}", + hex::encode(&root_digest) + ); - if notes_with_issuer.is_empty() { - tracing::info!("No stored notes found, AVL tree remains empty"); - return Ok(()); - } + Ok(()) + } - // Sort notes by timestamp ascending to ensure deterministic insertion order - // AVL tree structure depends on insertion order, so we must insert in the - // same order as when notes were originally created - notes_with_issuer.sort_by_key(|(_, note)| note.timestamp); + fn build_validated_avl_tree(&self) -> Result { + // Build in isolation. The live tree and its published digest remain + // untouched if any storage, signature, ordering, or AVL validation fails. + let mut rebuilt_tree = basis_trees::BasisAvlTree::new().map_err(|e| { + NoteError::StorageError(format!("Failed to initialize rebuilt AVL tree: {:?}", e)) + })?; + + let persisted_state = self.storage.read_state_strict()?; + let expected_root = persisted_state.avl_root_digest; + let ordered_notes = persisted_state.notes; tracing::info!( - "Inserting {} notes into AVL tree in chronological order...", - notes_with_issuer.len() + "Replaying {} ordered live note keys...", + ordered_notes.len() ); - for (issuer_pubkey, note) in ¬es_with_issuer { - let key = NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey); + for (issuer_pubkey, note) in ordered_notes { + note.verify_signature(&issuer_pubkey) + .map_err(|_| NoteError::InvalidSignature)?; + if note.amount_redeemed > note.amount_collected { + return Err(NoteError::StorageError( + "Stored redeemed amount exceeds cumulative debt".to_string(), + )); + } + + let key = NoteKey::from_keys(&issuer_pubkey, ¬e.recipient_pubkey); let key_bytes = key.to_bytes(); let value_bytes = note.amount_collected.to_be_bytes().to_vec(); - self.avl_state.update(key_bytes, value_bytes).map_err(|e| { - NoteError::StorageError(format!("AVL tree update failed during rebuild: {:?}", e)) - })?; + rebuilt_tree + .update(key_bytes.clone(), value_bytes) + .map_err(|e| { + NoteError::StorageError(format!( + "AVL tree update failed during rebuild: {:?}", + e + )) + })?; } - self.update_state(); - let root_digest = self.current_state.avl_root_digest; - tracing::info!( - "AVL tree rebuilt successfully with root digest: {}", - hex::encode(&root_digest) - ); + if rebuilt_tree.root_digest() != expected_root { + return Err(NoteError::StorageError( + "Persisted note snapshot does not reproduce its committed AVL root".to_string(), + )); + } - Ok(()) + Ok(rebuilt_tree) + } + + fn validate_complete_snapshot_against_live( + &self, + ) -> Result { + self.ensure_healthy()?; + let state = match self.storage.read_state_strict() { + Ok(state) => state, + Err(error) => { + self.poison(); + return Err(error); + } + }; + let mut rebuilt = basis_trees::BasisAvlTree::new().map_err(|error| { + self.poison(); + NoteError::StorageError(format!("Failed to initialize validation tree: {error}")) + })?; + + for (issuer_pubkey, note) in &state.notes { + if note.verify_signature(issuer_pubkey).is_err() { + self.poison(); + return Err(NoteError::InvalidSignature); + } + if note.amount_redeemed > note.amount_collected { + self.poison(); + return Err(NoteError::StorageError( + "Stored redeemed amount exceeds cumulative debt".to_string(), + )); + } + let key = NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey).to_bytes(); + if let Err(error) = rebuilt.update(key, note.amount_collected.to_be_bytes().to_vec()) { + self.poison(); + return Err(NoteError::StorageError(format!( + "AVL validation update failed: {error}" + ))); + } + } + + let rebuilt_root = rebuilt.root_digest(); + if rebuilt_root != state.avl_root_digest + || rebuilt_root != self.avl_state.root_digest() + || rebuilt_root != self.current_state.avl_root_digest + { + self.poison(); + return Err(NoteError::StorageError( + "Persisted snapshot, physical AVL keys, and live root do not agree".to_string(), + )); + } + Ok(state) } /// Create a new tracker state manager with temporary storage (used in tests only) @@ -447,7 +834,11 @@ impl TrackerStateManager { // Try to clean up any existing storage at this path first let _ = std::fs::remove_dir_all(&storage_path); - let storage = match persistence::NoteStorage::open(&storage_path) { + let generation = TrackerGenerationConfig { + tracker_nft_id: [0x55; 32], + fresh_generation: FreshGenerationApproval::Approve, + }; + let storage = match persistence::NoteStorage::open(&storage_path, generation) { Ok(storage) => { tracing::debug!("Note storage opened successfully at: {:?}", storage_path); storage @@ -474,7 +865,7 @@ impl TrackerStateManager { // Try to clean up the retry path as well let _ = std::fs::remove_dir_all(&storage_path_retry); - match persistence::NoteStorage::open(&storage_path_retry) { + match persistence::NoteStorage::open(&storage_path_retry, generation) { Ok(storage) => { tracing::debug!( "Note storage opened successfully at retry path: {:?}", @@ -504,18 +895,6 @@ impl TrackerStateManager { } }; - // Create reserve AVL tree for tracking already_redeemed - let reserve_avl_state = match basis_trees::BasisAvlTree::new() { - Ok(tree) => { - tracing::debug!("Reserve AVL tree created successfully"); - tree - } - Err(e) => { - tracing::error!("Failed to initialize reserve AVL tree: {:?}", e); - panic!("Failed to initialize reserve AVL tree: {:?}", e); - } - }; - tracing::debug!("TrackerStateManager created successfully"); let mut manager = Self { avl_state, @@ -525,15 +904,21 @@ impl TrackerStateManager { last_update_timestamp: 0, }, storage, - reserve_avl_state, confirmations: std::collections::HashMap::new(), + poisoned: AtomicBool::new(false), + publication_health: PublicationHealth::new(), }; // Rebuild AVL tree and confirmations so test instances mirror production. if let Err(e) = manager.rebuild_avl_tree() { - tracing::warn!("Failed to rebuild AVL tree in test instance: {:?}", e); + panic!( + "Failed to rebuild AVL tree in test instance from authoritative snapshot: {:?}", + e + ); + } + if let Err(e) = manager.rebuild_confirmations() { + panic!("Failed to rebuild confirmations in test instance: {:?}", e); } - manager.rebuild_confirmations(); manager } @@ -541,6 +926,9 @@ impl TrackerStateManager { /// Add a new note to the tracker state /// Updates the AVL tree with hash(issuer||receiver) -> totalDebt mapping pub fn add_note(&mut self, issuer_pubkey: &PubKey, note: &IouNote) -> Result<(), NoteError> { + self.ensure_healthy()?; + let persisted_state = self.validate_complete_snapshot_against_live()?; + // Validate that timestamp is not in the future let current_time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -551,12 +939,25 @@ impl TrackerStateManager { return Err(NoteError::FutureTimestamp); } - // Check if there is an existing note with the same issuer-recipient pair - // and ensure the new timestamp is greater than the existing one (ever increasing) - if let Ok(existing_note) = self.lookup_note(issuer_pubkey, ¬e.recipient_pubkey) { + // A note is a cumulative debt record. Both its timestamp and totalDebt + // must be monotone for a given issuer-recipient edge. + let target_key = NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey).to_bytes(); + let existing_note = + persisted_state + .notes + .iter() + .find_map(|(stored_issuer, stored_note)| { + (NoteKey::from_keys(stored_issuer, &stored_note.recipient_pubkey).to_bytes() + == target_key) + .then_some(stored_note.clone()) + }); + if let Some(existing_note) = &existing_note { if note.timestamp <= existing_note.timestamp { return Err(NoteError::PastTimestamp); } + if note.amount_collected < existing_note.amount_collected { + return Err(NoteError::DebtRegression); + } } // Verify the note signature before storing it @@ -565,6 +966,21 @@ impl TrackerStateManager { NoteError::InvalidSignature })?; + // Capacity is an expected admission failure, not a durability or + // structural failure. Preflight it before cloning or mutating a tree. + self.storage.ensure_capacity_for_validated_state( + persisted_state.notes.len(), + existing_note.is_none(), + )?; + + // Settlement progress is tracker-derived local state and is not part of + // the issuer-signed cumulative-debt message. A newer signed successor + // must therefore preserve, rather than reset, existing redemptions. + let mut stored_note = note.clone(); + stored_note.amount_redeemed = existing_note + .map(|existing| existing.amount_redeemed) + .unwrap_or(0); + // Prepare AVL tree key: hash(issuer_pubkey || receiver_pubkey) let key = NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey); let key_bytes = key.to_bytes(); @@ -573,28 +989,38 @@ impl TrackerStateManager { // This matches the contract spec: hash(A||B) -> totalDebt let value_bytes = note.amount_collected.to_be_bytes().to_vec(); - // Update AVL tree state first to ensure consistency - let avl_result = self.avl_state.update(key_bytes.clone(), value_bytes); - - // Only proceed with database storage if AVL tree update succeeded - match avl_result { - Ok(()) => { - // Now store note in persistent storage - self.storage.store_note(issuer_pubkey, note)?; - self.update_state(); - - // Recompute the confirmation status for this note based on the - // new local value versus the confirmed/pending values. The local - // value has just changed, so the note is only Confirmed/Pending - // if the new value matches what is already on-chain / in-flight. - let mut key32 = [0u8; 32]; - key32.copy_from_slice(&key_bytes); - self.recompute_confirmation_status(&key32, note.amount_collected); - - Ok(()) + // Prepare a fully isolated tree candidate. Storage failure leaves the + // published in-memory root untouched; successful durable storage is + // followed only by an infallible ownership swap. + let mut candidate = match self.avl_state.try_clone() { + Ok(candidate) => candidate, + Err(error) => { + self.poison(); + return Err(NoteError::StorageError(error.to_string())); } - Err(e) => Err(NoteError::StorageError(e.to_string())), + }; + if let Err(error) = candidate.update(key_bytes.clone(), value_bytes) { + self.poison(); + return Err(NoteError::StorageError(error.to_string())); } + + let storage_result = + self.storage + .store_note(issuer_pubkey, &stored_note, candidate.root_digest()); + self.quarantine_on_storage_failure(storage_result)?; + + self.avl_state = candidate; + self.update_state(); + + // Recompute the confirmation status for this note based on the + // new local value versus the confirmed/pending values. The local + // value has just changed, so the note is only Confirmed/Pending + // if the new value matches what is already on-chain / in-flight. + let mut key32 = [0u8; 32]; + key32.copy_from_slice(&key_bytes); + self.recompute_confirmation_status(&key32, note.amount_collected)?; + + Ok(()) } /// Convert an issuer/recipient pair into the fixed-size confirmation key. @@ -606,86 +1032,208 @@ impl TrackerStateManager { out } - /// Recompute a single note's confirmation status from its local value versus - /// the cached confirmed/pending values. Persists the result best-effort. - fn recompute_confirmation_status(&mut self, key: &NoteKeyBytes, local_value: u64) { - let entry = self + /// Recompute and durably persist one note's confirmation status. + fn recompute_confirmation_status( + &mut self, + key: &NoteKeyBytes, + local_value: u64, + ) -> Result<(), NoteError> { + let mut updated = self .confirmations - .entry(*key) - .or_insert_with(NoteConfirmation::local_only); + .get(key) + .cloned() + .unwrap_or_else(NoteConfirmation::local_only); - let confirmed = entry.confirmed_total_debt; - let pending = entry.pending_total_debt; - - entry.status = if Some(local_value) == confirmed { + updated.status = if Some(local_value) == updated.confirmed_total_debt { NoteConfirmationStatus::Confirmed - } else if Some(local_value) == pending { + } else if Some(local_value) == updated.pending_total_debt { NoteConfirmationStatus::Pending } else { NoteConfirmationStatus::LocalOnly }; - let _ = self.storage.store_confirmation(key, entry); + let storage_result = self.storage.store_confirmation(key, &updated); + self.quarantine_on_storage_failure(storage_result)?; + self.confirmations.insert(*key, updated); + Ok(()) } /// Rebuild the in-memory confirmation map from storage. Called on startup. /// - /// On-chain state may have advanced while the server was offline, so we keep - /// the persisted `confirmed_total_debt` metadata but recompute the status from - /// the current local value and clear any pending in-flight state (pending - /// transactions do not survive a restart). - pub fn rebuild_confirmations(&mut self) { - self.confirmations.clear(); - - let notes = match self.storage.get_all_notes_with_issuer() { - Ok(n) => n, - Err(e) => { - tracing::warn!("Failed to load notes for confirmation rebuild: {:?}", e); - return; - } - }; - - let stored = self.storage.get_all_confirmations().unwrap_or_default(); + /// A pending publication survives restart only when its checksummed durable + /// receipt and every per-note pending tx id agree. Stale per-note metadata + /// without that receipt is demoted to `LocalOnly`. + pub fn rebuild_confirmations(&mut self) -> Result<(), NoteError> { + self.ensure_healthy()?; + let notes = self.validate_complete_snapshot_against_live()?.notes; + let stored = self.quarantine_on_storage_failure(self.storage.get_all_confirmations())?; + let pending_publication = + self.quarantine_on_storage_failure(self.storage.pending_publication())?; let stored_map: std::collections::HashMap = stored.into_iter().collect(); + let mut rebuilt = std::collections::HashMap::with_capacity(notes.len()); for (issuer_pubkey, note) in ¬es { let key = Self::confirmation_key(issuer_pubkey, ¬e.recipient_pubkey); let mut record = stored_map.get(&key).cloned().unwrap_or_default(); - // Pending state does not survive a restart. - record.pending_total_debt = None; - record.pending_tx_id = None; - let local_value = note.amount_collected; - record.status = if Some(local_value) == record.confirmed_total_debt { - NoteConfirmationStatus::Confirmed + let durable_pending = pending_publication.as_ref().is_some_and(|pending| { + record.pending_total_debt == Some(local_value) + && record + .pending_tx_id + .as_deref() + .is_some_and(|tx_id| tx_id.eq_ignore_ascii_case(&pending.tx_id)) + }); + record.status = if durable_pending { + NoteConfirmationStatus::Pending } else { + record.pending_total_debt = None; + record.pending_tx_id = None; + // A persisted chain point is historical evidence until the + // confirmed-chain watcher revalidates its exact block against + // a fresh coherent tip after this restart. NoteConfirmationStatus::LocalOnly }; - self.confirmations.insert(key, record); + rebuilt.insert(key, record); } + self.confirmations = rebuilt; + tracing::info!( "Rebuilt confirmation records for {} notes", self.confirmations.len() ); + Ok(()) } /// Get a clone of the confirmation record for a note, if one exists. + pub fn try_get_confirmation( + &self, + issuer_pubkey: &PubKey, + recipient_pubkey: &PubKey, + ) -> Result, NoteError> { + self.ensure_healthy()?; + let key = Self::confirmation_key(issuer_pubkey, recipient_pubkey); + Ok(self.confirmations.get(&key).cloned()) + } + + /// Compatibility accessor for process-internal callers. A quarantined + /// publisher is service-fatal and must never yield a stale confirmation. pub fn get_confirmation( &self, issuer_pubkey: &PubKey, recipient_pubkey: &PubKey, ) -> Option { - let key = Self::confirmation_key(issuer_pubkey, recipient_pubkey); - self.confirmations.get(&key).cloned() + self.try_get_confirmation(issuer_pubkey, recipient_pubkey) + .unwrap_or_else(|error| { + panic!("Cannot read confirmation from quarantined tracker: {error:?}") + }) } /// Get a snapshot of all confirmation records keyed by note key. + pub fn try_all_confirmations( + &self, + ) -> Result, NoteError> { + self.ensure_healthy()?; + Ok(self.confirmations.clone()) + } + pub fn all_confirmations(&self) -> std::collections::HashMap { - self.confirmations.clone() + self.try_all_confirmations().unwrap_or_else(|error| { + panic!("Cannot read confirmations from quarantined tracker: {error:?}") + }) + } + + /// Reconstruct the latest historical on-chain projection from the + /// persisted confirmed values, never from the newer live AVL root. + pub fn validated_confirmation_anchor( + &self, + ) -> Result, NoteError> { + self.ensure_healthy()?; + let persisted_anchor = + self.quarantine_on_storage_failure(self.storage.confirmed_projection())?; + let pending = self.quarantine_on_storage_failure(self.storage.pending_publication())?; + + // An acceptance-ready replay may have persisted any prefix of the + // advisory per-note rows. While the exact BPA1 receipt is still armed, + // only the independent global projection receipt is stable enough to + // join to the journal; the sealed journal effect will repair the rows. + if pending.is_some() { + return Ok(persisted_anchor); + } + let Some(anchor) = persisted_anchor else { + // Rollback clears the global receipt before demoting advisory rows. + // A crash in that window is completed only by ApplyRollback from + // the journal, so stale row fragments are not an anchor by + // themselves. + return Ok(None); + }; + + let snapshot = self.validate_complete_snapshot_against_live()?; + let mut historical_tree = basis_trees::BasisAvlTree::new().map_err(|error| { + NoteError::StorageError(format!( + "failed to initialize historical confirmation tree: {error}" + )) + })?; + + for (issuer_pubkey, note) in &snapshot.notes { + let key = Self::confirmation_key(issuer_pubkey, ¬e.recipient_pubkey); + let Some(record) = self.confirmations.get(&key) else { + continue; + }; + if !record.has_confirmed_history() { + continue; + } + let row_matches_anchor = record.confirmed_tx_id.as_deref() == Some(anchor.tx_id()) + && record.confirmed_box_id.as_deref() == Some(anchor.box_id()) + && record.confirmed_block_id.as_deref() == Some(anchor.block_id()) + && record.confirmed_height == Some(anchor.height()) + && record.confirmed_successor_depth == Some(anchor.successor_depth()) + && record.confirmed_intent_id.as_deref() == Some(anchor.intent_id()) + && record.confirmed_root.as_deref() == Some(anchor.root().as_slice()); + if !row_matches_anchor { + return Err(NoteError::StorageError( + "historical confirmation row does not match the durable projection anchor" + .to_string(), + )); + } + let confirmed_total = record.confirmed_total_debt.ok_or_else(|| { + NoteError::StorageError("historical confirmation lacks debt value".to_string()) + })?; + historical_tree + .update( + NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey).to_bytes(), + confirmed_total.to_be_bytes().to_vec(), + ) + .map_err(|error| { + NoteError::StorageError(format!( + "historical confirmation AVL replay failed: {error}" + )) + })?; + } + + if historical_tree.root_digest() != anchor.root { + return Err(NoteError::StorageError( + "historical confirmation values do not reproduce the accepted AVL root".to_string(), + )); + } + Ok(Some(anchor)) + } + + /// Whether any durable BNS1 chain-accounting artifact exists, including a + /// partial crash prefix whose global projection receipt was already + /// cleared or not yet written. This is a bootstrap guard, not confirmation + /// authority. + pub fn has_persisted_confirmation_history(&self) -> Result { + self.ensure_healthy()?; + let global = self.quarantine_on_storage_failure(self.storage.confirmed_projection())?; + Ok(global.is_some() + || self + .confirmations + .values() + .any(NoteConfirmation::has_confirmed_history)) } /// Mark every note whose local value differs from its confirmed value as @@ -697,23 +1245,37 @@ impl TrackerStateManager { tx_id: &str, submitted_height: u64, ) -> Result { - let _ = (digest, submitted_height); - let notes = self.storage.get_all_notes_with_issuer()?; + self.ensure_healthy()?; + let snapshot = self.validate_complete_snapshot_against_live()?; + if snapshot.avl_root_digest != digest { + return Err(NoteError::PublicationLeaseMismatch); + } + let publication = PendingTrackerPublication { + digest, + tx_id: tx_id.to_ascii_lowercase(), + submitted_height, + }; + let storage_result = self.storage.store_pending_publication(&publication); + self.quarantine_on_storage_failure(storage_result)?; + let notes = snapshot.notes; let mut count = 0usize; for (issuer_pubkey, note) in ¬es { let key = Self::confirmation_key(issuer_pubkey, ¬e.recipient_pubkey); let local_value = note.amount_collected; - let entry = self + let mut updated = self .confirmations - .entry(key) - .or_insert_with(NoteConfirmation::local_only); - - if Some(local_value) != entry.confirmed_total_debt { - entry.pending_total_debt = Some(local_value); - entry.pending_tx_id = Some(tx_id.to_string()); - entry.status = NoteConfirmationStatus::Pending; - let _ = self.storage.store_confirmation(&key, entry); + .get(&key) + .cloned() + .unwrap_or_else(NoteConfirmation::local_only); + + if Some(local_value) != updated.confirmed_total_debt { + updated.pending_total_debt = Some(local_value); + updated.pending_tx_id = Some(tx_id.to_string()); + updated.status = NoteConfirmationStatus::Pending; + let storage_result = self.storage.store_confirmation(&key, &updated); + self.quarantine_on_storage_failure(storage_result)?; + self.confirmations.insert(key, updated); count += 1; } } @@ -727,32 +1289,143 @@ impl TrackerStateManager { Ok(count) } + /// Return the checksummed external-effect receipt, if a tracker publication + /// is still awaiting active-chain reconciliation. + pub fn pending_publication(&self) -> Result, NoteError> { + self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; + self.quarantine_on_storage_failure(self.storage.pending_publication()) + } + /// Promote every `Pending` note to `Confirmed`, copying the pending value to /// the confirmed value and recording the confirming box metadata. Returns the /// number of notes transitioned to `Confirmed`. - pub fn confirm_pending_notes(&mut self, box_id: &str, height: u64) -> Result { - let keys: Vec = self.confirmations.keys().copied().collect(); + #[cfg(test)] + pub(crate) fn confirm_pending_notes( + &mut self, + box_id: &str, + height: u64, + ) -> Result { + let pending = self + .pending_publication()? + .ok_or(NoteError::PublicationLeaseMismatch)?; + self.confirm_pending_publication(&pending.tx_id, box_id, height) + } + + /// Confirm exactly the durable publication receipt observed on the active + /// chain. A different transaction cannot release the publication fence. + #[cfg(test)] + pub(crate) fn confirm_pending_publication( + &mut self, + tx_id: &str, + box_id: &str, + height: u64, + ) -> Result { + self.apply_confirmed_tracker_projection( + tx_id, + &"00".repeat(32), + box_id, + &"00".repeat(32), + height, + 0, + None, + ) + } + + /// Apply only a private ticket which has already bound the signed + /// transaction to its exact predecessor, successor, R5 root, active block, + /// coherent tip and configured successor depth. + pub fn confirm_validated_publication( + &mut self, + effect: &chain_reconciliation::ValidatedChainEffect, + ) -> Result { + let root = effect + .tracker_root() + .ok_or(NoteError::UnsupportedOperation)?; + self.apply_confirmed_tracker_projection( + effect.tx_id(), + effect.intent_id(), + effect.successor_box_id(), + effect.block_id(), + effect.inclusion_height(), + effect.successor_depth(), + Some(root), + ) + } + + #[allow(clippy::too_many_arguments)] + fn apply_confirmed_tracker_projection( + &mut self, + tx_id: &str, + intent_id: &str, + box_id: &str, + block_id: &str, + height: u64, + successor_depth: u64, + expected_root: Option<[u8; 33]>, + ) -> Result { + self.ensure_healthy()?; + let snapshot = self.validate_complete_snapshot_against_live()?; + let pending = self.quarantine_on_storage_failure(self.storage.pending_publication())?; + let Some(pending) = pending else { + let root = expected_root.ok_or(NoteError::PublicationLeaseMismatch)?; + return self.restore_confirmed_tracker_projection( + tx_id, + intent_id, + box_id, + block_id, + height, + successor_depth, + root, + ); + }; + if !pending.tx_id.eq_ignore_ascii_case(tx_id) + || pending.digest != snapshot.avl_root_digest + || expected_root.is_some_and(|root| root != snapshot.avl_root_digest) + { + return Err(NoteError::PublicationLeaseMismatch); + } + let confirmed_root = expected_root.unwrap_or(snapshot.avl_root_digest); let mut count = 0usize; - for key in keys { - let should_confirm = self + // The publication receipt is persisted before the per-note advisory + // records. A crash can therefore leave any prefix of those records on + // disk. The confirmed root authenticates the complete current snapshot, + // so replay confirmation from that snapshot instead of trusting which + // advisory rows happened to reach storage before the crash. + for (issuer_pubkey, note) in &snapshot.notes { + let key = Self::confirmation_key(issuer_pubkey, ¬e.recipient_pubkey); + let mut updated = self .confirmations .get(&key) - .map(|c| c.status == NoteConfirmationStatus::Pending) - .unwrap_or(false); - - if should_confirm { - if let Some(entry) = self.confirmations.get_mut(&key) { - entry.confirmed_total_debt = entry.pending_total_debt; - entry.pending_total_debt = None; - entry.pending_tx_id = None; - entry.confirmed_box_id = Some(box_id.to_string()); - entry.confirmed_height = Some(height); - entry.status = NoteConfirmationStatus::Confirmed; - let _ = self.storage.store_confirmation(&key, entry); - count += 1; - } - } + .cloned() + .unwrap_or_else(NoteConfirmation::local_only); + let changed = updated.confirmed_total_debt != Some(note.amount_collected) + || updated.status != NoteConfirmationStatus::Confirmed + || updated.pending_total_debt.is_some() + || updated.pending_tx_id.is_some() + || updated.confirmed_box_id.as_deref() != Some(box_id) + || updated.confirmed_height != Some(height) + || updated.confirmed_tx_id.as_deref() != Some(tx_id) + || updated.confirmed_block_id.as_deref() != Some(block_id) + || updated.confirmed_successor_depth != Some(successor_depth) + || updated.confirmed_intent_id.as_deref() != Some(intent_id) + || updated.confirmed_root.as_deref() != Some(confirmed_root.as_slice()); + updated.confirmed_total_debt = Some(note.amount_collected); + updated.pending_total_debt = None; + updated.pending_tx_id = None; + updated.confirmed_box_id = Some(box_id.to_string()); + updated.confirmed_height = Some(height); + updated.confirmed_tx_id = Some(tx_id.to_ascii_lowercase()); + updated.confirmed_block_id = Some(block_id.to_ascii_lowercase()); + updated.confirmed_successor_depth = Some(successor_depth); + updated.confirmed_intent_id = Some(intent_id.to_ascii_lowercase()); + updated.confirmed_root = Some(confirmed_root.to_vec()); + updated.status = NoteConfirmationStatus::Confirmed; + let storage_result = self.storage.store_confirmation(&key, &updated); + self.quarantine_on_storage_failure(storage_result)?; + self.confirmations.insert(key, updated); + count += usize::from(changed); } tracing::info!( @@ -761,6 +1434,138 @@ impl TrackerStateManager { box_id, height ); + if expected_root.is_some() { + let anchor = ConfirmedProjectionAnchor::from_parts( + tx_id.to_ascii_lowercase(), + box_id.to_ascii_lowercase(), + block_id.to_ascii_lowercase(), + height, + successor_depth, + intent_id.to_ascii_lowercase(), + confirmed_root, + ); + let storage_result = self.storage.store_confirmed_projection(&anchor); + self.quarantine_on_storage_failure(storage_result)?; + } + let clear_result = self.storage.clear_pending_publication(tx_id); + self.quarantine_on_storage_failure(clear_result)?; + Ok(count) + } + + #[allow(clippy::too_many_arguments)] + fn restore_confirmed_tracker_projection( + &mut self, + tx_id: &str, + intent_id: &str, + box_id: &str, + block_id: &str, + height: u64, + successor_depth: u64, + root: [u8; 33], + ) -> Result { + let anchor = self + .validated_confirmation_anchor()? + .ok_or(NoteError::PublicationLeaseMismatch)?; + if anchor.tx_id != tx_id + || anchor.intent_id != intent_id + || anchor.box_id != box_id + || anchor.block_id != block_id + || anchor.height != height + || anchor.successor_depth != successor_depth + || anchor.root != root + { + return Err(NoteError::PublicationLeaseMismatch); + } + let snapshot = self.validate_complete_snapshot_against_live()?; + let mut count = 0usize; + for (issuer_pubkey, note) in &snapshot.notes { + let key = Self::confirmation_key(issuer_pubkey, ¬e.recipient_pubkey); + let Some(mut updated) = self.confirmations.get(&key).cloned() else { + continue; + }; + if updated.confirmed_tx_id.as_deref() != Some(tx_id) + || updated.confirmed_box_id.as_deref() != Some(box_id) + || updated.confirmed_block_id.as_deref() != Some(block_id) + || updated.confirmed_height != Some(height) + || updated.confirmed_successor_depth != Some(successor_depth) + || updated.confirmed_intent_id.as_deref() != Some(intent_id) + || updated.confirmed_root.as_deref() != Some(root.as_slice()) + { + continue; + } + let restored_status = if updated.confirmed_total_debt == Some(note.amount_collected) { + NoteConfirmationStatus::Confirmed + } else { + NoteConfirmationStatus::LocalOnly + }; + let changed = updated.status != restored_status + || updated.pending_total_debt.is_some() + || updated.pending_tx_id.is_some(); + updated.status = restored_status; + updated.pending_total_debt = None; + updated.pending_tx_id = None; + let storage_result = self.storage.store_confirmation(&key, &updated); + self.quarantine_on_storage_failure(storage_result)?; + self.confirmations.insert(key, updated); + count += usize::from(changed); + } + Ok(count) + } + + /// Demote exactly the application projection whose accepted block has been + /// displaced at its original height. Duplicate rollback replay is a no-op. + pub fn rollback_validated_publication( + &mut self, + rollback: &chain_reconciliation::ValidatedRollback, + ) -> Result { + self.ensure_healthy()?; + let projection = self.quarantine_on_storage_failure(self.storage.confirmed_projection())?; + if projection.as_ref().is_some_and(|anchor| { + anchor.tx_id() != rollback.tx_id() + || anchor.block_id() != rollback.removed_block_id() + || anchor.intent_id() != rollback.intent_id() + }) { + return Err(NoteError::PublicationLeaseMismatch); + } + // Remove the authoritative projection receipt before touching the + // advisory rows. A crash at any later point restarts with no accepted + // projection and the durable ApplyRollback ticket completes the + // demotion idempotently. + let clear_result = self.storage.clear_confirmed_projection(); + self.quarantine_on_storage_failure(clear_result)?; + let notes = self.validate_complete_snapshot_against_live()?.notes; + let mut count = 0usize; + for (issuer_pubkey, note) in notes { + let key = Self::confirmation_key(&issuer_pubkey, ¬e.recipient_pubkey); + let Some(mut updated) = self.confirmations.get(&key).cloned() else { + continue; + }; + let matches_removed_effect = updated.confirmed_tx_id.as_deref() + == Some(rollback.tx_id()) + && updated.confirmed_block_id.as_deref() == Some(rollback.removed_block_id()) + && updated.confirmed_intent_id.as_deref() == Some(rollback.intent_id()); + if !matches_removed_effect { + continue; + } + updated.status = + if updated.pending_tx_id.is_some() && updated.pending_total_debt.is_some() { + NoteConfirmationStatus::Pending + } else { + NoteConfirmationStatus::LocalOnly + }; + updated.confirmed_total_debt = None; + updated.confirmed_box_id = None; + updated.confirmed_height = None; + updated.confirmed_tx_id = None; + updated.confirmed_block_id = None; + updated.confirmed_successor_depth = None; + updated.confirmed_intent_id = None; + updated.confirmed_root = None; + let storage_result = self.storage.store_confirmation(&key, &updated); + self.quarantine_on_storage_failure(storage_result)?; + self.confirmations.insert(key, updated); + count += 1; + } Ok(count) } @@ -768,8 +1573,10 @@ impl TrackerStateManager { /// transaction is dropped or rejected). Clears pending metadata and recomputes /// the status from the local value versus the confirmed value. Returns the /// number of notes reverted. - pub fn revert_pending_notes(&mut self) -> Result { - let notes = self.storage.get_all_notes_with_issuer()?; + #[cfg(test)] + pub(crate) fn revert_pending_notes(&mut self) -> Result { + self.ensure_healthy()?; + let notes = self.validate_complete_snapshot_against_live()?.notes; let mut count = 0usize; for (issuer_pubkey, note) in ¬es { @@ -781,22 +1588,30 @@ impl TrackerStateManager { .unwrap_or(false); if is_pending { - if let Some(entry) = self.confirmations.get_mut(&key) { - entry.pending_total_debt = None; - entry.pending_tx_id = None; + if let Some(mut updated) = self.confirmations.get(&key).cloned() { + updated.pending_total_debt = None; + updated.pending_tx_id = None; let local_value = note.amount_collected; - entry.status = if Some(local_value) == entry.confirmed_total_debt { + updated.status = if Some(local_value) == updated.confirmed_total_debt { NoteConfirmationStatus::Confirmed } else { NoteConfirmationStatus::LocalOnly }; - let _ = self.storage.store_confirmation(&key, entry); + let storage_result = self.storage.store_confirmation(&key, &updated); + self.quarantine_on_storage_failure(storage_result)?; + self.confirmations.insert(key, updated); count += 1; } } } tracing::info!("Reverted {} pending notes to local state", count); + if let Some(pending) = + self.quarantine_on_storage_failure(self.storage.pending_publication())? + { + let clear_result = self.storage.clear_pending_publication(&pending.tx_id); + self.quarantine_on_storage_failure(clear_result)?; + } Ok(count) } @@ -804,37 +1619,41 @@ impl TrackerStateManager { /// confirmed digest equals the current local digest, every note's local value /// is the confirmed value, so mark them all as `Confirmed` with the given box /// metadata. Returns the number of notes promoted to `Confirmed`. - pub fn reconcile_with_confirmed_digest( + #[cfg(test)] + pub(crate) fn reconcile_with_confirmed_digest( &mut self, confirmed_digest: &[u8; 33], box_id: &str, height: u64, ) -> Result { + self.ensure_healthy()?; + let notes = self.validate_complete_snapshot_against_live()?.notes; if confirmed_digest != &self.current_state.avl_root_digest { return Ok(0); } - - let notes = self.storage.get_all_notes_with_issuer()?; let mut count = 0usize; for (issuer_pubkey, note) in ¬es { let key = Self::confirmation_key(issuer_pubkey, ¬e.recipient_pubkey); let local_value = note.amount_collected; - let entry = self + let mut updated = self .confirmations - .entry(key) - .or_insert_with(NoteConfirmation::local_only); + .get(&key) + .cloned() + .unwrap_or_else(NoteConfirmation::local_only); - if entry.status != NoteConfirmationStatus::Confirmed - || entry.confirmed_total_debt != Some(local_value) + if updated.status != NoteConfirmationStatus::Confirmed + || updated.confirmed_total_debt != Some(local_value) { - entry.confirmed_total_debt = Some(local_value); - entry.pending_total_debt = None; - entry.pending_tx_id = None; - entry.confirmed_box_id = Some(box_id.to_string()); - entry.confirmed_height = Some(height); - entry.status = NoteConfirmationStatus::Confirmed; - let _ = self.storage.store_confirmation(&key, entry); + updated.confirmed_total_debt = Some(local_value); + updated.pending_total_debt = None; + updated.pending_tx_id = None; + updated.confirmed_box_id = Some(box_id.to_string()); + updated.confirmed_height = Some(height); + updated.status = NoteConfirmationStatus::Confirmed; + let storage_result = self.storage.store_confirmation(&key, &updated); + self.quarantine_on_storage_failure(storage_result)?; + self.confirmations.insert(key, updated); count += 1; } } @@ -850,48 +1669,69 @@ impl TrackerStateManager { Ok(count) } - /// Update an existing note in the tracker state - /// Updates the AVL tree with hash(issuer||receiver) -> totalDebt mapping - pub fn update_note(&mut self, issuer_pubkey: &PubKey, note: &IouNote) -> Result<(), NoteError> { - // Validate that timestamp is not in the future - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| NoteError::StorageError("Failed to get current time".to_string()))? - .as_millis() as u64; - - if note.timestamp > current_time { - return Err(NoteError::FutureTimestamp); + /// Persist tracker-derived settlement progress without accepting an arbitrary + /// replacement for the issuer-signed note. + /// + /// The signed cumulative debt, timestamp, recipient and signature are kept + /// byte-for-byte. Only the unsigned local `amount_redeemed` field may advance, + /// with checked arithmetic and a hard cap at `amount_collected`. + #[cfg(test)] + pub(crate) fn record_redemption_progress( + &mut self, + issuer_pubkey: &PubKey, + recipient_pubkey: &PubKey, + redeemed_amount: u64, + ) -> Result { + self.ensure_healthy()?; + let target_key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey).to_bytes(); + let mut note = self + .validate_complete_snapshot_against_live()? + .notes + .into_iter() + .find_map(|(stored_issuer, note)| { + (NoteKey::from_keys(&stored_issuer, ¬e.recipient_pubkey).to_bytes() + == target_key) + .then_some(note) + }) + .ok_or_else(|| NoteError::StorageError("Note not found".to_string()))?; + if note.verify_signature(issuer_pubkey).is_err() { + self.poison(); + return Err(NoteError::InvalidSignature); } - // Check if there is an existing note with the same issuer-recipient pair - // and ensure the new timestamp is greater than the existing one (ever increasing) - if let Ok(existing_note) = self.lookup_note(issuer_pubkey, ¬e.recipient_pubkey) { - if note.timestamp <= existing_note.timestamp { - return Err(NoteError::PastTimestamp); + let committed_total = match self.get_total_debt(issuer_pubkey, recipient_pubkey) { + Ok(total) => total, + Err(error) => { + // A persisted note without the matching live AVL entry means the + // two authoritative views have diverged. Do not keep serving a + // root or accepting writes from this manager instance. + self.poison(); + return Err(error); } + }; + if committed_total != note.amount_collected { + self.poison(); + return Err(NoteError::StorageError( + "Stored note does not match the live AVL commitment".to_string(), + )); } - // Prepare AVL tree key: hash(issuer_pubkey || receiver_pubkey) - let key = NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey); - let key_bytes = key.to_bytes(); - - // Value is just the totalDebt (amount_collected) as 8-byte big-endian - // This matches the contract spec: hash(A||B) -> totalDebt - let value_bytes = note.amount_collected.to_be_bytes().to_vec(); - - // Update AVL tree state first to ensure consistency - let avl_result = self.avl_state.update(key_bytes.clone(), value_bytes); - - // Only proceed with database storage if AVL tree update succeeded - match avl_result { - Ok(()) => { - // Now store note in persistent storage - self.storage.store_note(issuer_pubkey, note)?; - self.update_state(); - Ok(()) - } - Err(e) => Err(NoteError::StorageError(e.to_string())), + let new_amount_redeemed = note + .amount_redeemed + .checked_add(redeemed_amount) + .ok_or(NoteError::AmountOverflow)?; + if new_amount_redeemed > note.amount_collected { + return Err(NoteError::StorageError( + "Redeemed amount exceeds cumulative debt".to_string(), + )); } + + note.amount_redeemed = new_amount_redeemed; + let storage_result = + self.storage + .store_note(issuer_pubkey, ¬e, self.current_state.avl_root_digest); + self.quarantine_on_storage_failure(storage_result)?; + Ok(note) } /// Get the total debt for a specific (issuer, receiver) pair from the AVL tree @@ -901,6 +1741,7 @@ impl TrackerStateManager { issuer_pubkey: &PubKey, recipient_pubkey: &PubKey, ) -> Result { + self.ensure_healthy()?; let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); let key_bytes = key.to_bytes(); @@ -921,217 +1762,75 @@ impl TrackerStateManager { Ok(u64::from_be_bytes(bytes)) } - /// Generate a tracker lookup proof for context var #8 - /// This proof verifies that totalDebt exists in the tracker's AVL tree - pub fn generate_tracker_lookup_proof( - &mut self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - - // Get the total debt value - let total_debt = self.get_total_debt(issuer_pubkey, recipient_pubkey)?; - - // Generate AVL proof for the lookup of this specific key - let (avl_proof, _returned_value) = self.avl_state.generate_lookup_proof(key_bytes.to_vec()); - - Ok(TrackerLookupProof { - key: key_bytes, - value: total_debt.to_be_bytes().to_vec(), - proof: avl_proof, - }) - } - - /// Get the already_redeemed amount for a specific (issuer, receiver) pair from the reserve AVL tree - /// Returns the cumulative redeemed amount stored in the reserve's AVL tree - pub fn get_already_redeemed( + /// Lookup a note by issuer and recipient + pub fn lookup_note( &self, issuer_pubkey: &PubKey, recipient_pubkey: &PubKey, - ) -> Result { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - - // Lookup value in reserve AVL tree - let value_bytes = match self.reserve_avl_state.get(&key_bytes) { - Some(bytes) => bytes, - None => return Ok(0u64), // First redemption - no already_redeemed amount - }; - - // Value format: timestamp (8 bytes BE) || redeemedAmount (8 bytes BE) = 16 bytes total - if value_bytes.len() != 16 { - return Err(NoteError::StorageError(format!( - "Invalid reserve tree value format: expected 16 bytes (timestamp || redeemedAmount), got {}", - value_bytes.len() - ))); - } - - let mut redeemed_bytes = [0u8; 8]; - redeemed_bytes.copy_from_slice(&value_bytes[8..16]); - Ok(u64::from_be_bytes(redeemed_bytes)) + ) -> Result { + self.ensure_healthy()?; + self.quarantine_on_storage_failure(self.storage.get_note(issuer_pubkey, recipient_pubkey))? + .ok_or_else(|| NoteError::StorageError("Note not found".to_string())) } - /// Generate a reserve lookup proof for context var #7 - /// This proof verifies that already_redeemed exists in the reserve's AVL tree - /// Returns None proof for first redemption (no lookup proof needed) - pub fn generate_reserve_lookup_proof( - &mut self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - - // Get the already_redeemed value - let already_redeemed = self.get_already_redeemed(issuer_pubkey, recipient_pubkey)?; - - // For first redemption, no lookup proof is needed (per spec) - let is_first_redemption = already_redeemed == 0; - - // Value: timestamp (8 bytes BE) || already_redeemed (8 bytes BE) = 16 bytes - let mut value_bytes = Vec::with_capacity(16); - // For lookup, use the stored timestamp if available; otherwise 0 for first redemption. - // The persistent tree stores timestamp || already_redeemed, so retrieve the actual value. - let stored_value = self.reserve_avl_state.get(&key_bytes).unwrap_or_else(|| { - let mut empty_value = vec![0u8; 8]; // timestamp = 0 - empty_value.extend_from_slice(&already_redeemed.to_be_bytes()); - empty_value - }); - if stored_value.len() == 16 { - value_bytes.extend_from_slice(&stored_value); - } else { - // Fallback for old-format entries or first redemption: 0 timestamp - value_bytes.extend_from_slice(&0u64.to_be_bytes()); - value_bytes.extend_from_slice(&already_redeemed.to_be_bytes()); - } - - if is_first_redemption { - Ok(ReserveLookupProof { - key: key_bytes, - value: value_bytes, - proof: None, // Omitted for first redemption - }) - } else { - // Generate AVL proof for the lookup of this specific key - let (avl_proof, _returned_value) = self - .reserve_avl_state - .generate_lookup_proof(key_bytes.to_vec()); - - Ok(ReserveLookupProof { - key: key_bytes, - value: value_bytes, - proof: Some(avl_proof), - }) - } + /// Get all notes for a specific issuer + pub fn get_issuer_notes(&self, issuer_pubkey: &PubKey) -> Result, NoteError> { + self.ensure_healthy()?; + self.quarantine_on_storage_failure(self.storage.get_issuer_notes(issuer_pubkey)) } - /// Generate a reserve insert proof for context var #5 and return updated tree digest for R5. + /// Calculate a conservative issuer-wide debt snapshot for an acceptance check. /// - /// This operates on a temporary clone of the reserve AVL tree so that proof generation - /// is idempotent and does not mutate the persistent tracker state. The persistent tree - /// is only updated when `update_already_redeemed` is called after a successful on-chain - /// redemption. - /// - /// Value format: timestamp (8 bytes BE) || already_redeemed (8 bytes BE) = 16 bytes total - /// - /// # Returns - /// * `(insert_proof, updated_tree_digest)` - Proof bytes and serialized tree digest - pub fn generate_reserve_insert_proof( + /// Every edge uses the greatest cumulative `totalDebt` known locally, pending, + /// or confirmed. The candidate replaces its recipient edge exactly once, but a + /// lower candidate cannot reduce an already observed cumulative value. When no + /// recipient is supplied, the candidate is treated as a new edge. Gross debt is + /// intentional here: local redemption state is not yet reconstructed on reorgs, + /// so subtracting it could make a collateral check fail open. + pub fn projected_issuer_gross_debt( &self, issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - timestamp: u64, - new_already_redeemed: u64, - ) -> Result<(Vec, Vec), NoteError> { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - // Value: timestamp (8 bytes BE) || already_redeemed (8 bytes BE) - let mut value_bytes = Vec::with_capacity(16); - value_bytes.extend_from_slice(×tamp.to_be_bytes()); - value_bytes.extend_from_slice(&new_already_redeemed.to_be_bytes()); - - // Use the non-mutating proof generator so repeated calls return the same proof. - let (insert_proof, updated_digest) = self - .reserve_avl_state - .generate_insert_proof(key_bytes, value_bytes) - .map_err(|e| { - NoteError::StorageError(format!("Reserve AVL tree insert proof failed: {}", e)) - })?; - - Ok((insert_proof, updated_digest.to_vec())) - } - - /// Current reserve AVL tree root digest (33 bytes). The on-chain reserve box being spent must - /// have exactly this R5 digest for the insert proof to verify on-chain. - pub fn reserve_state_digest(&self) -> Vec { - self.reserve_avl_state.root_digest().to_vec() - } - - /// Update the already_redeemed amount in the reserve AVL tree. - /// Called after a successful redemption to prevent double-spending. - /// Value format: timestamp (8 bytes BE) || already_redeemed (8 bytes BE) = 16 bytes total - pub fn update_already_redeemed( - &mut self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - timestamp: u64, - already_redeemed: u64, - ) -> Result<(), NoteError> { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - let mut value_bytes = Vec::with_capacity(16); - value_bytes.extend_from_slice(×tamp.to_be_bytes()); - value_bytes.extend_from_slice(&already_redeemed.to_be_bytes()); - - // Update reserve AVL tree - self.reserve_avl_state - .update(key_bytes, value_bytes) - .map_err(|e| { - NoteError::StorageError(format!("Reserve AVL tree update failed: {}", e)) - })?; - - Ok(()) - } - - /// Generate proof for a specific note - pub fn generate_proof( - &mut self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - - // Generate a lookup proof for the key in the AVL tree - // This captures the path to the key, which can be verified against the root digest - let (avl_proof, _value) = self.avl_state.generate_lookup_proof(key_bytes.to_vec()); + candidate_recipient: Option<&PubKey>, + candidate_total_debt: u64, + ) -> Result { + self.ensure_healthy()?; + // The versioned primary snapshot is the sole liability authority. A + // malformed, missing or root-inconsistent snapshot fails this check. + let notes = self + .quarantine_on_storage_failure(self.storage.get_issuer_notes_strict(issuer_pubkey))?; + let mut total = 0u64; + let mut replaced_candidate_edge = false; + + for note in notes { + let confirmation = self.get_confirmation(issuer_pubkey, ¬e.recipient_pubkey); + let mut edge_debt = note.amount_collected; + + if let Some(confirmation) = confirmation { + if let Some(confirmed) = confirmation.confirmed_total_debt { + edge_debt = edge_debt.max(confirmed); + } + if let Some(pending) = confirmation.pending_total_debt { + edge_debt = edge_debt.max(pending); + } + } - // Lookup the note to include in proof - let note = self.lookup_note(issuer_pubkey, recipient_pubkey)?; + if candidate_recipient == Some(¬e.recipient_pubkey) { + edge_debt = edge_debt.max(candidate_total_debt); + replaced_candidate_edge = true; + } - Ok(NoteProof { - note, - avl_proof, - operations: Vec::new(), - }) - } + total = total + .checked_add(edge_debt) + .ok_or(NoteError::AmountOverflow)?; + } - /// Lookup a note by issuer and recipient - pub fn lookup_note( - &self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result { - self.storage - .get_note(issuer_pubkey, recipient_pubkey)? - .ok_or_else(|| NoteError::StorageError("Note not found".to_string())) - } + if candidate_recipient.is_none() || !replaced_candidate_edge { + total = total + .checked_add(candidate_total_debt) + .ok_or(NoteError::AmountOverflow)?; + } - /// Get all notes for a specific issuer - pub fn get_issuer_notes(&self, issuer_pubkey: &PubKey) -> Result, NoteError> { - self.storage.get_issuer_notes(issuer_pubkey) + Ok(total) } /// Get all notes for a specific recipient @@ -1139,7 +1838,8 @@ impl TrackerStateManager { &self, recipient_pubkey: &PubKey, ) -> Result, NoteError> { - self.storage.get_recipient_notes(recipient_pubkey) + self.ensure_healthy()?; + self.quarantine_on_storage_failure(self.storage.get_recipient_notes(recipient_pubkey)) } /// Get all notes for a specific recipient with issuer information @@ -1147,18 +1847,23 @@ impl TrackerStateManager { &self, recipient_pubkey: &PubKey, ) -> Result, NoteError> { - self.storage - .get_recipient_notes_with_issuer(recipient_pubkey) + self.ensure_healthy()?; + self.quarantine_on_storage_failure( + self.storage + .get_recipient_notes_with_issuer(recipient_pubkey), + ) } /// Get all notes in the tracker pub fn get_all_notes(&self) -> Result, NoteError> { - self.storage.get_all_notes() + self.ensure_healthy()?; + self.quarantine_on_storage_failure(self.storage.get_all_notes()) } /// Get all notes in the tracker with issuer information pub fn get_all_notes_with_issuer(&self) -> Result, NoteError> { - self.storage.get_all_notes_with_issuer() + self.ensure_healthy()?; + self.quarantine_on_storage_failure(self.storage.get_all_notes_with_issuer()) } /// Update the current state with latest AVL tree root @@ -1171,8 +1876,25 @@ impl TrackerStateManager { .as_millis() as u64; } - /// Get the current tracker state + /// Return a fully validated snapshot of the current tracker state. + /// + /// Root exposure is a publication boundary: the checksummed BNS2 snapshot, + /// replayed physical AVL state, and cached digest must still agree at the + /// instant this value is produced. + pub fn validated_state(&self) -> Result { + self.ensure_healthy()?; + self.validate_complete_snapshot_against_live()?; + Ok(self.current_state.clone()) + } + + /// Compatibility accessor for local callers. It performs the same complete + /// validation but preserves the historical reference-returning API. Server + /// publication surfaces use `validated_state` through the sole actor so a + /// quarantine becomes a typed unavailable response rather than a panic. pub fn get_state(&self) -> &TrackerState { + if let Err(error) = self.validated_state() { + panic!("Cannot expose invalid tracker state: {error:?}"); + } &self.current_state } } @@ -1184,6 +1906,7 @@ impl TrackerStateManager { issuer_pubkey_hex: &str, reserve_tracker: &ReserveTracker, ) -> Result { + self.ensure_healthy()?; // Get all reserves from the reserve tracker let all_reserves = reserve_tracker.get_all_reserves(); @@ -1374,8 +2097,5 @@ pub use ergo_scanner::{ ScannerError, ServerState, }; -// Re-export redemption types -pub use redemption::{RedemptionData, RedemptionError, RedemptionManager, RedemptionRequest}; - // Re-export reqwest for use in dependent crates pub use reqwest; diff --git a/crates/basis_store/src/persistence.rs b/crates/basis_store/src/persistence.rs index f6b2929..98b3f1b 100644 --- a/crates/basis_store/src/persistence.rs +++ b/crates/basis_store/src/persistence.rs @@ -1,26 +1,73 @@ -//! Persistence layer for IouNote storage using fjall database with extra indices -//! -//! This module provides efficient storage and retrieval of IOU notes with secondary indices -//! for fast lookups by issuer, recipient, and timestamp without full partition scans. +//! Persistence layer for a bounded, versioned IOU-note state snapshot. use crate::{ - reserve_tracker::ExtendedReserveInfo, IouNote, NoteConfirmation, NoteError, NoteKey, PubKey, - TrackerBoxInfo, + blake2b256_hash, reserve_tracker::ExtendedReserveInfo, ConfirmedProjectionAnchor, + FreshGenerationApproval, IouNote, NoteConfirmation, NoteError, NoteKey, + PendingTrackerPublication, PubKey, TrackerBoxInfo, TrackerGenerationConfig, }; -use fjall::{Config, PartitionCreateOptions}; -use std::path::Path; +use fjall::{Config, Keyspace, PartitionCreateOptions, PersistMode}; +use fs2::FileExt; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::{ + fs::{File, OpenOptions}, + path::Path, + sync::Mutex, +}; + +const NOTE_STATE_MAGIC: &[u8; 4] = b"BNS2"; +const LEGACY_NOTE_STATE_MAGIC: &[u8; 4] = b"BNS1"; +const NOTE_STATE_KEY: &[u8] = b"note_state_v2"; +const NOTE_SCHEMA_KEY: &[u8] = b"note_schema_v2"; +const NOTE_GENERATION_KEY: &[u8] = b"tracker_generation_v1"; +const PENDING_PUBLICATION_KEY: &[u8] = b"pending_publication_v1"; +const CONFIRMED_PROJECTION_KEY: &[u8] = b"confirmed_projection_v1"; +const GENERATION_MAGIC: &[u8; 4] = b"BNG1"; +const PENDING_PUBLICATION_MAGIC: &[u8; 4] = b"BPA1"; +const CONFIRMED_PROJECTION_MAGIC: &[u8; 4] = b"BCP1"; +const SNAPSHOT_CHECKSUM_DOMAIN: &[u8] = b"basis-note-state-snapshot-v2"; +const GENERATION_CHECKSUM_DOMAIN: &[u8] = b"basis-tracker-generation-v1"; +const PENDING_PUBLICATION_CHECKSUM_DOMAIN: &[u8] = b"basis-pending-publication-v1"; +const CONFIRMED_PROJECTION_CHECKSUM_DOMAIN: &[u8] = b"basis-confirmed-projection-v1"; +const NOTE_STATE_HEADER_LEN: usize = 4 + 4 + 33 + 32; +const NOTE_RECORD_LEN: usize = 33 + 8 + 8 + 8 + 65 + 33; +const MAX_NOTE_COUNT: usize = 50_000; +const GENERATION_MANIFEST_BODY_LEN: usize = 4 + 32 + 33 + 1 + 33; +const GENERATION_MANIFEST_LEN: usize = GENERATION_MANIFEST_BODY_LEN + 32; +const PENDING_PUBLICATION_BODY_LEN: usize = 4 + 33 + 32 + 8; +const PENDING_PUBLICATION_LEN: usize = PENDING_PUBLICATION_BODY_LEN + 32; +const CONFIRMED_PROJECTION_BODY_LEN: usize = 4 + 32 + 32 + 32 + 8 + 8 + 32 + 33; +const CONFIRMED_PROJECTION_LEN: usize = CONFIRMED_PROJECTION_BODY_LEN + 32; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StoredNoteState { + pub avl_root_digest: [u8; 33], + pub notes: Vec<(PubKey, IouNote)>, +} -/// Database storage for IOU notes with extra indices for efficient querying +#[derive(Debug, Clone, PartialEq, Eq)] +struct StoredGeneration { + tracker_nft_id: [u8; 32], + bootstrap_root: [u8; 33], + anchor_root: Option<[u8; 33]>, +} + +/// Database storage for versioned IOU note snapshots. /// -/// Uses three partitions: -/// - `iou_notes`: Main data storage (issuer+recipient -> note data) -/// - `issuer_index`: Secondary index (issuer_pubkey -> list of note keys) -/// - `recipient_index`: Secondary index (recipient_pubkey -> list of note keys) -pub struct NoteStorage { +/// One `iou_notes` value contains the complete ordered live-note set and the AVL +/// root it must reproduce. Repeated updates replace a record in place, so disk +/// and restart work are bounded by the live edge count rather than history. +pub(crate) struct NoteStorage { + keyspace: Keyspace, notes_partition: fjall::Partition, - issuer_index: fjall::Partition, - recipient_index: fjall::Partition, + schema_partition: fjall::Partition, confirmations_partition: fjall::Partition, + write_lock: Mutex<()>, + _writer_file_lock: File, + #[cfg(test)] + fail_next_persist: AtomicBool, + #[cfg(test)] + capacity_limit: AtomicUsize, } /// Database storage for scanner metadata @@ -152,8 +199,43 @@ impl ScannerMetadataStorage { } impl NoteStorage { + fn capacity_limit(&self) -> usize { + #[cfg(test)] + { + return self.capacity_limit.load(Ordering::SeqCst); + } + #[cfg(not(test))] + { + MAX_NOTE_COUNT + } + } + /// Open or create a new note storage database with extra indices - pub fn open>(path: P) -> Result { + pub(crate) fn open>( + path: P, + generation: TrackerGenerationConfig, + ) -> Result { + let path = path.as_ref(); + std::fs::create_dir_all(path).map_err(|e| { + NoteError::StorageError(format!("Failed to create note storage directory: {}", e)) + })?; + let writer_lock_path = path.join(".basis-writer.lock"); + let writer_file_lock = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&writer_lock_path) + .map_err(|e| { + NoteError::StorageError(format!("Failed to open note writer lock: {}", e)) + })?; + writer_file_lock.try_lock_exclusive().map_err(|e| { + NoteError::StorageError(format!( + "Note storage already has an active writer ({}): {}", + writer_lock_path.display(), + e + )) + })?; + let keyspace = Config::new(path) .open() .map_err(|e| NoteError::StorageError(format!("Failed to open database: {}", e)))?; @@ -164,17 +246,19 @@ impl NoteStorage { NoteError::StorageError(format!("Failed to open notes partition: {}", e)) })?; - let issuer_index = keyspace - .open_partition("issuer_index", PartitionCreateOptions::default()) - .map_err(|e| { - NoteError::StorageError(format!("Failed to open issuer index partition: {}", e)) - })?; - - let recipient_index = keyspace - .open_partition("recipient_index", PartitionCreateOptions::default()) - .map_err(|e| { - NoteError::StorageError(format!("Failed to open recipient index partition: {}", e)) - })?; + if notes_partition + .get(NOTE_STATE_KEY) + .map_err(|e| NoteError::StorageError(format!("Failed to inspect note state: {}", e)))? + .is_none() + && !notes_partition.is_empty().map_err(|e| { + NoteError::StorageError(format!("Failed to inspect legacy note state: {}", e)) + })? + { + return Err(NoteError::MigrationRequired( + "Legacy note rows require explicit export/migration or an approved reset" + .to_string(), + )); + } let confirmations_partition = keyspace .open_partition("confirmations", PartitionCreateOptions::default()) @@ -182,191 +266,993 @@ impl NoteStorage { NoteError::StorageError(format!("Failed to open confirmations partition: {}", e)) })?; - Ok(Self { + let schema_partition = keyspace + .open_partition("note_schema", PartitionCreateOptions::default()) + .map_err(|e| { + NoteError::StorageError(format!("Failed to open note schema partition: {}", e)) + })?; + + let storage = Self { + keyspace, notes_partition, - issuer_index, - recipient_index, + schema_partition, confirmations_partition, - }) + write_lock: Mutex::new(()), + _writer_file_lock: writer_file_lock, + #[cfg(test)] + fail_next_persist: AtomicBool::new(false), + #[cfg(test)] + capacity_limit: AtomicUsize::new(MAX_NOTE_COUNT), + }; + storage.ensure_state_initialized(generation)?; + Ok(storage) } - /// Serialize a list of note keys to bytes - fn serialize_note_keys(keys: &[NoteKey]) -> Vec { - let mut bytes = Vec::new(); - // Store count as u32 - bytes.extend_from_slice(&(keys.len() as u32).to_be_bytes()); - // Store each key (66 bytes) - for key in keys { - bytes.extend_from_slice(&key.to_bytes()); + fn ensure_state_initialized( + &self, + generation: TrackerGenerationConfig, + ) -> Result<(), NoteError> { + let schema = self + .schema_partition + .get(NOTE_SCHEMA_KEY) + .map_err(|e| NoteError::StorageError(format!("Failed to read note schema: {}", e)))?; + let state = self + .notes_partition + .get(NOTE_STATE_KEY) + .map_err(|e| NoteError::StorageError(format!("Failed to read note state: {}", e)))?; + + // Inspect the generation binding before mutating either authoritative + // partition. A manifest without a complete schema/state pair is an + // interrupted or foreign initialization, not permission to synthesize + // a fresh empty state over the same tracker NFT. + let stored_generation = self + .schema_partition + .get(NOTE_GENERATION_KEY) + .map_err(|e| NoteError::StorageError(format!("Failed to read generation: {}", e)))?; + let pending_publication = + self.schema_partition + .get(PENDING_PUBLICATION_KEY) + .map_err(|e| { + NoteError::StorageError(format!("Failed to read pending publication: {}", e)) + })?; + if let Some(bytes) = pending_publication.as_ref() { + // Validate before any initialization write. A malformed or orphaned + // external-effect receipt is never permission to synthesize a new + // empty generation over potentially published state. + Self::deserialize_pending_publication(bytes.as_ref())?; } - bytes + + match (schema, state, stored_generation) { + (Some(schema), Some(_), Some(generation_bytes)) => { + if schema.as_ref() != NOTE_STATE_MAGIC { + return Err(NoteError::MigrationRequired( + "Unsupported note storage schema requires an explicit migration" + .to_string(), + )); + } + self.read_state_strict()?; + self.validate_generation_manifest(generation, generation_bytes.as_ref()) + } + (None, None, None) => { + if pending_publication.is_some() { + return Err(NoteError::GenerationMismatch( + "Pending tracker publication exists without a complete authoritative generation" + .to_string(), + )); + } + if generation.fresh_generation != FreshGenerationApproval::Approve { + return Err(NoteError::GenerationBindingRequired( + "A new data directory requires explicit fresh tracker generation approval" + .to_string(), + )); + } + if !self.notes_partition.is_empty().map_err(|e| { + NoteError::StorageError(format!("Failed to inspect legacy note state: {}", e)) + })? { + return Err(NoteError::MigrationRequired( + "Legacy note rows require explicit export/migration or an approved reset" + .to_string(), + )); + } + let empty_root = basis_trees::BasisAvlTree::new() + .map_err(|e| NoteError::StorageError(e.to_string()))? + .root_digest(); + let empty = StoredNoteState { + avl_root_digest: empty_root, + notes: Vec::new(), + }; + let manifest = StoredGeneration { + tracker_nft_id: generation.tracker_nft_id, + bootstrap_root: empty.avl_root_digest, + anchor_root: None, + }; + + // Publish all three initialization records as one durability + // attempt. If any insert or the sync has an unknown outcome, + // the next open sees a partial tuple and fails closed without + // writing a replacement. + self.schema_partition + .insert(NOTE_GENERATION_KEY, Self::serialize_generation(&manifest)) + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Tracker generation binding outcome is unknown: {}", + e + )) + })?; + self.notes_partition + .insert(NOTE_STATE_KEY, Self::serialize_note_state(&empty)?) + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Empty note state initialization outcome is unknown: {}", + e + )) + })?; + self.schema_partition + .insert(NOTE_SCHEMA_KEY, NOTE_STATE_MAGIC) + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Note schema initialization outcome is unknown: {}", + e + )) + })?; + self.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Tracker generation initialization durability is unknown: {}", + e + )) + }) + } + (Some(_), Some(_), None) => Err(NoteError::GenerationBindingRequired( + "Complete note state is missing its tracker generation manifest; explicit migration is required" + .to_string(), + )), + (None, None, Some(_)) | (None, Some(_), Some(_)) | (Some(_), None, Some(_)) => { + Err(NoteError::GenerationMismatch( + "Tracker generation manifest exists without a complete authoritative schema/state pair" + .to_string(), + )) + } + (Some(_), None, None) => Err(NoteError::StorageError( + "Note schema exists without authoritative state".to_string(), + )), + (None, Some(_), None) => Err(NoteError::StorageError( + "Authoritative note state exists without its schema and generation binding" + .to_string(), + )), + } + } + + fn validate_generation_manifest( + &self, + generation: TrackerGenerationConfig, + stored: &[u8], + ) -> Result<(), NoteError> { + let manifest = Self::deserialize_generation(stored)?; + if manifest.tracker_nft_id != generation.tracker_nft_id { + return Err(NoteError::GenerationMismatch( + "Configured tracker NFT does not match the generation bound to this data directory" + .to_string(), + )); + } + Ok(()) + } + + #[cfg(test)] + pub(crate) fn remove_state_for_test(&self) -> Result<(), NoteError> { + self.notes_partition + .remove(NOTE_STATE_KEY) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn remove_schema_for_test(&self) -> Result<(), NoteError> { + self.schema_partition + .remove(NOTE_SCHEMA_KEY) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn remove_generation_for_test(&self) -> Result<(), NoteError> { + self.schema_partition + .remove(NOTE_GENERATION_KEY) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn persist_for_test(&self) -> Result<(), NoteError> { + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn remove_confirmation_for_test( + &self, + key_bytes: &[u8; 32], + ) -> Result<(), NoteError> { + self.confirmations_partition + .remove(key_bytes) + .map_err(|e| NoteError::StorageError(e.to_string()))?; + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn corrupt_state_for_test(&self) -> Result<(), NoteError> { + self.notes_partition + .insert(NOTE_STATE_KEY, [0u8]) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn tamper_first_total_debt_for_test(&self) -> Result<(), NoteError> { + let mut bytes = self + .notes_partition + .get(NOTE_STATE_KEY) + .map_err(|e| NoteError::StorageError(e.to_string()))? + .ok_or_else(|| NoteError::StorageError("Note state missing".to_string()))? + .to_vec(); + let amount_last_byte = NOTE_STATE_HEADER_LEN + 33 + 7; + if bytes.len() <= amount_last_byte { + return Err(NoteError::StorageError( + "Note state has no record to tamper".to_string(), + )); + } + bytes[amount_last_byte] ^= 1; + self.notes_partition + .insert(NOTE_STATE_KEY, bytes) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn rewrite_first_total_debt_with_valid_checksum_for_test( + &self, + amount_collected: u64, + ) -> Result<(), NoteError> { + let mut state = self.read_state_strict()?; + let (_, note) = state + .notes + .first_mut() + .ok_or_else(|| NoteError::StorageError("Note state has no record".to_string()))?; + note.amount_collected = amount_collected; + self.notes_partition + .insert(NOTE_STATE_KEY, Self::serialize_note_state(&state)?) + .map_err(|e| NoteError::StorageError(e.to_string()))?; + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn set_declared_note_count_for_test(&self, count: u32) -> Result<(), NoteError> { + let mut bytes = self + .notes_partition + .get(NOTE_STATE_KEY) + .map_err(|e| NoteError::StorageError(e.to_string()))? + .ok_or_else(|| NoteError::StorageError("Note state missing".to_string()))? + .to_vec(); + if bytes.len() < 8 { + return Err(NoteError::StorageError( + "Note state header is truncated".to_string(), + )); + } + bytes[4..8].copy_from_slice(&count.to_be_bytes()); + self.notes_partition + .insert(NOTE_STATE_KEY, bytes) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn insert_unexpected_note_row_for_test(&self) -> Result<(), NoteError> { + self.notes_partition + .insert(b"unexpected", [0u8]) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn fail_next_persist_for_test(&self) { + self.fail_next_persist.store(true, Ordering::SeqCst); + } + + #[cfg(test)] + pub(crate) fn set_capacity_limit_for_test(&self, limit: usize) { + self.capacity_limit.store(limit, Ordering::SeqCst); } - /// Deserialize a list of note keys from bytes - fn deserialize_note_keys(bytes: &[u8]) -> Result, NoteError> { - if bytes.len() < 4 { - return Ok(Vec::new()); + #[cfg(test)] + pub(crate) fn tamper_first_redeemed_amount_for_test(&self) -> Result<(), NoteError> { + let mut bytes = self + .notes_partition + .get(NOTE_STATE_KEY) + .map_err(|e| NoteError::StorageError(e.to_string()))? + .ok_or_else(|| NoteError::StorageError("Note state missing".to_string()))? + .to_vec(); + let amount_last_byte = NOTE_STATE_HEADER_LEN + 33 + 8 + 7; + if bytes.len() <= amount_last_byte { + return Err(NoteError::StorageError( + "Note state has no redeemed amount to tamper".to_string(), + )); + } + bytes[amount_last_byte] ^= 1; + self.notes_partition + .insert(NOTE_STATE_KEY, bytes) + .map_err(|e| NoteError::StorageError(e.to_string()))?; + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn tamper_generation_manifest_for_test(&self) -> Result<(), NoteError> { + let mut bytes = self + .schema_partition + .get(NOTE_GENERATION_KEY) + .map_err(|e| NoteError::StorageError(e.to_string()))? + .ok_or_else(|| NoteError::StorageError("Generation missing".to_string()))? + .to_vec(); + bytes[4] ^= 1; + self.schema_partition + .insert(NOTE_GENERATION_KEY, bytes) + .map_err(|e| NoteError::StorageError(e.to_string()))?; + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|e| NoteError::StorageError(e.to_string())) + } + + #[cfg(test)] + pub(crate) fn generation_for_test( + &self, + ) -> Result<([u8; 32], [u8; 33], Option<[u8; 33]>), NoteError> { + let bytes = self + .schema_partition + .get(NOTE_GENERATION_KEY) + .map_err(|e| NoteError::StorageError(e.to_string()))? + .ok_or_else(|| NoteError::StorageError("Generation missing".to_string()))?; + let generation = Self::deserialize_generation(bytes.as_ref())?; + Ok(( + generation.tracker_nft_id, + generation.bootstrap_root, + generation.anchor_root, + )) + } + + fn deserialize_note_state(value_bytes: &[u8]) -> Result { + if value_bytes.len() >= 4 && &value_bytes[..4] == LEGACY_NOTE_STATE_MAGIC { + return Err(NoteError::MigrationRequired( + "BNS1 note state requires explicit migration to checksummed BNS2".to_string(), + )); + } + if value_bytes.len() < NOTE_STATE_HEADER_LEN || &value_bytes[..4] != NOTE_STATE_MAGIC { + return Err(NoteError::StorageError( + "Unsupported or malformed note state; explicit migration is required".to_string(), + )); } - let count = u32::from_be_bytes(bytes[0..4].try_into().unwrap()) as usize; - let mut keys = Vec::with_capacity(count); - let expected_len = 4 + count * 32; // NoteKey is 32 bytes (blake2b hash) - if bytes.len() < expected_len { + + let count = u32::from_be_bytes(value_bytes[4..8].try_into().unwrap()) as usize; + if count > MAX_NOTE_COUNT { + return Err(NoteError::StorageError( + "Stored note count exceeds configured bound".to_string(), + )); + } + let records_len = count.checked_mul(NOTE_RECORD_LEN).ok_or_else(|| { + NoteError::StorageError("Stored note state length overflow".to_string()) + })?; + let expected_len = NOTE_STATE_HEADER_LEN + .checked_add(records_len) + .ok_or_else(|| { + NoteError::StorageError("Stored note state length overflow".to_string()) + })?; + if value_bytes.len() != expected_len { + return Err(NoteError::StorageError( + "Stored note state length does not match count".to_string(), + )); + } + + let mut avl_root_digest = [0u8; 33]; + avl_root_digest.copy_from_slice(&value_bytes[8..41]); + let stored_checksum = &value_bytes[41..73]; + let calculated_checksum = Self::snapshot_checksum( + count, + &avl_root_digest, + &value_bytes[NOTE_STATE_HEADER_LEN..], + ); + if stored_checksum != calculated_checksum { return Err(NoteError::StorageError( - "Invalid note key list format".to_string(), + "Authoritative note snapshot checksum mismatch".to_string(), )); } - let mut offset = 4; + let mut notes = Vec::with_capacity(count); + let mut seen_keys = std::collections::HashSet::with_capacity(count); + let mut offset = NOTE_STATE_HEADER_LEN; for _ in 0..count { - let key_bytes: [u8; 32] = bytes[offset..offset + 32].try_into().unwrap(); - keys.push(NoteKey::from_bytes(&key_bytes)); - offset += 32; + let issuer_pubkey: PubKey = value_bytes[offset..offset + 33].try_into().unwrap(); + offset += 33; + let amount_collected = + u64::from_be_bytes(value_bytes[offset..offset + 8].try_into().unwrap()); + offset += 8; + let amount_redeemed = + u64::from_be_bytes(value_bytes[offset..offset + 8].try_into().unwrap()); + offset += 8; + let timestamp = u64::from_be_bytes(value_bytes[offset..offset + 8].try_into().unwrap()); + offset += 8; + let signature: [u8; 65] = value_bytes[offset..offset + 65].try_into().unwrap(); + offset += 65; + let recipient_pubkey: PubKey = value_bytes[offset..offset + 33].try_into().unwrap(); + offset += 33; + let key = NoteKey::from_keys(&issuer_pubkey, &recipient_pubkey).to_bytes(); + if !seen_keys.insert(key) { + return Err(NoteError::StorageError( + "Duplicate issuer-recipient edge in note state".to_string(), + )); + } + notes.push(( + issuer_pubkey, + IouNote { + recipient_pubkey, + amount_collected, + amount_redeemed, + timestamp, + signature, + }, + )); } - Ok(keys) + + Ok(StoredNoteState { + avl_root_digest, + notes, + }) } - /// Add a note key to an index partition - fn add_to_index( - index: &fjall::Partition, - pubkey: &PubKey, - note_key: &NoteKey, - ) -> Result<(), NoteError> { - let pubkey_bytes = pubkey; - let existing = index - .get(pubkey_bytes) - .map_err(|e| NoteError::StorageError(format!("Failed to read index: {}", e)))?; - - let mut keys = match existing { - Some(bytes) => Self::deserialize_note_keys(&bytes)?, - None => Vec::new(), + fn serialize_note_state(state: &StoredNoteState) -> Result, NoteError> { + if state.notes.len() > MAX_NOTE_COUNT { + return Err(NoteError::CapacityExceeded { + limit: MAX_NOTE_COUNT, + }); + } + let count = u32::try_from(state.notes.len()) + .map_err(|_| NoteError::StorageError("Note count overflow".to_string()))?; + let capacity = NOTE_STATE_HEADER_LEN + .checked_add( + state + .notes + .len() + .checked_mul(NOTE_RECORD_LEN) + .ok_or_else(|| { + NoteError::StorageError("Note state length overflow".to_string()) + })?, + ) + .ok_or_else(|| NoteError::StorageError("Note state length overflow".to_string()))?; + let mut records = Vec::with_capacity(state.notes.len() * NOTE_RECORD_LEN); + for (issuer_pubkey, note) in &state.notes { + records.extend_from_slice(issuer_pubkey); + records.extend_from_slice(¬e.amount_collected.to_be_bytes()); + records.extend_from_slice(¬e.amount_redeemed.to_be_bytes()); + records.extend_from_slice(¬e.timestamp.to_be_bytes()); + records.extend_from_slice(¬e.signature); + records.extend_from_slice(¬e.recipient_pubkey); + } + let checksum = Self::snapshot_checksum(state.notes.len(), &state.avl_root_digest, &records); + let mut bytes = Vec::with_capacity(capacity); + bytes.extend_from_slice(NOTE_STATE_MAGIC); + bytes.extend_from_slice(&count.to_be_bytes()); + bytes.extend_from_slice(&state.avl_root_digest); + bytes.extend_from_slice(&checksum); + bytes.extend_from_slice(&records); + Ok(bytes) + } + + fn snapshot_checksum(count: usize, root: &[u8; 33], records: &[u8]) -> [u8; 32] { + let mut bytes = Vec::with_capacity( + SNAPSHOT_CHECKSUM_DOMAIN.len() + + std::mem::size_of::() + + root.len() + + records.len(), + ); + bytes.extend_from_slice(SNAPSHOT_CHECKSUM_DOMAIN); + bytes.extend_from_slice(&(count as u32).to_be_bytes()); + bytes.extend_from_slice(root); + bytes.extend_from_slice(records); + blake2b256_hash(&bytes) + } + + fn serialize_generation(generation: &StoredGeneration) -> Vec { + let mut bytes = Vec::with_capacity(GENERATION_MANIFEST_LEN); + bytes.extend_from_slice(GENERATION_MAGIC); + bytes.extend_from_slice(&generation.tracker_nft_id); + bytes.extend_from_slice(&generation.bootstrap_root); + match generation.anchor_root { + Some(root) => { + bytes.push(1); + bytes.extend_from_slice(&root); + } + None => { + bytes.push(0); + bytes.extend_from_slice(&[0u8; 33]); + } + } + let mut checksum_input = Vec::with_capacity(GENERATION_CHECKSUM_DOMAIN.len() + bytes.len()); + checksum_input.extend_from_slice(GENERATION_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes); + bytes.extend_from_slice(&blake2b256_hash(&checksum_input)); + bytes + } + + fn deserialize_generation(bytes: &[u8]) -> Result { + if bytes.len() != GENERATION_MANIFEST_LEN || &bytes[..4] != GENERATION_MAGIC { + return Err(NoteError::StorageError( + "Malformed tracker generation manifest".to_string(), + )); + } + let mut checksum_input = + Vec::with_capacity(GENERATION_CHECKSUM_DOMAIN.len() + GENERATION_MANIFEST_BODY_LEN); + checksum_input.extend_from_slice(GENERATION_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes[..GENERATION_MANIFEST_BODY_LEN]); + if bytes[GENERATION_MANIFEST_BODY_LEN..] != blake2b256_hash(&checksum_input) { + return Err(NoteError::StorageError( + "Tracker generation manifest checksum mismatch".to_string(), + )); + } + let mut tracker_nft_id = [0u8; 32]; + tracker_nft_id.copy_from_slice(&bytes[4..36]); + let mut bootstrap_root = [0u8; 33]; + bootstrap_root.copy_from_slice(&bytes[36..69]); + let anchor_root = match bytes[69] { + 0 if bytes[70..103].iter().all(|byte| *byte == 0) => None, + 1 => { + let mut root = [0u8; 33]; + root.copy_from_slice(&bytes[70..103]); + Some(root) + } + _ => { + return Err(NoteError::StorageError( + "Malformed tracker generation anchor".to_string(), + )) + } }; + Ok(StoredGeneration { + tracker_nft_id, + bootstrap_root, + anchor_root, + }) + } - // Check if key already exists to avoid duplicates - let key_bytes = note_key.to_bytes(); - if !keys.iter().any(|k| k.to_bytes() == key_bytes) { - keys.push(note_key.clone()); - let serialized = Self::serialize_note_keys(&keys); - index - .insert(pubkey_bytes, &serialized) - .map_err(|e| NoteError::StorageError(format!("Failed to update index: {}", e)))?; + fn serialize_pending_publication( + publication: &PendingTrackerPublication, + ) -> Result, NoteError> { + let tx_id = hex::decode(&publication.tx_id).map_err(|_| NoteError::InvalidTransactionId)?; + if tx_id.len() != 32 { + return Err(NoteError::InvalidTransactionId); } + let mut bytes = Vec::with_capacity(PENDING_PUBLICATION_LEN); + bytes.extend_from_slice(PENDING_PUBLICATION_MAGIC); + bytes.extend_from_slice(&publication.digest); + bytes.extend_from_slice(&tx_id); + bytes.extend_from_slice(&publication.submitted_height.to_be_bytes()); + let mut checksum_input = + Vec::with_capacity(PENDING_PUBLICATION_CHECKSUM_DOMAIN.len() + bytes.len()); + checksum_input.extend_from_slice(PENDING_PUBLICATION_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes); + bytes.extend_from_slice(&blake2b256_hash(&checksum_input)); + Ok(bytes) + } - Ok(()) + fn deserialize_pending_publication( + bytes: &[u8], + ) -> Result { + if bytes.len() != PENDING_PUBLICATION_LEN || &bytes[..4] != PENDING_PUBLICATION_MAGIC { + return Err(NoteError::StorageError( + "Malformed pending tracker publication".to_string(), + )); + } + let mut checksum_input = Vec::with_capacity( + PENDING_PUBLICATION_CHECKSUM_DOMAIN.len() + PENDING_PUBLICATION_BODY_LEN, + ); + checksum_input.extend_from_slice(PENDING_PUBLICATION_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes[..PENDING_PUBLICATION_BODY_LEN]); + if bytes[PENDING_PUBLICATION_BODY_LEN..] != blake2b256_hash(&checksum_input) { + return Err(NoteError::StorageError( + "Pending tracker publication checksum mismatch".to_string(), + )); + } + let mut digest = [0u8; 33]; + digest.copy_from_slice(&bytes[4..37]); + let tx_id = hex::encode(&bytes[37..69]); + let submitted_height = + u64::from_be_bytes(bytes[69..77].try_into().map_err(|_| { + NoteError::StorageError("Malformed publication height".to_string()) + })?); + Ok(PendingTrackerPublication { + digest, + tx_id, + submitted_height, + }) } - /// Remove a note key from an index partition - fn remove_from_index( - index: &fjall::Partition, - pubkey: &PubKey, - note_key: &NoteKey, - ) -> Result<(), NoteError> { - let pubkey_bytes = pubkey; - let existing = index - .get(pubkey_bytes) - .map_err(|e| NoteError::StorageError(format!("Failed to read index: {}", e)))?; - - let mut keys = match existing { - Some(bytes) => Self::deserialize_note_keys(&bytes)?, - None => return Ok(()), + fn serialize_confirmed_projection( + anchor: &ConfirmedProjectionAnchor, + ) -> Result, NoteError> { + let decode_id = |value: &str| { + hex::decode(value) + .ok() + .filter(|bytes| bytes.len() == 32) + .ok_or_else(|| { + NoteError::StorageError( + "confirmed projection contains a malformed id".to_string(), + ) + }) }; + let tx_id = decode_id(anchor.tx_id())?; + let box_id = decode_id(anchor.box_id())?; + let block_id = decode_id(anchor.block_id())?; + let intent_id = decode_id(anchor.intent_id())?; + let mut bytes = Vec::with_capacity(CONFIRMED_PROJECTION_LEN); + bytes.extend_from_slice(CONFIRMED_PROJECTION_MAGIC); + bytes.extend_from_slice(&tx_id); + bytes.extend_from_slice(&box_id); + bytes.extend_from_slice(&block_id); + bytes.extend_from_slice(&anchor.height().to_be_bytes()); + bytes.extend_from_slice(&anchor.successor_depth().to_be_bytes()); + bytes.extend_from_slice(&intent_id); + bytes.extend_from_slice(&anchor.root()); + let mut checksum_input = + Vec::with_capacity(CONFIRMED_PROJECTION_CHECKSUM_DOMAIN.len() + bytes.len()); + checksum_input.extend_from_slice(CONFIRMED_PROJECTION_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes); + bytes.extend_from_slice(&blake2b256_hash(&checksum_input)); + Ok(bytes) + } + + fn deserialize_confirmed_projection( + bytes: &[u8], + ) -> Result { + if bytes.len() != CONFIRMED_PROJECTION_LEN || &bytes[..4] != CONFIRMED_PROJECTION_MAGIC { + return Err(NoteError::StorageError( + "Malformed confirmed tracker projection".to_string(), + )); + } + let mut checksum_input = Vec::with_capacity( + CONFIRMED_PROJECTION_CHECKSUM_DOMAIN.len() + CONFIRMED_PROJECTION_BODY_LEN, + ); + checksum_input.extend_from_slice(CONFIRMED_PROJECTION_CHECKSUM_DOMAIN); + checksum_input.extend_from_slice(&bytes[..CONFIRMED_PROJECTION_BODY_LEN]); + if bytes[CONFIRMED_PROJECTION_BODY_LEN..] != blake2b256_hash(&checksum_input) { + return Err(NoteError::StorageError( + "Confirmed tracker projection checksum mismatch".to_string(), + )); + } + let height = u64::from_be_bytes(bytes[100..108].try_into().map_err(|_| { + NoteError::StorageError("Malformed confirmed projection height".to_string()) + })?); + let successor_depth = u64::from_be_bytes(bytes[108..116].try_into().map_err(|_| { + NoteError::StorageError("Malformed confirmed projection depth".to_string()) + })?); + let mut root = [0u8; 33]; + root.copy_from_slice(&bytes[148..181]); + Ok(ConfirmedProjectionAnchor::from_parts( + hex::encode(&bytes[4..36]), + hex::encode(&bytes[36..68]), + hex::encode(&bytes[68..100]), + height, + successor_depth, + hex::encode(&bytes[116..148]), + root, + )) + } - let key_bytes = note_key.to_bytes(); - keys.retain(|k| k.to_bytes() != key_bytes); + pub(crate) fn pending_publication( + &self, + ) -> Result, NoteError> { + self.schema_partition + .get(PENDING_PUBLICATION_KEY) + .map_err(|e| { + NoteError::StorageError(format!("Failed to read pending publication: {}", e)) + })? + .map(|bytes| Self::deserialize_pending_publication(bytes.as_ref())) + .transpose() + } - if keys.is_empty() { - index.remove(pubkey_bytes).map_err(|e| { - NoteError::StorageError(format!("Failed to remove index entry: {}", e)) + pub(crate) fn store_pending_publication( + &self, + publication: &PendingTrackerPublication, + ) -> Result<(), NoteError> { + if publication.tx_id.len() != 64 + || hex::decode(&publication.tx_id) + .map(|bytes| bytes.len() != 32) + .unwrap_or(true) + { + return Err(NoteError::InvalidTransactionId); + } + let _guard = self.write_lock.lock().map_err(|_| { + NoteError::StorageError("Note storage write lock is poisoned".to_string()) + })?; + if let Some(existing) = self.pending_publication()? { + return if existing == *publication { + Ok(()) + } else { + Err(NoteError::PublicationInProgress) + }; + } + self.schema_partition + .insert( + PENDING_PUBLICATION_KEY, + Self::serialize_pending_publication(publication)?, + ) + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Pending publication write outcome is unknown: {}", + e + )) })?; - } else { - let serialized = Self::serialize_note_keys(&keys); - index - .insert(pubkey_bytes, &serialized) - .map_err(|e| NoteError::StorageError(format!("Failed to update index: {}", e)))?; + self.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Pending publication durability is unknown: {}", + e + )) + }) + } + + pub(crate) fn clear_pending_publication(&self, tx_id: &str) -> Result<(), NoteError> { + let _guard = self.write_lock.lock().map_err(|_| { + NoteError::StorageError("Note storage write lock is poisoned".to_string()) + })?; + let existing = self + .pending_publication()? + .ok_or(NoteError::PublicationLeaseMismatch)?; + if !existing.tx_id.eq_ignore_ascii_case(tx_id) { + return Err(NoteError::PublicationLeaseMismatch); } + self.schema_partition + .remove(PENDING_PUBLICATION_KEY) + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Pending publication removal outcome is unknown: {}", + e + )) + })?; + self.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Pending publication removal durability is unknown: {}", + e + )) + }) + } - Ok(()) + pub(crate) fn confirmed_projection( + &self, + ) -> Result, NoteError> { + self.schema_partition + .get(CONFIRMED_PROJECTION_KEY) + .map_err(|error| { + NoteError::StorageError(format!( + "Failed to read confirmed tracker projection: {error}" + )) + })? + .map(|bytes| Self::deserialize_confirmed_projection(bytes.as_ref())) + .transpose() } - /// Store an IOU note with its issuer public key - pub fn store_note(&self, issuer_pubkey: &PubKey, note: &IouNote) -> Result<(), NoteError> { - let key = NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey); - let key_bytes = key.to_bytes(); - - // Manual serialization to avoid serde issues with arrays - let mut value_bytes = Vec::new(); - value_bytes.extend_from_slice(issuer_pubkey); - value_bytes.extend_from_slice(¬e.amount_collected.to_be_bytes()); - value_bytes.extend_from_slice(¬e.amount_redeemed.to_be_bytes()); - value_bytes.extend_from_slice(¬e.timestamp.to_be_bytes()); - value_bytes.extend_from_slice(¬e.signature); - value_bytes.extend_from_slice(¬e.recipient_pubkey); + pub(crate) fn store_confirmed_projection( + &self, + anchor: &ConfirmedProjectionAnchor, + ) -> Result<(), NoteError> { + let _guard = self.write_lock.lock().map_err(|_| { + NoteError::StorageError("Note storage write lock is poisoned".to_string()) + })?; + self.schema_partition + .insert( + CONFIRMED_PROJECTION_KEY, + Self::serialize_confirmed_projection(anchor)?, + ) + .map_err(|error| { + NoteError::StorageOutcomeUnknown(format!( + "Confirmed projection write outcome is unknown: {error}" + )) + })?; + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|error| { + NoteError::StorageOutcomeUnknown(format!( + "Confirmed projection durability is unknown: {error}" + )) + }) + } - self.notes_partition - .insert(&key_bytes, &value_bytes) - .map_err(|e| NoteError::StorageError(format!("Failed to insert note: {}", e)))?; + pub(crate) fn clear_confirmed_projection(&self) -> Result<(), NoteError> { + let _guard = self.write_lock.lock().map_err(|_| { + NoteError::StorageError("Note storage write lock is poisoned".to_string()) + })?; + self.schema_partition + .remove(CONFIRMED_PROJECTION_KEY) + .map_err(|error| { + NoteError::StorageOutcomeUnknown(format!( + "Confirmed projection removal outcome is unknown: {error}" + )) + })?; + self.keyspace + .persist(PersistMode::SyncData) + .map_err(|error| { + NoteError::StorageOutcomeUnknown(format!( + "Confirmed projection removal durability is unknown: {error}" + )) + }) + } - // Update indices for efficient querying - Self::add_to_index(&self.issuer_index, issuer_pubkey, &key)?; - Self::add_to_index(&self.recipient_index, ¬e.recipient_pubkey, &key)?; + /// Store an IOU note with its issuer public key + pub(crate) fn store_note( + &self, + issuer_pubkey: &PubKey, + note: &IouNote, + avl_root_digest: [u8; 33], + ) -> Result<(), NoteError> { + let _guard = self.write_lock.lock().map_err(|_| { + NoteError::StorageError("Note storage write lock is poisoned".to_string()) + })?; + let key = NoteKey::from_keys(issuer_pubkey, ¬e.recipient_pubkey).to_bytes(); + let mut state = self.read_state_strict()?; + if let Some((_, stored_note)) = + state.notes.iter_mut().find(|(stored_issuer, stored_note)| { + NoteKey::from_keys(stored_issuer, &stored_note.recipient_pubkey).to_bytes() == key + }) + { + *stored_note = note.clone(); + } else { + if state.notes.len() >= self.capacity_limit() { + return Err(NoteError::CapacityExceeded { + limit: self.capacity_limit(), + }); + } + state.notes.push((*issuer_pubkey, note.clone())); + } + state.avl_root_digest = avl_root_digest; + let value_bytes = Self::serialize_note_state(&state)?; + self.notes_partition + .insert(NOTE_STATE_KEY, value_bytes.as_slice()) + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Authoritative note write outcome is unknown; restart and reconcile: {}", + e + )) + })?; + #[cfg(test)] + if self.fail_next_persist.swap(false, Ordering::SeqCst) { + return Err(NoteError::StorageOutcomeUnknown( + "Injected durability outcome uncertainty".to_string(), + )); + } + self.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Durable note write outcome is unknown; restart and reconcile: {}", + e + )) + })?; Ok(()) } - /// Retrieve an IOU note by issuer and recipient public keys - pub fn get_note( + pub(crate) fn ensure_capacity_for_validated_state( &self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result, NoteError> { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - - match self.notes_partition.get(&key_bytes) { - Ok(Some(value_bytes)) => { - // Manual deserialization - if value_bytes.len() != 33 + 8 + 8 + 8 + 65 + 33 { - return Err(NoteError::StorageError( - "Invalid stored note format".to_string(), - )); - } + note_count: usize, + is_new_edge: bool, + ) -> Result<(), NoteError> { + if is_new_edge && note_count >= self.capacity_limit() { + return Err(NoteError::CapacityExceeded { + limit: self.capacity_limit(), + }); + } + Ok(()) + } - let mut offset = 0; - let _stored_issuer_pubkey: PubKey = - value_bytes[offset..offset + 33].try_into().unwrap(); - offset += 33; + pub(crate) fn validate_or_anchor_generation( + &self, + tracker_nft_id: &[u8; 32], + observed_root: [u8; 33], + ) -> Result<(), NoteError> { + let _guard = self.write_lock.lock().map_err(|_| { + NoteError::StorageError("Note storage write lock is poisoned".to_string()) + })?; + let bytes = self + .schema_partition + .get(NOTE_GENERATION_KEY) + .map_err(|e| NoteError::StorageError(format!("Failed to read generation: {}", e)))? + .ok_or_else(|| { + NoteError::GenerationBindingRequired( + "Tracker generation manifest is missing".to_string(), + ) + })?; + let mut generation = Self::deserialize_generation(bytes.as_ref())?; + if &generation.tracker_nft_id != tracker_nft_id { + return Err(NoteError::GenerationMismatch( + "Observed tracker NFT does not match the persisted generation".to_string(), + )); + } + if let Some(_anchor_root) = generation.anchor_root { + return Ok(()); + } + if observed_root != generation.bootstrap_root { + return Err(NoteError::GenerationMismatch( + "First observed on-chain root does not match the explicitly approved fresh generation root" + .to_string(), + )); + } - let amount_collected = - u64::from_be_bytes(value_bytes[offset..offset + 8].try_into().unwrap()); - offset += 8; + generation.anchor_root = Some(observed_root); + self.schema_partition + .insert(NOTE_GENERATION_KEY, Self::serialize_generation(&generation)) + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Tracker generation anchor outcome is unknown: {}", + e + )) + })?; + self.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Tracker generation anchor durability is unknown: {}", + e + )) + }) + } - let amount_redeemed = - u64::from_be_bytes(value_bytes[offset..offset + 8].try_into().unwrap()); - offset += 8; + pub(crate) fn read_state_strict(&self) -> Result { + let schema = self + .schema_partition + .get(NOTE_SCHEMA_KEY) + .map_err(|e| NoteError::StorageError(format!("Failed to read note schema: {}", e)))? + .ok_or_else(|| NoteError::StorageError("Note schema is missing".to_string()))?; + if schema.as_ref() != NOTE_STATE_MAGIC { + return Err(NoteError::StorageError( + "Unsupported note storage schema".to_string(), + )); + } - let timestamp = - u64::from_be_bytes(value_bytes[offset..offset + 8].try_into().unwrap()); - offset += 8; + self.read_state_partition_strict() + } - let signature: [u8; 65] = value_bytes[offset..offset + 65].try_into().unwrap(); - offset += 65; + fn read_state_partition_strict(&self) -> Result { + let mut state = None; + for item in self.notes_partition.iter() { + let (stored_key, value_bytes) = item.map_err(|e| { + NoteError::StorageError(format!("Failed to iterate note partition: {}", e)) + })?; + if stored_key.as_ref() != NOTE_STATE_KEY || state.is_some() { + return Err(NoteError::StorageError( + "Unexpected row in authoritative note partition".to_string(), + )); + } + state = Some(Self::deserialize_note_state(value_bytes.as_ref())?); + } + state.ok_or_else(|| { + NoteError::StorageError("Authoritative note state is missing".to_string()) + }) + } - let recipient_pubkey: PubKey = value_bytes[offset..offset + 33].try_into().unwrap(); + #[cfg(test)] + pub(crate) fn reverse_note_order_for_test(&self) -> Result<(), NoteError> { + let mut state = self.read_state_strict()?; + state.notes.reverse(); + self.notes_partition + .insert(NOTE_STATE_KEY, Self::serialize_note_state(&state)?) + .map_err(|e| NoteError::StorageError(e.to_string()))?; + Ok(()) + } - let note = IouNote { - recipient_pubkey, - amount_collected, - amount_redeemed, - timestamp, - signature, - }; + #[cfg(test)] + pub(crate) fn note_row_count_for_test(&self) -> Result { + self.read_state_strict().map(|state| state.notes.len()) + } - Ok(Some(note)) - } - Ok(None) => Ok(None), - Err(e) => Err(NoteError::StorageError(format!( - "Failed to get note: {}", - e - ))), - } + /// Retrieve an IOU note by issuer and recipient public keys + pub fn get_note( + &self, + issuer_pubkey: &PubKey, + recipient_pubkey: &PubKey, + ) -> Result, NoteError> { + let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey).to_bytes(); + let state = self.read_state_strict()?; + Ok(state.notes.into_iter().find_map(|(stored_issuer, note)| { + (NoteKey::from_keys(&stored_issuer, ¬e.recipient_pubkey).to_bytes() == key) + .then_some(note) + })) } /// Persist a confirmation record for a note key. @@ -375,34 +1261,32 @@ impl NoteStorage { key_bytes: &[u8; 32], confirmation: &NoteConfirmation, ) -> Result<(), NoteError> { + let _guard = self.write_lock.lock().map_err(|_| { + NoteError::StorageError("Note storage write lock is poisoned".to_string()) + })?; let value = serde_json::to_vec(confirmation).map_err(|e| { NoteError::StorageError(format!("Failed to serialize confirmation: {}", e)) })?; self.confirmations_partition .insert(key_bytes, &value) - .map_err(|e| NoteError::StorageError(format!("Failed to store confirmation: {}", e)))?; - Ok(()) - } - - /// Retrieve a confirmation record for a note key. - pub fn get_confirmation( - &self, - key_bytes: &[u8; 32], - ) -> Result, NoteError> { - match self.confirmations_partition.get(key_bytes) { - Ok(Some(value_bytes)) => { - let confirmation: NoteConfirmation = - serde_json::from_slice(&value_bytes).map_err(|e| { - NoteError::StorageError(format!("Failed to parse confirmation: {}", e)) - })?; - Ok(Some(confirmation)) - } - Ok(None) => Ok(None), - Err(e) => Err(NoteError::StorageError(format!( - "Failed to get confirmation: {}", - e - ))), + .map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Confirmation write outcome is unknown; restart and reconcile: {}", + e + )) + })?; + #[cfg(test)] + if self.fail_next_persist.swap(false, Ordering::SeqCst) { + return Err(NoteError::StorageOutcomeUnknown( + "Injected confirmation durability outcome uncertainty".to_string(), + )); } + self.keyspace.persist(PersistMode::SyncData).map_err(|e| { + NoteError::StorageOutcomeUnknown(format!( + "Confirmation durability is unknown; restart and reconcile: {}", + e + )) + }) } /// Retrieve all confirmation records. @@ -425,129 +1309,61 @@ impl NoteStorage { Ok(results) } - /// Retrieve notes by their keys using the main partition - fn get_notes_by_keys(&self, keys: &[NoteKey]) -> Result, NoteError> { - let mut notes = Vec::new(); - for key in keys { - let key_bytes = key.to_bytes(); - match self.notes_partition.get(&key_bytes) { - Ok(Some(value_bytes)) => { - if value_bytes.len() != 33 + 8 + 8 + 8 + 65 + 33 { - continue; // Skip invalid entries - } - let amount_collected = - u64::from_be_bytes(value_bytes[33..41].try_into().unwrap()); - let amount_redeemed = - u64::from_be_bytes(value_bytes[41..49].try_into().unwrap()); - let timestamp = u64::from_be_bytes(value_bytes[49..57].try_into().unwrap()); - let signature: [u8; 65] = value_bytes[57..122].try_into().unwrap(); - let recipient_pubkey: PubKey = value_bytes[122..155].try_into().unwrap(); - - notes.push(IouNote { - recipient_pubkey, - amount_collected, - amount_redeemed, - timestamp, - signature, - }); - } - Ok(None) => {} - Err(_) => {} - } - } - Ok(notes) + /// Get all notes for a specific issuer from the strict primary snapshot. + pub fn get_issuer_notes(&self, issuer_pubkey: &PubKey) -> Result, NoteError> { + self.read_state_strict().map(|state| { + state + .notes + .into_iter() + .filter_map(|(issuer, note)| (issuer == *issuer_pubkey).then_some(note)) + .collect() + }) } - fn get_notes_by_keys_with_issuer( + /// Read an issuer's liabilities from the primary note partition. + /// + /// The versioned primary note rows are the only liability authority. + pub fn get_issuer_notes_strict( &self, - keys: &[NoteKey], - ) -> Result, NoteError> { - let mut notes = Vec::new(); - for key in keys { - let key_bytes = key.to_bytes(); - match self.notes_partition.get(&key_bytes) { - Ok(Some(value_bytes)) => { - if value_bytes.len() != 33 + 8 + 8 + 8 + 65 + 33 { - continue; // Skip invalid entries - } - let issuer_pubkey: PubKey = value_bytes[0..33].try_into().unwrap(); - let amount_collected = - u64::from_be_bytes(value_bytes[33..41].try_into().unwrap()); - let amount_redeemed = - u64::from_be_bytes(value_bytes[41..49].try_into().unwrap()); - let timestamp = u64::from_be_bytes(value_bytes[49..57].try_into().unwrap()); - let signature: [u8; 65] = value_bytes[57..122].try_into().unwrap(); - let recipient_pubkey: PubKey = value_bytes[122..155].try_into().unwrap(); - - notes.push(( - issuer_pubkey, - IouNote { - recipient_pubkey, - amount_collected, - amount_redeemed, - timestamp, - signature, - }, - )); - } - Ok(None) => {} - Err(_) => {} + issuer_pubkey: &PubKey, + ) -> Result, NoteError> { + let notes = self.get_issuer_notes(issuer_pubkey)?; + for note in ¬es { + note.verify_signature(issuer_pubkey) + .map_err(|_| NoteError::InvalidSignature)?; + if note.amount_redeemed > note.amount_collected { + return Err(NoteError::StorageError( + "Stored redeemed amount exceeds cumulative debt".to_string(), + )); } } Ok(notes) } - /// Get all notes for a specific issuer (uses issuer index for O(1) lookup) - pub fn get_issuer_notes(&self, issuer_pubkey: &PubKey) -> Result, NoteError> { - tracing::debug!( - "Looking for notes from issuer using index: {:?}", - issuer_pubkey - ); - - // Use the issuer index for efficient lookup - match self.issuer_index.get(issuer_pubkey) { - Ok(Some(bytes)) => { - let keys = Self::deserialize_note_keys(&bytes)?; - tracing::debug!("Found {} note keys in issuer index", keys.len()); - self.get_notes_by_keys(&keys) - } - Ok(None) => { - tracing::debug!("No notes found in issuer index"); - Ok(Vec::new()) - } - Err(e) => Err(NoteError::StorageError(format!( - "Failed to read issuer index: {}", - e - ))), - } + /// Read every primary note row without tolerating malformed or misplaced data. + /// + /// The versioned state vector is the insertion order. Malformed state or a + /// duplicate logical edge stops recovery. + pub(crate) fn get_all_notes_with_issuer_strict( + &self, + ) -> Result, NoteError> { + self.read_state_strict().map(|state| state.notes) } - /// Get all notes for a specific recipient (uses recipient index for O(1) lookup) + /// Get all notes for a specific recipient from the strict primary snapshot. pub fn get_recipient_notes( &self, recipient_pubkey: &PubKey, ) -> Result, NoteError> { - tracing::debug!( - "Looking for notes for recipient using index: {:?}", - recipient_pubkey - ); - - // Use the recipient index for efficient lookup - match self.recipient_index.get(recipient_pubkey) { - Ok(Some(bytes)) => { - let keys = Self::deserialize_note_keys(&bytes)?; - tracing::debug!("Found {} note keys in recipient index", keys.len()); - self.get_notes_by_keys(&keys) - } - Ok(None) => { - tracing::debug!("No notes found in recipient index"); - Ok(Vec::new()) - } - Err(e) => Err(NoteError::StorageError(format!( - "Failed to read recipient index: {}", - e - ))), - } + self.read_state_strict().map(|state| { + state + .notes + .into_iter() + .filter_map(|(_, note)| { + (note.recipient_pubkey == *recipient_pubkey).then_some(note) + }) + .collect() + }) } /// Get all notes for a specific recipient with issuer information @@ -555,156 +1371,26 @@ impl NoteStorage { &self, recipient_pubkey: &PubKey, ) -> Result, NoteError> { - tracing::debug!( - "Looking for notes for recipient with issuer using index: {:?}", - recipient_pubkey - ); - - // Use the recipient index for efficient lookup - match self.recipient_index.get(recipient_pubkey) { - Ok(Some(bytes)) => { - let keys = Self::deserialize_note_keys(&bytes)?; - tracing::debug!("Found {} note keys in recipient index", keys.len()); - self.get_notes_by_keys_with_issuer(&keys) - } - Ok(None) => { - tracing::debug!("No notes found in recipient index"); - Ok(Vec::new()) - } - Err(e) => Err(NoteError::StorageError(format!( - "Failed to read recipient index: {}", - e - ))), - } - } - - /// Rebuild secondary indices from existing notes in the database - /// This should be called after upgrading to a version with indices when - /// existing data may not have index entries - pub fn rebuild_indices(&self) -> Result { - tracing::info!("Rebuilding note indices from existing data..."); - let mut count = 0; - - for item in self.notes_partition.iter() { - let (key_bytes, value_bytes) = item.map_err(|e| { - NoteError::StorageError(format!("Failed to iterate partition: {}", e)) - })?; - - // Manual deserialization to extract issuer and recipient - if value_bytes.len() != 33 + 8 + 8 + 8 + 65 + 33 { - continue; // Skip invalid entries - } - - let issuer_pubkey: PubKey = value_bytes[0..33].try_into().unwrap(); - let recipient_pubkey: PubKey = value_bytes[122..155].try_into().unwrap(); - - // Reconstruct the note key from the stored key bytes - let note_key = if key_bytes.len() == 32 { - NoteKey::from_bytes(&key_bytes.as_ref().try_into().unwrap()) - } else { - // Fallback: compute from pubkeys - NoteKey::from_keys(&issuer_pubkey, &recipient_pubkey) - }; - - // Rebuild indices - Self::add_to_index(&self.issuer_index, &issuer_pubkey, ¬e_key)?; - Self::add_to_index(&self.recipient_index, &recipient_pubkey, ¬e_key)?; - count += 1; - } - - tracing::info!("Index rebuild complete: {} notes indexed", count); - Ok(count) + self.read_state_strict().map(|state| { + state + .notes + .into_iter() + .filter_map(|(issuer, note)| { + (note.recipient_pubkey == *recipient_pubkey).then_some((issuer, note)) + }) + .collect() + }) } /// Get all notes in the database pub fn get_all_notes(&self) -> Result, NoteError> { - let mut notes = Vec::new(); - - for item in self.notes_partition.iter() { - let (_key_bytes, value_bytes) = item.map_err(|e| { - NoteError::StorageError(format!("Failed to iterate partition: {}", e)) - })?; - - // Manual deserialization - if value_bytes.len() != 33 + 8 + 8 + 8 + 65 + 33 { - continue; // Skip invalid entries - } - - let _stored_issuer_pubkey: PubKey = value_bytes[0..33].try_into().unwrap(); - let amount_collected = u64::from_be_bytes(value_bytes[33..41].try_into().unwrap()); - let amount_redeemed = u64::from_be_bytes(value_bytes[41..49].try_into().unwrap()); - let timestamp = u64::from_be_bytes(value_bytes[49..57].try_into().unwrap()); - let signature: [u8; 65] = value_bytes[57..122].try_into().unwrap(); - let recipient_pubkey: PubKey = value_bytes[122..155].try_into().unwrap(); - - let note = IouNote { - recipient_pubkey, - amount_collected, - amount_redeemed, - timestamp, - signature, - }; - - notes.push(note); - } - - Ok(notes) + self.read_state_strict() + .map(|state| state.notes.into_iter().map(|(_, note)| note).collect()) } /// Get all notes with issuer information pub fn get_all_notes_with_issuer(&self) -> Result, NoteError> { - let mut notes_with_issuer = Vec::new(); - - for item in self.notes_partition.iter() { - let (_key_bytes, value_bytes) = item.map_err(|e| { - NoteError::StorageError(format!("Failed to iterate partition: {}", e)) - })?; - - // Manual deserialization - if value_bytes.len() != 33 + 8 + 8 + 8 + 65 + 33 { - continue; // Skip invalid entries - } - - let issuer_pubkey: PubKey = value_bytes[0..33].try_into().unwrap(); - let amount_collected = u64::from_be_bytes(value_bytes[33..41].try_into().unwrap()); - let amount_redeemed = u64::from_be_bytes(value_bytes[41..49].try_into().unwrap()); - let timestamp = u64::from_be_bytes(value_bytes[49..57].try_into().unwrap()); - let signature: [u8; 65] = value_bytes[57..122].try_into().unwrap(); - let recipient_pubkey: PubKey = value_bytes[122..155].try_into().unwrap(); - - let note = IouNote { - recipient_pubkey, - amount_collected, - amount_redeemed, - timestamp, - signature, - }; - - notes_with_issuer.push((issuer_pubkey, note)); - } - - Ok(notes_with_issuer) - } - - /// Delete a note and update indices - pub fn delete_note( - &self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - ) -> Result<(), NoteError> { - let key = NoteKey::from_keys(issuer_pubkey, recipient_pubkey); - let key_bytes = key.to_bytes(); - - // Remove from main storage - self.notes_partition - .remove(&key_bytes) - .map_err(|e| NoteError::StorageError(format!("Failed to remove note: {}", e)))?; - - // Update indices - Self::remove_from_index(&self.issuer_index, issuer_pubkey, &key)?; - Self::remove_from_index(&self.recipient_index, recipient_pubkey, &key)?; - - Ok(()) + self.get_all_notes_with_issuer_strict() } } diff --git a/crates/basis_store/src/property_tests.rs b/crates/basis_store/src/property_tests.rs index 89cfe69..58d7bb8 100644 --- a/crates/basis_store/src/property_tests.rs +++ b/crates/basis_store/src/property_tests.rs @@ -1,7 +1,6 @@ use crate::{ schnorr::{self, generate_keypair}, - transaction_builder::{RedemptionTransactionBuilder, TxContext}, - IouNote, RedemptionManager, RedemptionRequest, TrackerStateManager, + IouNote, }; #[cfg(test)] @@ -216,205 +215,4 @@ mod property_tests { } } } - - // ============================================================================ - // Redemption-specific property tests - // ============================================================================ - - proptest! { - #[test] - fn test_redemption_request_validation_proptest( - amount in 1u64..1000000, - timestamp in 1000000000u64..2000000000, - issuer_sig_prefix in prop::collection::vec(any::(), 1..10) - ) { - // Test that RedemptionRequest with various valid inputs can be created - let (_, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - let issuer_sig = format!("{}{}", hex::encode(&issuer_sig_prefix), "0".repeat(130usize.saturating_sub(issuer_sig_prefix.len() * 2))); - let issuer_sig = if issuer_sig.len() > 130 { issuer_sig[..130].to_string() } else { issuer_sig }; - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(issuer_pubkey), - recipient_pubkey: hex::encode(recipient_pubkey), - amount, - timestamp, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b".to_string(), - current_height: 1000, - recipient_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - change_address: "9hNQcqi72NB5u5Tw6tbfCGbEKByguR7njvcyZXnXPLvV3Do1DiJ".to_string(), - issuer_signature: issuer_sig, - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: amount + 1000000 + 1000000, // Reserve must cover debt + fee + buffer - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - // Basic validation: amount should be positive - prop_assert!(request.amount > 0); - // Public keys should be valid hex and 66 chars (33 bytes) - prop_assert_eq!(request.issuer_pubkey.len(), 66); - prop_assert_eq!(request.recipient_pubkey.len(), 66); - // Reserve and tracker box IDs should not be empty - prop_assert!(!request.reserve_box_id.is_empty()); - prop_assert!(!request.tracker_box_id.is_empty()); - } - - #[test] - fn test_transaction_building_random_amounts_proptest( - redemption_amount in 1u64..1000000u64, - fee in 100000u64..5000000u64 - ) { - // Test transaction building with random valid amounts - let (secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - // Create a note with sufficient outstanding debt - let total_debt = redemption_amount + 1; // Ensure outstanding_debt >= redemption_amount - let note = IouNote::create_and_sign(recipient_pubkey, total_debt, 1234567890, &secret).unwrap(); - - prop_assume!(redemption_amount <= note.outstanding_debt()); - - let context = TxContext { - current_height: 1000, - fee, - change_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - network_prefix: 0, - }; - - let result = RedemptionTransactionBuilder::build_unsigned_redemption_transaction( - "test_reserve_box_1234567890abcdef", - "test_tracker_box_abcdef1234567890", - "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304", - ¬e, - "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33", - &[0x01, 0x02, 0x03], - &[0u8; 65], - &[0u8; 65], - &issuer_pubkey, - &context, - redemption_amount + fee + 1000000, // Reserve box value: enough to cover redemption + fee + buffer - 0, // No pending refund - None, // First redemption: no reserve lookup proof - vec![0x03, 0x04], - redemption_amount, - ); - - prop_assert!(result.is_ok(), "Transaction building should succeed for valid amounts: {:?}", result.err()); - - let tx_data = result.unwrap(); - prop_assert_eq!(tx_data.redemption_amount, redemption_amount); - prop_assert_eq!(tx_data.fee, fee); - prop_assert!(tx_data.context_extension.is_some()); - } - - #[test] - fn test_transaction_building_invalid_amounts_proptest( - redemption_amount in 1u64..u64::MAX, - note_amount in 1u64..1000000u64 - ) { - // Test that transaction builder rejects amounts exceeding outstanding debt - prop_assume!(redemption_amount > note_amount); - - let (secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - let note = IouNote::create_and_sign(recipient_pubkey, note_amount, 1234567890, &secret).unwrap(); - - let context = TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - network_prefix: 0, - }; - - let result = RedemptionTransactionBuilder::build_unsigned_redemption_transaction( - "test_reserve_box_1234567890abcdef", - "test_tracker_box_abcdef1234567890", - "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304", - ¬e, - "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33", - &[0x01, 0x02, 0x03], - &[0u8; 65], - &[0u8; 65], - &issuer_pubkey, - &context, - note_amount + 1000000 + 1000000, // Reserve box value: enough to cover max debt + fee + buffer - 0, // No pending refund - None, - vec![0x03, 0x04], - redemption_amount, - ); - - prop_assert!(result.is_err(), "Transaction building should fail when redemption amount {} exceeds outstanding debt {}", redemption_amount, note_amount); - } - - #[test] - fn test_multiple_redemption_sequence_proptest( - initial_amount in 1000u64..10000000u64, - num_redemptions in 1usize..10usize - ) { - // Test that a sequence of partial redemptions maintains invariants - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let (secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - // Create and add a note - let note = IouNote::create_and_sign(recipient_pubkey, initial_amount, 1234567890, &secret).unwrap(); - redemption_manager.tracker.add_note(&issuer_pubkey, ¬e).unwrap(); - - let mut total_redeemed: u64 = 0; - - for i in 0..num_redemptions { - let remaining = initial_amount - total_redeemed; - prop_assume!(remaining > 0); - - // Redeem a random portion of the remaining amount - let redeem_amount = if remaining > 1 { (i as u64 + 1) * (remaining / (num_redemptions as u64 + 1)).max(1) } else { remaining }; - let redeem_amount = redeem_amount.min(remaining); - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(issuer_pubkey), - recipient_pubkey: hex::encode(recipient_pubkey), - amount: redeem_amount, - timestamp: 1234567890 + i as u64, - reserve_box_id: "test_reserve_box_1234567890abcdef".to_string(), - tracker_box_id: "test_tracker_box_abcdef1234567890".to_string(), - tracker_nft_id: "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304".to_string(), - current_height: 1000, - recipient_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - change_address: "9hNQcqi72NB5u5Tw6tbfCGbEKByguR7njvcyZXnXPLvV3Do1DiJ".to_string(), - issuer_signature: "01".repeat(65), - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: initial_amount + 1000000 + 1000000, // Reserve must cover max debt + fee + buffer - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let result = redemption_manager.initiate_redemption(&request); - if result.is_ok() { - total_redeemed += redeem_amount; - let _ = redemption_manager.complete_redemption(&issuer_pubkey, &recipient_pubkey, redeem_amount, None); - } - } - - // Verify that total redeemed never exceeds initial amount - prop_assert!(total_redeemed <= initial_amount, "Total redeemed {} should not exceed initial amount {}", total_redeemed, initial_amount); - - // Verify final state - let final_note = redemption_manager.tracker.lookup_note(&issuer_pubkey, &recipient_pubkey).unwrap(); - prop_assert_eq!(final_note.amount_redeemed, total_redeemed, "Final redeemed amount should match total redeemed"); - prop_assert!(final_note.outstanding_debt() == initial_amount - total_redeemed || final_note.outstanding_debt() == 0, - "Outstanding debt should be initial - total_redeemed or 0"); - } - } } diff --git a/crates/basis_store/src/real_scanner_integration_tests.rs b/crates/basis_store/src/real_scanner_integration_tests.rs index 00053a5..b101068 100644 --- a/crates/basis_store/src/real_scanner_integration_tests.rs +++ b/crates/basis_store/src/real_scanner_integration_tests.rs @@ -13,6 +13,10 @@ impl RealScannerIntegrationTestSuite { pub fn new(node_url: &str) -> Result { let config = NodeConfig { node_url: node_url.to_string(), + reserve_contract_p2s: Some( + crate::contract_compiler::get_basis_reserve_contract_p2s() + .map_err(|error| ScannerError::Generic(error.to_string()))?, + ), ..Default::default() }; let scanner = ServerState::new( diff --git a/crates/basis_store/src/redemption.rs b/crates/basis_store/src/redemption.rs deleted file mode 100644 index 245c53d..0000000 --- a/crates/basis_store/src/redemption.rs +++ /dev/null @@ -1,1334 +0,0 @@ -//! Redemption flow for Basis offchain notes - -use thiserror::Error; - -use crate::transaction_builder::{RedemptionTransactionBuilder, TxContext}; -use crate::{blake2b256_hash, IouNote, NoteError, PubKey, TrackerStateManager}; - -#[derive(Error, Debug)] -pub enum RedemptionError { - #[error("Note not found")] - NoteNotFound, - #[error("Invalid note signature")] - InvalidNoteSignature, - #[error("Redemption too early: {0} < {1}")] - RedemptionTooEarly(u64, u64), - #[error("Insufficient collateral: {0} < {1}")] - InsufficientCollateral(u64, u64), - #[error("Reserve not found: {0}")] - ReserveNotFound(String), - #[error("Transaction building error: {0}")] - TransactionError(String), - #[error("Storage error: {0}")] - StorageError(String), - #[error("Invalid public key: {0}")] - InvalidPublicKey(String), -} - -impl From for RedemptionError { - fn from(err: NoteError) -> Self { - match err { - NoteError::InvalidSignature => RedemptionError::InvalidNoteSignature, - NoteError::FutureTimestamp => { - RedemptionError::StorageError("Future timestamp".to_string()) - } - NoteError::PastTimestamp => RedemptionError::StorageError("Past timestamp".to_string()), - NoteError::RedemptionTooEarly => RedemptionError::RedemptionTooEarly(0, 0), - NoteError::StorageError(msg) => RedemptionError::StorageError(msg), - _ => RedemptionError::StorageError(format!("{:?}", err)), - } - } -} - -/// Redemption request parameters -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct RedemptionRequest { - /// Issuer's public key (hex encoded) - pub issuer_pubkey: String, - /// Recipient's public key (hex encoded) - pub recipient_pubkey: String, - /// Amount to redeem - pub amount: u64, - /// Timestamp of the note being redeemed - pub timestamp: u64, - /// Reserve contract box ID (hex encoded) - pub reserve_box_id: String, - /// Tracker box ID (hex encoded) - fetched from blockchain - pub tracker_box_id: String, - /// Tracker NFT ID from reserve box R6 register (hex encoded, 64 chars = 32 bytes) - pub tracker_nft_id: String, - /// Current blockchain height - fetched from Ergo node - pub current_height: u64, // Stored as u64 from API, converted to u32 for transaction builder - /// Recipient's address for redemption output - pub recipient_address: String, - /// Change address for transaction outputs (derived from tracker pubkey if not specified) - pub change_address: String, - /// Issuer's Schnorr signature (65 bytes, hex encoded = 130 chars) - /// Signs: key || totalDebt || timestamp (48 bytes) - /// where key = blake2b256(ownerKey || receiverKey) - pub issuer_signature: String, - /// Whether this is an emergency redemption (after 3 days tracker unavailability) - #[serde(default)] - pub emergency: bool, - /// Tracker's Schnorr signature (65 bytes, hex encoded = 130 chars) - /// Optional: will be generated by server if not provided for normal redemption - #[serde(default)] - pub tracker_signature: Option, - /// Value of the reserve box being spent (in nanoERG) - /// Fetched from the blockchain by the API layer - pub reserve_box_value: u64, - /// Optional wallet-owned fee input box IDs. When empty, the redemption manager - /// will use a placeholder fee input in test/offline mode. - #[serde(default)] - pub fee_input_box_ids: Vec, - /// Total value provided by the fee input boxes. Must be >= the required fee. - #[serde(default)] - pub fee_input_total_value: u64, - /// Refund initiation height from the reserve box's R7 register (0 if no refund pending) - #[serde(default)] - pub reserve_refund_initiation_height: u64, -} - -/// Redemption proof and transaction data -#[derive(Debug, Clone)] -pub struct RedemptionData { - /// Unique redemption ID - pub redemption_id: String, - /// The note being redeemed - pub note: IouNote, - /// AVL tree proof for the note - pub avl_proof: Vec, - /// Redemption transaction bytes (hex encoded) - pub transaction_bytes: String, - /// Required signatures for the transaction - pub required_signatures: Vec, - /// Estimated transaction fee - pub estimated_fee: u64, - /// Timestamp when redemption can be executed - pub redemption_time: u64, -} - -/// Redemption manager for handling note redemptions -pub struct RedemptionManager { - pub tracker: TrackerStateManager, -} - -impl RedemptionManager { - /// Create a new redemption manager - pub fn new(tracker: TrackerStateManager) -> Self { - Self { tracker } - } - - /// Initiate redemption process for a note - pub fn initiate_redemption( - &mut self, - request: &RedemptionRequest, - ) -> Result { - // Parse public keys - let issuer_pubkey = parse_pubkey(&request.issuer_pubkey)?; - let recipient_pubkey = parse_pubkey(&request.recipient_pubkey)?; - - // Lookup the note - let note = self - .tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .map_err(|_| RedemptionError::NoteNotFound)?; - - // Verify note signature - note.verify_signature(&issuer_pubkey) - .map_err(|_| RedemptionError::InvalidNoteSignature)?; - - // Verify the AVL proof that the note exists in the tracker's tree. - // This ensures the note is committed to the current on-chain state. - let avl_root_digest = self.tracker.get_state().avl_root_digest; - let avl_proof = self - .tracker - .generate_proof(&issuer_pubkey, &recipient_pubkey) - .map_err(|e| { - RedemptionError::StorageError(format!("Proof generation failed: {:?}", e)) - })?; - - let proof_valid = self.verify_redemption_proof( - &avl_proof.avl_proof, - ¬e, - &issuer_pubkey, - &avl_root_digest, - )?; - - if !proof_valid { - return Err(RedemptionError::StorageError( - "AVL proof verification failed: note not found in tracker state".to_string(), - )); - } - - // Check if there's sufficient outstanding debt to redeem - if note.outstanding_debt() < request.amount { - return Err(RedemptionError::InsufficientCollateral( - note.outstanding_debt(), - request.amount, - )); - } - - // Note: Time lock validation is handled by the ErgoScript contract (basis.es). - // Normal redemption requires valid signatures (no time restriction). - // Emergency redemption requires (HEIGHT - trackerCreationHeight) > 2160. - // The transaction builder and manager do NOT enforce time locks. - - // Build redemption transaction using the transaction builder directly - // The reserve_box_id should already be set in the request from the API layer - let redemption_data = - build_redemption_transaction(&mut self.tracker, ¬e, &avl_proof, request)?; - - Ok(redemption_data) - } - - /// Build unsigned redemption transaction data for blockchain integration - /// - /// This method prepares the complete unsigned redemption transaction structure by: - /// 1. Validating the redemption request parameters - /// 2. Preparing transaction components (inputs, outputs, data inputs) - /// 3. Assembling context extension with contract parameters - /// 4. Creating transaction data structure ready for signing - /// - /// The resulting transaction follows the Basis contract specification: - /// - Spends the reserve box to redeem collateral - /// - Uses tracker box as data input for AVL proof verification - /// - Creates updated reserve box with reduced collateral - /// - Sends redeemed funds to recipient address - /// - Includes Schnorr signatures and AVL proofs - /// - /// When blockchain integration is complete, this will produce actual - /// Ergo transactions that can be submitted to the network. - pub fn build_unsigned_redemption_transaction( - &mut self, - note: &IouNote, - proof: &crate::NoteProof, - request: &RedemptionRequest, - reserve_box_id: &str, - tracker_box_id: &str, - tracker_nft_id: &str, - issuer_sig: &[u8], - tracker_sig: &[u8], - context: &TxContext, - reserve_box_value: u64, - ) -> Result { - // Generate real proofs using tracker state - let issuer_pubkey_bytes = parse_pubkey(&request.issuer_pubkey).map_err(|e| { - RedemptionError::TransactionError(format!("Invalid issuer pubkey: {}", e)) - })?; - let recipient_pubkey_bytes = parse_pubkey(&request.recipient_pubkey).map_err(|e| { - RedemptionError::TransactionError(format!("Invalid recipient pubkey: {}", e)) - })?; - - // Generate reserve lookup proof FIRST (before inserting, for old state) - // For first redemption, this will return None proof - let reserve_lookup_proof = self - .tracker - .generate_reserve_lookup_proof(&issuer_pubkey_bytes, &recipient_pubkey_bytes) - .map_err(|e| { - RedemptionError::TransactionError(format!( - "Failed to generate reserve lookup proof: {:?}", - e - )) - })?; - - // Generate reserve insert proof (for inserting redeemed amount into reserve tree) - // This also updates the reserve AVL tree and returns the updated tree digest for R5 - let new_already_redeemed = note.amount_redeemed + request.amount; - let (reserve_insert_proof, updated_reserve_tree) = self - .tracker - .generate_reserve_insert_proof( - &issuer_pubkey_bytes, - &recipient_pubkey_bytes, - note.timestamp, - new_already_redeemed, - ) - .map_err(|e| { - RedemptionError::TransactionError(format!( - "Failed to generate reserve insert proof: {:?}", - e - )) - })?; - - // Generate tracker lookup proof (for totalDebt) - let tracker_lookup_proof = self - .tracker - .generate_tracker_lookup_proof(&issuer_pubkey_bytes, &recipient_pubkey_bytes) - .map_err(|e| { - RedemptionError::TransactionError(format!( - "Failed to generate tracker lookup proof: {:?}", - e - )) - })?; - - let mut transaction_data = - RedemptionTransactionBuilder::build_unsigned_redemption_transaction( - reserve_box_id, - tracker_box_id, - tracker_nft_id, - note, - &request.recipient_address, - &reserve_insert_proof, - issuer_sig, - tracker_sig, - &issuer_pubkey_bytes, - context, - reserve_box_value, - request.reserve_refund_initiation_height, - reserve_lookup_proof.proof, - tracker_lookup_proof.proof, - request.amount, - ) - .map_err(|e| RedemptionError::TransactionError(e.to_string()))?; - - // Set the updated reserve tree for R5 register - transaction_data.updated_reserve_tree = Some(updated_reserve_tree); - - // Configure fee inputs. If the request provides them, use them; otherwise use a - // placeholder so offline/tests can still build a balanced transaction. - let required_fee = transaction_data.fee; - if !request.fee_input_box_ids.is_empty() { - transaction_data.fee_input_box_ids = request.fee_input_box_ids.clone(); - transaction_data.fee_input_total_value = request.fee_input_total_value; - transaction_data.change_address = Some(request.change_address.clone()); - } else { - transaction_data.fee_input_box_ids = vec!["test_fee_input_placeholder".to_string()]; - transaction_data.fee_input_total_value = required_fee; - transaction_data.change_address = Some(request.change_address.clone()); - } - - // Generate unique redemption ID for tracking - let redemption_id = format!( - "redeem_{}_{}_{}", - &request.issuer_pubkey[..16], - &request.recipient_pubkey[..16], - note.timestamp - ); - - // Create transaction bytes using real transaction builder - let transaction_bytes = - RedemptionTransactionBuilder::build_redemption_transaction(&transaction_data) - .map_err(|e| RedemptionError::TransactionError(e.to_string()))?; - - // Required signatures: issuer and tracker - let required_signatures = vec![ - request.issuer_pubkey.clone(), - "tracker_signature_key".to_string(), // Placeholder - in real implementation, this would be tracker's pubkey - ]; - - // Use configured fee - let estimated_fee = context.fee; - - // Redemption can happen immediately since we checked the time lock - let redemption_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - - Ok(RedemptionData { - redemption_id, - note: note.clone(), - avl_proof: proof.avl_proof.clone(), - transaction_bytes: hex::encode(transaction_bytes), - required_signatures, - estimated_fee, - redemption_time, - }) - } - - /// Complete redemption by updating the note with redeemed amount. - /// - /// `new_already_redeemed` optionally overrides the cumulative value written to the - /// reserve AVL tree; pass the value proven on-chain by the redemption build when it - /// can diverge from the note's cumulative redeemed amount (e.g. fresh reserves). - pub fn complete_redemption( - &mut self, - issuer_pubkey: &PubKey, - recipient_pubkey: &PubKey, - redeemed_amount: u64, - new_already_redeemed: Option, - ) -> Result<(), RedemptionError> { - // Get the current note - let mut note = self - .tracker - .lookup_note(issuer_pubkey, recipient_pubkey) - .map_err(|_| RedemptionError::NoteNotFound)?; - - // Capture the note's payment timestamp before it is refreshed; the reserve AVL tree value - // is `payment_timestamp || already_redeemed` and must match the on-chain reserve entry. - let payment_timestamp = note.timestamp; - - // Update the redeemed amount - note.amount_redeemed += redeemed_amount; - - // Update the timestamp to ensure it's newer than the existing one - note.timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| RedemptionError::StorageError("Failed to get current time".to_string()))? - .as_millis() as u64; - - // Update the note in tracker - self.tracker - .update_note(issuer_pubkey, ¬e) - .map_err(RedemptionError::from)?; - - // Keep the reserve AVL tree in sync with the cumulative redeemed amount so subsequent - // redemptions generate insert/lookup proofs that verify against the on-chain reserve R5. - // When the on-chain build used an explicit cumulative value (e.g. a fresh reserve whose - // tree did not yet contain earlier redemptions), that value takes precedence. - let tree_value = new_already_redeemed.unwrap_or(note.amount_redeemed); - self.tracker - .update_already_redeemed( - issuer_pubkey, - recipient_pubkey, - payment_timestamp, - tree_value, - ) - .map_err(RedemptionError::from)?; - - Ok(()) - } - - /// Verify redemption proof against on-chain state - /// - /// This verifies: - /// 1. The note signature (Schnorr signature from issuer) - /// 2. The AVL proof that the note exists in the tracker's AVL tree - /// - /// # Arguments - /// * `proof` - The AVL proof bytes for the note lookup - /// * `note` - The IOU note being redeemed - /// * `issuer_pubkey` - The issuer's public key (33 bytes compressed secp256k1) - /// * `avl_root_digest` - The expected AVL tree root digest (33 bytes) from on-chain state - /// - /// # Returns - /// `true` if both the signature and AVL proof are valid, `false` otherwise - pub fn verify_redemption_proof( - &self, - proof: &[u8], - note: &IouNote, - issuer_pubkey: &PubKey, - avl_root_digest: &[u8; 33], - ) -> Result { - // Step 1: Verify the note signature (Schnorr signature from issuer) - note.verify_signature(issuer_pubkey) - .map_err(|_| RedemptionError::InvalidNoteSignature)?; - - // Step 2: Verify the AVL proof that the note exists in the tracker's tree - // The key is blake2b256(issuer_pubkey || recipient_pubkey) - let mut key_data = Vec::with_capacity(66); - key_data.extend_from_slice(issuer_pubkey); - key_data.extend_from_slice(¬e.recipient_pubkey); - let key = blake2b256_hash(&key_data); - - // The value is the note's totalDebt (amount_collected) as 8-byte big-endian. - // The tracker AVL tree stores totalDebt, not outstanding debt, so this matches - // the on-chain contract's context var #3 and the proof generated by the tracker. - let total_debt = note.amount_collected; - let value = total_debt.to_be_bytes().to_vec(); - - let avl_valid = - basis_trees::BasisAvlTree::verify_proof(avl_root_digest, proof, &key, &value); - - if !avl_valid { - return Ok(false); - } - - Ok(true) - } -} - -/// Parse hex-encoded public key -fn parse_pubkey(hex_str: &str) -> Result { - let bytes = hex::decode(hex_str) - .map_err(|_| RedemptionError::InvalidPublicKey("Invalid hex encoding".to_string()))?; - - bytes - .try_into() - .map_err(|_| RedemptionError::InvalidPublicKey("Must be 33 bytes".to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::IouNote; - - #[test] - fn test_redemption_validation() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let redemption_manager = RedemptionManager::new(tracker); - - // Test public key parsing - let valid_pubkey = "02".to_string() + &"0".repeat(64); // 33 bytes hex - let parsed = parse_pubkey(&valid_pubkey); - assert!(parsed.is_ok()); - - // Test invalid hex - let invalid_hex = "zz".to_string(); - let parsed = parse_pubkey(&invalid_hex); - assert!(parsed.is_err()); - - // Test wrong length - let wrong_length = "02".to_string() + &"0".repeat(62); // 32 bytes - let parsed = parse_pubkey(&wrong_length); - assert!(parsed.is_err()); - } - - #[test] - fn test_build_unsigned_redemption_transaction_invalid_issuer_pubkey() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - // Create a valid note first - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret); - let issuer_pubkey_bytes = issuer_pubkey.serialize(); - - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret); - let recipient_pubkey_bytes = recipient_pubkey.serialize(); - - let note = IouNote { - recipient_pubkey: recipient_pubkey_bytes, - amount_collected: 10000, - amount_redeemed: 0, - timestamp: 1672531200, - signature: [0u8; 65], // dummy signature - }; - - let request = RedemptionRequest { - issuer_pubkey: "invalid_hex".to_string(), - recipient_pubkey: hex::encode(&recipient_pubkey_bytes), - amount: 1000, - timestamp: 1672531200, - reserve_box_id: "box123".to_string(), - tracker_box_id: "tracker123".to_string(), - tracker_nft_id: "nft123".to_string(), - current_height: 1000, - recipient_address: "9".repeat(51), - change_address: "9".repeat(51), - issuer_signature: "01".repeat(65), - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: 20000000, // 0.02 ERG reserve box value - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let proof = crate::NoteProof { - note: note.clone(), - avl_proof: vec![], - operations: vec![], - }; - - let result = redemption_manager.build_unsigned_redemption_transaction( - ¬e, - &proof, - &request, - "box123", - "tracker123", - "nft123", - &[0u8; 65], - &[0u8; 65], - &TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9".repeat(51), - network_prefix: 0, - }, - 20000000, // Reserve box value: enough to cover redemption + fee - ); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, RedemptionError::TransactionError(_))); - } - - #[test] - fn test_build_unsigned_redemption_transaction_invalid_recipient_pubkey() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret); - let issuer_pubkey_bytes = issuer_pubkey.serialize(); - - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret); - let recipient_pubkey_bytes = recipient_pubkey.serialize(); - - let note = IouNote { - recipient_pubkey: recipient_pubkey_bytes, - amount_collected: 10000, - amount_redeemed: 0, - timestamp: 1672531200, - signature: [0u8; 65], - }; - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(&issuer_pubkey_bytes), - recipient_pubkey: "invalid_hex".to_string(), - amount: 1000, - timestamp: 1672531200, - reserve_box_id: "box123".to_string(), - tracker_box_id: "tracker123".to_string(), - tracker_nft_id: "nft123".to_string(), - current_height: 1000, - recipient_address: "9".repeat(51), - change_address: "9".repeat(51), - issuer_signature: "01".repeat(65), - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: 20000000, // 0.02 ERG reserve box value - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let proof = crate::NoteProof { - note: note.clone(), - avl_proof: vec![], - operations: vec![], - }; - - let result = redemption_manager.build_unsigned_redemption_transaction( - ¬e, - &proof, - &request, - "box123", - "tracker123", - "nft123", - &[0u8; 65], - &[0u8; 65], - &TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9".repeat(51), - network_prefix: 0, - }, - 20000000, // Reserve box value: enough to cover redemption + fee - ); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, RedemptionError::TransactionError(_))); - } - - #[test] - fn test_build_unsigned_redemption_transaction_empty_reserve_box_id() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret); - let issuer_pubkey_bytes = issuer_pubkey.serialize(); - - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret); - let recipient_pubkey_bytes = recipient_pubkey.serialize(); - - let note = IouNote { - recipient_pubkey: recipient_pubkey_bytes, - amount_collected: 10000, - amount_redeemed: 0, - timestamp: 1672531200, - signature: [0u8; 65], - }; - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(&issuer_pubkey_bytes), - recipient_pubkey: hex::encode(&recipient_pubkey_bytes), - amount: 1000, - timestamp: 1672531200, - reserve_box_id: "".to_string(), - tracker_box_id: "tracker123".to_string(), - tracker_nft_id: "nft123".to_string(), - current_height: 1000, - recipient_address: "9".repeat(51), - change_address: "9".repeat(51), - issuer_signature: "01".repeat(65), - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: 20000000, // 0.02 ERG reserve box value - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let proof = crate::NoteProof { - note: note.clone(), - avl_proof: vec![], - operations: vec![], - }; - - let result = redemption_manager.build_unsigned_redemption_transaction( - ¬e, - &proof, - &request, - "box123", - "tracker123", - "nft123", - &[0u8; 65], - &[0u8; 65], - &TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9".repeat(51), - network_prefix: 0, - }, - 20000000, // Reserve box value: enough to cover redemption + fee - ); - - // Empty reserve box ID should be rejected - assert!(result.is_err()); - } - - #[test] - fn test_build_unsigned_redemption_transaction_empty_tracker_box_id() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret); - let issuer_pubkey_bytes = issuer_pubkey.serialize(); - - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret); - let recipient_pubkey_bytes = recipient_pubkey.serialize(); - - let note = IouNote { - recipient_pubkey: recipient_pubkey_bytes, - amount_collected: 10000, - amount_redeemed: 0, - timestamp: 1672531200, - signature: [0u8; 65], - }; - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(&issuer_pubkey_bytes), - recipient_pubkey: hex::encode(&recipient_pubkey_bytes), - amount: 1000, - timestamp: 1672531200, - reserve_box_id: "box123".to_string(), - tracker_box_id: "".to_string(), - tracker_nft_id: "nft123".to_string(), - current_height: 1000, - recipient_address: "9".repeat(51), - change_address: "9".repeat(51), - issuer_signature: "01".repeat(65), - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: 20000000, // 0.02 ERG reserve box value - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let proof = crate::NoteProof { - note: note.clone(), - avl_proof: vec![], - operations: vec![], - }; - - let result = redemption_manager.build_unsigned_redemption_transaction( - ¬e, - &proof, - &request, - "box123", - "", - "nft123", - &[0u8; 65], - &[0u8; 65], - &TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9".repeat(51), - network_prefix: 0, - }, - 20000000, // Reserve box value: enough to cover redemption + fee - ); - - // Empty tracker box ID should be rejected - assert!(result.is_err()); - } - - #[test] - fn test_build_unsigned_redemption_transaction_invalid_issuer_signature_length() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret); - let issuer_pubkey_bytes = issuer_pubkey.serialize(); - - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret); - let recipient_pubkey_bytes = recipient_pubkey.serialize(); - - let note = IouNote { - recipient_pubkey: recipient_pubkey_bytes, - amount_collected: 10000, - amount_redeemed: 0, - timestamp: 1672531200, - signature: [0u8; 65], - }; - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(&issuer_pubkey_bytes), - recipient_pubkey: hex::encode(&recipient_pubkey_bytes), - amount: 1000, - timestamp: 1672531200, - reserve_box_id: "box123".to_string(), - tracker_box_id: "tracker123".to_string(), - tracker_nft_id: "nft123".to_string(), - current_height: 1000, - recipient_address: "9".repeat(51), - change_address: "9".repeat(51), - issuer_signature: "01".repeat(64), // 64 bytes instead of 65 - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: 20000000, // 0.02 ERG reserve box value - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let proof = crate::NoteProof { - note: note.clone(), - avl_proof: vec![], - operations: vec![], - }; - - let result = redemption_manager.build_unsigned_redemption_transaction( - ¬e, - &proof, - &request, - "box123", - "tracker123", - "nft123", - &[0u8; 65], - &[0u8; 65], - &TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9".repeat(51), - network_prefix: 0, - }, - 20000000, // Reserve box value: enough to cover redemption + fee - ); - - // Invalid issuer signature length should be rejected - assert!(result.is_err()); - } - - #[test] - fn test_build_unsigned_redemption_transaction_zero_amount() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret); - let issuer_pubkey_bytes = issuer_pubkey.serialize(); - - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret); - let recipient_pubkey_bytes = recipient_pubkey.serialize(); - - let note = IouNote { - recipient_pubkey: recipient_pubkey_bytes, - amount_collected: 10000, - amount_redeemed: 0, - timestamp: 1672531200, - signature: [0u8; 65], - }; - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(&issuer_pubkey_bytes), - recipient_pubkey: hex::encode(&recipient_pubkey_bytes), - amount: 0, // Zero amount - timestamp: 1672531200, - reserve_box_id: "box123".to_string(), - tracker_box_id: "tracker123".to_string(), - tracker_nft_id: "nft123".to_string(), - current_height: 1000, - recipient_address: "9".repeat(51), - change_address: "9".repeat(51), - issuer_signature: "01".repeat(65), - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: 20000000, // 0.02 ERG reserve box value - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let proof = crate::NoteProof { - note: note.clone(), - avl_proof: vec![], - operations: vec![], - }; - - let result = redemption_manager.build_unsigned_redemption_transaction( - ¬e, - &proof, - &request, - "box123", - "tracker123", - "nft123", - &[0u8; 65], - &[0u8; 65], - &TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9".repeat(51), - network_prefix: 0, - }, - 20000000, // Reserve box value: enough to cover redemption + fee - ); - - // Zero amount should be rejected - assert!(result.is_err()); - } - - #[test] - fn test_build_unsigned_redemption_transaction_excessive_amount() { - let mut tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret); - let issuer_pubkey_bytes = issuer_pubkey.serialize(); - - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret); - let recipient_pubkey_bytes = recipient_pubkey.serialize(); - - let note = IouNote { - recipient_pubkey: recipient_pubkey_bytes, - amount_collected: 10000, - amount_redeemed: 0, - timestamp: 1672531200, - signature: [0u8; 65], - }; - - let request = RedemptionRequest { - issuer_pubkey: hex::encode(&issuer_pubkey_bytes), - recipient_pubkey: hex::encode(&recipient_pubkey_bytes), - amount: 20000, // Exceeds outstanding debt - timestamp: 1672531200, - reserve_box_id: "box123".to_string(), - tracker_box_id: "tracker123".to_string(), - tracker_nft_id: "nft123".to_string(), - current_height: 1000, - recipient_address: "9".repeat(51), - change_address: "9".repeat(51), - issuer_signature: "01".repeat(65), - emergency: false, - tracker_signature: Some("02".repeat(65)), - reserve_box_value: 20000000, // 0.02 ERG reserve box value - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let proof = crate::NoteProof { - note: note.clone(), - avl_proof: vec![], - operations: vec![], - }; - - let result = redemption_manager.build_unsigned_redemption_transaction( - ¬e, - &proof, - &request, - "box123", - "tracker123", - "nft123", - &[0u8; 65], - &[0u8; 65], - &TxContext { - current_height: 1000, - fee: 1000000, - change_address: "9".repeat(51), - network_prefix: 0, - }, - 20000000, // Reserve box value: enough to cover redemption + fee - ); - - // Excessive amount should be rejected - assert!(result.is_err()); - } - - /// Regression test: `complete_redemption` must keep the reserve AVL tree in sync using the - /// note's PRE-refresh payment timestamp (the reserve tree value is - /// `payment_timestamp || cumulative_redeemed`, and the on-chain contract inserts exactly - /// that). Otherwise subsequent redemptions produce proofs that fail on-chain. - #[test] - fn test_complete_redemption_syncs_reserve_tree_with_pre_refresh_timestamp() { - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let secp = secp256k1::Secp256k1::new(); - let issuer_secret = secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(); - let issuer_pubkey = - secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret).serialize(); - let recipient_secret = secp256k1::SecretKey::from_slice(&[2u8; 32]).unwrap(); - let recipient_pubkey = - secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret).serialize(); - - let total_debt: u64 = 400_000_000; - let payment_timestamp: u64 = 1_783_612_740_170; // arbitrary past timestamp - - // Sign the note with the issuer key so add_note's signature check passes. - let message = crate::schnorr::signing_message( - &issuer_pubkey, - &recipient_pubkey, - total_debt, - payment_timestamp, - ); - let signature = - crate::schnorr::schnorr_sign(&message, issuer_secret.as_ref(), &issuer_pubkey) - .expect("sign note"); - - let note = IouNote { - recipient_pubkey, - amount_collected: total_debt, - amount_redeemed: 0, - timestamp: payment_timestamp, - signature, - }; - redemption_manager - .tracker - .add_note(&issuer_pubkey, ¬e) - .expect("add note"); - - let redeemed: u64 = 100_000_000; - redemption_manager - .complete_redemption(&issuer_pubkey, &recipient_pubkey, redeemed, None) - .expect("complete redemption"); - - // Note state updated. - let updated = redemption_manager - .tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .expect("lookup note"); - assert_eq!(updated.amount_redeemed, redeemed); - assert!( - updated.timestamp > payment_timestamp, - "note timestamp must be refreshed" - ); - - // Reserve tree must contain (key, payment_timestamp || cumulative_redeemed) with the - // PRE-refresh timestamp — compare against an independently built reference tree. - let mut reference = TrackerStateManager::new_with_temp_storage(); - reference - .update_already_redeemed( - &issuer_pubkey, - &recipient_pubkey, - payment_timestamp, - redeemed, - ) - .expect("reference update"); - assert_eq!( - redemption_manager.tracker.reserve_state_digest(), - reference.reserve_state_digest(), - "reserve tree must use pre-refresh timestamp and cumulative amount" - ); - - // A second completion must accumulate (cumulative redeemed amount) and use the - // timestamp the note carried after the first completion (captured before the second). - let first_refresh_ts = updated.timestamp; - redemption_manager - .complete_redemption(&issuer_pubkey, &recipient_pubkey, 50_000_000, None) - .expect("second completion"); - let mut reference2 = TrackerStateManager::new_with_temp_storage(); - reference2 - .update_already_redeemed( - &issuer_pubkey, - &recipient_pubkey, - first_refresh_ts, - redeemed + 50_000_000, - ) - .expect("reference update 2"); - assert_eq!( - redemption_manager.tracker.reserve_state_digest(), - reference2.reserve_state_digest(), - "second redemption must accumulate with the note's pre-second-refresh timestamp" - ); - } - - /// When an explicit `new_already_redeemed` is provided (the cumulative value proven - /// on-chain by the build), the note still accumulates the redeemed amount but the - /// reserve tree is synced to the explicit value. This covers fresh reserves whose - /// tree did not contain earlier redemptions recorded against another reserve. - #[test] - fn test_complete_redemption_with_explicit_reserve_tree_value() { - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let secp = secp256k1::Secp256k1::new(); - let issuer_secret = secp256k1::SecretKey::from_slice(&[3u8; 32]).unwrap(); - let issuer_pubkey = - secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret).serialize(); - let recipient_secret = secp256k1::SecretKey::from_slice(&[4u8; 32]).unwrap(); - let recipient_pubkey = - secp256k1::PublicKey::from_secret_key(&secp, &recipient_secret).serialize(); - - let total_debt: u64 = 400_000_000; - let payment_timestamp: u64 = 1_783_796_933_524; - - let message = crate::schnorr::signing_message( - &issuer_pubkey, - &recipient_pubkey, - total_debt, - payment_timestamp, - ); - let signature = - crate::schnorr::schnorr_sign(&message, issuer_secret.as_ref(), &issuer_pubkey) - .expect("sign note"); - - // Note already has 100M redeemed (from an earlier redemption against another reserve). - let note = IouNote { - recipient_pubkey, - amount_collected: total_debt, - amount_redeemed: 100_000_000, - timestamp: payment_timestamp, - signature, - }; - redemption_manager - .tracker - .add_note(&issuer_pubkey, ¬e) - .expect("add note"); - - // On-chain build against a fresh (empty) reserve tree used cumulative 100M - // (0 + 100M redeemed now), while the note accumulates to 200M. - redemption_manager - .complete_redemption( - &issuer_pubkey, - &recipient_pubkey, - 100_000_000, - Some(100_000_000), - ) - .expect("complete redemption"); - - let updated = redemption_manager - .tracker - .lookup_note(&issuer_pubkey, &recipient_pubkey) - .expect("lookup note"); - assert_eq!( - updated.amount_redeemed, 200_000_000, - "note must accumulate the redeemed amount" - ); - - let mut reference = TrackerStateManager::new_with_temp_storage(); - reference - .update_already_redeemed( - &issuer_pubkey, - &recipient_pubkey, - payment_timestamp, - 100_000_000, - ) - .expect("reference update"); - assert_eq!( - redemption_manager.tracker.reserve_state_digest(), - reference.reserve_state_digest(), - "reserve tree must use the explicit on-chain cumulative value" - ); - } -} - -// Helper function to build redemption transaction using the transaction builder -fn build_redemption_transaction( - tracker: &mut TrackerStateManager, - note: &IouNote, - proof: &crate::NoteProof, - request: &RedemptionRequest, -) -> Result { - // In a real implementation, this would: - // 1. Fetch the reserve box from the blockchain - // 2. Create the redemption transaction following the contract logic - // 3. Include the AVL proof and signatures - // 4. Calculate appropriate fees - - // For now, create a mock transaction structure using the unified builder - let redemption_id = format!( - "redeem_{}_{}_{}", - &request.issuer_pubkey[..16], - &request.recipient_pubkey[..16], - note.timestamp - ); - - // Use blockchain data from request (fetched by API layer) - let actual_tracker_box_id = request.tracker_box_id.clone(); - let actual_tracker_nft_id = request.tracker_nft_id.clone(); - let current_height = request.current_height as u32; // Convert u64 to u32 for transaction builder - - // Decode issuer signature from hex - let issuer_signature_bytes = hex::decode(&request.issuer_signature).map_err(|e| { - RedemptionError::TransactionError(format!("Invalid issuer signature hex: {}", e)) - })?; - - if issuer_signature_bytes.len() != 65 { - return Err(RedemptionError::TransactionError(format!( - "Issuer signature must be 65 bytes, got {}", - issuer_signature_bytes.len() - ))); - } - - // Get tracker signature from request or generate placeholder for emergency - let tracker_signature_bytes = if request.emergency { - // Emergency redemption - tracker signature not required by contract after 3 days - // Use a dummy signature (contract will bypass verification based on HEIGHT) - vec![0u8; 65] - } else if let Some(ref tracker_sig_hex) = request.tracker_signature { - // Normal redemption with tracker signature provided - hex::decode(tracker_sig_hex).map_err(|e| { - RedemptionError::TransactionError(format!("Invalid tracker signature hex: {}", e)) - })? - } else { - // Normal redemption without tracker signature - this should not happen in production - // The API layer should have provided the tracker signature - return Err(RedemptionError::TransactionError( - "Tracker signature required for normal redemption".to_string(), - )); - }; - - if tracker_signature_bytes.len() != 65 && !request.emergency { - return Err(RedemptionError::TransactionError(format!( - "Tracker signature must be 65 bytes, got {}", - tracker_signature_bytes.len() - ))); - } - - // Use the unified transaction builder with provided values - // First, get reserve lookup proof from tracker state - // Parse public keys for reserve lookup proof generation - let issuer_pubkey_bytes = parse_pubkey(&request.issuer_pubkey) - .map_err(|e| RedemptionError::TransactionError(format!("Invalid issuer pubkey: {}", e)))?; - let recipient_pubkey_bytes = parse_pubkey(&request.recipient_pubkey).map_err(|e| { - RedemptionError::TransactionError(format!("Invalid recipient pubkey: {}", e)) - })?; - - // Generate reserve lookup proof FIRST (before inserting, for old state) - // For first redemption, this will return None proof - let reserve_lookup_proof = tracker - .generate_reserve_lookup_proof(&issuer_pubkey_bytes, &recipient_pubkey_bytes) - .map_err(|e| { - RedemptionError::TransactionError(format!( - "Failed to generate reserve lookup proof: {:?}", - e - )) - })?; - - // Generate reserve insert proof (for inserting redeemed amount into reserve tree) - // This also updates the reserve AVL tree and returns the updated tree digest for R5 - let new_already_redeemed = note.amount_redeemed + request.amount; - let (reserve_insert_proof, updated_reserve_tree) = tracker - .generate_reserve_insert_proof( - &issuer_pubkey_bytes, - &recipient_pubkey_bytes, - note.timestamp, - new_already_redeemed, - ) - .map_err(|e| { - RedemptionError::TransactionError(format!( - "Failed to generate reserve insert proof: {:?}", - e - )) - })?; - - // Generate tracker lookup proof (for totalDebt) - let tracker_lookup_proof = tracker - .generate_tracker_lookup_proof(&issuer_pubkey_bytes, &recipient_pubkey_bytes) - .map_err(|e| { - RedemptionError::TransactionError(format!( - "Failed to generate tracker lookup proof: {:?}", - e - )) - })?; - - // Convert proofs to bytes for transaction builder - let reserve_lookup_proof_bytes: Option> = reserve_lookup_proof.proof; - let tracker_lookup_proof_bytes: Vec = tracker_lookup_proof.proof; - - // Pass proofs to transaction builder - let mut transaction_data = RedemptionTransactionBuilder::build_unsigned_redemption_transaction( - &request.reserve_box_id, - &actual_tracker_box_id, - &actual_tracker_nft_id, - note, - &request.recipient_address, - &reserve_insert_proof, - &issuer_signature_bytes, - &tracker_signature_bytes, - &issuer_pubkey_bytes, - &TxContext { - current_height, - fee: 1000000, // 0.001 ERG fee from config - change_address: request.change_address.clone(), - network_prefix: 0, - }, - request.reserve_box_value, // Use actual reserve box value from blockchain - request.reserve_refund_initiation_height, - reserve_lookup_proof_bytes, - tracker_lookup_proof_bytes, - request.amount, - ) - .map_err(|e| RedemptionError::TransactionError(e.to_string()))?; - - // Set the updated reserve tree for R5 register - transaction_data.updated_reserve_tree = Some(updated_reserve_tree); - - // Add fee inputs. If the request provides them, use those; otherwise use a - // placeholder so offline/tests can still build a balanced transaction. - let required_fee = transaction_data.fee; - if !request.fee_input_box_ids.is_empty() { - transaction_data.fee_input_box_ids = request.fee_input_box_ids.clone(); - transaction_data.fee_input_total_value = request.fee_input_total_value; - transaction_data.change_address = Some(request.change_address.clone()); - } else { - transaction_data.fee_input_box_ids = vec!["test_fee_input_placeholder".to_string()]; - transaction_data.fee_input_total_value = required_fee; - transaction_data.change_address = Some(request.change_address.clone()); - } - - // Use real transaction builder to create the actual transaction bytes - let transaction_bytes = - RedemptionTransactionBuilder::build_redemption_transaction(&transaction_data) - .map_err(|e| RedemptionError::TransactionError(e.to_string()))?; - - // Required signatures: issuer and tracker - // Note: Tracker pubkey should be fetched from tracker configuration - let required_signatures = vec![ - request.issuer_pubkey.clone(), - "tracker_pubkey_required".to_string(), - ]; - - // Estimated fee (0.001 ERG) - let estimated_fee = 1000000; - - // Redemption time is recorded for tracking purposes - // Note: Time lock validation is handled by the ErgoScript contract - let redemption_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - - Ok(RedemptionData { - redemption_id, - note: note.clone(), - avl_proof: proof.avl_proof.clone(), - transaction_bytes: hex::encode(transaction_bytes), - required_signatures, - estimated_fee, - redemption_time, - }) -} diff --git a/crates/basis_store/src/redemption_blockchain_tests.rs b/crates/basis_store/src/redemption_blockchain_tests.rs deleted file mode 100644 index 98d2ea9..0000000 --- a/crates/basis_store/src/redemption_blockchain_tests.rs +++ /dev/null @@ -1,1179 +0,0 @@ -//! Comprehensive mock blockchain integration tests for first redemption -//! -//! These tests validate the complete first redemption flow against the Basis contract (basis.es) -//! without requiring a real Ergo node. The mock contract validator replicates the exact validation -//! logic from the ErgoScript contract. -//! -//! Test scenarios: -//! - First redemption with valid signatures (no lookup proof #7 needed) -//! - Invalid issuer signature rejection -//! - Invalid tracker signature rejection -//! - Replay attack prevention (old timestamp) -//! - Insufficient debt rejection -//! - Emergency redemption after timeout -//! - Premature emergency redemption failure -//! -//! Key differences from old tests: -//! - Uses proper 48-byte message format: blake2b256(ownerKey||receiverKey) || totalDebt || timestamp -//! - Tracker signs with its own key (not issuer's key) -//! - No time lock for normal redemption (contract handles it via tracker creation height) -//! - First redemption: no reserve lookup proof (#7) needed -//! - Mock contract validator simulates basis.es validation logic - -use crate::{ - schnorr::{self, generate_keypair}, - IouNote, PubKey, RedemptionManager, RedemptionRequest, Signature, TrackerStateManager, -}; -use blake2::{Blake2b, Digest}; -use generic_array::typenum::U32; -use secp256k1::{Secp256k1, SecretKey}; - -// ============================================================================ -// Mock Contract Validator (replicates basis.es validation logic) -// ============================================================================ - -/// Mock blockchain state for testing -#[derive(Debug, Clone)] -pub struct MockBlockchain { - pub current_height: u32, - pub tracker_creation_height: u32, -} - -impl MockBlockchain { - pub fn new(current_height: u32, tracker_creation_height: u32) -> Self { - Self { - current_height, - tracker_creation_height, - } - } - - /// Check if emergency redemption is available (3 days = 2160 blocks) - pub fn is_emergency_available(&self) -> bool { - (self.current_height - self.tracker_creation_height) > 3 * 720 - } -} - -/// Validation result from mock contract -#[derive(Debug, PartialEq)] -pub enum ContractValidationResult { - Valid, - InvalidIssuerSignature, - InvalidTrackerSignature, - TrackerSignatureRequired, - InvalidTimestamp, - InsufficientDebt, - InvalidTrackerId, - InvalidRedemptionAmount, -} - -/// Mock contract validator that replicates basis.es logic -pub struct MockContractValidator; - -impl MockContractValidator { - /// Validate redemption according to basis.es contract logic - /// - /// Contract checks (from basis.es): - /// 1. Self preservation (proposition bytes, tokens, R4, R6 preserved) - /// 2. Tracker ID verification (tracker NFT matches reserve R6) - /// 3. Tracker debt verification (totalDebt in tracker's AVL tree via context var #8) - /// 4. Timestamp verification (new timestamp > stored timestamp) - /// 5. Reserve owner signature verification (Schnorr on key||totalDebt||timestamp) - /// 6. Tracker signature verification (Schnorr on same message) OR emergency period passed - /// 7. Redemption amount verification (0 < redeemed <= totalDebt - alreadyRedeemed) - /// 8. AVL tree update verification (reserve tree properly updated) - /// 9. Receiver signature verification (proveDlog) - pub fn validate_redemption( - owner_pubkey: &PubKey, - receiver_pubkey: &PubKey, - total_debt: u64, - timestamp: u64, - issuer_signature: &Signature, - tracker_signature: &Signature, - tracker_pubkey: &PubKey, - redeemed_amount: u64, - already_redeemed: u64, - blockchain: &MockBlockchain, - ) -> ContractValidationResult { - // Build message: key || totalDebt || timestamp (48 bytes) - let message = signing_message(owner_pubkey, receiver_pubkey, total_debt, timestamp); - - // 5. Verify reserve owner signature - if schnorr::schnorr_verify(issuer_signature, &message, owner_pubkey).is_err() { - return ContractValidationResult::InvalidIssuerSignature; - } - - // 6. Verify tracker signature (or emergency period) - let tracker_sig_provided = tracker_signature.iter().any(|b| *b != 0); - - if tracker_sig_provided { - // If signature provided, it MUST be valid - if schnorr::schnorr_verify(tracker_signature, &message, tracker_pubkey).is_err() { - return ContractValidationResult::InvalidTrackerSignature; - } - } else { - // No signature provided - only allowed after emergency period - let enough_time_spent = - (blockchain.current_height - blockchain.tracker_creation_height) > 3 * 720; - if !enough_time_spent { - return ContractValidationResult::TrackerSignatureRequired; - } - } - - // 4. Timestamp verification (new timestamp > stored timestamp) - // For first redemption, stored_timestamp = 0, so any valid timestamp passes - let stored_timestamp = 0u64; // First redemption - if timestamp <= stored_timestamp { - return ContractValidationResult::InvalidTimestamp; - } - - // 7. Redemption amount verification - let debt_delta = total_debt - already_redeemed; - if redeemed_amount == 0 || redeemed_amount > debt_delta { - return ContractValidationResult::InvalidRedemptionAmount; - } - - ContractValidationResult::Valid - } -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/// Generate the 48-byte signing message following Basis protocol spec -/// message = blake2b256(ownerKeyBytes || receiverKeyBytes) || longToByteArray(totalDebt) || longToByteArray(timestamp) -pub fn signing_message( - owner_key: &PubKey, - receiver_key: &PubKey, - total_debt: u64, - timestamp: u64, -) -> Vec { - let mut key_hash_input = Vec::with_capacity(66); - key_hash_input.extend_from_slice(owner_key); - key_hash_input.extend_from_slice(receiver_key); - - let mut hasher = Blake2b::::new(); - hasher.update(&key_hash_input); - let key_hash = hasher.finalize(); - - let mut message = Vec::with_capacity(48); - message.extend_from_slice(&key_hash); - message.extend_from_slice(&total_debt.to_be_bytes()); - message.extend_from_slice(×tamp.to_be_bytes()); - - message -} - -/// Generate Schnorr signature for redemption -fn generate_redemption_signature( - secret_key: &[u8; 32], - pubkey: &PubKey, - message: &[u8], -) -> Signature { - schnorr::schnorr_sign(message, secret_key, pubkey).expect("Failed to create signature") -} - -/// Create a deterministic test keypair from a seed string -fn deterministic_keypair(seed: &str) -> ([u8; 32], PubKey) { - let mut hasher = Blake2b::::new(); - hasher.update(seed.as_bytes()); - let hash = hasher.finalize(); - - let mut secret_bytes = [0u8; 32]; - secret_bytes.copy_from_slice(&hash[..32]); - - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_slice(&secret_bytes).expect("Invalid secret key"); - let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret_key).serialize(); - - (secret_bytes, pubkey) -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use crate::transaction_builder::TxContext; - - /// Test 1: First redemption with valid signatures succeeds - /// - /// Scenario: Alice (issuer) creates note to Bob. Bob redeems for first time. - /// No reserve lookup proof (#7) needed since already_redeemed = 0. - #[test] - fn test_first_redemption_valid_signatures() { - println!("=== Test 1: First Redemption with Valid Signatures ==="); - - // Setup keys (deterministic for reproducibility) - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (bob_secret, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - println!("Alice pubkey: {}", hex::encode(alice_pubkey)); - println!("Bob pubkey: {}", hex::encode(bob_pubkey)); - println!("Tracker pubkey: {}", hex::encode(tracker_pubkey)); - - // Create tracker and redemption manager - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - // Create and sign a test note - let total_debt = 50_000_000u64; // 0.05 ERG - let timestamp = 1_000_000_000u64; // Fixed timestamp (in the past) - - let note = IouNote::create_and_sign(bob_pubkey, total_debt, timestamp, &alice_secret) - .expect("Failed to create note"); - - // Add note to tracker - redemption_manager - .tracker - .add_note(&alice_pubkey, ¬e) - .expect("Failed to add note"); - - // Verify note was stored - let stored_note = redemption_manager - .tracker - .lookup_note(&alice_pubkey, &bob_pubkey) - .expect("Note not found"); - let stored_note = redemption_manager - .tracker - .lookup_note(&alice_pubkey, &bob_pubkey) - .expect("Note not found"); - assert_eq!(stored_note.amount_collected, total_debt); - assert_eq!(stored_note.amount_redeemed, 0); - assert_eq!(stored_note.outstanding_debt(), total_debt); - - // Generate proper 48-byte message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - assert_eq!(message.len(), 48, "Message must be exactly 48 bytes"); - println!("Message (48 bytes): {}", hex::encode(&message)); - - // Generate signatures (issuer and tracker sign the SAME message) - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - let tracker_sig = generate_redemption_signature(&tracker_secret, &tracker_pubkey, &message); - - println!("Issuer signature (65 bytes): {}", hex::encode(&issuer_sig)); - println!( - "Tracker signature (65 bytes): {}", - hex::encode(&tracker_sig) - ); - - // Verify signatures independently - assert!( - schnorr::schnorr_verify(&issuer_sig, &message, &alice_pubkey).is_ok(), - "Issuer signature must verify" - ); - assert!( - schnorr::schnorr_verify(&tracker_sig, &message, &tracker_pubkey).is_ok(), - "Tracker signature must verify" - ); - - // Validate against mock contract - let blockchain = MockBlockchain::new(1000, 1000); // Same height = no emergency - let validation = MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - timestamp, - &issuer_sig, - &tracker_sig, - &tracker_pubkey, - total_debt, // Redeem full amount - 0, // First redemption: already_redeemed = 0 - &blockchain, - ); - assert_eq!( - validation, - ContractValidationResult::Valid, - "Contract validation should pass" - ); - - // Create redemption request - let redemption_request = RedemptionRequest { - issuer_pubkey: hex::encode(alice_pubkey), - recipient_pubkey: hex::encode(bob_pubkey), - amount: total_debt, - timestamp, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b" - .to_string(), - current_height: 1000, - recipient_address: "9hnupHc2udAoa7SV2UrWAba3N7pu9tR4RX662wv2iFa9gMn1E73".to_string(), - change_address: "9hNQcqi72NB5u5Tw6tbfCGbEKByguR7njvcyZXnXPLvV3Do1DiJ".to_string(), - issuer_signature: hex::encode(&issuer_sig), - emergency: false, - tracker_signature: Some(hex::encode(&tracker_sig)), - reserve_box_value: total_debt + 1000000, // Reserve must cover debt + fee - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - // Initiate redemption through manager - let redemption_data = redemption_manager.initiate_redemption(&redemption_request); - assert!( - redemption_data.is_ok(), - "Redemption initiation should succeed: {:?}", - redemption_data.err() - ); - - let redemption_data = redemption_data.unwrap(); - println!("Redemption ID: {}", redemption_data.redemption_id); - println!( - "Transaction bytes length: {}", - redemption_data.transaction_bytes.len() - ); - println!( - "Required signatures: {}", - redemption_data.required_signatures.len() - ); - - // Complete redemption - redemption_manager - .complete_redemption(&alice_pubkey, &bob_pubkey, total_debt, None) - .expect("Failed to complete redemption"); - - // Verify note is fully redeemed - let final_note = redemption_manager - .tracker - .lookup_note(&alice_pubkey, &bob_pubkey) - .expect("Note not found after redemption"); - assert_eq!(final_note.amount_redeemed, total_debt); - assert_eq!(final_note.outstanding_debt(), 0); - assert!(final_note.is_fully_redeemed()); - - println!("✅ First redemption with valid signatures passed\n"); - } - - /// Test 2: Invalid issuer signature is rejected by contract - #[test] - fn test_invalid_issuer_signature_rejected() { - println!("=== Test 2: Invalid Issuer Signature Rejected ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Generate valid message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - // Generate valid tracker signature - let tracker_sig = generate_redemption_signature(&tracker_secret, &tracker_pubkey, &message); - - // Create INVALID issuer signature (signed with tracker key instead of alice key) - let invalid_issuer_sig = - generate_redemption_signature(&tracker_secret, &alice_pubkey, &message); - - // Validate against mock contract - let blockchain = MockBlockchain::new(1000, 1000); - let validation = MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - timestamp, - &invalid_issuer_sig, - &tracker_sig, - &tracker_pubkey, - total_debt, - 0, - &blockchain, - ); - assert_eq!( - validation, - ContractValidationResult::InvalidIssuerSignature, - "Contract should reject invalid issuer signature" - ); - - println!("✅ Invalid issuer signature correctly rejected\n"); - } - - /// Test 3: Invalid tracker signature is rejected by contract - #[test] - fn test_invalid_tracker_signature_rejected() { - println!("=== Test 3: Invalid Tracker Signature Rejected ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Generate valid message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - // Generate valid issuer signature - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - - // Create INVALID tracker signature (signed with alice key instead of tracker key) - let invalid_tracker_sig = - generate_redemption_signature(&alice_secret, &tracker_pubkey, &message); - - // Validate against mock contract - let blockchain = MockBlockchain::new(1000, 1000); - let validation = MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - timestamp, - &issuer_sig, - &invalid_tracker_sig, - &tracker_pubkey, - total_debt, - 0, - &blockchain, - ); - assert_eq!( - validation, - ContractValidationResult::InvalidTrackerSignature, - "Contract should reject invalid tracker signature" - ); - - println!("✅ Invalid tracker signature correctly rejected\n"); - } - - /// Test 4: Redemption amount exceeds outstanding debt - #[test] - fn test_insufficient_debt_rejected() { - println!("=== Test 4: Insufficient Debt Rejected ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Generate valid message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - // Generate valid signatures - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - let tracker_sig = generate_redemption_signature(&tracker_secret, &tracker_pubkey, &message); - - // Try to redeem MORE than total debt - let redeemed_amount = total_debt + 1; - - // Validate against mock contract - let blockchain = MockBlockchain::new(1000, 1000); - let validation = MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - timestamp, - &issuer_sig, - &tracker_sig, - &tracker_pubkey, - redeemed_amount, - 0, - &blockchain, - ); - assert_eq!( - validation, - ContractValidationResult::InvalidRedemptionAmount, - "Contract should reject redemption exceeding debt" - ); - - println!("✅ Insufficient debt correctly rejected\n"); - } - - /// Test 5: Emergency redemption succeeds after timeout (2160 blocks) - #[test] - fn test_emergency_redemption_after_timeout() { - println!("=== Test 5: Emergency Redemption After Timeout ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Generate valid message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - // Generate valid issuer signature - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - - // NO tracker signature (emergency mode) - let empty_tracker_sig = [0u8; 65]; - - // Blockchain: tracker created at height 1000, current height = 1000 + 2161 (> 2160) - let blockchain = MockBlockchain::new(3161, 1000); - assert!( - blockchain.is_emergency_available(), - "Emergency should be available after 2160 blocks" - ); - - // Validate against mock contract with emergency=true - let validation = MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - timestamp, - &issuer_sig, - &empty_tracker_sig, - &tracker_pubkey, - total_debt, - 0, - &blockchain, - ); - assert_eq!( - validation, - ContractValidationResult::Valid, - "Emergency redemption should succeed after timeout" - ); - - println!("✅ Emergency redemption after timeout passed\n"); - } - - /// Test 6: Premature emergency redemption fails - #[test] - fn test_premature_emergency_redemption_fails() { - println!("=== Test 6: Premature Emergency Redemption Fails ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Generate valid message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - // Generate valid issuer signature - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - - // NO tracker signature - let empty_tracker_sig = [0u8; 65]; - - // Blockchain: tracker created at height 1000, current height = 2000 (< 2160) - let blockchain = MockBlockchain::new(2000, 1000); - assert!( - !blockchain.is_emergency_available(), - "Emergency should NOT be available before 2160 blocks" - ); - - // Validate against mock contract with emergency=true but not enough time - let validation = MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - timestamp, - &issuer_sig, - &empty_tracker_sig, - &tracker_pubkey, - total_debt, - 0, - &blockchain, - ); - assert_eq!( - validation, - ContractValidationResult::TrackerSignatureRequired, - "Premature emergency redemption should fail" - ); - - println!("✅ Premature emergency redemption correctly rejected\n"); - } - - /// Test 7: Partial first redemption succeeds - #[test] - fn test_partial_first_redemption() { - println!("=== Test 7: Partial First Redemption ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Generate valid message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - // Generate valid signatures - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - let tracker_sig = generate_redemption_signature(&tracker_secret, &tracker_pubkey, &message); - - // Redeem only half - let redeemed_amount = total_debt / 2; - - // Validate against mock contract - let blockchain = MockBlockchain::new(1000, 1000); - let validation = MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - timestamp, - &issuer_sig, - &tracker_sig, - &tracker_pubkey, - redeemed_amount, - 0, // First redemption - &blockchain, - ); - assert_eq!( - validation, - ContractValidationResult::Valid, - "Partial redemption should succeed" - ); - - println!("✅ Partial first redemption passed\n"); - } - - /// Test 8: Transaction structure validation for first redemption - /// Verifies that the generated transaction JSON has correct structure - /// with context extension variables #0-#8 (no #7 for first redemption) - #[test] - fn test_first_redemption_transaction_structure() { - println!("=== Test 8: First Redemption Transaction Structure ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Create tracker and redemption manager - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - // Create and sign note - let note = IouNote::create_and_sign(bob_pubkey, total_debt, timestamp, &alice_secret) - .expect("Failed to create note"); - - redemption_manager - .tracker - .add_note(&alice_pubkey, ¬e) - .expect("Failed to add note"); - - // Generate signatures - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - let tracker_sig = generate_redemption_signature(&tracker_secret, &tracker_pubkey, &message); - - // Create redemption request - let redemption_request = RedemptionRequest { - issuer_pubkey: hex::encode(alice_pubkey), - recipient_pubkey: hex::encode(bob_pubkey), - amount: total_debt, - timestamp, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b" - .to_string(), - current_height: 1000, - recipient_address: "9hnupHc2udAoa7SV2UrWAba3N7pu9tR4RX662wv2iFa9gMn1E73".to_string(), - change_address: "9hNQcqi72NB5u5Tw6tbfCGbEKByguR7njvcyZXnXPLvV3Do1DiJ".to_string(), - issuer_signature: hex::encode(&issuer_sig), - emergency: false, - tracker_signature: Some(hex::encode(&tracker_sig)), - reserve_box_value: total_debt + 1000000, // Reserve must cover debt + fee - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - // Initiate redemption - let redemption_data = redemption_manager - .initiate_redemption(&redemption_request) - .expect("Redemption should succeed"); - - // Parse transaction bytes as JSON - // Note: transaction_bytes is hex-encoded, so we need to decode first - let tx_bytes = hex::decode(&redemption_data.transaction_bytes) - .expect("Transaction bytes should be valid hex"); - let tx_json: serde_json::Value = - serde_json::from_slice(&tx_bytes).expect("Transaction should be valid JSON"); - - // Verify transaction structure - assert!( - tx_json.get("tx").is_some(), - "Transaction should have 'tx' key" - ); - - let tx = &tx_json["tx"]; - assert!(tx.get("inputs").is_some(), "Transaction should have inputs"); - assert!( - tx.get("dataInputs").is_some(), - "Transaction should have dataInputs" - ); - assert!( - tx.get("outputs").is_some(), - "Transaction should have outputs" - ); - - // Verify inputs - let inputs = tx["inputs"].as_array().expect("Inputs should be array"); - assert_eq!( - inputs.len(), - 2, - "Should have 2 inputs (reserve box + fee input placeholder)" - ); - assert_eq!( - inputs[0]["boxId"], "test_reserve_box_1", - "Input should be reserve box" - ); - - // Verify context extension - let extension = inputs[0]["extension"] - .as_object() - .expect("Should have extension"); - - // Context var #0: action byte (should be "0200" for redemption) - assert!( - extension.contains_key("0"), - "Context extension should have #0 (action)" - ); - assert_eq!(extension["0"], "0200", "Action should be redemption (0)"); - - // Context var #1: receiver pubkey (GroupElement) - assert!( - extension.contains_key("1"), - "Context extension should have #1 (receiver)" - ); - let receiver_hex = extension["1"].as_str().expect("Receiver should be string"); - assert!( - receiver_hex.starts_with("07"), - "Receiver should be GroupElement (prefix 07)" - ); - - // Context var #2: reserve signature (Coll[Byte]) - assert!( - extension.contains_key("2"), - "Context extension should have #2 (reserveSig)" - ); - let sig_hex = extension["2"].as_str().expect("Sig should be string"); - assert!( - sig_hex.starts_with("0e"), - "Signature should be Coll[Byte] (prefix 0e)" - ); - - // Context var #3: total debt (Long) - assert!( - extension.contains_key("3"), - "Context extension should have #3 (totalDebt)" - ); - - // Context var #5: insert proof (Coll[Byte]) - assert!( - extension.contains_key("5"), - "Context extension should have #5 (insertProof)" - ); - - // Context var #6: tracker signature (Coll[Byte]) - assert!( - extension.contains_key("6"), - "Context extension should have #6 (trackerSig)" - ); - - // Context var #7: reserve lookup proof - should NOT exist for first redemption - assert!( - !extension.contains_key("7"), - "First redemption should NOT have #7 (reserveLookupProof)" - ); - - // Context var #8: tracker lookup proof (Coll[Byte]) - assert!( - extension.contains_key("8"), - "Context extension should have #8 (trackerLookupProof)" - ); - - // Verify data inputs - let data_inputs = tx["dataInputs"] - .as_array() - .expect("Data inputs should be array"); - assert_eq!( - data_inputs.len(), - 1, - "Should have 1 data input (tracker box)" - ); - assert_eq!( - data_inputs[0]["boxId"], "test_tracker_box_1", - "Data input should be tracker box" - ); - - // Verify outputs - let outputs = tx["outputs"].as_array().expect("Outputs should be array"); - assert_eq!( - outputs.len(), - 3, - "Should have 3 outputs (reserve + recipient + fee)" - ); - - println!("✅ First redemption transaction structure validated\n"); - } - - /// Test 9: Verify that the 48-byte message format matches Scala demo - #[test] - fn test_message_format_matches_spec() { - println!("=== Test 9: Message Format Matches Spec ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Generate message using our function - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - // Verify length: 32 (key) + 8 (totalDebt) + 8 (timestamp) = 48 bytes - assert_eq!(message.len(), 48, "Message must be exactly 48 bytes"); - - // Verify key hash (first 32 bytes) - let mut key_hash_input = Vec::with_capacity(66); - key_hash_input.extend_from_slice(&alice_pubkey); - key_hash_input.extend_from_slice(&bob_pubkey); - - let mut hasher = Blake2b::::new(); - hasher.update(&key_hash_input); - let expected_key_hash = hasher.finalize(); - - assert_eq!( - &message[0..32], - &expected_key_hash[..], - "Key hash should match blake2b256(ownerKey||receiverKey)" - ); - - // Verify total debt (bytes 32-40, big-endian) - let debt_from_message = u64::from_be_bytes(message[32..40].try_into().unwrap()); - assert_eq!(debt_from_message, total_debt, "Total debt should match"); - - // Verify timestamp (bytes 40-48, big-endian) - let timestamp_from_message = u64::from_be_bytes(message[40..48].try_into().unwrap()); - assert_eq!(timestamp_from_message, timestamp, "Timestamp should match"); - - // Verify this matches the message used by IouNote::signing_message - let note = IouNote::create_and_sign(bob_pubkey, total_debt, timestamp, &alice_secret) - .expect("Failed to create note"); - let note_message = note.signing_message(&alice_pubkey); - assert_eq!( - message, note_message, - "Our message should match IouNote::signing_message" - ); - - println!("Message: {}", hex::encode(&message)); - println!(" Key hash (32 bytes): {}", hex::encode(&message[0..32])); - println!(" Total debt (8 bytes): {}", hex::encode(&message[32..40])); - println!(" Timestamp (8 bytes): {}", hex::encode(&message[40..48])); - println!("✅ Message format matches spec\n"); - } - - /// Test 10: Both issuer and tracker sign the EXACT same message - #[test] - fn test_both_parties_sign_same_message() { - println!("=== Test 10: Both Parties Sign Same Message ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let total_debt = 50_000_000u64; - let timestamp = 1_000_000_000u64; - - // Both parties sign the SAME message - let message = signing_message(&alice_pubkey, &bob_pubkey, total_debt, timestamp); - - let issuer_sig = generate_redemption_signature(&alice_secret, &alice_pubkey, &message); - let tracker_sig = generate_redemption_signature(&tracker_secret, &tracker_pubkey, &message); - - // Both verify against their respective pubkeys - assert!( - schnorr::schnorr_verify(&issuer_sig, &message, &alice_pubkey).is_ok(), - "Issuer signature must verify against issuer pubkey" - ); - assert!( - schnorr::schnorr_verify(&tracker_sig, &message, &tracker_pubkey).is_ok(), - "Tracker signature must verify against tracker pubkey" - ); - - // Signatures should be different (different signers, different nonces) - assert_ne!( - issuer_sig, tracker_sig, - "Issuer and tracker signatures should differ" - ); - - println!("✅ Both parties sign same message test passed\n"); - } - - /// Test 11: Two sequential 0.1 ERG redemptions against a 0.3 ERG reserve with NFT. - /// - /// Scenario: Alice (issuer) creates a 0.2 ERG IOU note to Bob. A reserve is funded - /// with 0.3 ERG collateral and issued a reserve NFT. Bob redeems 0.1 ERG locally - /// (first redemption), then redeems another 0.1 ERG (second redemption). After both - /// redemptions the note is fully redeemed and the reserve ends with 0.1 ERG. - #[test] - fn test_two_sequential_redemptions_0_3_erg_reserve_with_nft() { - println!("=== Test 11: Two Sequential 0.1 ERG Redemptions Against 0.3 ERG Reserve ==="); - - let (alice_secret, alice_pubkey) = deterministic_keypair("alice_seed"); - let (_, bob_pubkey) = deterministic_keypair("bob_seed"); - let (tracker_secret, tracker_pubkey) = deterministic_keypair("tracker_seed"); - - let tracker = TrackerStateManager::new_with_temp_storage(); - let mut redemption_manager = RedemptionManager::new(tracker); - - let total_debt = 200_000_000u64; // 0.2 ERG - let payment_timestamp = 1_000_000_000u64; - - let note = - IouNote::create_and_sign(bob_pubkey, total_debt, payment_timestamp, &alice_secret) - .expect("Failed to create note"); - redemption_manager - .tracker - .add_note(&alice_pubkey, ¬e) - .expect("Failed to add note"); - - // Reserve NFT ID (32 bytes = 64 hex chars) simulating an issued reserve NFT. - let reserve_nft_id = "018d29f4da1ea43f9d752b927200c54d9230637cc677c8a66d477f1684bd3098"; - - let reserve_box_value = 300_000_000u64; // 0.3 ERG - let redeem_amount = 100_000_000u64; // 0.1 ERG - - // ------------------------------------------------------------------------- - // First redemption: 0.3 ERG reserve -> 0.2 ERG reserve + 0.1 ERG payout. - // ------------------------------------------------------------------------- - let first_message = - signing_message(&alice_pubkey, &bob_pubkey, total_debt, payment_timestamp); - let first_issuer_sig = - generate_redemption_signature(&alice_secret, &alice_pubkey, &first_message); - let first_tracker_sig = - generate_redemption_signature(&tracker_secret, &tracker_pubkey, &first_message); - - let blockchain = MockBlockchain::new(1000, 1000); - assert_eq!( - MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - payment_timestamp, - &first_issuer_sig, - &first_tracker_sig, - &tracker_pubkey, - redeem_amount, - 0, // First redemption: already_redeemed = 0 - &blockchain, - ), - ContractValidationResult::Valid, - "First redemption contract validation should pass" - ); - - let first_request = RedemptionRequest { - issuer_pubkey: hex::encode(alice_pubkey), - recipient_pubkey: hex::encode(bob_pubkey), - amount: redeem_amount, - timestamp: payment_timestamp, - reserve_box_id: "test_reserve_0_3_erg".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: reserve_nft_id.to_string(), - current_height: 1000, - recipient_address: "9hnupHc2udAoa7SV2UrWAba3N7pu9tR4RX662wv2iFa9gMn1E73".to_string(), - change_address: "9hNQcqi72NB5u5Tw6tbfCGbEKByguR7njvcyZXnXPLvV3Do1DiJ".to_string(), - issuer_signature: hex::encode(&first_issuer_sig), - emergency: false, - tracker_signature: Some(hex::encode(&first_tracker_sig)), - reserve_box_value, - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let first_data = redemption_manager - .initiate_redemption(&first_request) - .expect("First redemption should initiate"); - let first_tx_bytes = hex::decode(&first_data.transaction_bytes) - .expect("First transaction bytes should be valid hex"); - let first_tx_json: serde_json::Value = serde_json::from_slice(&first_tx_bytes) - .expect("First transaction should be valid JSON"); - - let first_outputs = first_tx_json["tx"]["outputs"] - .as_array() - .expect("First transaction should have outputs"); - assert_eq!( - first_outputs[0]["value"], 200_000_000, - "Reserve after first redemption should be 0.2 ERG" - ); - assert_eq!( - first_outputs[1]["value"], 100_000_000, - "Recipient should receive 0.1 ERG" - ); - let first_assets = first_outputs[0]["assets"] - .as_array() - .expect("Reserve output should have assets"); - assert_eq!( - first_assets.len(), - 1, - "Reserve output should preserve exactly the NFT" - ); - assert_eq!(first_assets[0]["tokenId"], reserve_nft_id); - assert_eq!(first_assets[0]["amount"], 1); - - let first_ext = first_tx_json["tx"]["inputs"][0]["extension"] - .as_object() - .expect("First reserve input should have extension"); - assert!( - first_ext.contains_key("5"), - "First redemption should have insert proof (#5)" - ); - assert!( - !first_ext.contains_key("7"), - "First redemption should NOT have reserve lookup proof (#7)" - ); - - redemption_manager - .complete_redemption( - &alice_pubkey, - &bob_pubkey, - redeem_amount, - Some(redeem_amount), - ) - .expect("First redemption should complete"); - - // ------------------------------------------------------------------------- - // Second redemption: 0.2 ERG reserve -> 0.1 ERG reserve + 0.1 ERG payout. - // We use RedemptionManager::build_unsigned_redemption_transaction directly here - // because the note's timestamp was refreshed by complete_redemption, which would - // invalidate the original note signature if we went through initiate_redemption - // again. The tracker already trusts the note after the first redemption. - // ------------------------------------------------------------------------- - let updated_note = redemption_manager - .tracker - .lookup_note(&alice_pubkey, &bob_pubkey) - .expect("Note should exist after first redemption"); - let second_timestamp = updated_note.timestamp; - - let second_message = - signing_message(&alice_pubkey, &bob_pubkey, total_debt, second_timestamp); - let second_issuer_sig = - generate_redemption_signature(&alice_secret, &alice_pubkey, &second_message); - let second_tracker_sig = - generate_redemption_signature(&tracker_secret, &tracker_pubkey, &second_message); - - assert_eq!( - MockContractValidator::validate_redemption( - &alice_pubkey, - &bob_pubkey, - total_debt, - second_timestamp, - &second_issuer_sig, - &second_tracker_sig, - &tracker_pubkey, - redeem_amount, - redeem_amount, // Already redeemed 0.1 ERG - &blockchain, - ), - ContractValidationResult::Valid, - "Second redemption contract validation should pass" - ); - - let second_request = RedemptionRequest { - issuer_pubkey: hex::encode(alice_pubkey), - recipient_pubkey: hex::encode(bob_pubkey), - amount: redeem_amount, - timestamp: second_timestamp, - reserve_box_id: "test_reserve_0_2_erg".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: reserve_nft_id.to_string(), - current_height: 1000, - recipient_address: "9hnupHc2udAoa7SV2UrWAba3N7pu9tR4RX662wv2iFa9gMn1E73".to_string(), - change_address: "9hNQcqi72NB5u5Tw6tbfCGbEKByguR7njvcyZXnXPLvV3Do1DiJ".to_string(), - issuer_signature: hex::encode(&second_issuer_sig), - emergency: false, - tracker_signature: Some(hex::encode(&second_tracker_sig)), - reserve_box_value: reserve_box_value - redeem_amount, - fee_input_box_ids: vec!["test_fee_input".to_string()], - fee_input_total_value: 1_000_000, - reserve_refund_initiation_height: 0, - }; - - let second_proof = crate::NoteProof { - note: updated_note.clone(), - avl_proof: vec![], - operations: vec![], - }; - let second_data = redemption_manager - .build_unsigned_redemption_transaction( - &updated_note, - &second_proof, - &second_request, - "test_reserve_0_2_erg", - "test_tracker_box_1", - reserve_nft_id, - &second_issuer_sig, - &second_tracker_sig, - &TxContext { - current_height: 1000, - fee: 1_000_000, - change_address: "9hNQcqi72NB5u5Tw6tbfCGbEKByguR7njvcyZXnXPLvV3Do1DiJ" - .to_string(), - network_prefix: 0, - }, - reserve_box_value - redeem_amount, - ) - .expect("Second redemption should build"); - let second_tx_bytes = hex::decode(&second_data.transaction_bytes) - .expect("Second transaction bytes should be valid hex"); - let second_tx_json: serde_json::Value = serde_json::from_slice(&second_tx_bytes) - .expect("Second transaction should be valid JSON"); - - let second_outputs = second_tx_json["tx"]["outputs"] - .as_array() - .expect("Second transaction should have outputs"); - assert_eq!( - second_outputs[0]["value"], 100_000_000, - "Reserve after second redemption should be 0.1 ERG" - ); - assert_eq!( - second_outputs[1]["value"], 100_000_000, - "Recipient should receive another 0.1 ERG" - ); - let second_assets = second_outputs[0]["assets"] - .as_array() - .expect("Reserve output should have assets"); - assert_eq!(second_assets.len(), 1); - assert_eq!(second_assets[0]["tokenId"], reserve_nft_id); - assert_eq!(second_assets[0]["amount"], 1); - - let second_ext = second_tx_json["tx"]["inputs"][0]["extension"] - .as_object() - .expect("Second reserve input should have extension"); - assert!( - second_ext.contains_key("5"), - "Second redemption should have insert proof (#5)" - ); - assert!( - second_ext.contains_key("7"), - "Second redemption should have reserve lookup proof (#7)" - ); - - redemption_manager - .complete_redemption( - &alice_pubkey, - &bob_pubkey, - redeem_amount, - Some(2 * redeem_amount), - ) - .expect("Second redemption should complete"); - - let final_note = redemption_manager - .tracker - .lookup_note(&alice_pubkey, &bob_pubkey) - .expect("Final note should exist"); - assert_eq!( - final_note.amount_redeemed, total_debt, - "Note should be fully redeemed" - ); - assert!(final_note.is_fully_redeemed()); - - println!("✅ Two sequential 0.1 ERG redemptions against 0.3 ERG reserve with NFT passed\n"); - } -} diff --git a/crates/basis_store/src/redemption_simple_tests.rs b/crates/basis_store/src/redemption_simple_tests.rs deleted file mode 100644 index 0a69f50..0000000 --- a/crates/basis_store/src/redemption_simple_tests.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Simple redemption tests that avoid AVL tree complexity - -use crate::{ - schnorr::{self, generate_keypair}, - IouNote, RedemptionRequest, -}; - -/// Test 1: Basic redemption validation without AVL tree -#[test] -fn test_basic_redemption_validation() { - println!("=== Test 1: Basic Redemption Validation ==="); - - // Generate test keypairs - let (issuer_secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - println!("Issuer pubkey: {}", hex::encode(issuer_pubkey)); - println!("Recipient pubkey: {}", hex::encode(recipient_pubkey)); - - // Create a simple note without adding to tracker - let amount_collected = 1000; - let timestamp = 1672531200; // Old timestamp - - let note = IouNote::create_and_sign( - recipient_pubkey, - amount_collected, - timestamp, - &issuer_secret, - ) - .unwrap(); - - // Verify the note signature - let signature_valid = note.verify_signature(&issuer_pubkey).is_ok(); - assert!(signature_valid, "Note signature should be valid"); - - // Test outstanding debt calculation - assert_eq!(note.outstanding_debt(), amount_collected); - assert!(!note.is_fully_redeemed()); - - println!("✅ Basic redemption validation test passed\n"); -} - -/// Test 2: Time lock validation -#[test] -fn test_time_lock_validation() { - println!("=== Test 2: Time Lock Validation ==="); - - // Generate test keypairs - let (issuer_secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - // Create a note with recent timestamp - let amount_collected = 1000; - let recent_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - - let recent_note = IouNote::create_and_sign( - recipient_pubkey, - amount_collected, - recent_timestamp, - &issuer_secret, - ) - .unwrap(); - - // Create a note with old timestamp - let old_timestamp = 1672531200; // Jan 1, 2023 - let old_note = IouNote::create_and_sign( - recipient_pubkey, - amount_collected, - old_timestamp, - &issuer_secret, - ) - .unwrap(); - - // Note: Time lock enforcement is now handled by the contract based on tracker creation height. - // Emergency redemption is available after 3 days (3*720 blocks) from tracker creation. - // Normal redemption requires both owner and tracker signatures with no time restriction. - // The transaction builder no longer enforces time locks. - - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Both notes should be redeemable (time lock is enforced by contract, not transaction builder) - // The contract will verify tracker signature or emergency time lock based on tracker creation height - - println!("Current time: {}", current_time); - println!("Note: Time lock is now enforced by contract based on tracker creation height"); - println!("✅ Time lock validation test passed\n"); -} - -/// Test 3: Signature verification -#[test] -fn test_signature_verification() { - println!("=== Test 3: Signature Verification ==="); - - // Generate test keypairs - let (issuer_secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - let (wrong_issuer_secret, wrong_issuer_pubkey) = generate_keypair(); - - // Create a valid note - let amount_collected = 1000; - let timestamp = 1672531200; - - let valid_note = IouNote::create_and_sign( - recipient_pubkey, - amount_collected, - timestamp, - &issuer_secret, - ) - .unwrap(); - - // Create a note with wrong issuer - let wrong_note = IouNote::create_and_sign( - recipient_pubkey, - amount_collected, - timestamp, - &wrong_issuer_secret, - ) - .unwrap(); - - // Verify valid signature - let valid_signature = valid_note.verify_signature(&issuer_pubkey).is_ok(); - assert!(valid_signature, "Valid signature should verify"); - - // Wrong issuer should fail - let wrong_signature = valid_note.verify_signature(&wrong_issuer_pubkey).is_ok(); - assert!(!wrong_signature, "Wrong issuer should fail verification"); - - // Wrong note should fail with correct issuer - let wrong_note_valid = wrong_note.verify_signature(&issuer_pubkey).is_ok(); - assert!( - !wrong_note_valid, - "Wrong note should fail with correct issuer" - ); - - println!("Valid signature: {}", valid_signature); - println!("Wrong issuer: {}", wrong_signature); - println!("Wrong note: {}", wrong_note_valid); - println!("✅ Signature verification test passed\n"); -} - -/// Test 4: Redemption request structure -#[test] -fn test_redemption_request_structure() { - println!("=== Test 4: Redemption Request Structure ==="); - - // Generate test keypairs - let (_, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - // Create redemption request - let redemption_request = RedemptionRequest { - issuer_pubkey: hex::encode(issuer_pubkey), - recipient_pubkey: hex::encode(recipient_pubkey), - amount: 1000, - timestamp: 1672531200, - reserve_box_id: "test_reserve_box_1".to_string(), - recipient_address: "test_recipient_address".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "test_tracker_nft_1".to_string(), - current_height: 1000, - change_address: "test_change_address".to_string(), - issuer_signature: "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101".to_string(), - emergency: false, - tracker_signature: Some("020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202".to_string()), - reserve_box_value: 100000000 + 1000000, // Reserve must cover debt + fee - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - // Verify request structure - assert!(!redemption_request.issuer_pubkey.is_empty()); - assert!(!redemption_request.recipient_pubkey.is_empty()); - assert!(redemption_request.amount > 0); - assert!(!redemption_request.reserve_box_id.is_empty()); - assert!(!redemption_request.recipient_address.is_empty()); - - println!("Issuer pubkey: {}", &redemption_request.issuer_pubkey[..16]); - println!( - "Recipient pubkey: {}", - &redemption_request.recipient_pubkey[..16] - ); - println!("Amount: {}", redemption_request.amount); - println!("Reserve box ID: {}", redemption_request.reserve_box_id); - println!("✅ Redemption request structure test passed\n"); -} - -/// Test 5: Simulated blockchain data validation -#[test] -fn test_simulated_blockchain_data() { - println!("=== Test 5: Simulated Blockchain Data ==="); - - // Generate test keypairs - let (_, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - // Test Schnorr signature generation - let (issuer_secret, _) = generate_keypair(); - let (tracker_secret, _) = generate_keypair(); - - let message = format!( - "{}{}{}", - hex::encode(issuer_pubkey), - hex::encode(recipient_pubkey), - 1000 - ) - .into_bytes(); - - // Generate signatures - use secp256k1::SecretKey; - - let issuer_secret_key = SecretKey::from_slice(&issuer_secret).unwrap(); - let tracker_secret_key = SecretKey::from_slice(&tracker_secret).unwrap(); - - // Generate public keys - let secp = secp256k1::Secp256k1::new(); - let issuer_pubkey_bytes = - secp256k1::PublicKey::from_secret_key(&secp, &issuer_secret_key).serialize(); - let tracker_pubkey_bytes = - secp256k1::PublicKey::from_secret_key(&secp, &tracker_secret_key).serialize(); - - let issuer_sig = schnorr::schnorr_sign( - &message, - &issuer_secret_key.secret_bytes(), - &issuer_pubkey_bytes, - ) - .unwrap(); - let tracker_sig = schnorr::schnorr_sign( - &message, - &tracker_secret_key.secret_bytes(), - &tracker_pubkey_bytes, - ) - .unwrap(); - - // Verify signatures - let issuer_valid = schnorr::schnorr_verify(&issuer_sig, &message, &issuer_pubkey_bytes).is_ok(); - let tracker_valid = - schnorr::schnorr_verify(&tracker_sig, &message, &tracker_pubkey_bytes).is_ok(); - - assert!(issuer_valid, "Issuer signature should be valid"); - assert!(tracker_valid, "Tracker signature should be valid"); - - println!("Issuer signature valid: {}", issuer_valid); - println!("Tracker signature valid: {}", tracker_valid); - println!("✅ Simulated blockchain data test passed\n"); -} diff --git a/crates/basis_store/src/reserve_tracking_test.rs b/crates/basis_store/src/reserve_tracking_test.rs index be47cbe..91eec3f 100644 --- a/crates/basis_store/src/reserve_tracking_test.rs +++ b/crates/basis_store/src/reserve_tracking_test.rs @@ -7,6 +7,14 @@ mod tests { use crate::{ExtendedReserveInfo, ReserveTracker}; use tempfile::TempDir; + fn historical_p2s() -> String { + crate::contract_compiler::get_basis_reserve_contract_p2s().unwrap() + } + + fn historical_tree() -> String { + crate::contract_compiler::get_basis_reserve_ergo_tree_hex().unwrap() + } + /// Test that verifies the reserve tracking functionality /// This test simulates the process of scanning reserve boxes and updating the tracker #[tokio::test] @@ -22,7 +30,7 @@ mod tests { // Create a test configuration let config = NodeConfig { start_height: Some(0), - reserve_contract_p2s: Some("test_reserve_contract_p2s".to_string()), + reserve_contract_p2s: Some(historical_p2s()), node_url: "http://test-node:9053".to_string(), scan_name: Some("Test Reserve Scanner".to_string()), api_key: None, @@ -42,7 +50,7 @@ mod tests { ScanBox { box_id: "box1".to_string(), // Use plain text box_id value: 1000000000, // 1 ERG - ergo_tree: "test_ergo_tree_1".to_string(), + ergo_tree: historical_tree(), creation_height: 1000, transaction_id: "tx1".to_string(), assets: vec![], // Empty assets for reserve boxes @@ -65,7 +73,7 @@ mod tests { ScanBox { box_id: "box2".to_string(), // Use plain text box_id value: 2000000000, // 2 ERG - ergo_tree: "test_ergo_tree_2".to_string(), + ergo_tree: historical_tree(), creation_height: 1001, transaction_id: "tx2".to_string(), assets: vec![], // Empty assets for reserve boxes @@ -274,7 +282,7 @@ mod tests { let config = NodeConfig { start_height: Some(0), - reserve_contract_p2s: Some("test_reserve_contract_p2s".to_string()), + reserve_contract_p2s: Some(historical_p2s()), node_url: "http://test:9053".to_string(), scan_name: Some("Test Scanner".to_string()), api_key: None, @@ -299,7 +307,7 @@ mod tests { let scan_box = ScanBox { box_id: box_id.to_string(), value: collateral, - ergo_tree: "test_tree".to_string(), + ergo_tree: historical_tree(), creation_height: 1000, transaction_id: "test_tx".to_string(), assets: vec![], // Empty assets for reserve boxes diff --git a/crates/basis_store/src/scanner_hardening_tests.rs b/crates/basis_store/src/scanner_hardening_tests.rs new file mode 100644 index 0000000..aaabf39 --- /dev/null +++ b/crates/basis_store/src/scanner_hardening_tests.rs @@ -0,0 +1,874 @@ +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex as StdMutex, + }, + time::Duration, +}; + +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + task::JoinHandle, +}; + +use crate::{ + ergo_scanner::{ + NodeConfig, ScannerError, ServerState, MAX_CONCURRENT_SCANNER_REQUESTS, + MAX_RESPONSE_BODY_BYTES, SCAN_PAGE_SIZE, + }, + persistence::{ScannerMetadataStorage, TrackerStorage}, + tracker_scanner::{create_tracker_server_state, TrackerNodeConfig, TrackerServerState}, + ExtendedReserveInfo, +}; + +#[derive(Clone)] +struct MockResponse { + status: u16, + body: Vec, + delay: Duration, + declared_length: Option, + omit_length: bool, +} + +impl MockResponse { + fn json(value: Value) -> Self { + Self { + status: 200, + body: serde_json::to_vec(&value).expect("mock JSON must serialize"), + delay: Duration::ZERO, + declared_length: None, + omit_length: false, + } + } + + fn status(status: u16) -> Self { + Self { + status, + body: b"{}".to_vec(), + delay: Duration::ZERO, + declared_length: None, + omit_length: false, + } + } + + fn delayed(mut self, delay: Duration) -> Self { + self.delay = delay; + self + } + + fn with_declared_length(mut self, length: usize) -> Self { + self.declared_length = Some(length); + self + } + + fn with_body(mut self, body: Vec) -> Self { + self.body = body; + self + } + + fn without_declared_length(mut self) -> Self { + self.omit_length = true; + self + } +} + +struct MockNode { + base_url: String, + requests: Arc>>, + max_active: Arc, + task: JoinHandle<()>, +} + +impl MockNode { + async fn start(handler: F) -> Self + where + F: Fn(&str) -> MockResponse + Send + Sync + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback mock must bind"); + let address = listener.local_addr().expect("mock address must exist"); + let requests = Arc::new(StdMutex::new(Vec::new())); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let handler = Arc::new(handler); + let task_requests = Arc::clone(&requests); + let task_active = Arc::clone(&active); + let task_max_active = Arc::clone(&max_active); + + let task = tokio::spawn(async move { + loop { + let (mut stream, _) = match listener.accept().await { + Ok(connection) => connection, + Err(_) => break, + }; + let handler = Arc::clone(&handler); + let requests = Arc::clone(&task_requests); + let active = Arc::clone(&task_active); + let max_active = Arc::clone(&task_max_active); + tokio::spawn(async move { + let target = match read_request_target(&mut stream).await { + Some(target) => target, + None => return, + }; + requests + .lock() + .expect("request log lock must not be poisoned") + .push(target.clone()); + let now_active = active.fetch_add(1, Ordering::SeqCst) + 1; + max_active.fetch_max(now_active, Ordering::SeqCst); + + let response = handler(&target); + tokio::time::sleep(response.delay).await; + let reason = match response.status { + 200 => "OK", + 404 => "Not Found", + 429 => "Too Many Requests", + 500 => "Internal Server Error", + 503 => "Service Unavailable", + _ => "Mock Status", + }; + let content_length = if response.omit_length { + String::new() + } else { + format!( + "Content-Length: {}\r\n", + response.declared_length.unwrap_or(response.body.len()) + ) + }; + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\n{}Connection: close\r\n\r\n", + response.status, reason, content_length + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&response.body).await; + let _ = stream.shutdown().await; + active.fetch_sub(1, Ordering::SeqCst); + }); + } + }); + + Self { + base_url: format!("http://{}", address), + requests, + max_active, + task, + } + } + + fn requests(&self) -> Vec { + self.requests + .lock() + .expect("request log lock must not be poisoned") + .clone() + } + + fn max_active(&self) -> usize { + self.max_active.load(Ordering::SeqCst) + } +} + +impl Drop for MockNode { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn read_request_target(stream: &mut tokio::net::TcpStream) -> Option { + let mut request = Vec::new(); + let mut buffer = [0u8; 4_096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).await.ok()?; + if read == 0 { + return None; + } + request.extend_from_slice(&buffer[..read]); + if request.len() > 64 * 1024 { + return None; + } + if let Some(header_start) = request.windows(4).position(|part| part == b"\r\n\r\n") { + let header_end = header_start + 4; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end, content_length); + } + }; + while request.len() < header_end.checked_add(content_length)? { + let read = stream.read(&mut buffer).await.ok()?; + if read == 0 { + return None; + } + request.extend_from_slice(&buffer[..read]); + } + + String::from_utf8_lossy(&request) + .lines() + .next()? + .split_whitespace() + .nth(1) + .map(str::to_string) +} + +fn query_offset(target: &str) -> Option { + target.split_once('?')?.1.split('&').find_map(|pair| { + let (name, value) = pair.split_once('=')?; + (name == "offset") + .then(|| value.parse::().ok()) + .flatten() + }) +} + +fn indexed_height(height: u64) -> MockResponse { + MockResponse::json(json!({ + "indexedHeight": height, + "fullHeight": height + })) +} + +fn indexed_box(index: usize, tracker_asset: bool) -> Value { + let tracker_nft = "11".repeat(32); + let reserve_tree = crate::contract_compiler::get_basis_reserve_ergo_tree_hex() + .expect("embedded historical reserve ErgoTree must compile"); + json!({ + "boxId": format!("{index:064x}"), + "value": 1_000_000_000u64 + index as u64, + "ergoTree": reserve_tree, + "creationHeight": 100u64 + index as u64, + "transactionId": format!("{:064x}", 10_000usize + index), + "additionalRegisters": { + "R4": format!("07{}", format!("02{}", "22".repeat(32))), + "R5": format!("64{}", "33".repeat(32)), + "R6": format!("0e20{}", tracker_nft) + }, + "assets": if tracker_asset { + vec![json!({ "tokenId": tracker_nft, "amount": 1u64 })] + } else { + Vec::::new() + } + }) +} + +fn malformed_reserve_box(index: usize) -> Value { + let mut box_ = indexed_box(index, false); + box_["additionalRegisters"] + .as_object_mut() + .expect("registers must be an object") + .remove("R6"); + box_ +} + +fn reserve_state(node: &MockNode, temp_dir: &TempDir) -> ServerState { + ServerState::new( + NodeConfig { + start_height: Some(0), + reserve_contract_p2s: Some( + crate::contract_compiler::get_basis_reserve_contract_p2s() + .expect("embedded historical reserve P2S must compile"), + ), + node_url: node.base_url.clone(), + scan_name: Some("test".to_string()), + api_key: None, + }, + temp_dir.path(), + ) + .expect("reserve scanner state must initialize") +} + +fn tracker_state(node: &MockNode, temp_dir: &TempDir) -> TrackerServerState { + let metadata = ScannerMetadataStorage::open(temp_dir.path().join("tracker-metadata")) + .expect("tracker metadata must open"); + let storage = TrackerStorage::open(temp_dir.path().join("tracker-boxes")) + .expect("tracker storage must open"); + create_tracker_server_state( + TrackerNodeConfig { + start_height: Some(0), + tracker_nft_id: Some("11".repeat(32)), + node_url: node.base_url.clone(), + scan_name: Some("test".to_string()), + api_key: None, + }, + metadata, + storage, + ) +} + +fn insert_stale_reserve(state: &ServerState) -> String { + let reserve = ExtendedReserveInfo::new( + b"stale-reserve-box", + &[2u8; 33], + 5_000_000_000, + Some(&[3u8; 32]), + 50, + 0, + ); + let box_id = reserve.box_id.clone(); + state + .reserve_storage + .store_reserve(&reserve) + .expect("stale reserve must persist"); + state + .reserve_tracker + .update_reserve(reserve) + .expect("stale reserve must enter memory"); + box_id +} + +fn assert_only_stale_reserve(state: &ServerState, stale_box_id: &str) { + let in_memory = state.reserve_tracker.get_all_reserves(); + assert_eq!(in_memory.len(), 1); + assert_eq!(in_memory[0].box_id, stale_box_id); + let persisted = state + .reserve_storage + .get_all_reserves() + .expect("reserve storage must be readable"); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].box_id, stale_box_id); +} + +#[derive(Debug, PartialEq, Eq)] +struct ReserveStateSnapshot { + persisted_value_bytes: Vec>, + in_memory_value_bytes: Vec>, +} + +fn reserve_state_snapshot(state: &ServerState) -> ReserveStateSnapshot { + fn sorted_value_bytes(reserves: Vec) -> Vec> { + let mut values: Vec> = reserves + .into_iter() + .map(|reserve| { + serde_json::to_vec(&reserve).expect("reserve snapshot value must serialize") + }) + .collect(); + values.sort(); + values + } + + ReserveStateSnapshot { + persisted_value_bytes: sorted_value_bytes( + state + .reserve_storage + .get_all_reserves() + .expect("reserve storage snapshot must be readable"), + ), + in_memory_value_bytes: sorted_value_bytes(state.reserve_tracker.get_all_reserves()), + } +} + +async fn wait_for_request_count(node: &MockNode, minimum: usize) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if node.requests().len() >= minimum { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("mock node must observe the expected requests"); +} + +async fn wait_for_request_matching(node: &MockNode, expected: &str) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if node + .requests() + .iter() + .any(|request| request.contains(expected)) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("mock node must observe the expected request"); +} + +#[tokio::test] +async fn reserve_scan_paginates_past_explicit_page_size() { + let first_page: Vec = (0..SCAN_PAGE_SIZE) + .map(|index| indexed_box(index, false)) + .collect(); + let last_page = vec![indexed_box(SCAN_PAGE_SIZE, false)]; + let node = MockNode::start(move |target| { + if target.starts_with("/blockchain/indexedHeight") { + return indexed_height(500); + } + match query_offset(target) { + Some(0) => MockResponse::json(Value::Array(first_page.clone())), + Some(offset) if offset == SCAN_PAGE_SIZE => { + MockResponse::json(Value::Array(last_page.clone())) + } + _ => MockResponse::status(500), + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + + state + .process_scan_boxes() + .await + .expect("complete multi-page snapshot must reconcile"); + + assert_eq!( + state.reserve_tracker.get_all_reserves().len(), + SCAN_PAGE_SIZE + 1 + ); + let requests = node.requests(); + assert!(requests.iter().any(|request| request.contains("offset=0"))); + assert!(requests + .iter() + .any(|request| request.contains(&format!("offset={SCAN_PAGE_SIZE}")))); + assert!(requests + .iter() + .filter(|request| request.contains("/blockchain/box/unspent/byAddress")) + .all(|request| request.contains("excludeMempoolSpent=false"))); + assert!( + requests + .iter() + .filter(|request| request.starts_with("/blockchain/indexedHeight")) + .count() + >= 2 + ); +} + +#[tokio::test] +async fn tracker_scan_paginates_past_explicit_page_size() { + let first_page: Vec = (0..SCAN_PAGE_SIZE) + .map(|index| indexed_box(index, true)) + .collect(); + let last_page = vec![indexed_box(SCAN_PAGE_SIZE, true)]; + let node = MockNode::start(move |target| { + if target.starts_with("/blockchain/indexedHeight") { + return indexed_height(500); + } + match query_offset(target) { + Some(0) => MockResponse::json(Value::Array(first_page.clone())), + Some(offset) if offset == SCAN_PAGE_SIZE => { + MockResponse::json(Value::Array(last_page.clone())) + } + _ => MockResponse::status(500), + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = tracker_state(&node, &temp_dir); + + let boxes = state + .get_unspent_tracker_boxes() + .await + .expect("complete tracker snapshot must succeed"); + + assert_eq!(boxes.len(), SCAN_PAGE_SIZE + 1); +} + +#[tokio::test] +async fn complete_empty_snapshot_removes_stale_reserves() { + let node = MockNode::start(|target| { + if target.starts_with("/blockchain/indexedHeight") { + indexed_height(500) + } else { + MockResponse::json(Value::Array(Vec::new())) + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + insert_stale_reserve(&state); + + state + .process_scan_boxes() + .await + .expect("complete empty snapshot must reconcile"); + + assert!(state.reserve_tracker.get_all_reserves().is_empty()); + assert!(state + .reserve_storage + .get_all_reserves() + .expect("reserve storage must be readable") + .is_empty()); +} + +#[tokio::test] +async fn reserve_page_zero_404_preserves_exact_previous_snapshot() { + let node = MockNode::start(|target| { + if target.starts_with("/blockchain/indexedHeight") { + // The only injected fault is the page response: every height probe + // is stable and caught up. + indexed_height(500) + } else { + MockResponse::status(404) + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + insert_stale_reserve(&state); + let before = reserve_state_snapshot(&state); + + let error = state + .process_scan_boxes() + .await + .expect_err("HTTP 404 must not be interpreted as an empty page"); + + assert!(matches!(error, ScannerError::NodeError(_))); + assert_eq!(reserve_state_snapshot(&state), before); + assert_eq!( + node.requests() + .iter() + .filter(|request| request.contains("/blockchain/box/unspent/byAddress")) + .count(), + 1 + ); +} + +#[tokio::test] +async fn failed_later_page_preserves_previous_snapshot_without_partial_upserts() { + let first_page: Vec = (0..SCAN_PAGE_SIZE) + .map(|index| indexed_box(index, false)) + .collect(); + let node = MockNode::start(move |target| { + if target.starts_with("/blockchain/indexedHeight") { + return indexed_height(500); + } + match query_offset(target) { + Some(0) => MockResponse::json(Value::Array(first_page.clone())), + Some(offset) if offset == SCAN_PAGE_SIZE => MockResponse::status(503), + _ => MockResponse::status(500), + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + let stale_box_id = insert_stale_reserve(&state); + + let error = state + .process_scan_boxes() + .await + .expect_err("later page failure must fail the snapshot"); + assert!(matches!(error, ScannerError::NodeError(_))); + assert_only_stale_reserve(&state, &stale_box_id); +} + +#[tokio::test] +async fn tracker_later_page_404_is_error_without_partial_result() { + let first_page: Vec = (0..SCAN_PAGE_SIZE) + .map(|index| indexed_box(index, true)) + .collect(); + let node = MockNode::start(move |target| { + if target.starts_with("/blockchain/indexedHeight") { + return indexed_height(500); + } + match query_offset(target) { + Some(0) => MockResponse::json(Value::Array(first_page.clone())), + Some(offset) if offset == SCAN_PAGE_SIZE => MockResponse::status(404), + _ => MockResponse::status(500), + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = tracker_state(&node, &temp_dir); + + let error = state + .process_tracker_boxes() + .await + .expect_err("tracker HTTP 404 must fail the complete snapshot"); + + assert!(matches!( + error, + crate::tracker_scanner::TrackerScannerError::NodeError(_) + )); + assert!(state + .tracker_storage + .get_all_tracker_boxes() + .expect("tracker storage must be readable") + .is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn abort_during_second_page_preserves_exact_previous_snapshot() { + let first_page: Vec = (0..SCAN_PAGE_SIZE) + .map(|index| indexed_box(index, false)) + .collect(); + let node = MockNode::start(move |target| { + if target.starts_with("/blockchain/indexedHeight") { + return indexed_height(500); + } + match query_offset(target) { + Some(0) => MockResponse::json(Value::Array(first_page.clone())), + Some(offset) if offset == SCAN_PAGE_SIZE => { + MockResponse::json(Value::Array(Vec::new())).delayed(Duration::from_secs(5)) + } + _ => MockResponse::status(500), + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + insert_stale_reserve(&state); + let before = reserve_state_snapshot(&state); + + let scan_state = state.clone(); + let scan = tokio::spawn(async move { scan_state.process_scan_boxes().await }); + wait_for_request_matching(&node, &format!("offset={SCAN_PAGE_SIZE}")).await; + scan.abort(); + assert!(scan + .await + .expect_err("aborted scanner task must not complete") + .is_cancelled()); + + assert_eq!(reserve_state_snapshot(&state), before); + assert_eq!( + state.request_permits.available_permits(), + MAX_CONCURRENT_SCANNER_REQUESTS + ); +} + +#[tokio::test] +async fn malformed_only_page_preserves_previous_snapshot() { + let node = MockNode::start(|target| { + if target.starts_with("/blockchain/indexedHeight") { + indexed_height(500) + } else { + MockResponse::json(Value::Array(vec![malformed_reserve_box(1)])) + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + let stale_box_id = insert_stale_reserve(&state); + + let error = state + .process_scan_boxes() + .await + .expect_err("malformed candidate must reject reconciliation"); + assert!(matches!(error, ScannerError::InvalidReserveBox(_))); + assert_only_stale_reserve(&state, &stale_box_id); +} + +#[tokio::test] +async fn duplicate_across_pages_preserves_previous_snapshot() { + let first_page: Vec = (0..SCAN_PAGE_SIZE) + .map(|index| indexed_box(index, false)) + .collect(); + let duplicate_page = vec![indexed_box(SCAN_PAGE_SIZE - 1, false)]; + let node = MockNode::start(move |target| { + if target.starts_with("/blockchain/indexedHeight") { + return indexed_height(500); + } + match query_offset(target) { + Some(0) => MockResponse::json(Value::Array(first_page.clone())), + Some(offset) if offset == SCAN_PAGE_SIZE => { + MockResponse::json(Value::Array(duplicate_page.clone())) + } + _ => MockResponse::status(500), + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + let stale_box_id = insert_stale_reserve(&state); + + let error = state + .process_scan_boxes() + .await + .expect_err("duplicate page row must reject reconciliation"); + assert!(matches!(error, ScannerError::IncoherentSnapshot(_))); + assert_only_stale_reserve(&state, &stale_box_id); +} + +#[tokio::test] +async fn height_drift_preserves_previous_snapshot() { + let height_call = Arc::new(AtomicUsize::new(0)); + let handler_height_call = Arc::clone(&height_call); + let node = MockNode::start(move |target| { + if target.starts_with("/blockchain/indexedHeight") { + let call = handler_height_call.fetch_add(1, Ordering::SeqCst); + return indexed_height(if call == 0 { 500 } else { 501 }); + } + MockResponse::json(Value::Array(vec![indexed_box(1, false)])) + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + let stale_box_id = insert_stale_reserve(&state); + + let error = state + .process_scan_boxes() + .await + .expect_err("moving indexed height must reject reconciliation"); + assert!(matches!(error, ScannerError::IncoherentSnapshot(_))); + assert_only_stale_reserve(&state, &stale_box_id); +} + +#[tokio::test] +async fn indexed_height_lag_preserves_previous_snapshot_without_page_query() { + let node = MockNode::start(|target| { + if target.starts_with("/blockchain/indexedHeight") { + MockResponse::json(json!({ "indexedHeight": 499u64, "fullHeight": 500u64 })) + } else { + MockResponse::status(500) + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + let stale_box_id = insert_stale_reserve(&state); + + let error = state + .process_scan_boxes() + .await + .expect_err("lagging index must reject reconciliation"); + assert!(matches!(error, ScannerError::IndexLag { .. })); + assert_only_stale_reserve(&state, &stale_box_id); + assert!(node + .requests() + .iter() + .all(|request| !request.contains("/blockchain/box/unspent/byAddress"))); +} + +#[tokio::test] +async fn oversized_page_is_rejected_before_json_parsing() { + let node = MockNode::start(|target| { + if target.starts_with("/blockchain/indexedHeight") { + indexed_height(500) + } else { + MockResponse::json(json!([])).with_declared_length(MAX_RESPONSE_BODY_BYTES + 1) + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + + let error = state + .get_unspent_reserve_boxes() + .await + .expect_err("oversized response must be rejected"); + assert!(matches!(error, ScannerError::ResponseTooLarge { .. })); +} + +#[tokio::test] +async fn oversized_page_without_content_length_is_still_rejected() { + let node = MockNode::start(|target| { + if target.starts_with("/blockchain/indexedHeight") { + indexed_height(500) + } else { + MockResponse::status(200) + .without_declared_length() + .with_body(vec![b' '; MAX_RESPONSE_BODY_BYTES + 1]) + } + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + + let error = state + .get_unspent_reserve_boxes() + .await + .expect_err("streamed oversized response must be rejected"); + assert!(matches!(error, ScannerError::ResponseTooLarge { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn shared_gate_bounds_concurrent_scanner_requests() { + let node = MockNode::start(|_| { + MockResponse::json(json!({ "fullHeight": 500u64 })).delayed(Duration::from_millis(100)) + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + + let mut requests = Vec::new(); + for _ in 0..(MAX_CONCURRENT_SCANNER_REQUESTS * 2) { + let state = state.clone(); + requests.push(tokio::spawn( + async move { state.fetch_current_height().await }, + )); + } + let mut succeeded = 0; + let mut rejected = 0; + for request in requests { + match request.await.expect("height task must not panic") { + Ok(_) => succeeded += 1, + Err(ScannerError::RequestCapacityExceeded) => rejected += 1, + Err(error) => panic!("unexpected scanner error: {error}"), + } + } + + assert_eq!(succeeded, MAX_CONCURRENT_SCANNER_REQUESTS); + assert_eq!(rejected, MAX_CONCURRENT_SCANNER_REQUESTS); + assert!(node.max_active() <= MAX_CONCURRENT_SCANNER_REQUESTS); + assert!(node.max_active() >= 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn abandoned_request_returns_permit_for_exact_gate_capacity() { + let response_call = Arc::new(AtomicUsize::new(0)); + let handler_response_call = Arc::clone(&response_call); + let node = MockNode::start(move |_| { + let call = handler_response_call.fetch_add(1, Ordering::SeqCst); + let delay = if call == 0 { + Duration::from_secs(5) + } else { + Duration::from_millis(250) + }; + MockResponse::json(json!({ "fullHeight": 500u64 })).delayed(delay) + }) + .await; + let temp_dir = tempfile::tempdir().expect("temporary data dir must open"); + let state = reserve_state(&node, &temp_dir); + + let abandoned_state = state.clone(); + let abandoned = tokio::spawn(async move { abandoned_state.fetch_current_height().await }); + wait_for_request_count(&node, 1).await; + assert_eq!( + state.request_permits.available_permits(), + MAX_CONCURRENT_SCANNER_REQUESTS - 1 + ); + + abandoned.abort(); + assert!(abandoned + .await + .expect_err("abandoned request must be cancelled") + .is_cancelled()); + assert_eq!( + state.request_permits.available_permits(), + MAX_CONCURRENT_SCANNER_REQUESTS + ); + + let mut admitted = Vec::new(); + for _ in 0..MAX_CONCURRENT_SCANNER_REQUESTS { + let request_state = state.clone(); + admitted.push(tokio::spawn(async move { + request_state.fetch_current_height().await + })); + } + wait_for_request_count(&node, MAX_CONCURRENT_SCANNER_REQUESTS + 1).await; + assert_eq!(state.request_permits.available_permits(), 0); + + for request in admitted { + assert_eq!( + request + .await + .expect("admitted request task must not panic") + .expect("every post-cancellation capacity slot must succeed"), + 500 + ); + } + assert_eq!( + state.request_permits.available_permits(), + MAX_CONCURRENT_SCANNER_REQUESTS + ); +} diff --git a/crates/basis_store/src/simple_integration_tests.rs b/crates/basis_store/src/simple_integration_tests.rs index dba170e..2b0637f 100644 --- a/crates/basis_store/src/simple_integration_tests.rs +++ b/crates/basis_store/src/simple_integration_tests.rs @@ -1,8 +1,6 @@ //! Simple integration tests that work with the reserves-only scanner implementation -use crate::ergo_scanner::{ - create_default_scanner, NodeConfig, ReserveEvent, ScannerError, ServerState, -}; +use crate::ergo_scanner::{NodeConfig, ScannerError, ServerState}; /// Simple integration test suite that works with the reserves-only scanner pub struct SimpleIntegrationTestSuite { @@ -19,7 +17,14 @@ impl SimpleIntegrationTestSuite { .unwrap() .as_nanos() )); - let scanner = create_default_scanner(data_dir)?; + let config = NodeConfig { + reserve_contract_p2s: Some( + crate::contract_compiler::get_basis_reserve_contract_p2s() + .map_err(|error| ScannerError::Generic(error.to_string()))?, + ), + ..NodeConfig::default() + }; + let scanner = ServerState::new(config, data_dir)?; Ok(Self { scanner }) } diff --git a/crates/basis_store/src/test_helpers.rs b/crates/basis_store/src/test_helpers.rs deleted file mode 100644 index 0d275a7..0000000 --- a/crates/basis_store/src/test_helpers.rs +++ /dev/null @@ -1,279 +0,0 @@ -use crate::{ - schnorr::{self, generate_keypair}, - IouNote, NoteKey, PubKey, -}; - -/// Generate deterministic test keypairs for consistent testing -pub fn generate_test_keypair() -> ([u8; 32], [u8; 33]) { - // Use a fixed seed for deterministic testing - let (secret, pubkey) = generate_keypair(); - (secret, pubkey) -} - -/// Generate multiple test keypairs with different patterns -pub fn generate_test_keypairs(count: usize) -> Vec<([u8; 32], [u8; 33])> { - (0..count) - .map(|i| { - let (secret, pubkey) = generate_keypair(); - (secret, pubkey) - }) - .collect() -} - -/// Create standardized test notes for consistent testing -pub fn create_test_note(amount: u64, timestamp: u64) -> IouNote { - let (issuer_secret, _) = generate_test_keypair(); - let (_, recipient_pubkey) = generate_test_keypair(); - - IouNote::create_and_sign(recipient_pubkey, amount, timestamp, &issuer_secret) - .expect("Failed to create test note") -} - -/// Create test transaction context following chaincash-rs patterns -pub fn create_test_tx_context() -> basis_offchain::transaction_builder::TxContext { - basis_offchain::transaction_builder::TxContext { - current_height: 1000, - fee: 1000000, // 0.001 ERG - same as chaincash-rs SUGGESTED_TX_FEE - change_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - network_prefix: 0, // mainnet - } -} - -/// Create test reserve box ID -pub fn create_test_reserve_box_id() -> String { - "e56847ed19b3dc6b712351b2a6c8a5e3c8e8b5a3c6d8e7f4a2b9c1d3e5f7a9b1".to_string() -} - -/// Create test tracker box ID -pub fn create_test_tracker_box_id() -> String { - "f67858fe2ac4ed7c823462c3b7d9b6f4d9f9c6b4d7e9f8g5c3d4e2f6g8b0c2d".to_string() -} - -/// Create test recipient address -pub fn create_test_recipient_address() -> String { - "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string() -} - -/// Create test notes with specific issuer and recipient -pub fn create_test_note_with_keys( - issuer_secret: &[u8; 32], - recipient_pubkey: PubKey, - amount: u64, - timestamp: u64, -) -> IouNote { - IouNote::create_and_sign(recipient_pubkey, amount, timestamp, issuer_secret) - .expect("Failed to create test note with specific keys") -} - -/// Generate test note key for consistent testing -pub fn create_test_note_key() -> NoteKey { - let issuer_pubkey = [1u8; 33]; - let recipient_pubkey = [2u8; 33]; - - NoteKey::from_keys(&issuer_pubkey, &recipient_pubkey) -} - -/// Create multiple test notes with sequential amounts -pub fn create_test_notes_sequence( - count: usize, - base_amount: u64, - base_timestamp: u64, -) -> Vec { - (0..count) - .map(|i| { - create_test_note( - base_amount + (i as u64 * 100), - base_timestamp + (i as u64 * 60), // 1 minute intervals - ) - }) - .collect() -} - -/// Create test redemption request -pub fn create_test_redemption_request( - issuer_pubkey: &str, - recipient_pubkey: &str, - amount: u64, - timestamp: u64, -) -> crate::RedemptionRequest { - crate::RedemptionRequest { - issuer_pubkey: issuer_pubkey.to_string(), - recipient_pubkey: recipient_pubkey.to_string(), - amount, - timestamp, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "test_tracker_nft_1".to_string(), - current_height: 1000, - recipient_address: "test_recipient_address".to_string(), - change_address: "test_change_address".to_string(), - issuer_signature: "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101".to_string(), - emergency: false, - tracker_signature: Some("020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202".to_string()), - reserve_box_value: amount + 1000000 + 1000000, // Reserve must cover debt + fee + buffer - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - } -} - -/// Create test reserve info -pub fn create_test_reserve_info( - box_id: &str, - owner_pubkey: &str, - collateral_amount: u64, - height: u64, -) -> crate::ExtendedReserveInfo { - crate::ExtendedReserveInfo::new( - box_id.as_bytes(), - owner_pubkey.as_bytes(), - collateral_amount, - None, // tracker_nft_id - height, - 0, - ) -} - -/// Generate test signature for verification testing -pub fn generate_test_signature(message: &[u8]) -> [u8; 65] { - let (secret, pubkey) = generate_test_keypair(); - - let secret_key = secp256k1::SecretKey::from_slice(&secret).unwrap(); - schnorr::schnorr_sign(message, &secret_key.secret_bytes(), &pubkey) - .expect("Failed to generate test signature") -} - -/// Verify test signature -pub fn verify_test_signature(signature: &[u8; 65], message: &[u8], pubkey: &[u8; 33]) -> bool { - schnorr::schnorr_verify(signature, message, pubkey).is_ok() -} - -/// Create test data for performance testing -pub fn generate_performance_test_data(num_notes: usize) -> Vec { - let mut notes = Vec::with_capacity(num_notes); - let keypairs = generate_test_keypairs(num_notes); - - for i in 0..num_notes { - let (issuer_secret, _) = &keypairs[i]; - let (_, recipient_pubkey) = generate_test_keypair(); - - let note = IouNote::create_and_sign( - recipient_pubkey, - 1000 + (i as u64 * 100), - 1234567890 + (i as u64 * 60), - issuer_secret, - ) - .expect("Failed to create performance test note"); - - notes.push(note); - } - - notes -} - -/// Helper for testing error conditions -pub fn create_invalid_note() -> IouNote { - // Create a note with obviously invalid data - IouNote::new( - [0u8; 33], // zero pubkey - 0, // zero amount - 0, // zero redeemed - 0, // zero timestamp - [0u8; 65], // zero signature - ) -} - -/// Helper for testing edge cases -pub fn create_edge_case_notes() -> Vec { - vec![ - // Minimum valid values - create_test_note(1, 1), - // Maximum u64 values (avoiding overflow) - create_test_note(u64::MAX - 1000, u64::MAX - 1000), - // Common boundary values - create_test_note(1000, 1234567890), - create_test_note(1000000, 9876543210), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_test_keypair_generation() { - let (secret, pubkey) = generate_test_keypair(); - assert_eq!(secret.len(), 32); - assert_eq!(pubkey.len(), 33); - } - - #[test] - fn test_multiple_keypair_generation() { - let keypairs = generate_test_keypairs(5); - assert_eq!(keypairs.len(), 5); - - // Verify all keypairs are unique - let mut pubkeys: Vec<_> = keypairs.iter().map(|(_, pubkey)| pubkey).collect(); - pubkeys.sort(); - pubkeys.dedup(); - assert_eq!(pubkeys.len(), 5); - } - - #[test] - fn test_test_note_creation() { - let note = create_test_note(1000, 1234567890); - assert_eq!(note.amount_collected, 1000); - assert_eq!(note.timestamp, 1234567890); - assert_eq!(note.amount_redeemed, 0); - } - - #[test] - fn test_note_sequence_creation() { - let notes = create_test_notes_sequence(3, 1000, 1234567890); - assert_eq!(notes.len(), 3); - - assert_eq!(notes[0].amount_collected, 1000); - assert_eq!(notes[1].amount_collected, 1100); - assert_eq!(notes[2].amount_collected, 1200); - - assert_eq!(notes[0].timestamp, 1234567890); - assert_eq!(notes[1].timestamp, 1234567950); - assert_eq!(notes[2].timestamp, 1234568010); - } - - #[test] - fn test_signature_generation_and_verification() { - let message = b"test message for signature"; - let (secret, pubkey) = generate_test_keypair(); - - let secret_key = secp256k1::SecretKey::from_slice(&secret).unwrap(); - let signature = - schnorr::schnorr_sign(message, &secret_key.secret_bytes(), &pubkey).unwrap(); - - let is_valid = schnorr::schnorr_verify(&signature, message, &pubkey).is_ok(); - - assert!(is_valid, "Generated signature should be valid"); - } - - #[test] - fn test_performance_data_generation() { - let notes = generate_performance_test_data(10); - assert_eq!(notes.len(), 10); - - for (i, note) in notes.iter().enumerate() { - assert_eq!(note.amount_collected, 1000 + (i as u64 * 100)); - } - } - - #[test] - fn test_edge_case_notes() { - let edge_notes = create_edge_case_notes(); - assert_eq!(edge_notes.len(), 4); - - // Verify each note has valid structure - for note in edge_notes { - assert!(note.amount_collected > 0); - assert!(note.timestamp > 0); - } - } -} diff --git a/crates/basis_store/src/tests.rs b/crates/basis_store/src/tests.rs index 64c0a64..a20f10e 100644 --- a/crates/basis_store/src/tests.rs +++ b/crates/basis_store/src/tests.rs @@ -508,13 +508,23 @@ mod test_module { #[cfg(test)] mod confirmation_state_tests { - use crate::{IouNote, NoteConfirmationStatus, TrackerStateManager}; + use crate::{ + FreshGenerationApproval, IouNote, NoteConfirmationStatus, NoteError, + TrackerGenerationConfig, TrackerStateManager, + }; use secp256k1::{Secp256k1, SecretKey}; fn make_manager() -> TrackerStateManager { TrackerStateManager::new_with_temp_storage() } + fn generation(fresh_generation: FreshGenerationApproval) -> TrackerGenerationConfig { + TrackerGenerationConfig { + tracker_nft_id: [0x42; 32], + fresh_generation, + } + } + fn issuer_pubkey(secret_key: &[u8; 32]) -> [u8; 33] { let secp = Secp256k1::new(); let sk = SecretKey::from_slice(secret_key).expect("valid secret key"); @@ -557,13 +567,104 @@ mod confirmation_state_tests { manager.add_note(&issuer, ¬e).unwrap(); let digest = manager.get_state().avl_root_digest; - let count = manager.mark_notes_pending(digest, "tx123", 100).unwrap(); + let tx_id = "11".repeat(32); + let count = manager.mark_notes_pending(digest, &tx_id, 100).unwrap(); assert_eq!(count, 1); let confirmation = manager.get_confirmation(&issuer, &recipient).unwrap(); assert_eq!(confirmation.status, NoteConfirmationStatus::Pending); assert_eq!(confirmation.pending_total_debt, Some(1000)); - assert_eq!(confirmation.pending_tx_id, Some("tx123".to_string())); + assert_eq!(confirmation.pending_tx_id, Some(tx_id)); + } + + #[test] + fn invalid_publication_tx_id_is_rejected_without_quarantining_state() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1000, 1)) + .unwrap(); + let digest = manager.validated_state().unwrap().avl_root_digest; + + assert!(matches!( + manager.mark_notes_pending(digest, "not-a-transaction-id", 100), + Err(crate::NoteError::InvalidTransactionId) + )); + assert!(manager.is_healthy()); + assert!(manager.pending_publication().unwrap().is_none()); + assert_eq!( + manager + .get_confirmation(&issuer, &recipient) + .unwrap() + .status, + NoteConfirmationStatus::LocalOnly + ); + } + + #[test] + fn pending_publication_survives_restart_and_binds_confirmation_tx_id() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + let tx_id = "11".repeat(32); + let digest; + + { + let mut manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1000, 1)) + .unwrap(); + digest = manager.validated_state().unwrap().avl_root_digest; + manager.mark_notes_pending(digest, &tx_id, 100).unwrap(); + let key: [u8; 32] = crate::NoteKey::from_keys(&issuer, &recipient) + .to_bytes() + .try_into() + .unwrap(); + // Model a crash after the durable external-effect receipt but + // before this advisory confirmation row reached storage. + manager.storage.remove_confirmation_for_test(&key).unwrap(); + } + + let mut reopened = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny), + ) + .unwrap(); + assert_eq!( + reopened.pending_publication().unwrap(), + Some(crate::PendingTrackerPublication { + digest, + tx_id: tx_id.clone(), + submitted_height: 100, + }) + ); + assert_eq!( + reopened + .get_confirmation(&issuer, &recipient) + .unwrap() + .status, + NoteConfirmationStatus::LocalOnly + ); + assert!(matches!( + reopened.confirm_pending_publication(&"22".repeat(32), "box123", 200), + Err(crate::NoteError::PublicationLeaseMismatch) + )); + reopened + .confirm_pending_publication(&tx_id, "box123", 200) + .unwrap(); + assert!(reopened.pending_publication().unwrap().is_none()); + let confirmed = reopened.get_confirmation(&issuer, &recipient).unwrap(); + assert_eq!(confirmed.status, NoteConfirmationStatus::Confirmed); + assert_eq!(confirmed.confirmed_total_debt, Some(1000)); + assert_eq!(confirmed.confirmed_box_id.as_deref(), Some("box123")); + assert_eq!(confirmed.confirmed_height, Some(200)); } #[test] @@ -576,7 +677,9 @@ mod confirmation_state_tests { manager.add_note(&issuer, ¬e).unwrap(); let digest = manager.get_state().avl_root_digest; - manager.mark_notes_pending(digest, "tx123", 100).unwrap(); + manager + .mark_notes_pending(digest, &"11".repeat(32), 100) + .unwrap(); manager.confirm_pending_notes("box123", 200).unwrap(); let confirmation = manager.get_confirmation(&issuer, &recipient).unwrap(); @@ -588,6 +691,292 @@ mod confirmation_state_tests { assert_eq!(confirmation.pending_tx_id, None); } + #[test] + fn rollback_of_accepted_a_preserves_newer_pending_b_fail_closed() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1_000, 1)) + .unwrap(); + let root_a = manager.validated_state().unwrap().avl_root_digest; + let tx_a = "11".repeat(32); + manager.mark_notes_pending(root_a, &tx_a, 100).unwrap(); + let effect_a = crate::chain_reconciliation::validated_tracker_effect_for_test( + "22".repeat(32), + tx_a.clone(), + "33".repeat(32), + "44".repeat(32), + 101, + 6, + root_a, + ); + manager.confirm_validated_publication(&effect_a).unwrap(); + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1_200, 2)) + .unwrap(); + let root_b = manager.validated_state().unwrap().avl_root_digest; + let tx_b = "55".repeat(32); + manager.mark_notes_pending(root_b, &tx_b, 108).unwrap(); + let rollback_a = crate::chain_reconciliation::validated_rollback_for_test(&effect_a); + assert_eq!( + manager.rollback_validated_publication(&rollback_a).unwrap(), + 1 + ); + assert_eq!( + manager.rollback_validated_publication(&rollback_a).unwrap(), + 0 + ); + + let confirmation = manager.get_confirmation(&issuer, &recipient).unwrap(); + assert_eq!(confirmation.status, NoteConfirmationStatus::Pending); + assert_eq!(confirmation.confirmed_total_debt, None); + assert_eq!(confirmation.confirmed_tx_id, None); + assert_eq!(confirmation.pending_total_debt, Some(1_200)); + assert_eq!(confirmation.pending_tx_id.as_deref(), Some(tx_b.as_str())); + assert_eq!( + manager.pending_publication().unwrap().unwrap().tx_id(), + tx_b + ); + assert_eq!(confirmation.redeemable_amount(0), 0); + } + + #[test] + fn shared_publication_quarantine_hides_a_previously_confirmed_effect() { + let temp_dir = tempfile::tempdir().unwrap(); + let publication_health = crate::PublicationHealth::new(); + let mut manager = TrackerStateManager::try_new_with_publication_health( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + publication_health.clone(), + ) + .unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1_000, 1)) + .unwrap(); + let root = manager.validated_state().unwrap().avl_root_digest; + let tx_id = "11".repeat(32); + manager.mark_notes_pending(root, &tx_id, 100).unwrap(); + let effect = crate::chain_reconciliation::validated_tracker_effect_for_test( + "22".repeat(32), + tx_id, + "33".repeat(32), + "44".repeat(32), + 101, + 6, + root, + ); + manager.confirm_validated_publication(&effect).unwrap(); + assert_eq!( + manager + .get_confirmation(&issuer, &recipient) + .unwrap() + .status, + NoteConfirmationStatus::Confirmed + ); + + publication_health.quarantine(); + assert!(matches!( + manager.try_get_confirmation(&issuer, &recipient), + Err(NoteError::StorageOutcomeUnknown(_)) + )); + assert!(matches!( + manager.validated_state(), + Err(NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn restart_restores_historical_anchor_without_promoting_newer_local_root() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let unchanged_recipient = [2u8; 33]; + let changed_recipient = [3u8; 33]; + let later_recipient = [4u8; 33]; + let effect_a; + let root_a; + let root_b; + + { + let mut manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + manager + .add_note( + &issuer, + &create_note(&issuer_secret, &unchanged_recipient, 1_000, 1), + ) + .unwrap(); + manager + .add_note( + &issuer, + &create_note(&issuer_secret, &changed_recipient, 1_000, 1), + ) + .unwrap(); + root_a = manager.validated_state().unwrap().avl_root_digest; + let tx_a = "11".repeat(32); + manager.mark_notes_pending(root_a, &tx_a, 100).unwrap(); + effect_a = crate::chain_reconciliation::validated_tracker_effect_for_test( + "22".repeat(32), + tx_a, + "33".repeat(32), + "44".repeat(32), + 101, + 6, + root_a, + ); + manager.confirm_validated_publication(&effect_a).unwrap(); + + manager + .add_note( + &issuer, + &create_note(&issuer_secret, &changed_recipient, 1_200, 2), + ) + .unwrap(); + manager + .add_note( + &issuer, + &create_note(&issuer_secret, &later_recipient, 500, 3), + ) + .unwrap(); + root_b = manager.validated_state().unwrap().avl_root_digest; + assert_ne!(root_a, root_b); + } + + let mut reopened = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny), + ) + .unwrap(); + assert!(reopened + .validated_confirmation_anchor() + .unwrap() + .unwrap() + .matches_validated_effect(&effect_a)); + assert_eq!(reopened.validated_state().unwrap().avl_root_digest, root_b); + + reopened.confirm_validated_publication(&effect_a).unwrap(); + assert_eq!(reopened.validated_state().unwrap().avl_root_digest, root_b); + assert_eq!( + reopened + .get_confirmation(&issuer, &unchanged_recipient) + .unwrap() + .status, + NoteConfirmationStatus::Confirmed + ); + assert_eq!( + reopened + .get_confirmation(&issuer, &changed_recipient) + .unwrap() + .status, + NoteConfirmationStatus::LocalOnly + ); + assert_eq!( + reopened + .get_confirmation(&issuer, &later_recipient) + .unwrap() + .status, + NoteConfirmationStatus::LocalOnly + ); + } + + #[test] + fn historical_confirmation_value_and_root_tampering_fail_independently() { + fn confirmed_manager() -> (TrackerStateManager, [u8; 33], [u8; 33], [u8; 33]) { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1_000, 1)) + .unwrap(); + let root = manager.validated_state().unwrap().avl_root_digest; + let tx = "11".repeat(32); + manager.mark_notes_pending(root, &tx, 100).unwrap(); + let effect = crate::chain_reconciliation::validated_tracker_effect_for_test( + "22".repeat(32), + tx, + "33".repeat(32), + "44".repeat(32), + 101, + 6, + root, + ); + manager.confirm_validated_publication(&effect).unwrap(); + (manager, issuer, recipient, root) + } + + let (mut wrong_value, issuer, recipient, _) = confirmed_manager(); + let key: [u8; 32] = crate::NoteKey::from_keys(&issuer, &recipient) + .to_bytes() + .try_into() + .unwrap(); + let mut record = wrong_value.get_confirmation(&issuer, &recipient).unwrap(); + record.confirmed_total_debt = Some(999); + wrong_value + .storage + .store_confirmation(&key, &record) + .unwrap(); + wrong_value.confirmations.insert(key, record); + assert!(matches!( + wrong_value.validated_confirmation_anchor(), + Err(crate::NoteError::StorageError(message)) + if message.contains("do not reproduce") + )); + + let (mut wrong_root, issuer, recipient, root) = confirmed_manager(); + let key: [u8; 32] = crate::NoteKey::from_keys(&issuer, &recipient) + .to_bytes() + .try_into() + .unwrap(); + let mut record = wrong_root.get_confirmation(&issuer, &recipient).unwrap(); + let mut mutated_root = root; + mutated_root[0] ^= 0x01; + record.confirmed_root = Some(mutated_root.to_vec()); + wrong_root + .storage + .store_confirmation(&key, &record) + .unwrap(); + wrong_root.confirmations.insert(key, record); + assert!(matches!( + wrong_root.validated_confirmation_anchor(), + Err(crate::NoteError::StorageError(message)) + if message.contains("does not match the durable projection") + )); + + let (mut missing_row, issuer, recipient, _) = confirmed_manager(); + let key: [u8; 32] = crate::NoteKey::from_keys(&issuer, &recipient) + .to_bytes() + .try_into() + .unwrap(); + missing_row + .storage + .remove_confirmation_for_test(&key) + .unwrap(); + missing_row.confirmations.remove(&key); + assert!(matches!( + missing_row.validated_confirmation_anchor(), + Err(crate::NoteError::StorageError(message)) + if message.contains("do not reproduce") + )); + + let (orphan_rows, _, _, _) = confirmed_manager(); + orphan_rows.storage.clear_confirmed_projection().unwrap(); + assert!(orphan_rows.has_persisted_confirmation_history().unwrap()); + assert!(orphan_rows + .validated_confirmation_anchor() + .unwrap() + .is_none()); + } + #[test] fn revert_pending_notes_returns_to_local_only() { let mut manager = make_manager(); @@ -598,7 +987,9 @@ mod confirmation_state_tests { manager.add_note(&issuer, ¬e).unwrap(); let digest = manager.get_state().avl_root_digest; - manager.mark_notes_pending(digest, "tx123", 100).unwrap(); + manager + .mark_notes_pending(digest, &"11".repeat(32), 100) + .unwrap(); manager.revert_pending_notes().unwrap(); let confirmation = manager.get_confirmation(&issuer, &recipient).unwrap(); @@ -650,7 +1041,7 @@ mod confirmation_state_tests { } #[test] - fn rebuild_confirmations_clears_pending_state() { + fn rebuild_confirmations_preserves_durable_pending_state() { let mut manager = make_manager(); let issuer_secret = [1u8; 32]; let issuer = issuer_pubkey(&issuer_secret); @@ -659,14 +1050,16 @@ mod confirmation_state_tests { manager.add_note(&issuer, ¬e).unwrap(); let digest = manager.get_state().avl_root_digest; - manager.mark_notes_pending(digest, "tx123", 100).unwrap(); + manager + .mark_notes_pending(digest, &"11".repeat(32), 100) + .unwrap(); - manager.rebuild_confirmations(); + manager.rebuild_confirmations().unwrap(); let confirmation = manager.get_confirmation(&issuer, &recipient).unwrap(); - assert_eq!(confirmation.status, NoteConfirmationStatus::LocalOnly); - assert_eq!(confirmation.pending_total_debt, None); - assert_eq!(confirmation.pending_tx_id, None); + assert_eq!(confirmation.status, NoteConfirmationStatus::Pending); + assert_eq!(confirmation.pending_total_debt, Some(1000)); + assert_eq!(confirmation.pending_tx_id, Some("11".repeat(32))); } #[test] @@ -675,8 +1068,13 @@ mod confirmation_state_tests { status: NoteConfirmationStatus::Confirmed, confirmed_total_debt: Some(1000), pending_total_debt: None, - confirmed_box_id: None, - confirmed_height: None, + confirmed_box_id: Some("11".repeat(32)), + confirmed_height: Some(100), + confirmed_tx_id: Some("22".repeat(32)), + confirmed_block_id: Some("33".repeat(32)), + confirmed_successor_depth: Some(6), + confirmed_intent_id: Some("44".repeat(32)), + confirmed_root: Some(vec![0x55; 33]), pending_tx_id: None, }; @@ -689,4 +1087,1184 @@ mod confirmation_state_tests { assert!(!confirmation.is_redeemable(1500)); assert_eq!(confirmation.redeemable_amount(1500), 0); } + + #[test] + fn projected_issuer_gross_debt_aggregates_and_replaces_once() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 40, 1)) + .unwrap(); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_c, 40, 1)) + .unwrap(); + + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_b), 70) + .unwrap(), + 110 + ); + } + + #[test] + fn projected_issuer_gross_debt_counts_a_new_recipient() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) + .unwrap(); + + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .unwrap(), + 110 + ); + } + + #[test] + fn projected_issuer_gross_debt_never_drops_confirmed_value() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let digest = manager.get_state().avl_root_digest; + manager + .mark_notes_pending(digest, &"11".repeat(32), 100) + .unwrap(); + manager.confirm_pending_notes("box123", 200).unwrap(); + + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient), 50) + .unwrap(), + 100 + ); + } + + #[test] + fn cumulative_debt_regression_is_rejected_after_confirmation() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let digest = manager.get_state().avl_root_digest; + manager + .mark_notes_pending(digest, &"11".repeat(32), 100) + .unwrap(); + manager.confirm_pending_notes("box123", 200).unwrap(); + + assert!(matches!( + manager.add_note(&issuer, &create_note(&issuer_secret, &recipient, 40, 2)), + Err(crate::NoteError::DebtRegression) + )); + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient), 50) + .unwrap(), + 100 + ); + } + + #[test] + fn settlement_progress_preserves_signed_note_and_is_bounded() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + + let before = manager.lookup_note(&issuer, &recipient).unwrap(); + let after = manager + .record_redemption_progress(&issuer, &recipient, 40) + .unwrap(); + assert_eq!(after.timestamp, before.timestamp); + assert_eq!(after.signature, before.signature); + assert_eq!(after.amount_collected, before.amount_collected); + assert_eq!(after.amount_redeemed, 40); + after.verify_signature(&issuer).unwrap(); + + assert!(manager + .record_redemption_progress(&issuer, &recipient, 61) + .is_err()); + assert_eq!( + manager + .lookup_note(&issuer, &recipient) + .unwrap() + .amount_redeemed, + 40 + ); + } + + #[test] + fn settlement_progress_quarantines_on_snapshot_avl_divergence() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + manager.avl_state = basis_trees::BasisAvlTree::new().unwrap(); + + assert!(matches!( + manager.record_redemption_progress(&issuer, &recipient, 1), + Err(crate::NoteError::StorageError(message)) + if message.contains("live root") + )); + assert!(matches!( + manager.lookup_note(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn settlement_progress_quarantines_on_tampered_snapshot() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + manager.storage.tamper_first_total_debt_for_test().unwrap(); + + assert!(matches!( + manager.record_redemption_progress(&issuer, &recipient, 1), + Err(crate::NoteError::StorageError(message)) + if message.contains("checksum") + )); + assert!(matches!( + manager.lookup_note(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn projected_issuer_gross_debt_reads_authoritative_snapshot() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) + .unwrap(); + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .unwrap(), + 110 + ); + } + + #[test] + fn projected_issuer_gross_debt_rejects_missing_authoritative_state() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) + .unwrap(); + manager.storage.remove_state_for_test().unwrap(); + + assert!(manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .is_err()); + } + + #[test] + fn projected_issuer_gross_debt_rejects_corrupt_authoritative_state() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) + .unwrap(); + manager.storage.corrupt_state_for_test().unwrap(); + + assert!(manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .is_err()); + } + + #[test] + fn projected_issuer_gross_debt_without_recipient_is_conservative() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 60, 1)) + .unwrap(); + + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, None, 50) + .unwrap(), + 110 + ); + } + + #[test] + fn projected_issuer_gross_debt_rejects_overflow() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note( + &issuer, + &create_note(&issuer_secret, &recipient_b, u64::MAX, 1), + ) + .unwrap(); + + assert!(matches!( + manager.projected_issuer_gross_debt(&issuer, Some(&recipient_c), 1), + Err(crate::NoteError::AmountOverflow) + )); + } + + #[test] + fn projected_issuer_gross_debt_survives_restart() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + { + let mut manager = TrackerStateManager::new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 1)) + .unwrap(); + } + + let manager = + TrackerStateManager::new(temp_dir.path(), generation(FreshGenerationApproval::Deny)); + assert_eq!( + manager + .projected_issuer_gross_debt(&issuer, Some(&recipient_c), 50) + .unwrap(), + 110 + ); + } + + #[test] + fn avl_root_survives_restart_when_arrival_order_differs_from_timestamps() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + let root_before_restart = { + let mut manager = TrackerStateManager::new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 60, 2)) + .unwrap(); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_c, 50, 1)) + .unwrap(); + manager.get_state().avl_root_digest + }; + + let manager = + TrackerStateManager::new(temp_dir.path(), generation(FreshGenerationApproval::Deny)); + assert_eq!(manager.get_state().avl_root_digest, root_before_restart); + } + + #[test] + fn signed_note_successor_preserves_redeemed_progress() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + let initial = create_note(&issuer_secret, &recipient, 100, 1); + manager.add_note(&issuer, &initial).unwrap(); + manager + .record_redemption_progress(&issuer, &recipient, 40) + .unwrap(); + + let successor = create_note(&issuer_secret, &recipient, 120, 2); + manager.add_note(&issuer, &successor).unwrap(); + + let stored = manager.lookup_note(&issuer, &recipient).unwrap(); + assert_eq!(stored.amount_collected, 120); + assert_eq!(stored.amount_redeemed, 40); + assert_eq!(stored.outstanding_debt(), 80); + } + + #[test] + fn initial_note_cannot_inject_redeemed_progress() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + let mut note = create_note(&issuer_secret, &recipient, 100, 1); + note.amount_redeemed = 99; + manager.add_note(&issuer, ¬e).unwrap(); + + let stored = manager.lookup_note(&issuer, &recipient).unwrap(); + assert_eq!(stored.amount_redeemed, 0); + assert_eq!(stored.outstanding_debt(), 100); + } + + #[test] + fn missing_authoritative_state_fails_closed_without_mutating_live_tree() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let root_before = manager.get_state().avl_root_digest; + manager.storage.remove_state_for_test().unwrap(); + + assert!(manager.rebuild_avl_tree().is_err()); + assert_eq!(manager.avl_state.root_digest(), root_before); + assert!(matches!( + manager.get_total_debt(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn reordered_snapshot_fails_root_validation_without_mutating_live_tree() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 100, 1)) + .unwrap(); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_c, 50, 2)) + .unwrap(); + let root_before = manager.get_state().avl_root_digest; + manager.storage.reverse_note_order_for_test().unwrap(); + + assert!(matches!( + manager.rebuild_avl_tree(), + Err(crate::NoteError::StorageError(message)) + if message.contains("does not reproduce") + )); + assert_eq!(manager.avl_state.root_digest(), root_before); + assert!(matches!( + manager.get_total_debt(&issuer, &recipient_b), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn unexpected_partition_row_rejects_new_note_before_state_change() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 100, 1)) + .unwrap(); + let root_before = manager.get_state().avl_root_digest; + manager + .storage + .insert_unexpected_note_row_for_test() + .unwrap(); + + assert!(manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_c, 50, 2)) + .is_err()); + assert_eq!(manager.avl_state.root_digest(), root_before); + assert!(matches!( + manager.get_total_debt(&issuer, &recipient_b), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn repeated_updates_keep_bounded_first_insertion_order() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + let root_before_restart = { + let mut manager = TrackerStateManager::new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ); + for version in 1..=25u64 { + manager + .add_note( + &issuer, + &create_note(&issuer_secret, &recipient, 100 + version, version), + ) + .unwrap(); + } + assert_eq!(manager.storage.note_row_count_for_test().unwrap(), 1); + manager.get_state().avl_root_digest + }; + + let manager = + TrackerStateManager::new(temp_dir.path(), generation(FreshGenerationApproval::Deny)); + assert_eq!(manager.storage.note_row_count_for_test().unwrap(), 1); + assert_eq!(manager.get_state().avl_root_digest, root_before_restart); + assert_eq!(manager.get_total_debt(&issuer, &recipient).unwrap(), 125); + } + + #[test] + fn malformed_or_extra_authoritative_rows_fail_rebuild_without_clobbering_live_tree() { + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + let mut malformed = make_manager(); + malformed + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let root_before = malformed.get_state().avl_root_digest; + malformed.storage.corrupt_state_for_test().unwrap(); + assert!(malformed.rebuild_avl_tree().is_err()); + assert_eq!(malformed.avl_state.root_digest(), root_before); + assert!(matches!( + malformed.lookup_note(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + + let mut extra = make_manager(); + extra + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let root_before = extra.get_state().avl_root_digest; + extra.storage.insert_unexpected_note_row_for_test().unwrap(); + assert!(extra.rebuild_avl_tree().is_err()); + assert_eq!(extra.avl_state.root_digest(), root_before); + assert!(matches!( + extra.lookup_note(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn same_length_debt_tampering_fails_snapshot_integrity_and_quarantines() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let root_before = manager.get_state().avl_root_digest; + manager.storage.tamper_first_total_debt_for_test().unwrap(); + + assert!(matches!( + manager.rebuild_avl_tree(), + Err(crate::NoteError::StorageError(message)) + if message.contains("checksum") + )); + assert_eq!(manager.avl_state.root_digest(), root_before); + assert!(matches!( + manager.lookup_note(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn liability_projection_revalidates_signatures_and_quarantines_tampering() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + manager.storage.tamper_first_total_debt_for_test().unwrap(); + + assert!(matches!( + manager.projected_issuer_gross_debt(&issuer, Some(&recipient), 100), + Err(crate::NoteError::StorageError(message)) + if message.contains("checksum") + )); + assert!(matches!( + manager.get_all_notes(), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn tampered_snapshot_cannot_be_laundered_by_a_valid_successor() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 101, 1)) + .unwrap(); + manager + .storage + .rewrite_first_total_debt_with_valid_checksum_for_test(100) + .unwrap(); + + assert!(matches!( + manager.add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 2)), + Err(crate::NoteError::InvalidSignature) + )); + assert!(!manager.is_healthy()); + assert!(matches!( + manager.lookup_note(&issuer, &recipient), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn redeemed_progress_tampering_is_detected_even_when_in_range() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + manager + .record_redemption_progress(&issuer, &recipient, 40) + .unwrap(); + manager + .storage + .tamper_first_redeemed_amount_for_test() + .unwrap(); + + assert!(matches!( + manager.add_note(&issuer, &create_note(&issuer_secret, &recipient, 120, 2)), + Err(crate::NoteError::StorageError(message)) + if message.contains("checksum") + )); + assert!(!manager.is_healthy()); + } + + #[test] + fn in_range_redeemed_tampering_is_rejected_after_restart() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + + { + let mut manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + manager + .record_redemption_progress(&issuer, &recipient, 40) + .unwrap(); + manager + .storage + .tamper_first_redeemed_amount_for_test() + .unwrap(); + } + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ), + Err(crate::NoteError::StorageError(message)) + if message.contains("snapshot checksum") + )); + } + + #[test] + fn capacity_rejection_does_not_quarantine_or_change_the_root() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 100, 1)) + .unwrap(); + let root_before = manager.get_state().avl_root_digest; + manager.storage.set_capacity_limit_for_test(1); + + assert!(matches!( + manager.add_note(&issuer, &create_note(&issuer_secret, &recipient_c, 50, 2)), + Err(crate::NoteError::CapacityExceeded { limit: 1 }) + )); + assert!(manager.is_healthy()); + assert_eq!(manager.get_state().avl_root_digest, root_before); + assert_eq!(manager.get_total_debt(&issuer, &recipient_b).unwrap(), 100); + } + + #[test] + fn generation_requires_explicit_bootstrap_and_binds_nft_and_first_root() { + let temp_dir = tempfile::tempdir().unwrap(); + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ), + Err(crate::NoteError::GenerationBindingRequired(_)) + )); + + let manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + let empty_root = manager.get_state().avl_root_digest; + let (nft, bootstrap_root, anchor_root) = manager.storage.generation_for_test().unwrap(); + assert_eq!(nft, [0x42; 32]); + assert_eq!(bootstrap_root, empty_root); + assert_eq!(anchor_root, None); + manager + .validate_observed_generation(&[0x42; 32], empty_root) + .unwrap(); + assert_eq!( + manager.storage.generation_for_test().unwrap().2, + Some(empty_root) + ); + drop(manager); + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + TrackerGenerationConfig { + tracker_nft_id: [0x43; 32], + fresh_generation: FreshGenerationApproval::Deny, + } + ), + Err(crate::NoteError::GenerationMismatch(_)) + )); + } + + fn raw_note_layout( + path: &std::path::Path, + ) -> (Option>, Option>, Option>) { + let keyspace = fjall::Config::new(path).open().unwrap(); + let notes = keyspace + .open_partition("iou_notes", fjall::PartitionCreateOptions::default()) + .unwrap(); + let schema = keyspace + .open_partition("note_schema", fjall::PartitionCreateOptions::default()) + .unwrap(); + ( + notes + .get(b"note_state_v2") + .unwrap() + .map(|value| value.to_vec()), + schema + .get(b"note_schema_v2") + .unwrap() + .map(|value| value.to_vec()), + schema + .get(b"tracker_generation_v1") + .unwrap() + .map(|value| value.to_vec()), + ) + } + + fn raw_pending_publication(path: &std::path::Path) -> Option> { + let keyspace = fjall::Config::new(path).open().unwrap(); + let schema = keyspace + .open_partition("note_schema", fjall::PartitionCreateOptions::default()) + .unwrap(); + schema + .get(b"pending_publication_v1") + .unwrap() + .map(|value| value.to_vec()) + } + + fn replace_raw_pending_publication(path: &std::path::Path, bytes: &[u8]) { + let keyspace = fjall::Config::new(path).open().unwrap(); + let schema = keyspace + .open_partition("note_schema", fjall::PartitionCreateOptions::default()) + .unwrap(); + schema.insert(b"pending_publication_v1", bytes).unwrap(); + keyspace.persist(fjall::PersistMode::SyncData).unwrap(); + } + + #[test] + fn corrupted_pending_publication_is_rejected_without_rewriting_state() { + let mutation_names = [ + "magic", + "digest", + "transaction id", + "height", + "checksum", + "truncation", + ]; + + for (case, mutation_name) in mutation_names.into_iter().enumerate() { + let temp_dir = tempfile::tempdir().unwrap(); + { + let mut manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1000, 1)) + .unwrap(); + let digest = manager.validated_state().unwrap().avl_root_digest; + manager + .mark_notes_pending(digest, &"11".repeat(32), 100) + .unwrap(); + } + + let storage_path = temp_dir.path().join("notes"); + let mut corrupted = raw_pending_publication(&storage_path).unwrap(); + assert_eq!(corrupted.len(), 109); + match case { + 0 => corrupted[0] ^= 1, + 1 => corrupted[4] ^= 1, + 2 => corrupted[37] ^= 1, + 3 => corrupted[69] ^= 1, + 4 => corrupted[108] ^= 1, + 5 => { + corrupted.pop(); + } + _ => unreachable!(), + } + replace_raw_pending_publication(&storage_path, &corrupted); + let before_layout = raw_note_layout(&storage_path); + let before_pending = raw_pending_publication(&storage_path); + + assert!( + matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ), + Err(crate::NoteError::StorageError(_)) + ), + "{mutation_name} corruption must fail closed" + ); + assert_eq!( + raw_note_layout(&storage_path), + before_layout, + "{mutation_name} corruption must not rewrite the state snapshot" + ); + assert_eq!( + raw_pending_publication(&storage_path), + before_pending, + "{mutation_name} corruption must not rewrite the publication receipt" + ); + } + } + + #[test] + fn orphan_pending_publication_cannot_initialize_a_fresh_generation() { + let temp_dir = tempfile::tempdir().unwrap(); + { + let mut manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 1000, 1)) + .unwrap(); + let digest = manager.validated_state().unwrap().avl_root_digest; + manager + .mark_notes_pending(digest, &"11".repeat(32), 100) + .unwrap(); + manager.storage.remove_state_for_test().unwrap(); + manager.storage.remove_schema_for_test().unwrap(); + manager.storage.remove_generation_for_test().unwrap(); + manager.storage.persist_for_test().unwrap(); + } + let storage_path = temp_dir.path().join("notes"); + let before_layout = raw_note_layout(&storage_path); + let before_pending = raw_pending_publication(&storage_path); + assert!( + before_layout.0.is_none() + && before_layout.1.is_none() + && before_layout.2.is_none() + && before_pending.is_some() + ); + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve) + ), + Err(crate::NoteError::GenerationMismatch(message)) + if message.contains("Pending tracker publication") + )); + assert_eq!(raw_note_layout(&storage_path), before_layout); + assert_eq!(raw_pending_publication(&storage_path), before_pending); + } + + #[test] + fn orphan_generation_manifest_is_not_reset_or_completed() { + let temp_dir = tempfile::tempdir().unwrap(); + { + let manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + manager.storage.remove_state_for_test().unwrap(); + manager.storage.remove_schema_for_test().unwrap(); + manager.storage.persist_for_test().unwrap(); + } + let storage_path = temp_dir.path().join("notes"); + let before = raw_note_layout(&storage_path); + assert!(before.0.is_none() && before.1.is_none() && before.2.is_some()); + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve) + ), + Err(crate::NoteError::GenerationMismatch(message)) + if message.contains("complete authoritative") + )); + assert_eq!(raw_note_layout(&storage_path), before); + } + + #[test] + fn state_without_schema_or_generation_is_not_completed() { + let temp_dir = tempfile::tempdir().unwrap(); + { + let manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + manager.storage.remove_schema_for_test().unwrap(); + manager.storage.remove_generation_for_test().unwrap(); + manager.storage.persist_for_test().unwrap(); + } + let storage_path = temp_dir.path().join("notes"); + let before = raw_note_layout(&storage_path); + assert!(before.0.is_some() && before.1.is_none() && before.2.is_none()); + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve) + ), + Err(crate::NoteError::StorageError(message)) + if message.contains("without its schema and generation") + )); + assert_eq!(raw_note_layout(&storage_path), before); + } + + #[test] + fn wrong_generation_open_does_not_rewrite_any_authoritative_record() { + let temp_dir = tempfile::tempdir().unwrap(); + { + let _manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + } + let storage_path = temp_dir.path().join("notes"); + let before = raw_note_layout(&storage_path); + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + TrackerGenerationConfig { + tracker_nft_id: [0x43; 32], + fresh_generation: FreshGenerationApproval::Approve, + } + ), + Err(crate::NoteError::GenerationMismatch(_)) + )); + assert_eq!(raw_note_layout(&storage_path), before); + } + + #[test] + fn first_observed_nonbootstrap_root_quarantines_generation() { + let temp_dir = tempfile::tempdir().unwrap(); + let manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + let mut wrong_root = manager.get_state().avl_root_digest; + wrong_root[0] ^= 1; + + assert!(matches!( + manager.validate_observed_generation(&[0x42; 32], wrong_root), + Err(crate::NoteError::GenerationMismatch(_)) + )); + assert!(!manager.is_healthy()); + } + + #[test] + fn corrupted_generation_manifest_is_rejected_after_restart() { + let temp_dir = tempfile::tempdir().unwrap(); + { + let manager = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + manager + .storage + .tamper_generation_manifest_for_test() + .unwrap(); + } + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ), + Err(crate::NoteError::StorageError(message)) + if message.contains("generation manifest checksum") + )); + } + + #[test] + fn publication_generation_gate_revalidates_the_complete_snapshot() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let bootstrap_root = manager.storage.generation_for_test().unwrap().1; + manager.storage.tamper_first_total_debt_for_test().unwrap(); + + assert!(matches!( + manager.validate_observed_generation(&[0x42; 32], bootstrap_root), + Err(crate::NoteError::StorageError(message)) if message.contains("snapshot checksum") + )); + assert!(!manager.is_healthy()); + } + + #[test] + fn reserve_root_exposure_revalidates_the_complete_snapshot() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + manager.storage.tamper_first_total_debt_for_test().unwrap(); + + assert!(matches!( + manager.validated_state(), + Err(crate::NoteError::StorageError(message)) if message.contains("snapshot checksum") + )); + assert!(!manager.is_healthy()); + } + + #[test] + fn declared_note_count_above_bound_fails_closed() { + let mut manager = make_manager(); + manager + .storage + .set_declared_note_count_for_test(50_001) + .unwrap(); + + assert!(matches!( + manager.rebuild_avl_tree(), + Err(crate::NoteError::StorageError(message)) + if message.contains("exceeds configured bound") + )); + assert!(matches!( + manager.get_all_notes(), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn unknown_durable_outcome_quarantines_manager_until_validated_restart() { + let temp_dir = tempfile::tempdir().unwrap(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient_b = [2u8; 33]; + let recipient_c = [3u8; 33]; + + let publication_health = crate::PublicationHealth::new(); + let mut manager = TrackerStateManager::try_new_with_publication_health( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + publication_health.clone(), + ) + .unwrap(); + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient_b, 100, 1)) + .unwrap(); + manager.storage.fail_next_persist_for_test(); + + assert!(matches!( + manager.add_note(&issuer, &create_note(&issuer_secret, &recipient_c, 50, 2)), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + assert!(matches!( + manager.lookup_note(&issuer, &recipient_b), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + assert!(!publication_health.is_healthy()); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { manager.get_state() })) + .is_err() + ); + + drop(manager); + + let reopened = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny), + ) + .unwrap(); + let persisted = reopened.storage.read_state_strict().unwrap(); + assert_eq!( + reopened.get_state().avl_root_digest, + persisted.avl_root_digest + ); + assert_eq!(reopened.get_total_debt(&issuer, &recipient_b).unwrap(), 100); + } + + #[test] + fn confirmation_durability_uncertainty_blocks_publication() { + let mut manager = make_manager(); + let issuer_secret = [1u8; 32]; + let issuer = issuer_pubkey(&issuer_secret); + let recipient = [2u8; 33]; + manager + .add_note(&issuer, &create_note(&issuer_secret, &recipient, 100, 1)) + .unwrap(); + let digest = manager.validated_state().unwrap().avl_root_digest; + manager.storage.fail_next_persist_for_test(); + + assert!(matches!( + manager.reconcile_with_confirmed_digest(&digest, "box-1", 100), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + assert!(!manager.is_healthy()); + assert!(matches!( + manager.validated_state(), + Err(crate::NoteError::StorageOutcomeUnknown(_)) + )); + } + + #[test] + fn storage_allows_only_one_writer_per_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let first = TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Approve), + ) + .unwrap(); + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ), + Err(crate::NoteError::StorageError(message)) + if message.contains("active writer") + )); + drop(first); + assert!(TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ) + .is_ok()); + } + + #[test] + fn legacy_note_rows_require_explicit_migration_without_rewrite() { + let temp_dir = tempfile::tempdir().unwrap(); + let notes_path = temp_dir.path().join("notes"); + let legacy_key = [7u8; 32]; + let legacy_value = [9u8; 155]; + + { + let keyspace = fjall::Config::new(¬es_path).open().unwrap(); + let partition = keyspace + .open_partition("iou_notes", fjall::PartitionCreateOptions::default()) + .unwrap(); + partition.insert(legacy_key, legacy_value).unwrap(); + keyspace.persist(fjall::PersistMode::SyncData).unwrap(); + } + + assert!(matches!( + TrackerStateManager::try_new( + temp_dir.path(), + generation(FreshGenerationApproval::Deny) + ), + Err(crate::NoteError::MigrationRequired(message)) + if message.contains("explicit") + )); + + let keyspace = fjall::Config::new(¬es_path).open().unwrap(); + let partition = keyspace + .open_partition("iou_notes", fjall::PartitionCreateOptions::default()) + .unwrap(); + assert_eq!( + partition.get(legacy_key).unwrap().unwrap().as_ref(), + legacy_value + ); + assert_eq!(partition.len().unwrap(), 1); + } } diff --git a/crates/basis_store/src/tracker_scanner.rs b/crates/basis_store/src/tracker_scanner.rs index 1475b29..9d2da81 100644 --- a/crates/basis_store/src/tracker_scanner.rs +++ b/crates/basis_store/src/tracker_scanner.rs @@ -1,21 +1,24 @@ //! Tracker box scanner for monitoring Basis tracker state commitment boxes //! This module provides blockchain integration using /blockchain endpoints (no node scans). -use std::path::{Path, PathBuf}; +use std::collections::HashSet; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, Semaphore}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, error, info, warn}; -use reqwest::Client; +use reqwest::StatusCode; use crate::{ - ergo_scanner::{IndexedErgoBox, ScanBox}, + ergo_scanner::{ + node_http, summarize_error_body, BoundedHttpError, IndexedErgoBox, IndexedHeightResponse, + ScanBox, MAX_CONCURRENT_SCANNER_REQUESTS, MAX_SCAN_BOXES, MAX_SCAN_PAGES, SCAN_PAGE_SIZE, + }, persistence::{ScannerMetadataStorage, TrackerStorage}, - TrackerBoxInfo, TrackerStateManager, + TrackerBoxInfo, }; #[derive(Error, Debug)] @@ -44,10 +47,25 @@ pub enum TrackerScannerError { InvalidRegisterData(String), #[error("Missing tracker NFT in box assets")] MissingTrackerNft, + #[error("Tracker scanner response exceeds {max_bytes} bytes")] + ResponseTooLarge { max_bytes: usize }, + #[error("Tracker scanner request concurrency gate is closed")] + RequestGateClosed, + #[error("Tracker scanner request capacity is exhausted")] + RequestCapacityExceeded, + #[error("Indexed node is behind: indexed height {indexed_height}, full height {full_height}")] + IndexLag { + indexed_height: u64, + full_height: u64, + }, + #[error("Incoherent tracker scanner snapshot: {0}")] + IncoherentSnapshot(String), + #[error("Tracker scanner resource limit exceeded: {0}")] + ScanLimitExceeded(String), } /// Configuration for tracker scanner -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct TrackerNodeConfig { /// Starting block height for scanning pub start_height: Option, @@ -61,6 +79,18 @@ pub struct TrackerNodeConfig { pub api_key: Option, } +impl std::fmt::Debug for TrackerNodeConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrackerNodeConfig") + .field("start_height", &self.start_height) + .field("tracker_nft_id", &self.tracker_nft_id) + .field("node_url", &self.node_url) + .field("scan_name", &self.scan_name) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .finish() + } +} + /// Inner state for tracker scanner that requires synchronization #[derive(Clone)] pub struct TrackerServerStateInner { @@ -71,44 +101,131 @@ pub struct TrackerServerStateInner { /// Server state for tracker scanner /// Uses real blockchain integration with proper synchronization +#[derive(Clone)] pub struct TrackerServerState { pub config: TrackerNodeConfig, pub inner: Arc>, - pub client: Client, - pub tracker_state: TrackerStateManager, + pub(crate) request_permits: Arc, pub metadata_storage: ScannerMetadataStorage, pub tracker_storage: TrackerStorage, - pub data_dir: PathBuf, } -impl Clone for TrackerServerState { - fn clone(&self) -> Self { - Self { - config: self.config.clone(), - inner: Arc::clone(&self.inner), - client: self.client.clone(), - tracker_state: TrackerStateManager::new(&self.data_dir), // Create new instance since it doesn't implement Clone - metadata_storage: self.metadata_storage.clone(), - tracker_storage: self.tracker_storage.clone(), - data_dir: self.data_dir.clone(), - } +impl TrackerServerState { + async fn request_bytes( + &self, + request: reqwest::RequestBuilder, + context: &str, + ) -> Result<(StatusCode, Vec), TrackerScannerError> { + let _permit = self + .request_permits + .try_acquire() + .map_err(|error| match error { + tokio::sync::TryAcquireError::Closed => TrackerScannerError::RequestGateClosed, + tokio::sync::TryAcquireError::NoPermits => { + TrackerScannerError::RequestCapacityExceeded + } + })?; + let response = node_http() + .map_err(|error| TrackerScannerError::HttpError(format!("{}: {}", context, error)))? + .execute(request) + .await + .map_err(|error| match error { + BoundedHttpError::BodyTooLarge { limit } => { + TrackerScannerError::ResponseTooLarge { max_bytes: limit } + } + BoundedHttpError::Overloaded => TrackerScannerError::RequestCapacityExceeded, + other => TrackerScannerError::HttpError(format!("{}: {}", context, other)), + })?; + Ok(response.into_parts()) } -} -impl TrackerServerState { /// Create HTTP request builder with API key header if configured - fn request_builder(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder { + fn request_builder( + &self, + method: reqwest::Method, + url: &str, + ) -> Result { debug!("Tracker request method: {}, URL: {}", method, url); - let mut builder = self.client.request(method, url); + let client = + node_http().map_err(|error| TrackerScannerError::HttpError(error.to_string()))?; + let mut builder = client.request(method, url); if let Some(api_key) = &self.config.api_key { builder = builder.header("api_key", api_key); } - builder + Ok(builder) } - /// Get unspent tracker boxes via `GET /blockchain/box/unspent/byTokenId/{trackerNftId}`. + async fn fetch_indexed_height(&self) -> Result { + let url = format!("{}/blockchain/indexedHeight", self.config.node_url); + let (status, body) = self + .request_bytes( + self.request_builder(reqwest::Method::GET, &url)?, + "Failed to fetch indexed height", + ) + .await?; + if !status.is_success() { + return Err(TrackerScannerError::NodeError(format!( + "Failed to get indexed height with status {}: {}", + status, + summarize_error_body(&body) + ))); + } + serde_json::from_slice(&body).map_err(|e| { + TrackerScannerError::JsonError(format!( + "Failed to parse indexed height response: {}", + e + )) + }) + } + + fn require_caught_up(height: IndexedHeightResponse) -> Result<(), TrackerScannerError> { + if height.indexed_height < height.full_height { + return Err(TrackerScannerError::IndexLag { + indexed_height: height.indexed_height, + full_height: height.full_height, + }); + } + Ok(()) + } + + async fn fetch_unspent_tracker_page( + &self, + tracker_nft_id: &str, + offset: usize, + ) -> Result, TrackerScannerError> { + let url = format!( + "{}/blockchain/box/unspent/byTokenId/{}", + self.config.node_url, tracker_nft_id + ); + let request = self.request_builder(reqwest::Method::GET, &url)?.query(&[ + ("offset", offset.to_string()), + ("limit", SCAN_PAGE_SIZE.to_string()), + ("sortDirection", "asc".to_string()), + ("includeUnconfirmed", "false".to_string()), + ("excludeMempoolSpent", "false".to_string()), + ]); + let (status, body) = self + .request_bytes(request, "Failed to fetch tracker boxes") + .await?; + // The pinned v6.0.3 route returns a JSON sequence. Only an HTTP 200 + // carrying that sequence can prove page contents; in particular, a + // 404 is an ambiguous source failure rather than an exhausted page. + if status != StatusCode::OK { + return Err(TrackerScannerError::NodeError(format!( + "Failed to get tracker boxes with status {}: {}", + status, + summarize_error_body(&body) + ))); + } + serde_json::from_slice(&body).map_err(|e| { + TrackerScannerError::JsonError(format!("Failed to parse tracker boxes: {}", e)) + }) + } + + /// Get a complete, height-coherent set of unspent tracker boxes via the + /// indexed-node token route. pub async fn get_unspent_tracker_boxes(&self) -> Result, TrackerScannerError> { let tracker_nft_id = self .config @@ -116,41 +233,71 @@ impl TrackerServerState { .as_ref() .ok_or(TrackerScannerError::MissingTrackerNftId)?; - let url = format!( - "{}/blockchain/box/unspent/byTokenId/{}?limit=5&includeUnconfirmed=false", - self.config.node_url, tracker_nft_id - ); - - debug!("Fetching unspent tracker boxes from: {}", url); + let before = self.fetch_indexed_height().await?; + Self::require_caught_up(before)?; + let mut indexed_boxes = Vec::new(); + let mut seen_box_ids = HashSet::new(); + let mut exhausted = false; - let response = self - .request_builder(reqwest::Method::GET, &url) - .send() - .await - .map_err(|e| { - TrackerScannerError::HttpError(format!("Failed to fetch tracker boxes: {}", e)) + for page_index in 0..MAX_SCAN_PAGES { + let offset = page_index.checked_mul(SCAN_PAGE_SIZE).ok_or_else(|| { + TrackerScannerError::ScanLimitExceeded("page offset overflow".to_string()) })?; + let page = self + .fetch_unspent_tracker_page(tracker_nft_id, offset) + .await?; + if page.len() > SCAN_PAGE_SIZE { + return Err(TrackerScannerError::IncoherentSnapshot(format!( + "node returned {} boxes for requested page size {} at offset {}", + page.len(), + SCAN_PAGE_SIZE, + offset + ))); + } + let page_len = page.len(); + for box_ in page { + if !seen_box_ids.insert(box_.box_id.clone()) { + return Err(TrackerScannerError::IncoherentSnapshot(format!( + "duplicate box id {} across paginated response", + box_.box_id + ))); + } + if indexed_boxes.len() >= MAX_SCAN_BOXES { + return Err(TrackerScannerError::ScanLimitExceeded(format!( + "more than {} tracker boxes", + MAX_SCAN_BOXES + ))); + } + indexed_boxes.push(box_); + } + if page_len < SCAN_PAGE_SIZE { + exhausted = true; + break; + } + } - if !response.status().is_success() { - let status = response.status(); - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(TrackerScannerError::NodeError(format!( - "Failed to get unspent tracker boxes with status {}: {}", - status, error_text + if !exhausted { + return Err(TrackerScannerError::ScanLimitExceeded(format!( + "scan did not exhaust within {} pages", + MAX_SCAN_PAGES ))); } - let indexed_boxes: Vec = response.json().await.map_err(|e| { - error!("Failed to parse tracker boxes JSON: {}", e); - TrackerScannerError::JsonError(format!("Failed to parse tracker boxes: {}", e)) - })?; + let after = self.fetch_indexed_height().await?; + Self::require_caught_up(after)?; + if before != after { + return Err(TrackerScannerError::IncoherentSnapshot(format!( + "indexed/full height changed from {}/{} to {}/{} during pagination", + before.indexed_height, before.full_height, after.indexed_height, after.full_height + ))); + } let boxes: Vec = indexed_boxes.into_iter().map(Into::into).collect(); - info!("Retrieved {} unspent tracker boxes", boxes.len()); - + info!( + "Retrieved {} tracker boxes in a complete snapshot at indexed height {}", + boxes.len(), + after.indexed_height + ); Ok(boxes) } @@ -251,7 +398,8 @@ impl TrackerServerState { // It should start with 0x64 (SAvlTree type identifier) followed by the tree data if !state_commitment.starts_with("64") { return Err(TrackerScannerError::InvalidRegisterData( - format!("Invalid state commitment format: does not start with SAvlTree type identifier (64)") + "Invalid state commitment format: does not start with SAvlTree type identifier (64)" + .to_string(), )); } @@ -279,30 +427,22 @@ impl TrackerServerState { pub async fn process_tracker_boxes(&self) -> Result, TrackerScannerError> { let unspent_boxes = self.get_unspent_tracker_boxes().await?; let total_boxes = unspent_boxes.len(); - let mut processed_boxes = Vec::new(); + let mut processed_boxes = Vec::with_capacity(total_boxes); + // Validate the complete snapshot before persisting any derived box. for scan_box in &unspent_boxes { - match self.parse_tracker_box(scan_box) { - Ok(tracker_box) => { - // Store the parsed box - self.tracker_storage - .store_tracker_box(&tracker_box) - .map_err(|e| { - TrackerScannerError::StoreError(format!( - "Failed to store tracker box: {:?}", - e - )) - })?; - - processed_boxes.push(tracker_box); - - debug!("Successfully processed tracker box: {}", scan_box.box_id); - } - Err(e) => { - warn!("Failed to parse tracker box {}: {}", scan_box.box_id, e); - // Continue processing other boxes - } - } + processed_boxes.push(self.parse_tracker_box(scan_box)?); + } + for tracker_box in &processed_boxes { + self.tracker_storage + .store_tracker_box(tracker_box) + .map_err(|e| { + TrackerScannerError::StoreError(format!( + "Failed to store tracker box {}: {:?}", + tracker_box.box_id, e + )) + })?; + debug!("Successfully processed tracker box: {}", tracker_box.box_id); } info!( @@ -347,9 +487,17 @@ impl TrackerServerState { for tracker_box in tracker_boxes { debug!( "Tracker box: id={}, pubkey={}, commitment={}, height={}", - &tracker_box.box_id[..16], // First 16 chars of box ID - &tracker_box.tracker_pubkey[..16], // First 16 chars of pubkey - &tracker_box.state_commitment[..16], // First 16 chars of commitment + tracker_box.box_id.chars().take(16).collect::(), + tracker_box + .tracker_pubkey + .chars() + .take(16) + .collect::(), + tracker_box + .state_commitment + .chars() + .take(16) + .collect::(), tracker_box.last_verified_height ); } @@ -396,20 +544,21 @@ impl TrackerServerState { // Fetch from node let url = format!("{}/info", self.config.node_url); - let response = self - .request_builder(reqwest::Method::GET, &url) - .send() - .await - .map_err(|e| TrackerScannerError::HttpError(format!("Failed to get height: {}", e)))?; + let (status, body) = self + .request_bytes( + self.request_builder(reqwest::Method::GET, &url)?, + "Failed to get height", + ) + .await?; - if !response.status().is_success() { + if !status.is_success() { return Err(TrackerScannerError::NodeError(format!( "Failed to get height: {}", - response.status() + status ))); } - let info: serde_json::Value = response.json().await.map_err(|e| { + let info: serde_json::Value = serde_json::from_slice(&body).map_err(|e| { TrackerScannerError::JsonError(format!("Failed to parse height: {}", e)) })?; @@ -476,7 +625,6 @@ pub fn create_tracker_server_state( config: TrackerNodeConfig, metadata_storage: ScannerMetadataStorage, tracker_storage: TrackerStorage, - data_dir: impl AsRef, ) -> TrackerServerState { let inner = TrackerServerStateInner { _current_height: 0, @@ -487,10 +635,8 @@ pub fn create_tracker_server_state( TrackerServerState { config, inner: Arc::new(Mutex::new(inner)), - client: Client::new(), - tracker_state: TrackerStateManager::new(&data_dir), + request_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_SCANNER_REQUESTS)), metadata_storage, tracker_storage, - data_dir: data_dir.as_ref().to_path_buf(), } } diff --git a/crates/basis_store/src/tracker_scanner_test.rs b/crates/basis_store/src/tracker_scanner_test.rs index 5b6c6eb..8e33b78 100644 --- a/crates/basis_store/src/tracker_scanner_test.rs +++ b/crates/basis_store/src/tracker_scanner_test.rs @@ -4,12 +4,71 @@ mod tests { use super::*; use crate::{ - ergo_scanner::{BoxAsset, ScanBox}, + ergo_scanner::{BoxAsset, ScanBox, NODE_HTTP_MAX_BODY_BYTES}, persistence::{ScannerMetadataStorage, TrackerStorage}, - tracker_scanner::{create_tracker_server_state, TrackerNodeConfig}, + tracker_scanner::{create_tracker_server_state, TrackerNodeConfig, TrackerScannerError}, }; use std::collections::HashMap; use std::path::Path; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn tracker_node_config_debug_redacts_api_key() { + let sentinel = "tracker-node-api-key-sentinel"; + let config = TrackerNodeConfig { + start_height: Some(42), + tracker_nft_id: Some("11".repeat(32)), + node_url: "http://127.0.0.1:9053".to_string(), + scan_name: Some("redaction-test".to_string()), + api_key: Some(sentinel.to_string()), + }; + + let rendered = format!("{config:?}"); + assert!(!rendered.contains(sentinel)); + assert!(rendered.contains("")); + } + + async fn oversized_declared_response_server() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + NODE_HTTP_MAX_BODY_BYTES + 1 + ); + let _ = socket.write_all(response.as_bytes()).await; + }); + format!("http://{address}") + } + + #[tokio::test] + async fn tracker_scanner_rejects_oversized_declared_node_body() { + let temp_dir = tempfile::tempdir().unwrap(); + let metadata_storage = ScannerMetadataStorage::open(temp_dir.path().join("metadata")) + .expect("Failed to create metadata storage"); + let tracker_storage = TrackerStorage::open(temp_dir.path().join("tracker")) + .expect("Failed to create tracker storage"); + let config = TrackerNodeConfig { + start_height: Some(0), + tracker_nft_id: Some("11".repeat(32)), + node_url: oversized_declared_response_server().await, + scan_name: Some("bounded-http-test".to_string()), + api_key: None, + }; + let state = create_tracker_server_state(config, metadata_storage, tracker_storage); + + let error = state.get_current_height().await.unwrap_err(); + + assert!(matches!( + error, + TrackerScannerError::ResponseTooLarge { + max_bytes: NODE_HTTP_MAX_BODY_BYTES + } + )); + } #[tokio::test] async fn test_tracker_scan_registration_payload() { @@ -75,8 +134,7 @@ mod tests { api_key: None, }; - let server_state = - create_tracker_server_state(config, metadata_storage, tracker_storage, temp_dir.path()); + let server_state = create_tracker_server_state(config, metadata_storage, tracker_storage); // Verify the server state was created assert_eq!(server_state.config.node_url, "http://localhost:9053"); @@ -135,8 +193,7 @@ mod tests { api_key: None, }; - let server_state = - create_tracker_server_state(config, metadata_storage, tracker_storage, temp_dir.path()); + let server_state = create_tracker_server_state(config, metadata_storage, tracker_storage); // Create a mock ScanBox let mut registers = HashMap::new(); @@ -212,8 +269,7 @@ mod tests { api_key: None, }; - let server_state = - create_tracker_server_state(config, metadata_storage, tracker_storage, temp_dir.path()); + let server_state = create_tracker_server_state(config, metadata_storage, tracker_storage); // Create a mock ScanBox without the tracker NFT let mut registers = HashMap::new(); @@ -272,8 +328,7 @@ mod tests { api_key: None, }; - let server_state = - create_tracker_server_state(config, metadata_storage, tracker_storage, temp_dir.path()); + let server_state = create_tracker_server_state(config, metadata_storage, tracker_storage); // Create a mock ScanBox missing R5 register (required) let mut registers = HashMap::new(); diff --git a/crates/basis_store/src/transaction_builder.rs b/crates/basis_store/src/transaction_builder.rs deleted file mode 100644 index bb3c77e..0000000 --- a/crates/basis_store/src/transaction_builder.rs +++ /dev/null @@ -1,1048 +0,0 @@ -//! Transaction building for Basis redemption -//! -//! Context Extension Variables (following the deployed reserve contract): -//! - #0: action (Byte) - action*10 + output_index (0x00 for redemption at index 0) -//! - #1: receiver (GroupElement) - Receiver's public key -//! - #2: reserveSig (Coll[Byte]) - Reserve owner's Schnorr signature (65 bytes) -//! - #3: totalDebt (Long) - Total cumulative debt amount -//! - #5: insertProof (Coll[Byte]) - AVL proof for inserting into reserve tree -//! - #6: trackerSig (Coll[Byte]) - Tracker's Schnorr signature (65 bytes) -//! - #7: lookupProofReserve (Coll[Byte]) - AVL proof for looking up in reserve tree (optional for first redemption) -//! - #8: lookupProofTracker (Coll[Byte]) - AVL proof for looking up in tracker tree -//! -//! When blockchain integration is complete, this will use ergo-lib to build actual transactions -//! that can be submitted to the Ergo network. - -use thiserror::Error; - -use std::collections::HashMap; - -#[derive(Error, Debug)] -pub enum TransactionBuilderError { - #[error("Transaction building error: {0}")] - TransactionBuilding(String), - #[error("Insufficient funds: {0}")] - InsufficientFunds(String), - #[error("Configuration error: {0}")] - Configuration(String), -} - -/// Context extension variables for redemption transaction -/// Following specs/server/redemption_transaction_format_spec.md -#[derive(Debug, Clone)] -pub struct ContextExtension { - /// #0: Action byte (action*10 + output_index, 0x00 for redemption at index 0) - pub action: u8, - /// #1: Receiver's public key (33 bytes compressed) - pub receiver_pubkey: Vec, - /// #2: Reserve owner's Schnorr signature (65 bytes) - pub reserve_signature: Vec, - /// #3: Total debt amount - pub total_debt: u64, - /// #5: AVL insert proof for reserve tree - pub insert_proof: Vec, - /// #6: Tracker's Schnorr signature (65 bytes) - pub tracker_signature: Vec, - /// #7: AVL lookup proof for reserve tree (None for first redemption) - pub reserve_lookup_proof: Option>, - /// #8: AVL lookup proof for tracker tree - pub tracker_lookup_proof: Vec, -} - -impl ContextExtension { - /// Convert context extension to a HashMap for JSON serialization - pub fn to_json_map(&self) -> HashMap { - let mut map = HashMap::new(); - - // #0: Action byte - map.insert( - "0".to_string(), - serde_json::Value::Number(self.action.into()), - ); - - // #1: Receiver pubkey (hex-encoded) - map.insert( - "1".to_string(), - serde_json::Value::String(hex::encode(&self.receiver_pubkey)), - ); - - // #2: Reserve signature (hex-encoded) - map.insert( - "2".to_string(), - serde_json::Value::String(hex::encode(&self.reserve_signature)), - ); - - // #3: Total debt (number) - map.insert( - "3".to_string(), - serde_json::Value::Number(self.total_debt.into()), - ); - - // #5: Insert proof (hex-encoded) - map.insert( - "5".to_string(), - serde_json::Value::String(hex::encode(&self.insert_proof)), - ); - - // #6: Tracker signature (hex-encoded) - map.insert( - "6".to_string(), - serde_json::Value::String(hex::encode(&self.tracker_signature)), - ); - - // #7: Reserve lookup proof (hex-encoded, optional) - if let Some(ref proof) = self.reserve_lookup_proof { - map.insert( - "7".to_string(), - serde_json::Value::String(hex::encode(proof)), - ); - } - - // #8: Tracker lookup proof (hex-encoded) - map.insert( - "8".to_string(), - serde_json::Value::String(hex::encode(&self.tracker_lookup_proof)), - ); - - map - } -} - -/// Context for transaction building containing blockchain and fee parameters -/// -/// This structure holds all the contextual information needed to build a valid -/// redemption transaction that can be accepted by the Ergo network. -#[derive(Debug, Clone)] -pub struct TxContext { - /// Current blockchain height (required for transaction validity) - pub current_height: u32, - /// Transaction fee in nanoERG (0.001 ERG = 1,000,000 nanoERG) - pub fee: u64, - /// Change address for any leftover funds after redemption - pub change_address: String, - /// Network prefix for Ergo address encoding - pub network_prefix: u8, -} - -impl Default for TxContext { - fn default() -> Self { - Self { - current_height: 0, - fee: 1000000, // 0.001 ERG - change_address: "".to_string(), - network_prefix: 0, // mainnet - } - } -} - -/// Complete redemption transaction data structure -/// -/// This structure contains all the components needed to build a redemption transaction -/// that follows the Basis contract specification. The transaction structure is: -/// -/// - Inputs: [Reserve box, fee input boxes...] (spent) -/// - Data Inputs: [Tracker box] (for AVL proof verification) -/// - Outputs: [Updated reserve box, Redemption output box, fee recipient output, change box (optional)] -/// - Context Extension: Contract parameters (#0-#8) -#[derive(Debug, Clone)] -pub struct RedemptionTransactionData { - /// Reserve box ID being spent (contains collateral backing the debt) - pub reserve_box_id: String, - /// Tracker box ID used as data input (contains AVL tree commitment) - pub tracker_box_id: String, - /// Amount being redeemed from the reserve (debt amount) - pub redemption_amount: u64, - /// Recipient address where redeemed funds are sent - pub recipient_address: String, - /// AVL proof bytes proving the debt exists in the tracker's state - pub avl_proof: Vec, - /// Issuer's 65-byte Schnorr signature authorizing the redemption - pub issuer_signature: Vec, - /// Tracker's 65-byte Schnorr signature validating the debt - pub tracker_signature: Vec, - /// Transaction fee in nanoERG - pub fee: u64, - /// Tracker NFT ID from R6 register (hex-encoded, 32 bytes = 64 hex chars) - pub tracker_nft_id: String, - /// Context extension variables for contract validation - pub context_extension: Option, - /// Total debt amount from tracker's AVL tree - pub total_debt: u64, - /// Already redeemed amount for this (owner, receiver) pair - pub already_redeemed: u64, - /// Whether this is the first redemption (no lookup proof needed for reserve tree) - pub is_first_redemption: bool, - /// Current blockchain height for transaction validity - pub current_height: u32, - /// Issuer's public key (33 bytes compressed) for reserve output R4 register - pub issuer_pubkey: Vec, - /// Value of the reserve box being spent (in nanoERG) - /// Used to calculate the remaining reserve after redemption and fee - pub reserve_box_value: u64, - /// Updated reserve AVL tree digest after insert operation (for R5 register) - /// This is the serialized AVL tree that includes the new redemption entry - pub updated_reserve_tree: Option>, - /// Refund initiation height from the spent reserve box's R7 register (0 if none) - pub reserve_refund_initiation_height: u64, - /// Wallet-owned fee input boxes (boxId only) used to pay the miner fee. - /// Each box must have an empty context extension. - pub fee_input_box_ids: Vec, - /// Total value provided by the fee input boxes. Must be >= fee. - pub fee_input_total_value: u64, - /// Optional change output for leftover fee-input funds. If `None`, no change - /// output is created (caller must ensure fee inputs exactly match fee). - pub change_address: Option, -} - -/// Builder for redemption transactions following the Basis contract specification -/// -/// This builder assembles all components needed for a redemption transaction: -/// - Validates redemption parameters (sufficient funds, time locks) -/// - Prepares transaction structure with proper inputs/outputs -/// - Ensures Schnorr signature compatibility (65-byte format) -/// - Estimates transaction size for fee calculation -pub struct RedemptionTransactionBuilder; - -impl RedemptionTransactionBuilder { - /// Build an unsigned Ergo redemption transaction with complete validation - /// - /// This function creates an unsigned Ergo transaction that follows the Basis contract specification: - /// - Validates all redemption parameters (sufficient collateral, time locks, signatures) - /// - Spends the reserve box - /// - Uses tracker box as data input for AVL proof verification - /// - Creates updated reserve box output - /// - Creates redemption output box for recipient - /// - Includes proper context extension with contract parameters - /// - Preserves R6 register with tracker NFT ID in output reserve box following byte_array_register_serialization.md spec - /// - /// # Parameters - /// - `reserve_box_id`: The reserve box ID being spent - /// - `tracker_box_id`: The tracker box ID used as data input - /// - `tracker_nft_id`: The tracker NFT ID from R6 register (hex-encoded serialized SColl(SByte) format following byte_array_register_serialization.md spec) - /// - `note`: The IOU note being redeemed - /// - `recipient_address`: Address where redeemed funds are sent - /// - `avl_proof`: AVL proof for the debt in tracker's AVL tree (for insert operation) - /// - `issuer_sig`: 65-byte Schnorr signature from issuer - /// - `tracker_sig`: 65-byte Schnorr signature from tracker - /// - `context`: Transaction context (fee, height, network) - /// - `reserve_box_value`: Value of the reserve box being spent (in nanoERG) - /// - `reserve_refund_initiation_height`: Refund initiation height from the reserve box's R7 register (0 if none) - /// - `reserve_lookup_proof`: Optional AVL proof for looking up already_redeemed in reserve tree (None for first redemption) - /// - `tracker_lookup_proof`: AVL proof for looking up totalDebt in tracker tree - /// - /// # Returns - /// - RedemptionTransactionData structure containing all transaction components - pub fn build_unsigned_redemption_transaction( - reserve_box_id: &str, - tracker_box_id: &str, - tracker_nft_id: &str, - note: &crate::IouNote, - recipient_address: &str, - avl_proof: &[u8], - issuer_sig: &[u8], - tracker_sig: &[u8], - issuer_pubkey: &crate::PubKey, - context: &TxContext, - reserve_box_value: u64, - reserve_refund_initiation_height: u64, - reserve_lookup_proof: Option>, - tracker_lookup_proof: Vec, - redemption_amount: u64, - ) -> Result { - // Validate all required transaction components - // Reserve box validation - if reserve_box_id.is_empty() { - return Err(TransactionBuilderError::Configuration( - "Reserve box ID is required".to_string(), - )); - } - - // Tracker box validation (required for AVL proof verification) - if tracker_box_id.is_empty() { - return Err(TransactionBuilderError::Configuration( - "Tracker box ID is required".to_string(), - )); - } - - // Tracker NFT ID validation (required for R6 register preservation) - if tracker_nft_id.is_empty() { - return Err(TransactionBuilderError::Configuration( - "Tracker NFT ID is required".to_string(), - )); - } - - // Validate the tracker NFT ID format according to byte_array_register_serialization.md spec - // The register should contain exactly 32 bytes for the tracker NFT ID - if let Err(_) = hex::decode(tracker_nft_id) { - return Err(TransactionBuilderError::Configuration( - "Tracker NFT ID must be valid hex-encoded bytes".to_string(), - )); - } - - let tracker_nft_bytes = hex::decode(tracker_nft_id).unwrap(); // Safe to unwrap due to above check - - // Validate that the tracker NFT ID is exactly 32 bytes - if tracker_nft_bytes.len() != 32 { - return Err(TransactionBuilderError::Configuration(format!( - "Tracker NFT ID must be exactly 32 bytes, got {} bytes", - tracker_nft_bytes.len() - ))); - } - - // Recipient address validation - if recipient_address.is_empty() { - return Err(TransactionBuilderError::Configuration( - "Recipient address is required".to_string(), - )); - } - - // AVL proof validation (proves debt exists in tracker state) - if avl_proof.is_empty() { - return Err(TransactionBuilderError::Configuration( - "AVL proof is required".to_string(), - )); - } - - // Schnorr signature validation (must be 65 bytes: 33-byte a + 32-byte z) - if issuer_sig.len() != 65 { - return Err(TransactionBuilderError::Configuration( - "Issuer signature must be 65 bytes".to_string(), - )); - } - - if tracker_sig.len() != 65 { - return Err(TransactionBuilderError::Configuration( - "Tracker signature must be 65 bytes".to_string(), - )); - } - - // Validate redemption amount - if redemption_amount == 0 { - return Err(TransactionBuilderError::Configuration( - "Redemption amount must be greater than 0".to_string(), - )); - } - if redemption_amount > note.outstanding_debt() { - return Err(TransactionBuilderError::InsufficientFunds(format!( - "Redemption amount {} exceeds outstanding debt {}", - redemption_amount, - note.outstanding_debt() - ))); - } - - // Validate reserve box value - if reserve_box_value == 0 { - return Err(TransactionBuilderError::Configuration( - "Reserve box value must be greater than 0".to_string(), - )); - } - - // Check if reserve has sufficient collateral for redemption + fee - let total_required = redemption_amount.saturating_add(context.fee); - if reserve_box_value < total_required { - return Err(TransactionBuilderError::InsufficientFunds(format!( - "Reserve box value {} is insufficient for redemption amount {} + fee {}", - reserve_box_value, redemption_amount, context.fee - ))); - } - - // Note: Time lock enforcement is handled by the contract, not the transaction builder. - // Emergency redemption is available after 3 days (3*720 blocks) from tracker creation height. - // The contract checks: (HEIGHT - trackerCreationHeight) > 3 * 720 - // Normal redemption requires both owner and tracker signatures. - // Emergency redemption bypasses tracker signature verification after the time lock. - - // Decode recipient public key for context extension - let recipient_pubkey_bytes = - hex::decode(¬e.recipient_pubkey_hex()).unwrap_or_else(|_| vec![0u8; 33]); - - // Build context extension variables (following specs/server/redemption_transaction_format_spec.md) - // Note: For first redemption, reserve_lookup_proof (#7) is omitted - // Check if this is the first redemption by checking already_redeemed amount - let already_redeemed = note.amount_redeemed; - let is_first_redemption = already_redeemed == 0; - let total_debt = note.amount_collected; // Total debt from tracker's AVL tree - - // Use the reserve_lookup_proof passed as parameter - // This should be None for first redemption, Some(proof) for subsequent redemptions - let reserve_lookup_proof_to_use = reserve_lookup_proof; - - let context_extension = ContextExtension { - action: 0x00, // Redemption action - receiver_pubkey: recipient_pubkey_bytes, - reserve_signature: issuer_sig.to_vec(), - total_debt, - insert_proof: avl_proof.to_vec(), - tracker_signature: tracker_sig.to_vec(), - reserve_lookup_proof: reserve_lookup_proof_to_use, - tracker_lookup_proof, // Use actual tracker tree lookup proof from parameter - }; - - // Create transaction data structure with all components - Ok(RedemptionTransactionData { - reserve_box_id: reserve_box_id.to_string(), - tracker_box_id: tracker_box_id.to_string(), - redemption_amount, - recipient_address: recipient_address.to_string(), - avl_proof: avl_proof.to_vec(), - issuer_signature: issuer_sig.to_vec(), - tracker_signature: tracker_sig.to_vec(), - fee: context.fee, - tracker_nft_id: tracker_nft_id.to_string(), - context_extension: Some(context_extension), - total_debt, - already_redeemed, - is_first_redemption, - current_height: context.current_height, - issuer_pubkey: issuer_pubkey.to_vec(), - reserve_box_value, - reserve_refund_initiation_height, - updated_reserve_tree: None, // Will be set by caller after generating insert proof - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - change_address: None, - }) - } - - /// Build a real Ergo redemption transaction - /// - /// This function creates an actual Ergo transaction JSON that follows the Basis contract specification: - /// - Spends the reserve box - /// - Uses tracker box as data input for AVL proof verification - /// - Creates updated reserve box output - /// - Creates redemption output box for recipient - /// - Includes proper context extension with contract parameters - /// - Preserves R6 register with tracker NFT ID in output reserve box - /// - /// The returned JSON follows the Ergo node `/wallet/transaction/sign` API format. - /// - /// # Parameters - /// - `tx_data`: Complete redemption transaction data including context extension - /// - /// # Returns - /// - JSON bytes representing the unsigned transaction ready for Ergo node signing - pub fn build_redemption_transaction( - tx_data: &RedemptionTransactionData, - ) -> Result, TransactionBuilderError> { - // Build proper Ergo transaction JSON following node API format - let tx_json = Self::build_ergo_transaction_json(tx_data)?; - Ok(tx_json.into_bytes()) - } - - /// Serialize a byte value as Ergo constant (prefix 02) - fn serialize_ergo_byte(value: u8) -> String { - format!("02{:02x}", value) - } - - /// Serialize a long value as Ergo `Long` constant (prefix 0x05 + zigzag VLQ). - fn serialize_ergo_long(value: i64) -> String { - let zigzag = ((value << 1) ^ (value >> 63)) as u64; - let mut vlq = Vec::new(); - let mut n = zigzag; - loop { - let mut byte = (n & 0x7f) as u8; - n >>= 7; - if n != 0 { - byte |= 0x80; - } - vlq.push(byte); - if n == 0 { - break; - } - } - format!("05{}", hex::encode(vlq)) - } - - /// Serialize bytes as Coll[Byte] constant (prefix 0x0e + 1-byte length + data) - fn serialize_ergo_coll_bytes(data: &[u8]) -> String { - assert!( - data.len() <= 255, - "Coll[Byte] length {} exceeds 255-byte prefix", - data.len() - ); - format!("0e{:02x}{}", data.len(), hex::encode(data)) - } - - /// Serialize a GroupElement (33-byte compressed pubkey) as Ergo constant (prefix 07) - fn serialize_ergo_group_element(pubkey: &[u8]) -> String { - format!("07{}", hex::encode(pubkey)) - } - - /// Build Ergo transaction JSON for redemption - fn build_ergo_transaction_json( - tx_data: &RedemptionTransactionData, - ) -> Result { - let ctx = tx_data.context_extension.as_ref().ok_or_else(|| { - TransactionBuilderError::TransactionBuilding( - "Context extension is required".to_string(), - ) - })?; - - // Build context extension map with properly serialized Ergo constants - let mut extension = std::collections::HashMap::new(); - - // #0: Action byte (Byte constant) - extension.insert("0".to_string(), Self::serialize_ergo_byte(ctx.action)); - - // #1: Receiver pubkey (GroupElement constant) - extension.insert( - "1".to_string(), - Self::serialize_ergo_group_element(&ctx.receiver_pubkey), - ); - - // #2: Reserve signature (Coll[Byte] constant, 65 bytes) - extension.insert( - "2".to_string(), - Self::serialize_ergo_coll_bytes(&ctx.reserve_signature), - ); - - // #3: Total debt (Long constant) - extension.insert( - "3".to_string(), - Self::serialize_ergo_long(ctx.total_debt as i64), - ); - - // #5: Insert proof (Coll[Byte] constant) - extension.insert( - "5".to_string(), - Self::serialize_ergo_coll_bytes(&ctx.insert_proof), - ); - - // #6: Tracker signature (Coll[Byte] constant, 65 bytes) - extension.insert( - "6".to_string(), - Self::serialize_ergo_coll_bytes(&ctx.tracker_signature), - ); - - // #7: Reserve lookup proof (optional, Coll[Byte] constant) - if let Some(ref proof) = ctx.reserve_lookup_proof { - extension.insert("7".to_string(), Self::serialize_ergo_coll_bytes(proof)); - } - - // #8: Tracker lookup proof (Coll[Byte] constant) - extension.insert( - "8".to_string(), - Self::serialize_ergo_coll_bytes(&ctx.tracker_lookup_proof), - ); - - // Build transaction JSON following Ergo node API format - let recipient_ergo_tree = format!("0008cd{}", hex::encode(&ctx.receiver_pubkey)); - - // Get the reserve contract ErgoTree (P2S) for the reserve output - let reserve_ergo_tree = crate::contract_compiler::get_basis_reserve_ergo_tree_hex() - .map_err(|e| { - TransactionBuilderError::TransactionBuilding(format!( - "Failed to get reserve contract: {}", - e - )) - })?; - - // Reserve NFT ID from the transaction data (from reserve box R6) - let reserve_nft_id = &tx_data.tracker_nft_id; - - // The reserve output keeps the full original collateral minus the redeemed amount. - // The miner fee is paid by explicit fee inputs, not by reducing the reserve output. - let reserve_remaining = tx_data - .reserve_box_value - .saturating_sub(tx_data.redemption_amount); - - if reserve_remaining == 0 { - return Err(TransactionBuilderError::InsufficientFunds( - "Reserve output value would be zero after redemption".to_string(), - )); - } - - // Validate fee inputs cover the required fee. - if tx_data.fee_input_total_value < tx_data.fee { - return Err(TransactionBuilderError::InsufficientFunds(format!( - "Fee inputs total {} is less than required fee {}", - tx_data.fee_input_total_value, tx_data.fee - ))); - } - - let change_amount = tx_data.fee_input_total_value.saturating_sub(tx_data.fee); - - // Build inputs array: reserve input first, followed by fee inputs with empty extensions. - let mut inputs = vec![serde_json::json!({ - "boxId": tx_data.reserve_box_id, - "extension": extension - })]; - for fee_box_id in &tx_data.fee_input_box_ids { - inputs.push(serde_json::json!({ - "boxId": fee_box_id, - "extension": serde_json::json!({}) - })); - } - - // Build outputs array: reserve output (index 0), recipient output (index 1), - // fee recipient output (index 2), optional change output last. - let mut outputs = vec![ - serde_json::json!({ - "value": reserve_remaining, - "ergoTree": reserve_ergo_tree, - "assets": [ - { - "tokenId": reserve_nft_id, - "amount": 1 - } - ], - "additionalRegisters": { - "R4": format!("07{}", hex::encode(&tx_data.issuer_pubkey)), - "R5": match &tx_data.updated_reserve_tree { - Some(tree_bytes) => format!("0e{:04x}{}", tree_bytes.len(), hex::encode(tree_bytes)), - None => "64000000000000000000000000000000000000000000000000000000000000000000032000".to_string(), - }, - "R6": format!("0e20{}", tx_data.tracker_nft_id), - "R7": Self::serialize_ergo_long(tx_data.reserve_refund_initiation_height as i64) - }, - "creationHeight": tx_data.current_height - }), - serde_json::json!({ - "value": tx_data.redemption_amount, - "ergoTree": recipient_ergo_tree, - "assets": [], - "additionalRegisters": {}, - "creationHeight": tx_data.current_height - }), - serde_json::json!({ - "value": tx_data.fee, - // Fee recipient contract from the Scala reference implementation. - // It allows anyone to spend the box (used by the miner). - "ergoTree": "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a701730073011001020402d19683030193a38cc7b2a57300000193c2b2a57301007473027303830108cdeeac93b1a57304", - "assets": [], - "additionalRegisters": {}, - "creationHeight": tx_data.current_height - }), - ]; - - if change_amount > 0 { - let change_address = tx_data.change_address.as_ref().ok_or_else(|| { - TransactionBuilderError::Configuration( - "Change address required when fee inputs exceed fee".to_string(), - ) - })?; - let change_ergo_tree = address_to_ergo_tree(change_address).map_err(|e| { - TransactionBuilderError::TransactionBuilding(format!( - "Invalid change address: {}", - e - )) - })?; - outputs.push(serde_json::json!({ - "value": change_amount, - "ergoTree": change_ergo_tree, - "assets": [], - "additionalRegisters": {}, - "creationHeight": tx_data.current_height - })); - } - - let tx = serde_json::json!({ - "tx": { - "inputs": inputs, - "dataInputs": [ - { - "boxId": tx_data.tracker_box_id - } - ], - "outputs": outputs - } - }); - - serde_json::to_string_pretty(&tx).map_err(|e| { - TransactionBuilderError::TransactionBuilding(format!( - "JSON serialization failed: {}", - e - )) - }) - } -} - -/// Convert an Ergo address (P2PK or P2S) to its hex-encoded ergoTree bytes. -fn address_to_ergo_tree(address_str: &str) -> Result { - use ergo_lib::ergotree_ir::chain::address::{AddressEncoder, NetworkPrefix}; - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - - let encoder = AddressEncoder::new(NetworkPrefix::Mainnet); - let address = encoder.parse_address_from_str(address_str).map_err(|e| { - TransactionBuilderError::Configuration(format!("Invalid address '{}': {}", address_str, e)) - })?; - let tree = address.script().map_err(|e| { - TransactionBuilderError::Configuration(format!( - "Failed to get script for address '{}': {}", - address_str, e - )) - })?; - Ok(hex::encode(tree.sigma_serialize_bytes().map_err(|e| { - TransactionBuilderError::Configuration(format!("Failed to serialize ergoTree: {:?}", e)) - })?)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::schnorr::generate_keypair; - - #[test] - fn test_transaction_context() { - let context = TxContext { - current_height: 1000, - fee: 2000000, // 0.002 ERG - change_address: "test_change_address".to_string(), - network_prefix: 16, // testnet - }; - - assert_eq!(context.current_height, 1000); - assert_eq!(context.fee, 2000000); - assert_eq!(context.network_prefix, 16); - - let default_context = TxContext::default(); - assert_eq!(default_context.fee, 1000000); - assert_eq!(default_context.network_prefix, 0); - } - - #[test] - fn test_real_transaction_building() { - // Create a complete transaction data structure - let tx_data = RedemptionTransactionData { - reserve_box_id: "test_reserve_box_1234567890abcdef".to_string(), - tracker_box_id: "test_tracker_box_abcdef1234567890".to_string(), - redemption_amount: 100000000, // 0.1 ERG - recipient_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - avl_proof: vec![0x01, 0x02, 0x03], - issuer_signature: vec![0u8; 65], - tracker_signature: vec![0u8; 65], - fee: 1000000, // 0.001 ERG fee - tracker_nft_id: "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304" - .to_string(), - context_extension: Some(ContextExtension { - action: 0x00, - receiver_pubkey: vec![0x03; 33], - reserve_signature: vec![0u8; 65], - total_debt: 100000000, - insert_proof: vec![0x01, 0x02], - tracker_signature: vec![0u8; 65], - reserve_lookup_proof: None, - tracker_lookup_proof: vec![0x03, 0x04], - }), - total_debt: 100000000, - already_redeemed: 0, - is_first_redemption: true, - current_height: 1779469, - issuer_pubkey: vec![0x02; 33], - reserve_box_value: 200000000, // 0.2 ERG reserve box value - updated_reserve_tree: None, - reserve_refund_initiation_height: 0, - fee_input_box_ids: vec!["test_fee_input_1234567890abcdef".to_string()], - fee_input_total_value: 1000000, - change_address: None, - }; - - let result = RedemptionTransactionBuilder::build_redemption_transaction(&tx_data); - - assert!(result.is_ok()); - let tx_bytes = result.unwrap(); - assert!(!tx_bytes.is_empty()); - - // Verify the transaction is valid JSON with expected structure - let tx_json: serde_json::Value = - serde_json::from_slice(&tx_bytes).expect("Should be valid JSON"); - assert!(tx_json.get("tx").is_some()); - assert!(tx_json["tx"].get("inputs").is_some()); - assert!(tx_json["tx"].get("dataInputs").is_some()); - assert!(tx_json["tx"].get("outputs").is_some()); - - // Verify inputs contain reserve box and one fee input - let inputs = tx_json["tx"]["inputs"].as_array().unwrap(); - assert_eq!(inputs.len(), 2); - assert_eq!(inputs[0]["boxId"], "test_reserve_box_1234567890abcdef"); - assert_eq!(inputs[1]["boxId"], "test_fee_input_1234567890abcdef"); - - // Verify context extension contains action byte - let extension = inputs[0]["extension"].as_object().unwrap(); - assert!(extension.contains_key("0")); - assert_eq!(extension["0"], "0200"); - - // Verify data inputs contain tracker box - let data_inputs = tx_json["tx"]["dataInputs"].as_array().unwrap(); - assert_eq!(data_inputs.len(), 1); - assert_eq!(data_inputs[0]["boxId"], "test_tracker_box_abcdef1234567890"); - } - - #[test] - fn test_transaction_building_with_different_amounts() { - // Test various redemption amounts following chaincash-rs comprehensive testing pattern - let test_cases = vec![ - (1000000, "small amount"), // 0.001 ERG - (10000000, "medium amount"), // 0.01 ERG - (100000000, "large amount"), // 0.1 ERG - (1000000000, "very large amount"), // 1 ERG - ]; - - for (amount, description) in test_cases { - let tx_data = RedemptionTransactionData { - reserve_box_id: "test_reserve_box_1234567890abcdef".to_string(), - tracker_box_id: "test_tracker_box_abcdef1234567890".to_string(), - redemption_amount: amount, - recipient_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33" - .to_string(), - avl_proof: vec![0x01], - issuer_signature: vec![0u8; 65], - tracker_signature: vec![0u8; 65], - fee: 1000000, - tracker_nft_id: "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304" - .to_string(), - context_extension: Some(ContextExtension { - action: 0x00, - receiver_pubkey: vec![0x03; 33], - reserve_signature: vec![0u8; 65], - total_debt: amount, - insert_proof: vec![0x01], - tracker_signature: vec![0u8; 65], - reserve_lookup_proof: None, - tracker_lookup_proof: vec![0x02], - }), - total_debt: amount, - already_redeemed: 0, - is_first_redemption: true, - current_height: 1779469, - issuer_pubkey: vec![0x02; 33], - reserve_box_value: amount + 100000000, // Reserve must cover redemption + some buffer - updated_reserve_tree: None, - reserve_refund_initiation_height: 0, - fee_input_box_ids: vec!["test_fee_input_1234567890abcdef".to_string()], - fee_input_total_value: 1000000, - change_address: None, - }; - - let result = RedemptionTransactionBuilder::build_redemption_transaction(&tx_data); - - assert!( - result.is_ok(), - "Failed to build transaction for {}: {:?}", - description, - result.err() - ); - let tx_bytes = result.unwrap(); - assert!( - !tx_bytes.is_empty(), - "Transaction bytes empty for {}", - description - ); - - let tx_json: serde_json::Value = - serde_json::from_slice(&tx_bytes).expect("Should be valid JSON"); - assert!( - tx_json.get("tx").is_some(), - "Transaction JSON missing 'tx' key for {}", - description - ); - } - } - - #[test] - fn test_transaction_building_with_different_fees() { - // Test various fee amounts following chaincash-rs comprehensive testing pattern - let test_cases = vec![ - (500000, "low fee"), // 0.0005 ERG - (1000000, "standard fee"), // 0.001 ERG - (2000000, "high fee"), // 0.002 ERG - ]; - - for (fee, description) in test_cases { - let tx_data = RedemptionTransactionData { - reserve_box_id: "test_reserve_box_1234567890abcdef".to_string(), - tracker_box_id: "test_tracker_box_abcdef1234567890".to_string(), - redemption_amount: 100000000, - recipient_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33" - .to_string(), - avl_proof: vec![0x01], - issuer_signature: vec![0u8; 65], - tracker_signature: vec![0u8; 65], - fee, - tracker_nft_id: "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304" - .to_string(), - context_extension: Some(ContextExtension { - action: 0x00, - receiver_pubkey: vec![0x03; 33], - reserve_signature: vec![0u8; 65], - total_debt: 100000000, - insert_proof: vec![0x01], - tracker_signature: vec![0u8; 65], - reserve_lookup_proof: None, - tracker_lookup_proof: vec![0x02], - }), - total_debt: 100000000, - already_redeemed: 0, - is_first_redemption: true, - current_height: 1779469, - issuer_pubkey: vec![0x02; 33], - reserve_box_value: 200000000, // 0.2 ERG reserve box value - updated_reserve_tree: None, - reserve_refund_initiation_height: 0, - fee_input_box_ids: vec!["test_fee_input_1234567890abcdef".to_string()], - fee_input_total_value: fee, - change_address: Some( - "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - ), - }; - - let result = RedemptionTransactionBuilder::build_redemption_transaction(&tx_data); - - assert!( - result.is_ok(), - "Failed to build transaction with {}: {:?}", - description, - result.err() - ); - let tx_bytes = result.unwrap(); - assert!( - !tx_bytes.is_empty(), - "Transaction bytes empty with {}", - description - ); - - let tx_json: serde_json::Value = - serde_json::from_slice(&tx_bytes).expect("Should be valid JSON"); - assert!( - tx_json.get("tx").is_some(), - "Transaction JSON missing 'tx' key with {}", - description - ); - } - } - - #[test] - fn test_transaction_building_error_conditions() { - // Test error conditions following chaincash-rs error testing pattern - - // Test with missing context extension - let tx_data = RedemptionTransactionData { - reserve_box_id: "test_reserve_box_1234567890abcdef".to_string(), - tracker_box_id: "test_tracker_box_abcdef1234567890".to_string(), - redemption_amount: 100000000, - recipient_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - avl_proof: vec![0x01], - issuer_signature: vec![0u8; 65], - tracker_signature: vec![0u8; 65], - fee: 1000000, - tracker_nft_id: "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304" - .to_string(), - context_extension: None, // Missing context extension should fail - total_debt: 100000000, - already_redeemed: 0, - is_first_redemption: true, - current_height: 1779469, - issuer_pubkey: vec![0x02; 33], - reserve_box_value: 200000000, // 0.2 ERG reserve box value - updated_reserve_tree: None, - reserve_refund_initiation_height: 0, - fee_input_box_ids: vec!["test_fee_input_1234567890abcdef".to_string()], - fee_input_total_value: 1000000, - change_address: None, - }; - - let result = RedemptionTransactionBuilder::build_redemption_transaction(&tx_data); - assert!(result.is_err(), "Should fail without context extension"); - - // Test with empty reserve box ID (should still build JSON, just with empty string) - let tx_data = RedemptionTransactionData { - reserve_box_id: "".to_string(), - tracker_box_id: "test_tracker_box_abcdef1234567890".to_string(), - redemption_amount: 100000000, - recipient_address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33".to_string(), - avl_proof: vec![0x01], - issuer_signature: vec![0u8; 65], - tracker_signature: vec![0u8; 65], - fee: 1000000, - tracker_nft_id: "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304" - .to_string(), - context_extension: Some(ContextExtension { - action: 0x00, - receiver_pubkey: vec![0x03; 33], - reserve_signature: vec![0u8; 65], - total_debt: 100000000, - insert_proof: vec![0x01], - tracker_signature: vec![0u8; 65], - reserve_lookup_proof: None, - tracker_lookup_proof: vec![0x02], - }), - total_debt: 100000000, - already_redeemed: 0, - is_first_redemption: true, - current_height: 1779469, - issuer_pubkey: vec![0x02; 33], - reserve_box_value: 200000000, // 0.2 ERG reserve box value - updated_reserve_tree: None, - reserve_refund_initiation_height: 0, - fee_input_box_ids: vec!["test_fee_input_1234567890abcdef".to_string()], - fee_input_total_value: 1000000, - change_address: None, - }; - - let result = RedemptionTransactionBuilder::build_redemption_transaction(&tx_data); - assert!( - result.is_ok(), - "Should build even with empty reserve box ID" - ); - } - - #[test] - fn test_transaction_building_with_test_helpers() { - // Test using test helper functions following chaincash-rs pattern - use crate::test_helpers::{ - create_test_recipient_address, create_test_reserve_box_id, create_test_tracker_box_id, - }; - - let tx_data = RedemptionTransactionData { - reserve_box_id: create_test_reserve_box_id(), - tracker_box_id: create_test_tracker_box_id(), - redemption_amount: 100000000, // 0.1 ERG - recipient_address: create_test_recipient_address(), - avl_proof: vec![0x01], - issuer_signature: vec![0u8; 65], - tracker_signature: vec![0u8; 65], - fee: 1000000, // 0.001 ERG fee - tracker_nft_id: "1af23d4e5f6a7b8c9daebfc0d1e2f30415263748596a7b8c9daebfc0d1e2f304" - .to_string(), - context_extension: Some(ContextExtension { - action: 0x00, - receiver_pubkey: vec![0x03; 33], - reserve_signature: vec![0u8; 65], - total_debt: 100000000, - insert_proof: vec![0x01], - tracker_signature: vec![0u8; 65], - reserve_lookup_proof: None, - tracker_lookup_proof: vec![0x02], - }), - total_debt: 100000000, - already_redeemed: 0, - is_first_redemption: true, - current_height: 1779469, - issuer_pubkey: vec![0x02; 33], - reserve_box_value: 200000000, // 0.2 ERG reserve box value - updated_reserve_tree: None, - reserve_refund_initiation_height: 0, - fee_input_box_ids: vec!["test_fee_input_1234567890abcdef".to_string()], - fee_input_total_value: 1000000, - change_address: None, - }; - - let result = RedemptionTransactionBuilder::build_redemption_transaction(&tx_data); - - assert!(result.is_ok()); - let tx_bytes = result.unwrap(); - assert!(!tx_bytes.is_empty()); - - let tx_json: serde_json::Value = - serde_json::from_slice(&tx_bytes).expect("Should be valid JSON"); - assert!(tx_json.get("tx").is_some()); - assert!(tx_json["tx"]["inputs"][0]["boxId"] - .as_str() - .unwrap() - .contains("e56847ed")); - assert!(tx_json["tx"]["dataInputs"][0]["boxId"] - .as_str() - .unwrap() - .contains("f67858fe")); - } -} diff --git a/crates/basis_store/tests/fix_note_state.rs b/crates/basis_store/tests/fix_note_state.rs deleted file mode 100644 index 54f46eb..0000000 --- a/crates/basis_store/tests/fix_note_state.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! One-off repair: reset the Alice->Bob note to the state matching the on-chain -//! reserve box ef912b0c (cumulative redeemed = 0.1 ERG, payment timestamp -//! 1783612740170) so that a single `/redeem/complete` call re-syncs the -//! in-memory reserve AVL tree with the correct payment timestamp. -//! -//! Run with: -//! cargo test -p basis_store --test fix_note_state -- --ignored --nocapture - -use basis_store::persistence::NoteStorage; -use basis_store::PubKey; - -const NOTES_PATH: &str = "/home/kushti/chaincash/basis-tracker/crates/basis_server/data/notes"; -const ISSUER_HEX: &str = "0377709166937fcdc08bf7e841b31684e2377f489914c97ef7148de14d9c6e1f83"; -const RECIPIENT_HEX: &str = "03af13e39dd0ccc7429f9dfa5a056b71a8f5160eaf179763a03e0b55d8feec2cea"; -const ONCHAIN_PAYMENT_TIMESTAMP: u64 = 1783612740170; - -#[test] -#[ignore = "one-off manual repair of persisted note state"] -fn reset_alice_bob_note_to_onchain_state() { - let storage = NoteStorage::open(NOTES_PATH).expect("open notes storage"); - - let rebuilt = storage.rebuild_indices().expect("rebuild indices"); - println!("rebuilt indices: {} notes", rebuilt); - - let all = storage.get_all_notes_with_issuer().expect("list notes"); - println!("notes in storage: {}", all.len()); - for (iss, n) in &all { - println!( - " issuer={} recipient={} redeemed={} ts={}", - hex::encode(iss), - hex::encode(n.recipient_pubkey), - n.amount_redeemed, - n.timestamp - ); - } - - let issuer: PubKey = hex::decode(ISSUER_HEX).unwrap().try_into().unwrap(); - let recipient: PubKey = hex::decode(RECIPIENT_HEX).unwrap().try_into().unwrap(); - - let mut note = storage - .get_note(&issuer, &recipient) - .expect("get note") - .expect("note must exist"); - - println!( - "before: amount_redeemed={} timestamp={}", - note.amount_redeemed, note.timestamp - ); - - note.amount_redeemed = 0; - note.timestamp = ONCHAIN_PAYMENT_TIMESTAMP; - - storage - .store_note(&issuer, ¬e) - .expect("store repaired note"); - - let check = storage - .get_note(&issuer, &recipient) - .expect("get note") - .expect("note must exist"); - println!( - "after: amount_redeemed={} timestamp={}", - check.amount_redeemed, check.timestamp - ); - assert_eq!(check.amount_redeemed, 0); - assert_eq!(check.timestamp, ONCHAIN_PAYMENT_TIMESTAMP); -} diff --git a/crates/basis_store/tests/fixtures/basis_v2_context_abi.json b/crates/basis_store/tests/fixtures/basis_v2_context_abi.json new file mode 100644 index 0000000..e44ac5a --- /dev/null +++ b/crates/basis_store/tests/fixtures/basis_v2_context_abi.json @@ -0,0 +1,14 @@ +{ + "source_repository": "BetterMoneyLabs/chaincash", + "source_commit": "9a274396d5f78f7be5ed76bacee5329c42570317", + "normal": { + "context_ids": [0, 1, 2, 3, 4, 5, 6, 7, 8], + "scala_serialization_order": [0, 5, 1, 6, 2, 7, 3, 8, 4], + "tracker_data_inputs": 1 + }, + "emergency": { + "context_ids": [0, 1, 2, 3, 4, 5, 7], + "scala_serialization_order": [0, 5, 1, 2, 7, 3, 4], + "tracker_data_inputs": 0 + } +} diff --git a/crates/basis_store/tests/legacy_global_surface_guard.rs b/crates/basis_store/tests/legacy_global_surface_guard.rs new file mode 100644 index 0000000..01977a4 --- /dev/null +++ b/crates/basis_store/tests/legacy_global_surface_guard.rs @@ -0,0 +1,24 @@ +#[test] +fn tracker_state_manager_has_no_legacy_global_proof_or_reserve_surface() { + let source = include_str!("../src/lib.rs"); + let forbidden = [ + "reserve_avl_state", + "pub struct NoteProof", + "pub struct TrackerLookupProof", + "pub struct ReserveLookupProof", + "pub fn generate_proof(", + "pub fn generate_tracker_lookup_proof(", + "pub fn get_already_redeemed(", + "pub fn generate_reserve_lookup_proof(", + "pub fn generate_reserve_insert_proof(", + "pub fn reserve_state_digest(", + "pub fn update_already_redeemed(", + ]; + + for needle in forbidden { + assert!( + !source.contains(needle), + "legacy global v1 surface remains in TrackerStateManager: {needle}" + ); + } +} diff --git a/crates/basis_trees/examples/malformed_proof_abort_harness.rs b/crates/basis_trees/examples/malformed_proof_abort_harness.rs new file mode 100644 index 0000000..0b00142 --- /dev/null +++ b/crates/basis_trees/examples/malformed_proof_abort_harness.rs @@ -0,0 +1,91 @@ +use bytes::Bytes; +use ergo_avltree_rust::{ + authenticated_tree_ops::AuthenticatedTreeOps, + batch_avl_prover::BatchAVLProver, + batch_avl_verifier::BatchAVLVerifier, + batch_node::{AVLTree, LeafNode, Node, NodeHeader}, + operation::{ADKey, ADValue, KeyValue, Operation}, +}; + +const KEY_LENGTH: usize = 32; +const VALUE_LENGTH: usize = 24; + +fn resolver(_digest: &[u8; 32]) -> Node { + Node::Leaf(LeafNode { + hdr: NodeHeader { + visited: false, + is_new: false, + label: None, + key: Some(ADKey::from(vec![0; KEY_LENGTH])), + }, + value: ADValue::from(vec![]), + next_node_key: ADKey::from(vec![0; KEY_LENGTH]), + }) +} + +fn tree() -> AVLTree { + AVLTree::new(resolver, KEY_LENGTH, Some(VALUE_LENGTH)) +} + +fn insert(key: u8, value: u8) -> Operation { + Operation::Insert(KeyValue { + key: vec![key; KEY_LENGTH].into(), + value: vec![value; VALUE_LENGTH].into(), + }) +} + +fn valid_lookup_case() -> (Bytes, Bytes, ADKey) { + let mut prover = BatchAVLProver::new(tree(), true); + prover.perform_one_operation(&insert(1, 11)).unwrap(); + prover.perform_one_operation(&insert(3, 33)).unwrap(); + let _ = prover.generate_proof(); + let digest = prover.digest().unwrap(); + let key = ADKey::from(vec![1; KEY_LENGTH]); + prover + .perform_one_operation(&Operation::Lookup(key.clone())) + .unwrap(); + let proof = prover.generate_proof(); + (digest, proof, key) +} + +fn exercise(digest: &Bytes, proof: Bytes, key: &ADKey) { + if let Ok(mut verifier) = BatchAVLVerifier::new(digest, &proof, tree(), Some(1), Some(0)) { + let _ = verifier.perform_one_operation(&Operation::Lookup(key.clone())); + } +} + +fn main() { + let arbitrary_digest = Bytes::from(vec![0; 33]); + let malformed = [ + vec![], + vec![3], + vec![3, 0], + vec![2], + vec![2, 0, 0, 0], + vec![0, 4], + vec![2; 128], + ]; + for proof in malformed { + assert!(BatchAVLVerifier::new( + &arbitrary_digest, + &Bytes::from(proof), + tree(), + Some(1), + Some(0), + ) + .is_err()); + } + + let (digest, valid_proof, key) = valid_lookup_case(); + assert!(valid_proof.len() > 1); + for cut in 0..valid_proof.len() { + exercise(&digest, valid_proof.slice(..cut), &key); + } + for offset in 0..valid_proof.len() { + for bit in 0..8 { + let mut mutated = valid_proof.to_vec(); + mutated[offset] ^= 1 << bit; + exercise(&digest, Bytes::from(mutated), &key); + } + } +} diff --git a/crates/basis_trees/src/avl_tree.rs b/crates/basis_trees/src/avl_tree.rs index c5c5f1b..d1b9db0 100644 --- a/crates/basis_trees/src/avl_tree.rs +++ b/crates/basis_trees/src/avl_tree.rs @@ -19,6 +19,8 @@ pub struct BasisAvlTree { /// In-memory cache for key-value lookups /// This mirrors the AVL tree state for efficient get() operations cache: HashMap, Vec>, + /// Stable first-insertion order used whenever the prover must be rebuilt. + insertion_order: Vec>, } // Simple resolver function for AVL tree @@ -54,11 +56,13 @@ impl BasisAvlTree { prover, current_state, cache: HashMap::new(), + insertion_order: Vec::new(), }) } /// Insert a key-value pair into the AVL tree pub fn insert(&mut self, key: Vec, value: Vec) -> Result<(), TreeError> { + let is_new_key = !self.cache.contains_key(&key); let operation = Operation::Insert(KeyValue { key: key.clone().into(), value: value.clone().into(), @@ -72,6 +76,9 @@ impl BasisAvlTree { // Update cache self.cache.insert(key.clone(), value.clone()); + if is_new_key { + self.insertion_order.push(key.clone()); + } // Update state self.update_state(); @@ -81,6 +88,7 @@ impl BasisAvlTree { /// Update an existing key-value pair (or insert if key doesn't exist) pub fn update(&mut self, key: Vec, value: Vec) -> Result<(), TreeError> { + let is_new_key = !self.cache.contains_key(&key); // Try update first, and if it fails (e.g., key doesn't exist), try insert let update_op = Operation::Update(KeyValue { key: key.clone().into(), @@ -91,6 +99,9 @@ impl BasisAvlTree { Ok(_) => { // Update cache self.cache.insert(key.clone(), value.clone()); + if is_new_key { + self.insertion_order.push(key.clone()); + } self.update_state(); Ok(()) } @@ -107,12 +118,32 @@ impl BasisAvlTree { // Update cache self.cache.insert(key.clone(), value.clone()); + if is_new_key { + self.insertion_order.push(key.clone()); + } self.update_state(); Ok(()) } } } + /// Rebuild an independent prover with the exact first-insertion order. + /// + /// `AVLTree::clone()` is shallow, so callers that need an isolated candidate + /// must use this method instead of cloning the underlying prover. + pub fn try_clone(&self) -> Result { + let mut cloned = Self::new()?; + for key in &self.insertion_order { + let value = self.cache.get(key).ok_or_else(|| { + TreeError::StorageError( + "AVL insertion order references a missing cached value".to_string(), + ) + })?; + cloned.insert(key.clone(), value.clone())?; + } + Ok(cloned) + } + /// Generate a proof for a lookup operation on the given key. /// /// This performs a `Lookup` operation and returns the proof bytes that @@ -179,20 +210,10 @@ impl BasisAvlTree { key: Vec, value: Vec, ) -> Result<(Vec, [u8; 33]), TreeError> { - // Rebuild a temporary tree from the in-memory cache. `AVLTree::clone()` is shallow - // (`Rc>`), so operating on a clone would mutate the persistent tree's - // shared nodes and corrupt its state. - let mut temp_prover = BatchAVLProver::new(AVLTree::new(tree_resolver, 32, None), true); - for (cached_key, cached_value) in &self.cache { - temp_prover - .perform_one_operation(&Operation::Insert(KeyValue { - key: cached_key.clone().into(), - value: cached_value.clone().into(), - })) - .map_err(|e| { - TreeError::StorageError(format!("AVL tree rebuild failed: {:?}", e)) - })?; - } + // Rebuild from the stable first-insertion order. Iterating the HashMap + // directly would produce a different root for the same live key set. + let cloned = self.try_clone()?; + let mut temp_prover = cloned.prover; // Commit the rebuild so the proof below covers only the single insert-or-update // operation against the committed starting digest. let _ = temp_prover.generate_proof(); @@ -257,6 +278,49 @@ impl BasisAvlTree { mod tests { use super::*; + #[test] + fn isolated_clone_rebuilds_final_values_in_first_insertion_order() { + let mut original = BasisAvlTree::new().unwrap(); + let key_a = vec![1u8; 32]; + let key_b = vec![2u8; 32]; + + original.insert(key_a.clone(), vec![10u8; 8]).unwrap(); + original.insert(key_b.clone(), vec![20u8; 8]).unwrap(); + original.update(key_a.clone(), vec![30u8; 8]).unwrap(); + + let original_digest = original.root_digest(); + let mut cloned = original.try_clone().unwrap(); + assert_eq!(cloned.root_digest(), original_digest); + assert_eq!(cloned.get(&key_a), Some(vec![30u8; 8])); + assert_eq!(cloned.get(&key_b), Some(vec![20u8; 8])); + + cloned.update(key_b.clone(), vec![40u8; 8]).unwrap(); + assert_ne!(cloned.root_digest(), original_digest); + assert_eq!(original.root_digest(), original_digest); + assert_eq!(original.get(&key_b), Some(vec![20u8; 8])); + } + + #[test] + fn multi_key_insert_proof_is_deterministic_and_non_mutating() { + let mut tree = BasisAvlTree::new().unwrap(); + tree.insert(vec![1u8; 32], vec![1u8; 8]).unwrap(); + tree.insert(vec![2u8; 32], vec![2u8; 8]).unwrap(); + tree.update(vec![1u8; 32], vec![3u8; 8]).unwrap(); + let before = tree.root_digest(); + + let candidate_key = vec![3u8; 32]; + let candidate_value = vec![4u8; 8]; + let first = tree + .generate_insert_proof(candidate_key.clone(), candidate_value.clone()) + .unwrap(); + let second = tree + .generate_insert_proof(candidate_key, candidate_value) + .unwrap(); + + assert_eq!(first, second); + assert_eq!(tree.root_digest(), before); + } + #[test] fn generate_insert_proof_does_not_mutate_state() { let mut tree = BasisAvlTree::new().unwrap(); diff --git a/crates/basis_trees/src/fixed_avl.rs b/crates/basis_trees/src/fixed_avl.rs new file mode 100644 index 0000000..650ebce --- /dev/null +++ b/crates/basis_trees/src/fixed_avl.rs @@ -0,0 +1,818 @@ +//! Fixed-shape authenticated trees for the Basis v2 ABI. +//! +//! The legacy tree wrapper accepts variable-length values and rebuilds proof +//! candidates from a `HashMap`. Basis v2 instead authenticates exact 32/8 and +//! 32/24 tree shapes, and its root depends on first-insertion order. This +//! wrapper makes both constraints explicit and never exposes removal. +//! +//! Unsupported value widths are absent from the public type surface: +//! +//! ```compile_fail +//! use basis_trees::TrackerAvlTree; +//! let _ = TrackerAvlTree::<16>::new(); +//! ``` + +use crate::TreeError; +use bytes::Bytes; +use ergo_avltree_rust::{ + authenticated_tree_ops::AuthenticatedTreeOps, + batch_avl_prover::BatchAVLProver, + batch_avl_verifier::BatchAVLVerifier, + batch_node::AVLTree, + operation::{KeyValue, Operation}, +}; +use std::collections::HashMap; + +pub const BASIS_V2_KEY_LENGTH: usize = 32; + +fn tree_resolver(digest: &[u8; 32]) -> ergo_avltree_rust::batch_node::Node { + use ergo_avltree_rust::batch_node::{Node, NodeHeader}; + // Match Sigma's verifier resolver exactly. Replacing an unresolved label + // with a synthetic leaf changes an existing-key update digest even though + // first-insertion fixtures may still appear to verify. + Node::LabelOnly(NodeHeader::new(Some(*digest), None)) +} + +/// ErgoScript metadata authenticated by both Basis v2 reserve families. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FixedTreeShape { + key_length: usize, + value_length: usize, + insert_allowed: bool, + update_allowed: bool, + remove_allowed: bool, +} + +impl FixedTreeShape { + pub const fn key_length(&self) -> usize { + self.key_length + } + + pub const fn value_length(&self) -> usize { + self.value_length + } + + pub const fn insert_allowed(&self) -> bool { + self.insert_allowed + } + + pub const fn update_allowed(&self) -> bool { + self.update_allowed + } + + pub const fn remove_allowed(&self) -> bool { + self.remove_allowed + } +} + +/// Internal membership or non-membership lookup evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +struct LookupWitness { + proof: Vec, + value: Option<[u8; VALUE_LEN]>, +} + +/// An insert-or-update proof and the root it produces without mutating state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransitionWitness { + proof: Vec, + new_digest: [u8; 33], +} + +impl TransitionWitness { + pub fn proof(&self) -> &[u8] { + &self.proof + } + + pub const fn new_digest(&self) -> [u8; 33] { + self.new_digest + } +} + +/// In-memory fixed-width AVL prover with stable first-insertion ordering. +/// +/// Persistence owns the ordered entry vector. Reopening the tree must replay +/// exactly that vector; sorting a snapshot or iterating a hash map is not an +/// equivalent reconstruction. +struct FixedAvlInner { + prover: BatchAVLProver, + ordered_entries: Vec<([u8; BASIS_V2_KEY_LENGTH], [u8; VALUE_LEN])>, + positions: HashMap<[u8; BASIS_V2_KEY_LENGTH], usize>, +} + +impl FixedAvlInner { + fn new() -> Self { + Self { + prover: Self::empty_prover(), + ordered_entries: Vec::new(), + positions: HashMap::new(), + } + } + + fn from_ordered_entries(entries: I) -> Result + where + I: IntoIterator, + { + let mut tree = Self::new(); + for (key, value) in entries { + tree.insert(key, value)?; + } + Ok(tree) + } + + const fn shape() -> FixedTreeShape { + FixedTreeShape { + key_length: BASIS_V2_KEY_LENGTH, + value_length: VALUE_LEN, + insert_allowed: true, + update_allowed: true, + remove_allowed: false, + } + } + + fn len(&self) -> usize { + self.ordered_entries.len() + } + + fn is_empty(&self) -> bool { + self.ordered_entries.is_empty() + } + + fn get(&self, key: &[u8; BASIS_V2_KEY_LENGTH]) -> Option<[u8; VALUE_LEN]> { + self.positions + .get(key) + .map(|position| self.ordered_entries[*position].1) + } + + fn ordered_entries( + &self, + ) -> impl ExactSizeIterator { + self.ordered_entries.iter() + } + + fn root_digest(&self) -> Result<[u8; 33], TreeError> { + let mut digest = [0u8; 33]; + let value = self.prover.digest().ok_or(TreeError::InvalidState)?; + digest.copy_from_slice(&value); + Ok(digest) + } + + fn insert( + &mut self, + key: [u8; BASIS_V2_KEY_LENGTH], + value: [u8; VALUE_LEN], + ) -> Result<(), TreeError> { + if self.positions.contains_key(&key) { + return Err(TreeError::DuplicateKey); + } + self.perform_insert_or_update(key, value)?; + let position = self.ordered_entries.len(); + self.ordered_entries.push((key, value)); + self.positions.insert(key, position); + Ok(()) + } + + fn update( + &mut self, + key: [u8; BASIS_V2_KEY_LENGTH], + value: [u8; VALUE_LEN], + ) -> Result<(), TreeError> { + let position = *self.positions.get(&key).ok_or(TreeError::KeyNotFound)?; + self.perform_insert_or_update(key, value)?; + self.ordered_entries[position].1 = value; + Ok(()) + } + + /// Generate mandatory lookup evidence for either an existing or absent key. + fn lookup_witness( + &mut self, + key: [u8; BASIS_V2_KEY_LENGTH], + ) -> Result, TreeError> { + // Flush prior modifications so the returned proof contains one lookup. + let _ = self.prover.generate_proof(); + let result = self + .prover + .perform_one_operation(&Operation::Lookup(key.to_vec().into())) + .map_err(|error| TreeError::StorageError(format!("AVL lookup failed: {error:?}")))?; + let proof = self.prover.generate_proof().to_vec(); + if proof.is_empty() { + return Err(TreeError::InvalidState); + } + let value = match result { + Some(bytes) => Some( + bytes + .as_ref() + .try_into() + .map_err(|_| TreeError::TreeCorruption)?, + ), + None => None, + }; + Ok(LookupWitness { proof, value }) + } + + /// Generate the v2 `insertOrUpdate` proof against the current root. + /// + /// The real tree is not mutated. Rebuilding the temporary prover uses the + /// authoritative first-insertion order, never hash-map iteration order. + fn transition_witness( + &self, + key: [u8; BASIS_V2_KEY_LENGTH], + value: [u8; VALUE_LEN], + ) -> Result { + let mut prover = Self::empty_prover(); + for (existing_key, existing_value) in &self.ordered_entries { + prover + .perform_one_operation(&Operation::Insert(KeyValue { + key: existing_key.to_vec().into(), + value: existing_value.to_vec().into(), + })) + .map_err(|error| { + TreeError::StorageError(format!("AVL rebuild failed: {error:?}")) + })?; + } + let _ = prover.generate_proof(); + prover + .perform_one_operation(&Operation::InsertOrUpdate(KeyValue { + key: key.to_vec().into(), + value: value.to_vec().into(), + })) + .map_err(|error| { + TreeError::StorageError(format!("AVL transition failed: {error:?}")) + })?; + let proof = prover.generate_proof().to_vec(); + if proof.is_empty() { + return Err(TreeError::InvalidState); + } + let mut new_digest = [0u8; 33]; + let digest = prover.digest().ok_or(TreeError::InvalidState)?; + new_digest.copy_from_slice(&digest); + Ok(TransitionWitness { proof, new_digest }) + } + + /// Verify exactly one membership or non-membership lookup operation. + fn verify_lookup( + starting_digest: &[u8; 33], + key: &[u8; BASIS_V2_KEY_LENGTH], + witness: &LookupWitness, + ) -> bool { + let mut verifier = match BatchAVLVerifier::new( + &Bytes::copy_from_slice(starting_digest), + &Bytes::copy_from_slice(&witness.proof), + AVLTree::new(tree_resolver, BASIS_V2_KEY_LENGTH, Some(VALUE_LEN)), + Some(1), + Some(0), + ) { + Ok(verifier) => verifier, + Err(_) => return false, + }; + let actual = match verifier.perform_one_operation(&Operation::Lookup(key.to_vec().into())) { + Ok(value) => value, + Err(_) => return false, + }; + match (actual, witness.value) { + (None, None) => true, + (Some(actual), Some(expected)) => actual.as_ref() == expected, + _ => false, + } + } + + /// Verify one raw insert-or-update proof and bind the resulting digest. + /// + /// This is the signer-side counterpart of `transition_witness`: a CLI can + /// replay the exact context variable supplied by a remote builder without + /// trusting the builder's claimed successor R5. + fn verify_transition( + starting_digest: &[u8; 33], + key: &[u8; BASIS_V2_KEY_LENGTH], + value: &[u8; VALUE_LEN], + proof: &[u8], + expected_digest: &[u8; 33], + ) -> bool { + if proof.is_empty() { + return false; + } + let mut verifier = match BatchAVLVerifier::new( + &Bytes::copy_from_slice(starting_digest), + &Bytes::copy_from_slice(proof), + AVLTree::new(tree_resolver, BASIS_V2_KEY_LENGTH, Some(VALUE_LEN)), + Some(1), + Some(0), + ) { + Ok(verifier) => verifier, + Err(_) => return false, + }; + if verifier + .perform_one_operation(&Operation::InsertOrUpdate(KeyValue { + key: key.to_vec().into(), + value: value.to_vec().into(), + })) + .is_err() + { + return false; + } + verifier + .digest() + .map(|digest| digest.as_ref() == expected_digest) + .unwrap_or(false) + } + + fn empty_prover() -> BatchAVLProver { + BatchAVLProver::new( + AVLTree::new(tree_resolver, BASIS_V2_KEY_LENGTH, Some(VALUE_LEN)), + true, + ) + } + + fn perform_insert_or_update( + &mut self, + key: [u8; BASIS_V2_KEY_LENGTH], + value: [u8; VALUE_LEN], + ) -> Result<(), TreeError> { + self.prover + .perform_one_operation(&Operation::InsertOrUpdate(KeyValue { + key: key.to_vec().into(), + value: value.to_vec().into(), + })) + .map_err(|error| { + TreeError::StorageError(format!("AVL transition failed: {error:?}")) + })?; + Ok(()) + } +} + +macro_rules! define_fixed_tree { + ($tree:ident, $witness:ident, $value_len:literal, $description:literal) => { + #[doc = $description] + pub struct $tree { + inner: FixedAvlInner<$value_len>, + } + + #[doc = concat!("Mandatory membership or non-membership evidence for `", stringify!($tree), "`.")] + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct $witness { + inner: LookupWitness<$value_len>, + } + + impl $witness { + pub fn proof(&self) -> &[u8] { + &self.inner.proof + } + + pub const fn value(&self) -> Option<[u8; $value_len]> { + self.inner.value + } + } + + impl $tree { + pub fn new() -> Self { + Self { + inner: FixedAvlInner::new(), + } + } + + pub fn from_ordered_entries(entries: I) -> Result + where + I: IntoIterator< + Item = ([u8; BASIS_V2_KEY_LENGTH], [u8; $value_len]), + >, + { + Ok(Self { + inner: FixedAvlInner::from_ordered_entries(entries)?, + }) + } + + pub const fn shape() -> FixedTreeShape { + FixedAvlInner::<$value_len>::shape() + } + + pub fn len(&self) -> usize { + self.inner.len() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + pub fn get( + &self, + key: &[u8; BASIS_V2_KEY_LENGTH], + ) -> Option<[u8; $value_len]> { + self.inner.get(key) + } + + pub fn ordered_entries( + &self, + ) -> impl ExactSizeIterator< + Item = &([u8; BASIS_V2_KEY_LENGTH], [u8; $value_len]), + > { + self.inner.ordered_entries() + } + + pub fn root_digest(&self) -> Result<[u8; 33], TreeError> { + self.inner.root_digest() + } + + pub fn insert( + &mut self, + key: [u8; BASIS_V2_KEY_LENGTH], + value: [u8; $value_len], + ) -> Result<(), TreeError> { + self.inner.insert(key, value) + } + + pub fn update( + &mut self, + key: [u8; BASIS_V2_KEY_LENGTH], + value: [u8; $value_len], + ) -> Result<(), TreeError> { + self.inner.update(key, value) + } + + pub fn lookup_witness( + &mut self, + key: [u8; BASIS_V2_KEY_LENGTH], + ) -> Result<$witness, TreeError> { + Ok($witness { + inner: self.inner.lookup_witness(key)?, + }) + } + + pub fn transition_witness( + &self, + key: [u8; BASIS_V2_KEY_LENGTH], + value: [u8; $value_len], + ) -> Result { + self.inner.transition_witness(key, value) + } + + /// Verify exactly one lookup without unwinding on malformed proof bytes. + pub fn verify_lookup( + starting_digest: &[u8; 33], + key: &[u8; BASIS_V2_KEY_LENGTH], + witness: &$witness, + ) -> bool { + FixedAvlInner::<$value_len>::verify_lookup( + starting_digest, + key, + &witness.inner, + ) + } + + /// Verify raw mandatory membership or non-membership evidence. + pub fn verify_lookup_bytes( + starting_digest: &[u8; 33], + key: &[u8; BASIS_V2_KEY_LENGTH], + proof: &[u8], + value: Option<[u8; $value_len]>, + ) -> bool { + let witness = $witness { + inner: LookupWitness { + proof: proof.to_vec(), + value, + }, + }; + Self::verify_lookup(starting_digest, key, &witness) + } + + /// Verify a raw insert-or-update proof and its exact successor root. + pub fn verify_transition_bytes( + starting_digest: &[u8; 33], + key: &[u8; BASIS_V2_KEY_LENGTH], + value: &[u8; $value_len], + proof: &[u8], + expected_digest: &[u8; 33], + ) -> bool { + FixedAvlInner::<$value_len>::verify_transition( + starting_digest, + key, + value, + proof, + expected_digest, + ) + } + } + + impl Default for $tree { + fn default() -> Self { + Self::new() + } + } + }; +} + +define_fixed_tree!( + TrackerAvlTree, + TrackerLookupWitness, + 8, + "Basis v2 tracker debt tree with exact 32-byte keys and 8-byte values." +); + +define_fixed_tree!( + ReserveAvlTree, + ReserveLookupWitness, + 24, + "Basis v2 reserve redemption tree with exact 32-byte keys and 24-byte values." +); + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + const SCALA_VECTORS: &str = include_str!("../tests/fixtures/basis_v2_avl_scala_0403162.json"); + + fn decode_hex(encoded: &str) -> Vec { + assert_eq!(encoded.len() % 2, 0); + encoded + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let pair = std::str::from_utf8(pair).unwrap(); + u8::from_str_radix(pair, 16).unwrap() + }) + .collect() + } + + fn digest33(encoded: &str) -> [u8; 33] { + decode_hex(encoded).try_into().unwrap() + } + + #[test] + fn exposes_the_exact_v2_shapes() { + assert_eq!( + TrackerAvlTree::shape(), + FixedTreeShape { + key_length: 32, + value_length: 8, + insert_allowed: true, + update_allowed: true, + remove_allowed: false, + } + ); + assert_eq!(ReserveAvlTree::shape().value_length(), 24); + } + + #[test] + fn insertion_order_is_explicit_and_replayable() { + let entries = [([1u8; 32], [10u8; 8]), ([2u8; 32], [20u8; 8])]; + let tree = TrackerAvlTree::from_ordered_entries(entries).unwrap(); + let replay = TrackerAvlTree::from_ordered_entries(entries).unwrap(); + assert_eq!(tree.root_digest().unwrap(), replay.root_digest().unwrap()); + assert_eq!(tree.ordered_entries().copied().collect::>(), entries); + + let ordered = [ + ([1u8; 32], [11u8; 8]), + ([2u8; 32], [22u8; 8]), + ([3u8; 32], [33u8; 8]), + ([4u8; 32], [44u8; 8]), + ]; + let reversed = [ordered[3], ordered[2], ordered[1], ordered[0]]; + let ordered_tree = TrackerAvlTree::from_ordered_entries(ordered).unwrap(); + let reversed_tree = TrackerAvlTree::from_ordered_entries(reversed).unwrap(); + assert_ne!( + ordered_tree.root_digest().unwrap(), + reversed_tree.root_digest().unwrap(), + "reversing the authoritative replay order must not be treated as equivalent" + ); + } + + #[test] + fn insert_and_update_are_strict() { + let mut tree = TrackerAvlTree::new(); + tree.insert([1u8; 32], [2u8; 8]).unwrap(); + assert!(matches!( + tree.insert([1u8; 32], [3u8; 8]), + Err(TreeError::DuplicateKey) + )); + assert!(matches!( + tree.update([9u8; 32], [3u8; 8]), + Err(TreeError::KeyNotFound) + )); + tree.update([1u8; 32], [4u8; 8]).unwrap(); + assert_eq!(tree.get(&[1u8; 32]), Some([4u8; 8])); + assert_eq!(tree.len(), 1); + } + + #[test] + fn membership_and_non_membership_both_have_verifiable_proofs() { + let mut tree = ReserveAvlTree::new(); + tree.insert([1u8; 32], [2u8; 24]).unwrap(); + let digest = tree.root_digest().unwrap(); + + let membership = tree.lookup_witness([1u8; 32]).unwrap(); + assert_eq!(membership.value(), Some([2u8; 24])); + assert!(!membership.proof().is_empty()); + assert!(ReserveAvlTree::verify_lookup( + &digest, + &[1u8; 32], + &membership + )); + + let non_membership = tree.lookup_witness([9u8; 32]).unwrap(); + assert_eq!(non_membership.value(), None); + assert!(!non_membership.proof().is_empty()); + assert!(ReserveAvlTree::verify_lookup( + &digest, + &[9u8; 32], + &non_membership + )); + assert!(ReserveAvlTree::verify_lookup_bytes( + &digest, + &[1u8; 32], + membership.proof(), + membership.value(), + )); + assert!(ReserveAvlTree::verify_lookup_bytes( + &digest, + &[9u8; 32], + non_membership.proof(), + None, + )); + + let mut wrong = non_membership.clone(); + wrong.inner.value = Some([0u8; 24]); + assert!(!ReserveAvlTree::verify_lookup(&digest, &[9u8; 32], &wrong)); + + let mut wrong_proof = membership.clone(); + wrong_proof.inner.proof[0] ^= 1; + assert!(!ReserveAvlTree::verify_lookup( + &digest, + &[1u8; 32], + &wrong_proof + )); + assert!(!ReserveAvlTree::verify_lookup( + &digest, + &[3u8; 32], + &membership + )); + let mut wrong_digest = digest; + wrong_digest[1] ^= 1; + assert!(!ReserveAvlTree::verify_lookup( + &wrong_digest, + &[1u8; 32], + &membership + )); + } + + #[test] + fn transition_proof_is_deterministic_and_does_not_mutate() { + let mut tree = TrackerAvlTree::new(); + tree.insert([1u8; 32], [2u8; 8]).unwrap(); + tree.insert([3u8; 32], [4u8; 8]).unwrap(); + let before = tree.root_digest().unwrap(); + + let witness = tree.transition_witness([1u8; 32], [5u8; 8]).unwrap(); + let again = tree.transition_witness([1u8; 32], [5u8; 8]).unwrap(); + assert_eq!(witness, again); + assert_eq!(tree.root_digest().unwrap(), before); + + tree.update([1u8; 32], [5u8; 8]).unwrap(); + assert_eq!(tree.root_digest().unwrap(), witness.new_digest()); + assert!(TrackerAvlTree::verify_transition_bytes( + &before, + &[1u8; 32], + &[5u8; 8], + witness.proof(), + &witness.new_digest(), + )); + let mut wrong_root = witness.new_digest(); + wrong_root[0] ^= 1; + assert!(!TrackerAvlTree::verify_transition_bytes( + &before, + &[1u8; 32], + &[5u8; 8], + witness.proof(), + &wrong_root, + )); + } + + #[test] + fn new_key_transition_witness_matches_the_inserted_successor() { + let mut tree = ReserveAvlTree::new(); + tree.insert([1u8; 32], [2u8; 24]).unwrap(); + let before = tree.root_digest().unwrap(); + + let witness = tree.transition_witness([3u8; 32], [4u8; 24]).unwrap(); + assert_eq!(tree.root_digest().unwrap(), before); + + tree.insert([3u8; 32], [4u8; 24]).unwrap(); + assert_eq!(tree.root_digest().unwrap(), witness.new_digest()); + } + + #[test] + fn malformed_lookup_proofs_return_false_without_unwinding() { + let mut tree = ReserveAvlTree::new(); + tree.insert([1u8; 32], [2u8; 24]).unwrap(); + tree.insert([3u8; 32], [4u8; 24]).unwrap(); + let digest = tree.root_digest().unwrap(); + let valid = tree.lookup_witness([1u8; 32]).unwrap(); + + for cut in 0..valid.proof().len() { + let malformed = ReserveLookupWitness { + inner: LookupWitness { + proof: valid.proof()[..cut].to_vec(), + value: valid.value(), + }, + }; + assert!(!ReserveAvlTree::verify_lookup( + &digest, &[1u8; 32], &malformed + )); + } + + let malformed = ReserveLookupWitness { + inner: LookupWitness { + proof: vec![0, 4], + value: valid.value(), + }, + }; + assert!(!ReserveAvlTree::verify_lookup( + &digest, &[1u8; 32], &malformed + )); + + let mut bit_mutations_exercised = 0; + for offset in 0..valid.proof().len() { + for bit in 0..8 { + let mut mutated = valid.clone(); + mutated.inner.proof[offset] ^= 1 << bit; + let _ = ReserveAvlTree::verify_lookup(&digest, &[1u8; 32], &mutated); + bit_mutations_exercised += 1; + } + } + assert_eq!(bit_mutations_exercised, valid.proof().len() * 8); + } + + #[test] + fn matches_chaincash_scala_golden_vectors_byte_for_byte() { + let vectors: Value = serde_json::from_str(SCALA_VECTORS).unwrap(); + assert_eq!( + vectors["source"]["commit"].as_str().unwrap(), + "04031626f09c6590a20ad20d5583c6eccc14412d" + ); + + let tracker = &vectors["tracker_32_8"]; + let mut tracker_tree = TrackerAvlTree::new(); + assert_eq!( + tracker_tree.root_digest().unwrap(), + digest33(tracker["empty_digest_hex"].as_str().unwrap()) + ); + tracker_tree.insert([1; 32], [2; 8]).unwrap(); + tracker_tree.insert([3; 32], [4; 8]).unwrap(); + assert_eq!( + tracker_tree.root_digest().unwrap(), + digest33(tracker["starting_digest_hex"].as_str().unwrap()) + ); + let tracker_lookup = tracker_tree.lookup_witness([1; 32]).unwrap(); + let tracker_lookup_bytes = decode_hex(tracker["lookup_proof_hex"].as_str().unwrap()); + assert_eq!(tracker_lookup.proof(), tracker_lookup_bytes); + assert_eq!(tracker_lookup.proof().len(), 143); + assert_eq!( + tracker["lookup_proof_sha256"].as_str().unwrap(), + "5b3e3451d8137978f6b86ea2021bdc0d8e31a80ecbbe8ed48f9d67e9c1f3ca54" + ); + let tracker_update = tracker_tree.transition_witness([1; 32], [5; 8]).unwrap(); + let tracker_update_bytes = decode_hex(tracker["update_proof_hex"].as_str().unwrap()); + assert_eq!(tracker_update.proof(), tracker_update_bytes); + assert_eq!( + tracker["update_proof_sha256"].as_str().unwrap(), + "5b3e3451d8137978f6b86ea2021bdc0d8e31a80ecbbe8ed48f9d67e9c1f3ca54" + ); + tracker_tree.update([1; 32], [5; 8]).unwrap(); + assert_eq!( + tracker_tree.root_digest().unwrap(), + digest33(tracker["output_digest_hex"].as_str().unwrap()) + ); + + let reserve = &vectors["reserve_32_24"]; + let mut reserve_tree = ReserveAvlTree::new(); + assert_eq!( + reserve_tree.root_digest().unwrap(), + digest33(reserve["empty_digest_hex"].as_str().unwrap()) + ); + reserve_tree.insert([1; 32], [2; 24]).unwrap(); + assert_eq!( + reserve_tree.root_digest().unwrap(), + digest33(reserve["starting_digest_hex"].as_str().unwrap()) + ); + let reserve_absence = reserve_tree.lookup_witness([3; 32]).unwrap(); + let reserve_absence_bytes = decode_hex(reserve["absence_proof_hex"].as_str().unwrap()); + assert_eq!(reserve_absence.value(), None); + assert_eq!(reserve_absence.proof(), reserve_absence_bytes); + assert_eq!(reserve_absence.proof().len(), 125); + assert_eq!( + reserve["absence_proof_sha256"].as_str().unwrap(), + "80f33ed16eee7c83ba7d316d07a5bb988a2e5c8f83a50487e4698829dfe02d76" + ); + let reserve_insert = reserve_tree.transition_witness([3; 32], [4; 24]).unwrap(); + let reserve_insert_bytes = decode_hex(reserve["insert_proof_hex"].as_str().unwrap()); + assert_eq!(reserve_insert.proof(), reserve_insert_bytes); + assert_eq!( + reserve["insert_proof_sha256"].as_str().unwrap(), + "80f33ed16eee7c83ba7d316d07a5bb988a2e5c8f83a50487e4698829dfe02d76" + ); + reserve_tree.insert([3; 32], [4; 24]).unwrap(); + assert_eq!( + reserve_tree.root_digest().unwrap(), + digest33(reserve["output_digest_hex"].as_str().unwrap()) + ); + } +} diff --git a/crates/basis_trees/src/lib.rs b/crates/basis_trees/src/lib.rs index 6731c39..d0a2697 100644 --- a/crates/basis_trees/src/lib.rs +++ b/crates/basis_trees/src/lib.rs @@ -6,6 +6,7 @@ pub mod avl_tree; pub mod errors; +pub mod fixed_avl; pub mod proofs; pub mod state; pub mod storage; @@ -20,6 +21,10 @@ pub mod avl_tree_tests; pub use avl_tree::BasisAvlTree; pub use errors::TreeError; +pub use fixed_avl::{ + FixedTreeShape, ReserveAvlTree, ReserveLookupWitness, TrackerAvlTree, TrackerLookupWitness, + TransitionWitness, +}; pub use proofs::{MembershipProof, NonMembershipProof, StateProof}; pub use state::TrackerState; pub use storage::{NodeType, OperationType, TreeCheckpoint, TreeNode, TreeOperation, TreeStorage}; diff --git a/crates/basis_trees/tests/fixtures/basis_v2_avl_scala_0403162.json b/crates/basis_trees/tests/fixtures/basis_v2_avl_scala_0403162.json new file mode 100644 index 0000000..eb50791 --- /dev/null +++ b/crates/basis_trees/tests/fixtures/basis_v2_avl_scala_0403162.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "source": { + "repository": "BetterMoneyLabs/chaincash", + "commit": "04031626f09c6590a20ad20d5583c6eccc14412d", + "scala_version": "2.12.17", + "plasma_toolkit": "1.1.0", + "ergo_appkit": "6.0.0", + "reference_test": "src/test/scala/chaincash/BasisV2Spec.scala" + }, + "shape": { + "key_length": 32, + "insert_allowed": true, + "update_allowed": true, + "remove_allowed": false + }, + "tracker_32_8": { + "entries": [ + { "key_fill": 1, "value_fill": 2 }, + { "key_fill": 3, "value_fill": 4 } + ], + "lookup_key_fill": 1, + "update_value_fill": 5, + "empty_digest_hex": "aebde47e15b6bfb577265ea5a819f5779328085286d86e7e1089636641dae9b800", + "starting_digest_hex": "591e0f5f7172950ab9be5730ba39a48787a5729606924a215afda454b685a80902", + "lookup_proof_hex": "03ab4abecbeaed2ac82898c746116937f33e7c65af8b4f0df1e3782d663f67f2480201010101010101010101010101010101010101010101010101010101010101010303030303030303030303030303030303030303030303030303030303030303020202020202020203a6f2d1ca904906805d75f80c96a4bc9709814266545b258c3d48f66b9dedb08900010402", + "lookup_proof_sha256": "5b3e3451d8137978f6b86ea2021bdc0d8e31a80ecbbe8ed48f9d67e9c1f3ca54", + "update_proof_hex": "03ab4abecbeaed2ac82898c746116937f33e7c65af8b4f0df1e3782d663f67f2480201010101010101010101010101010101010101010101010101010101010101010303030303030303030303030303030303030303030303030303030303030303020202020202020203a6f2d1ca904906805d75f80c96a4bc9709814266545b258c3d48f66b9dedb08900010402", + "update_proof_sha256": "5b3e3451d8137978f6b86ea2021bdc0d8e31a80ecbbe8ed48f9d67e9c1f3ca54", + "output_digest_hex": "0e34a6b2c50f8da80ed5f253bece227e943ee21cd8e1533099e013bb2f94897202" + }, + "reserve_32_24": { + "existing_key_fill": 1, + "existing_value_fill": 2, + "new_key_fill": 3, + "new_value_fill": 4, + "empty_digest_hex": "b4a91cda32c6691b89913b30400b586ef792743e4c3e8ea938ede0de5d679dfe00", + "starting_digest_hex": "1ec099b5d613454bcc4d65d6d4c2c3d0f649e180a13d184e745a3ffb2a1d634e01", + "absence_proof_hex": "03f4749808979636f55fbea47d3963e6959c6d41029fcf90698c821b05968ff2da020101010101010101010101010101010101010101010101010101010101010101ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff020202020202020202020202020202020202020202020202000400", + "absence_proof_sha256": "80f33ed16eee7c83ba7d316d07a5bb988a2e5c8f83a50487e4698829dfe02d76", + "insert_proof_hex": "03f4749808979636f55fbea47d3963e6959c6d41029fcf90698c821b05968ff2da020101010101010101010101010101010101010101010101010101010101010101ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff020202020202020202020202020202020202020202020202000400", + "insert_proof_sha256": "80f33ed16eee7c83ba7d316d07a5bb988a2e5c8f83a50487e4698829dfe02d76", + "output_digest_hex": "97bd1c2dde43af94a8073e25ec7d0a9c65236d384bcc71088489ead69dc1cd6502" + } +} diff --git a/docs/AGENT_INTERFACE.md b/docs/AGENT_INTERFACE.md index b526b22..d874351 100644 --- a/docs/AGENT_INTERFACE.md +++ b/docs/AGENT_INTERFACE.md @@ -159,19 +159,17 @@ $ basis-cli reserve status --json - `reserve create --json` → the reserve-creation payload (`{nft_id, owner_pubkey, amount, payload: {requests, fee, change_address}}`). - `reserve collateralization --json` → `{issuer_pubkey, ratio, status}`. -- `note redeem --json` → `{amount, server_sign, redemption_id?, proof_available?, tx_id?}`. -- `transaction generate-redemption --json` → `{tx_id}` with `--local-sign`, - otherwise `{transaction, issuer_pubkey, ..., output_file?}` (the unsigned - transaction plus build metadata). -- `transaction redeem-assisted --json` → `{tx_id}`. -- `test test-redemption --json` → `{issuer_pubkey, recipient_pubkey, redemption_amount, output_file, transaction}`. +- `note redeem --json` is a compatibility tombstone and fails before account, + network, construction, signing, broadcast, or persistence effects. +- No transaction-generation or redemption-test command is compiled. V2 + admission has no active prover, signer, submitter, or broadcaster. ## Typed results for programmatic use The command logic lives in `basis_cli_lib::commands::*` as `pub` functions returning serde-serializable result structs (e.g. `account::create_account`, -`note::list_notes`, `reserve::get_reserve_status`, `status::get_server_status`, -`transaction::generate_redemption_transaction`). The `handle_*_command` +`note::list_notes`, `reserve::get_reserve_status`, and +`status::get_server_status`). The `handle_*_command` functions are thin wrappers that render either human text or JSON. Other binaries (TUI, MCP server) can depend on the library and reuse the same cores. @@ -212,7 +210,7 @@ Write tools: | `account_switch` | Switch current account (`name`) | | `account_import` | Import from `private_key_hex` (stored locally, never echoed back) | | `note_create` | Create note to `recipient` for `amount` nanoERG, signed with the current account | -| `note_redeem` | Redeem `amount` nanoERG from `issuer` (local-signing path; `destructiveHint: true`) | +| `note_redeem` | Retired compatibility tombstone; returns an error before effects | | `reserve_create` | Build reserve-creation payload (`nft_id`, `amount`; owner = current account) | | `policy_set` | Replace acceptance policy (`policy` object matching `AcceptanceConfig`); saves to `~/.basis/ui.toml` and uploads signed with the current account (`destructiveHint: true`) | @@ -225,8 +223,9 @@ error message — the server never exits on a tool error. - **No private-key export**: there is deliberately no key-export tool, and no tool response contains key material. `account_import` takes a key as input but only returns the account name and public key. -- **Signing stays in-process**: note creation, redemption, and policy upload - are signed locally by the wallet; keys never leave the `basis-mcp` process. +- **Signing stays in-process**: note creation and policy upload are signed + locally by the wallet; keys never leave the `basis-mcp` process. Redemption + is not exposed through MCP while the reviewed transaction flow is separate. ### Client configuration diff --git a/docs/Alice_Bob_Redemption_Test.md b/docs/Alice_Bob_Redemption_Test.md index 786e20a..8dac93d 100644 --- a/docs/Alice_Bob_Redemption_Test.md +++ b/docs/Alice_Bob_Redemption_Test.md @@ -1,5 +1,9 @@ # Alice and Bob Redemption Test Instructions +> **Status: historical v1 walkthrough — superseded.** The redemption and proof +> commands below are removed or `410 Gone`; do not execute this as a current +> test plan. + This document provides step-by-step instructions for testing the credit creation and redemption functionality with Alice and Bob. ## Step 1: Generate Alice's Keys @@ -165,4 +169,4 @@ curl -X GET "http://localhost:3048/proof?issuer=&recipient= **Basis v2 activation gate:** the server now recognizes only the exact +> committed Basis v2 ERG contract, but reserve creation remains disabled until +> the v2 runtime builder emits the complete R4-R9 ABI and a genuine singleton. +> The commands below are retained as historical operator examples; they must +> return `503 Service Unavailable` on this intermediate runtime and must not be +> used to create a reserve. + ```bash # Create a reserve using the specified NFT ID ./target/release/basis_cli reserve create \ @@ -121,22 +128,11 @@ If you want the exact JSON array format that the wallet/payment/send API expects This JSON array is ready to be submitted directly to the `wallet/payment/send` API endpoint of your Ergo node. -## Alternative: Submit via the Tracker - -If the tracker server is configured with an Ergo node, you can ask it to broadcast the reserve payload for you. The TUI wallet prompts for this after generating the payload, and the CLI supports a `--submit` flag: - -```bash -basis-cli reserve create --nft-id --amount 1000000000 --submit -``` - -Or submit the full `ReserveCreationResponse` JSON manually: - -```bash -curl -X POST http://127.0.0.1:3048/reserves/submit \ - -H "Content-Type: application/json" \ - -d '' -``` +## Submit with the reserve owner's wallet -The tracker converts the payload to the camelCase `tokenId` format expected by the Ergo node and returns the broadcast transaction id. +The tracker does not submit this payload or proxy its configured node wallet. +Review the returned outputs, map the payload into the owner wallet's API format, +and sign and submit the resulting transaction through that wallet's normal flow. +The legacy CLI `--submit` flag and `POST /reserves/submit` route fail closed. -Note: Make sure your Ergo node is running and you have the correct API key. Also, ensure that your wallet has sufficient ERG and the specified NFT to create the reserve. \ No newline at end of file +Note: Make sure your Ergo node is running and you have the correct API key. Also, ensure that your wallet has sufficient ERG and the specified NFT to create the reserve. diff --git a/docs/BUILD_INSTALL.md b/docs/BUILD_INSTALL.md index 617559e..882eea5 100644 --- a/docs/BUILD_INSTALL.md +++ b/docs/BUILD_INSTALL.md @@ -176,7 +176,8 @@ cp config/basis.toml.example config/basis.toml # if available [ergo] # This NFT identifies your tracker server - required for reserve operations tracker_nft_id = "your_tracker_nft_token_id_here" -basis_reserve_contract_p2s = "your_reserve_contract_p2s_address" +# Omit this key while running the temporary legacy compatibility mode. +# Basis v2 activation remains disabled until its scanner/state runtime exists. ``` 3. **Set Ergo Node Configuration**: @@ -201,7 +202,7 @@ The client stores configuration in `~/.basis/cli.toml` and creates it automatica ./target/release/basis_server --config /path/to/config.toml # Run with custom port -BASIS_SERVER_PORT=8080 ./target/release/basis_server +BASIS_SERVER__PORT=8080 ./target/release/basis_server ``` ### Using Run Scripts (Recommended) @@ -376,4 +377,4 @@ cargo build -p basis_cli --release | `./target/release/basis_server` | Run server directly | | `cargo run -p basis_server` | Run server from cargo | -Your Basis Tracker installation is now complete! The server provides API endpoints for managing IOU notes and reserves, while the client provides a convenient command-line interface for interacting with the system. \ No newline at end of file +Your Basis Tracker installation is now complete! The server provides API endpoints for managing IOU notes and reserves, while the client provides a convenient command-line interface for interacting with the system. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a54d1d7..fd82653 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,11 +6,11 @@ This document describes the configuration options for the Basis Tracker server a ## Configuration File -The main configuration file is `config/basis.toml`. The server will look for this file in the following locations: +The main configuration file is `config/basis.toml`. Configuration precedence is: -1. Current working directory: `config/basis.toml` -2. Environment variables with `BASIS_` prefix -3. Default values +1. Environment variables with the `BASIS_` prefix +2. `config/basis.toml` in the current working directory +3. Built-in default values ## Configuration Sections @@ -24,14 +24,24 @@ data_dir = "data" # Base directory for all on-disk storage (databases, ind database_url = "sqlite:data/basis.db" # Legacy field, kept for compatibility (currently unused) ``` -`data_dir` controls where the server writes all persistent state. It defaults to a `data/` directory relative to the working directory from which the server is launched. You can override it with the `BASIS_SERVER_DATA_DIR` environment variable. +`data_dir` controls where the server writes all persistent state. It defaults to a `data/` directory relative to the working directory from which the server is launched. You can override it with the `BASIS_SERVER__DATA_DIR` environment variable. + +The note store is permanently bound by a checksummed generation manifest to +the configured 32-byte tracker NFT. A new or unbound directory is rejected unless +`allow_fresh_tracker_generation = true` is supplied explicitly. That approval +creates only an unanchored empty generation; before publishing any successor, +the updater requires the first observed on-chain R5 root to equal the persisted +bootstrap root. Set the option back to `false` after first initialization. A +different NFT, missing or corrupt manifest, or non-matching first root fails closed. ### Ergo Blockchain Configuration ```toml [ergo] # Basis reserve contract P2S address -basis_reserve_contract_p2s = "3PQnJ92Krn6NeM1GdMSmNayw34Nuud7UKMoKSTRUTucsNybh99K1HEfjZqyvP7cPag1yBkDv3ruMAgb2NsVKq3tAygjHz7mKDzHK6CJGhD3WfNViD7DoViqbgsXrzvs6Kt8Wyzb48uGqJAFQFWes6ZPKELqUZowy8xtVCS5w1VwnyaeRiWpEyUVGaEHw3qWo5DcVxzmMAP8XXhVTw1rYYrUxsyGPNaBxQkkkTVD9L3bmw77EfeAJgJ1hLxghykNofHscHtMtES4v5FSfqke3Huun81S7gNoraEnsR6Dy6YnQgrBswwCZhyGc89YeNFQn1TCFh5Hct3nKGrd1bV5zoCw67Q9fKtoaCtvcPQ2GDWycGKNRNgyAnPEa8WbHbTEVcjAN25aBwhnY5LFGqYxnUAjhpfkTPJ4FJWRijSqMESzpyrmhTLZdivmn4YSwcchVZr7bHGbfncEDwqPKefdoxNnVPxuVdmeqQXL3aDL7TaqWgExzz1UPXHw3UiKYTUkNgQKCN4WV3LHqc9PecoisL77ydVbSCxPapaX2zTf26F8bGK3hsTVBZnMkt93SJP5GmPgZU5FT9NkFh4okjXK9ce2wmA4MV93ySyYnUKGwTRFJWwE7G1MYqBqTY3ESkn8PJHqVuL4cgtuV2GEPagKt19befRAuUV3FaLGVPJMzpKdANd7hKGZRcy3DnPfT1Q9dyFD4VpdBgFRXJWaaDqYjL7ni4nJcKKam9P395wRRnjGWhTV4hv3KoxC8Xk2CZAUjhkTzvuNHxQrLsWjyrKWJqZgs2uZxoAEHEobDegYWiTcnFCPU9EeJxZLSjysDFninqpQvA66Yt1SvJnSZm49RKsaoR98UJVScdiQfNZE76zTYBioXGatdRz7QVkXDzDPjPMu9Hhepc2XbHqo3ia8tszHptbnSzm2R3PC7iu2Tnhu3QT" +# Omit basis_reserve_contract_p2s for temporary legacy compatibility mode. +# The exact Basis v2 tree is recognized, but startup rejects v2 activation +# until the v2 scanner and BNS2/BRS2 state runtime are installed. # Starting block height for scanning (legacy) start_height = 0 @@ -41,6 +51,10 @@ start_height = 0 # Example: tracker_nft_id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" tracker_nft_id = "" +# One-time approval for a brand-new, empty tracker generation. +# Leave false for existing generations and ordinary restarts. +allow_fresh_tracker_generation = false + # Tracker public key - can be either: # 1. Hex-encoded compressed public key (33 bytes = 66 hex chars): "02dada811a888cd0dc7a0a41739a3ad9b0f427741fe6ca19700cf1a51200c96bf7" # 2. Ergo P2PK address: "9fRusAarL1KkrWQVsxSRVYnvWxaAT2A96cKtNn9tvPh5XUyCisr33" @@ -90,14 +104,16 @@ When creating a reserve contract box, you must set these registers: ## Environment Variables -All configuration options can also be set via environment variables with the `BASIS_` prefix: +All configuration options can also be set via environment variables with the `BASIS_` prefix. Use two underscores (`__`) between nested TOML sections; underscores inside a field name remain literal: ```bash -export BASIS_SERVER_HOST="0.0.0.0" -export BASIS_SERVER_PORT=3048 -export BASIS_ERGO_BASIS_RESERVE_CONTRACT_P2S="your_reserve_contract_p2s" -export BASIS_ERGO_TRACKER_NFT_ID="your_tracker_nft_id" -export BASIS_ERGO_NODE_URL="http://your-node:9053" +export BASIS_SERVER__HOST="0.0.0.0" +export BASIS_SERVER__PORT=3048 +export BASIS_ERGO__BASIS_RESERVE_CONTRACT_P2S="your_reserve_contract_p2s" +export BASIS_ERGO__TRACKER_NFT_ID="your_tracker_nft_id" +# Only for intentional first initialization of a new tracker NFT/data directory: +export BASIS_ERGO__ALLOW_FRESH_TRACKER_GENERATION="true" +export BASIS_ERGO__NODE__NODE_URL="http://your-node:9053" ``` ## Tracker Public Key Configuration @@ -141,9 +157,10 @@ data_dir = "data" database_url = "sqlite:data/basis.db" [ergo] -basis_reserve_contract_p2s = "" +# omit for temporary legacy compatibility mode; construction stays disabled start_height = 0 tracker_nft_id = "" +allow_fresh_tracker_generation = false [ergo.node] url = "http://159.89.116.15:11088" diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 572c3c6..6db01f2 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -1,231 +1,51 @@ # Basis Tracker OpenAPI Documentation -This directory contains the OpenAPI 3.0 specification for the Basis Tracker HTTP API. +The root `openapi.yaml` describes the current HTTP compatibility surface. -## Files +## Active API families -- `openapi.yaml` - Complete OpenAPI specification in YAML format -- `openapi.json` - Basic OpenAPI specification in JSON format (currently minimal) +- Note creation and lookup. +- Reserve observation and owner-wallet reserve-payload admission. +- Event, status, acceptance-policy, and tracker-state observation. -## API Overview +Reserve construction remains fail-closed for v2 until its confirmed scanner, +BNS2/BRS2 state, and exact builder inputs are integrated. The tracker is not a +wallet proxy. -The Basis Tracker API provides RESTful endpoints for managing decentralized debt issuance and tracking. The API supports: +## Retired v1 redemption routes -- **IOU Note Management**: Create and retrieve debt notes between issuers and recipients -- **Reserve Tracking**: Monitor collateral reserves for debt issuance -- **Event Monitoring**: Track system events including note updates and reserve changes -- **Health Checks**: Basic API status verification +The following compatibility routes are deprecated and always return +`410 Gone` before request-body or query parsing: -## Endpoints +- `POST /redeem` +- `POST /redeem/complete` +- `GET /proof/redemption` +- `GET /tracker/proof` +- `GET /reserve/proof` +- `POST /tracker/signature` +- `POST /redemption/prepare` +- `POST /redemption/build` +- `POST /redemption/submit` -### Health Check -- `GET /` - Returns "Hello, Basis Tracker API!" +The generic `GET /proof` route does not exist. The tombstones expose no proof, +signer, transaction-construction, node-submission, broadcast, or settlement +state effect. V2 manifest admission is a separate dormant Rust boundary and is +not an HTTP route. -### Notes Management -- `POST /notes` - Create a new IOU note -- `GET /notes/issuer/{pubkey}` - Get all notes for an issuer -- `GET /notes/issuer/{issuer_pubkey}/recipient/{recipient_pubkey}` - Get specific note +All tombstones return the standard error envelope: -### Reserve Management -- `GET /reserves/issuer/{pubkey}` - Get reserves for an issuer - -### Event Monitoring -- `GET /events` - Get recent tracker events (50 most recent) -- `GET /events/paginated` - Get paginated tracker events - -### Status and Monitoring -- `GET /key-status/{pubkey}` - Get comprehensive key status information - -### Redemption Operations -- `POST /redeem` - Initiate redemption of an IOU note - -### Proof Generation -- `GET /proof` - Generate proof for a specific note - -## Data Formats - -### Public Keys and Signatures -All public keys and signatures are hex-encoded strings: -- **Public Keys**: 33 bytes (66 hex characters) -- **Signatures**: 65 bytes (130 hex characters) - Schnorr format - -### API Response Format -All endpoints return a standardized response format: -```json -{ - "success": boolean, - "data": object | array | null, - "error": string | null -} -``` - -### Error Handling -- **400 Bad Request**: Invalid input parameters -- **404 Not Found**: Resource not found -- **500 Internal Server Error**: Server-side error - -## Usage Examples - -### Create a Note -```bash -curl -X POST http://localhost:3048/notes \ - -H "Content-Type: application/json" \ - -d '{ - "recipient_pubkey": "020202020202020202020202020202020202020202020202020202020202020202", - "amount": 1000, - "timestamp": 1234567890, - "signature": "0303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303", - "issuer_pubkey": "010101010101010101010101010101010101010101010101010101010101010101" - }' -``` - -### Get Notes by Issuer -```bash -curl http://localhost:3048/notes/issuer/010101010101010101010101010101010101010101010101010101010101010101 -``` - -### Get Events -```bash -# Get recent events -curl http://localhost:3048/events - -# Get paginated events -curl "http://localhost:3048/events/paginated?page=0&page_size=10" -``` - -### Get Key Status -```bash -curl http://localhost:3048/key-status/010101010101010101010101010101010101010101010101010101010101010101 -``` - -### Initiate Redemption -```bash -curl -X POST http://localhost:3048/redeem \ - -H "Content-Type: application/json" \ - -d '{ - "issuer_pubkey": "010101010101010101010101010101010101010101010101010101010101010101", - "recipient_pubkey": "020202020202020202020202020202020202020202020202020202020202020202", - "amount": 500000000, - "timestamp": 1234567890 - }' -``` - -### Get Proof -```bash -curl "http://localhost:3048/proof?issuer_pubkey=010101010101010101010101010101010101010101010101010101010101010101&recipient_pubkey=020202020202020202020202020202020202020202020202020202020202020202" -``` - -## Validation - -The OpenAPI specification includes validation rules: -- Public keys must match pattern: `^[0-9a-fA-F]{66}$` -- Signatures must match pattern: `^[0-9a-fA-F]{130}$` -- Amounts must be positive integers -- Timestamps must be valid Unix timestamps (tracked for record-keeping, not used in signing message) - -### Signing Message Format - -Note that the signature in note creation requests signs the following message format: -- **All redemptions**: `key || totalDebt || timestamp` (48 bytes) -- Emergency redemption uses the same message format; tracker signature becomes optional -- Where `key = blake2b256(ownerKey || receiverKey)` (32 bytes) - -### Context Extension Variables - -Redemption transactions use context extension variables to pass data to the Basis contract: - -| Variable | Register | Purpose | Source | -|----------|----------|---------|--------| -| **#0** | - | Action byte (0x00 = redemption) | Constant | -| **#1** | - | Receiver's public key (33 bytes) | From request | -| **#2** | - | Reserve owner's Schnorr signature (65 bytes) | From issuer wallet | -| **#3** | - | Total debt amount (8 bytes) | From tracker AVL proof | -| **#5** | - | Reserve insert proof | From `/reserve/proof` endpoint | -| **#6** | - | Tracker's Schnorr signature (65 bytes) | From `/tracker/signature` endpoint | -| **#7** | - | Reserve lookup proof (optional) | From `/reserve/proof` endpoint | -| **#8** | - | Tracker lookup proof | From `/tracker/proof` endpoint | - -**Note:** Context variable #7 (reserve lookup proof) is omitted for first redemptions when `already_redeemed = 0`. - -### AVL Proof Endpoints - -The API provides three proof endpoints for redemption transactions: - -1. **`GET /tracker/proof`** - Tracker lookup proof (context var #8) - - Proves `totalDebt` exists in tracker's AVL tree - - Returns: `key`, `value`, `proof`, `total_debt`, `tracker_state_digest` - -2. **`GET /reserve/proof`** - Reserve proofs (context var #5 and #7) - - Provides both insert proof (#5) and lookup proof (#7) - - Returns: `key`, `value`, `proof` (lookup), `insert_proof`, `already_redeemed`, `is_first_redemption` - -3. **`GET /proof/redemption`** - Comprehensive redemption proof - - Combines all proofs in single request - - Returns: `tracker_lookup_proof`, `reserve_lookup_proof`, `reserve_insert_proof`, state digests, amounts - -### Tracker Signature Endpoint - -**`POST /tracker/signature`** - Request tracker signature for redemption - -The tracker signs the same message as the issuer: -- All redemptions: `key || totalDebt || timestamp` (48 bytes) -- Emergency redemption uses the same format; tracker signature becomes optional - -Request body: -```json -{ - "issuer_pubkey": "hex...", - "recipient_pubkey": "hex...", - "total_debt": 1000000000, - "emergency": false -} -``` - -Response: ```json { - "success": true, - "tracker_signature": "hex...", - "tracker_pubkey": "hex...", - "message_signed": "hex...", - "is_emergency": false + "success": false, + "data": null, + "error": "Basis v1 redemption is retired; ..." } ``` -## Tools - -You can use the OpenAPI specification with various tools: - -### Swagger UI -```bash -# Install swagger-ui -npm install -g swagger-ui - -# Serve the specification -swagger-ui openapi.yaml -``` - -### Redoc -```bash -# Install redoc -npm install -g redoc-cli - -# Generate documentation -redoc-cli serve openapi.yaml -``` - -### OpenAPI Generator -```bash -# Generate client libraries -openapi-generator generate -i openapi.yaml -g typescript-axios -o ./client -``` - -## Development - -The OpenAPI specification is automatically generated from the Rust codebase. When adding new endpoints or modifying existing ones, update both the Rust implementation and the OpenAPI specification. - -## Related Documentation +## Data conventions -- [HTTP_API.md](../HTTP_API.md) - Detailed API usage instructions -- [CRUSH.md](../CRUSH.md) - Development guidelines and commands -- [README.md](../README.md) - Project overview +- Public keys and signatures used by active note APIs are hex encoded. +- API responses use the `success`, `data`, and `error` envelope documented in + `openapi.yaml`. +- The specification must be updated with route changes; it is not generated + automatically from the Rust source. diff --git a/docs/TRACKER_BOX_SETUP.md b/docs/TRACKER_BOX_SETUP.md index 5e2617c..84797a2 100644 --- a/docs/TRACKER_BOX_SETUP.md +++ b/docs/TRACKER_BOX_SETUP.md @@ -1,428 +1,97 @@ -# Tracker Box Setup Guide +# Tracker Box Setup -**Document Version:** 1.0 -**Last Updated:** 2026-03-02 -**Status:** Production Required +The tracker box publisher maintains the on-chain R5 commitment for one +configured tracker NFT. The server does not mint the NFT or create the initial +tracker box. Provision those assets through a separately reviewed wallet flow, +then configure the existing generation before enabling publication. ---- +## Required identity -## Overview +The active tracker input must have all of the following properties: -The **tracker box** is an on-chain Ergo box that contains the tracker server's state commitment (AVL tree root digest). It is a **critical component** of the Basis system that must be created and maintained **before any redemptions can be processed**. +- the configured tracker NFT is the first token, with amount one, and occurs + exactly once; +- R4 is a `GroupElement` equal to the configured tracker public key; +- the configured 32-byte secret derives that same public key; +- R5 is the current serialized AVL commitment; +- its value, ErgoTree, token order, and any R6-R9 registers can be preserved in + the successor. -### Why is the Tracker Box Required? - -The tracker box serves as the on-chain commitment to the tracker's offchain state: - -1. **State Commitment**: Contains the AVL tree root digest (R5 register) that commits to all debt relationships -2. **Tracker Identity**: Contains the tracker's public key (R4 register) for signature verification -3. **System Integrity**: Links the tracker to the reserve contracts via the tracker NFT (R6 register) - -**Without a tracker box:** -- ❌ CLI cannot generate valid redemption transactions -- ❌ Server cannot process redemptions -- ❌ No on-chain commitment to tracker state -- ❌ System falls back to placeholder values (transactions will fail) - ---- - -## Prerequisites - -Before setting up the tracker box, ensure you have: - -### 1. Tracker NFT Created - -The tracker NFT is a unique token that identifies your tracker instance. It should be created **before** the tracker box. - -```bash -# Example: Create tracker NFT using Ergo node -# This creates a box with a unique token ID -curl -X POST http:///wallet/transaction/send \ - -H "api_key: " \ - -H "Content-Type: application/json" \ - -d '{ - "requests": [{ - "address": "", - "value": 1000000, - "assets": [{ - "tokenId": "", - "amount": 1 - }] - }] - }' -``` - -**Record the NFT token ID** - you'll need it for configuration. - -### 2. Tracker Key Pair Generated - -Generate a secp256k1 key pair for the tracker: - -```bash -# Using the CLI -basis_cli keygen --output tracker_keys.json - -# Or using ergo-lib tools -# The key should be in compressed format (33 bytes, 66 hex chars) -``` - -**Securely store the private key** - it will be used to sign redemptions. - -### 3. Ergo Node Access - -You need access to an Ergo node with: -- API key for authentication -- Wallet access for transaction submission -- Sufficient ERG for box creation (minimum 0.001 ERG per box) - ---- +The tracker input is a contract box. Its ErgoTree is not required to be the +tracker key's P2PK tree. Fee inputs are different: every fee input must be +token-free and protected by exactly that derived P2PK tree. ## Configuration -### Step 1: Update Server Configuration - -Edit your `basis.yaml` or `config/basis.yaml` file: - -```yaml -ergo: - # Tracker NFT ID (required - 64 hex chars = 32 bytes) - tracker_nft_id: "69c5d7a4df2e72252b0015d981876fe338ca240d5576d4e731dfd848ae18fe2b" - - # Tracker public key (required - 66 hex chars = 33 bytes compressed) - # Can be hex-encoded pubkey OR P2PK address (starts with '9' for mainnet) - tracker_public_key: "030303030303030303030303030303030303030303030303030303030303030303" - # OR - tracker_public_key: "9fD5TqXvN8Z3k2LmP7wR4sY6uH1jC8bA0eG9iK3oM5nQ2xV" - - node: - node_url: "http://159.89.116.15:11088" - api_key: "hello" - scan_name: "Basis Tracker Scanner" - -transaction: - # Change address (optional - derived from tracker_public_key if not set) - change_address: "9fD5TqXvN8Z3k2LmP7wR4sY6uH1jC8bA0eG9iK3oM5nQ2xV" - fee: 1000000 # 0.001 ERG -``` - -### Step 2: Verify Configuration - -```bash -# Start the server and check logs -cargo run --bin basis_server - -# Expected log output: -# [INFO] Tracker NFT ID from config: Some("69c5d7a4...") -# [INFO] Initializing tracker scanner with tracker NFT ID... -# [INFO] Tracker scan registered with ID: -# [INFO] Tracker scanner initialization completed successfully -``` - ---- - -## Creating the Initial Tracker Box - -### Option 1: Automatic (Recommended) - -The tracker box updater will **automatically create** the initial tracker box on startup if: -1. Tracker NFT ID is configured -2. Tracker public key is configured -3. No existing tracker box is found - -**Process:** -1. Server starts and initializes tracker scanner -2. Scanner checks for existing tracker boxes -3. If none found, creates initial box with: - - R4: Tracker public key (GroupElement) - - R5: Empty AVL tree (root digest of empty tree) - - R6: Tracker NFT ID - -**Logs to watch for:** -``` -[INFO] No tracker boxes found, creating initial tracker box -[INFO] Tracker box update transaction submitted: tx_id= -[INFO] Tracker box created: box_id= -``` - -### Option 2: Manual Creation - -If automatic creation fails, create the tracker box manually: - -#### Using Ergo Node API - -```bash -# Create tracker box with proper registers -curl -X POST http:///wallet/transaction/send \ - -H "api_key: " \ - -H "Content-Type: application/json" \ - -d '{ - "requests": [{ - "address": "", - "value": 1000000, - "assets": [{ - "tokenId": "", - "amount": 1 - }], - "registers": { - "R4": "", - "R5": "", - "R6": "" - } - }] - }' -``` - -**Register Values:** -- **R4**: Tracker public key as GroupElement (use `Constant::from(pubkey_bytes).sigma_serialize_bytes()`) -- **R5**: Serialized SAvlTree (43 bytes): - - Byte 0: `0x64` (SAvlTree type) - - Bytes 1-33: Root digest (33 bytes) - - Byte 34: `0x01` (insert-only flag) - - Bytes 35-38: `0x00000040` (key length = 64) - - Bytes 39-42: `0x00000000` (value length = 0 for variable) -- **R6**: Tracker NFT ID (hex-encoded, 64 chars) - -#### Using CLI (Future Feature) - -```bash -# This command may be added in a future version -basis_cli tracker create-initial \ - --nft-id \ - --pubkey \ - --node-url \ - --api-key -``` - ---- - -## Verification - -### Check Tracker Box Exists - -```bash -# Query the tracker box via API -curl http://localhost:3048/tracker/latest-box-id - -# Expected response: -{ - "tracker_box_id": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - "timestamp": 1234567890, - "height": 1000000 -} -``` - -### Check Tracker Scanner Status - -```bash -# Server logs should show: -[INFO] Processed 1 tracker boxes (1 successful) -[INFO] Updated tracker state with 1 boxes -``` - -### Test Redemption Flow - -After tracker box is created, test the redemption flow: - -```bash -# 1. Create a test note -basis_cli note create \ - --recipient \ - --amount 1000000000 - -# 2. Get tracker proof -curl "http://localhost:3048/tracker/proof?issuer_pubkey=&recipient_pubkey=" - -# Expected: Valid proof data (not placeholder) -{ - "success": true, - "data": { - "key": "...", - "value": "...", - "proof": "...", - "total_debt": 1000000000 - } -} - -# 3. Get reserve proof -curl "http://localhost:3048/reserve/proof?issuer_pubkey=&recipient_pubkey=" - -# Expected: Valid proof with insert_proof field -{ - "success": true, - "data": { - "key": "...", - "value": "...", - "proof": null, // null for first redemption - "insert_proof": "...", // ← This should NOT be placeholder - "already_redeemed": 0, - "is_first_redemption": true - } -} -``` - ---- - -## Troubleshooting - -### Issue: "No tracker boxes found in scanner" - -**Symptoms:** -``` -[WARN] No tracker boxes found in scanner -[WARN] Tracker scanner not initialized -``` - -**Causes:** -1. Tracker NFT ID not configured -2. Tracker scanner failed to register scan -3. No tracker box exists on-chain - -**Solutions:** -1. Verify `tracker_nft_id` in configuration -2. Check Ergo node connectivity -3. Create initial tracker box (see "Creating the Initial Tracker Box" above) - ---- - -### Issue: "Tracker scan registration failed" - -**Symptoms:** -``` -[WARN] Failed to register tracker scan: -``` - -**Causes:** -1. Ergo node API unreachable -2. Invalid API key -3. Scan name conflict - -**Solutions:** -1. Verify `node_url` is accessible -2. Check `api_key` is correct -3. Try changing `scan_name` in configuration - ---- - -### Issue: "Failed to get tracker box ID from storage" - -**Symptoms:** -``` -[ERROR] Failed to get tracker box ID from storage: -``` - -**Causes:** -1. Tracker storage directory not writable -2. Database corruption -3. Tracker scanner not running - -**Solutions:** -1. Check `data/tracker_boxes` directory permissions -2. Delete and recreate storage directory (will rescan) -3. Restart server and check logs - ---- - -### Issue: CLI shows "using placeholder" warnings - -**Symptoms:** -``` -⚠️ Tracker box not found, using placeholder. -⚠️ Could not retrieve reserve contract P2S from server, using placeholder -``` - -**Causes:** -1. Tracker box doesn't exist yet -2. Server not running or unreachable -3. Configuration mismatch - -**Solutions:** -1. Create tracker box (see above) -2. Verify server is running: `curl http://localhost:3048/` -3. Check CLI configuration matches server - ---- - -## Maintenance - -### Tracker Box Updates - -The tracker box updater **automatically updates** the tracker box every 10 minutes (configurable): - -1. Fetches current AVL tree root digest -2. Creates update transaction with new R5 value -3. Submits to Ergo node - -**Logs:** -``` -[INFO] Tracker Box Update Transaction Submitted: R4=..., R5=..., tx_id=... -``` - -### Monitoring - -Monitor tracker box health: - -```bash -# Check latest tracker box -curl http://localhost:3048/tracker/latest-box-id - -# Check tracker proof (verifies AVL tree is working) -curl "http://localhost:3048/tracker/proof?issuer_pubkey=&recipient_pubkey=" - -# Check server health -curl http://localhost:3048/ -``` - -### Backup - -Backup tracker storage: -```bash -# Backup tracker box database -cp -r data/tracker_boxes /backup/tracker_boxes_$(date +%Y%m%d) - -# Backup scanner metadata -cp -r data/tracker_scanner_metadata /backup/scanner_metadata_$(date +%Y%m%d) -``` - ---- - -## Security Considerations - -### Private Key Storage - -⚠️ **CRITICAL**: The tracker's private key must be stored securely: - -- **DO**: Use hardware wallet or HSM for production -- **DO**: Restrict file permissions on key files -- **DON'T**: Store private key in configuration files -- **DON'T**: Commit keys to version control - -### Tracker Box Access - -The tracker box update mechanism requires: -- Ergo node API access with wallet permissions -- Sufficient ERG balance for transaction fees - -**Recommendations:** -- Use dedicated node for tracker operations -- Monitor ERG balance for fee payments -- Set up alerts for failed box updates - ---- - -## Related Documentation - -- [CONFIGURATION.md](CONFIGURATION.md) - Server configuration reference -- [BUILD_AND_CREATE_RESERVE.md](BUILD_AND_CREATE_RESERVE.md) - Reserve creation guide -- [specs/spec.md](../specs/spec.md) - Basis protocol specification - ---- - -## Support - -For issues or questions: -1. Check server logs for error messages -2. Verify configuration matches this guide -3. Test with `curl` commands above -4. Review troubleshooting section - -**Production deployments should test tracker box setup in a staging environment first.** +Use the TOML configuration shape from `config/basis.toml.example`: + +```toml +[ergo] +tracker_nft_id = "<64 lowercase or uppercase hex characters>" +allow_fresh_tracker_generation = false +tracker_public_key = "<66 hex characters or a P2PK address>" +tracker_secret_key = "<64 hex characters>" + +[ergo.node] +node_url = "http://127.0.0.1:9053" +api_key = "" + +[transaction] +fee = 1000000 +``` + +Keep `allow_fresh_tracker_generation = false` for every existing state +directory and every restart. Set it to `true` only for the one intentional +initialization of a new, empty state directory whose configured NFT and initial +root are being bound for the first time; return it to `false` immediately +afterward. It does not authorize minting, replacing, or silently adopting an +on-chain generation. + +Do not commit either secret. Configuration `Debug` output redacts the node API +key and tracker secret. + +`transaction.change_address` may still exist for unrelated compatibility +surfaces, but the tracker publisher ignores it. Publisher change is always +sent to the P2PK ErgoTree derived from `tracker_secret_key` after that secret is +matched to `tracker_public_key` and tracker R4. + +## Signing and submission boundary + +For every update the publisher: + +1. obtains the tracker and candidate fee boxes as node JSON; +2. obtains each same box from `/utxo/byIdBinary/{boxId}` and Sigma-parses the + canonical bytes; +3. requires exact JSON/raw equality for ID, value, ErgoTree, ordered assets, + R4-R9 key set and bytes, and creation height; +4. fetches exactly 10 newest-first, parent-linked headers and requires + `/info.fullHeight`, `/info.bestFullHeaderId`, and the nested current + parameter set to describe that same tip; +5. constructs a typed transaction with checked sums and current dust limits; +6. signs in process with ergo-lib `Wallet` and validates the signed transaction + against the same ordered, duplicate-free exact inputs and state context; +7. sends only the signed transaction to `POST /transactions`. + +The publisher does not call `/wallet/transaction/sign`, does not serialize a +`secrets` or `inputsRaw` signing bundle, and does not send the tracker secret to +the node. The node wallet endpoint is used only to discover candidate unspent +fee boxes; each candidate is independently rebound to its exact Sigma bytes and +owner tree before signing. + +## Fail-closed conditions + +Publication is refused when any of these conditions holds: + +- the tracker box is absent or its NFT/R4 authority is wrong; +- the secret is absent, invalid, or does not derive the configured public key; +- node JSON differs from canonical box bytes; +- a fee input has a token or a different ErgoTree; +- input cardinality, order, or uniqueness differs between construction and + signing; +- the 10-header chain or `/info` pin is incomplete or inconsistent; +- value arithmetic overflows, fee funds are insufficient, or an output is + dust; +- local proof generation or post-sign transaction validation fails. + +Successful node admission is not confirmation or settlement evidence. The +confirmed-chain reconciler owns that later transition and its reorg policy. diff --git a/openapi.yaml b/openapi.yaml index 15e8824..68a2425 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -280,259 +280,143 @@ paths: /redeem: post: - summary: Initiate redemption process - description: Initiate the redemption of an IOU note from a reserve + summary: Retired v1 redemption route + description: Compatibility tombstone with no request parsing or runtime effects. operationId: initiateRedemption + deprecated: true tags: - Redemption - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RedeemRequest' responses: - '200': - description: Redemption initiated successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponseRedeem' - '400': - description: Bad request - invalid input parameters - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponseError' - '500': - description: Internal server error + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - /proof: + /tracker/proof: get: - summary: Get proof for a specific note - description: Generate and retrieve a proof for a specific IOU note against the current tracker state - operationId: getProof + summary: Retired v1 tracker-proof route + description: Compatibility tombstone with no query parsing or runtime effects. + operationId: getTrackerProof + deprecated: true tags: - Proofs - parameters: - - name: issuer_pubkey - in: query - required: true - description: Hex-encoded issuer public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "010101010101010101010101010101010101010101010101010101010101010101" - - name: recipient_pubkey - in: query - required: true - description: Hex-encoded recipient public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "020202020202020202020202020202020202020202020202020202020202020202" responses: - '200': - description: Successfully generated proof - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponseProof' - '400': - description: Bad request - invalid public key format or missing parameters - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponseError' - '500': - description: Internal server error + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - /tracker/proof: + /reserve/proof: get: - summary: Get tracker lookup proof - description: | - Get AVL proof for totalDebt from tracker's AVL tree. - Returns proof for context extension variable #8 in redemption transactions. - operationId: getTrackerProof + summary: Retired v1 reserve-proof route + description: Compatibility tombstone with no query parsing or runtime effects. + operationId: getReserveProof + deprecated: true tags: - Proofs - parameters: - - name: issuer_pubkey - in: query - required: true - description: Hex-encoded issuer public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "010101010101010101010101010101010101010101010101010101010101010101" - - name: recipient_pubkey - in: query - required: true - description: Hex-encoded recipient public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "020202020202020202020202020202020202020202020202020202020202020202" responses: - '200': - description: Successfully generated tracker proof - content: - application/json: - schema: - $ref: '#/components/schemas/TrackerProofData' - '400': - description: Bad request - invalid public key format + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - '500': - description: Internal server error + + /tracker/signature: + post: + summary: Retired v1 tracker-signature route + description: Compatibility tombstone with no request parsing or signing effects. + operationId: requestTrackerSignature + deprecated: true + tags: + - Redemption + responses: + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - /reserve/proof: + /proof/redemption: get: - summary: Get reserve lookup proof - description: | - Get AVL proof for already_redeemed from reserve's AVL tree. - Returns proof for context extension variable #7 in redemption transactions. - For first redemptions, returns proof: null (no lookup proof needed). - operationId: getReserveProof + summary: Retired v1 aggregate-proof route + description: Compatibility tombstone with no query parsing or proof effects. + operationId: getRedemptionProof + deprecated: true tags: - Proofs - parameters: - - name: issuer_pubkey - in: query - required: true - description: Hex-encoded issuer public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "010101010101010101010101010101010101010101010101010101010101010101" - - name: recipient_pubkey - in: query - required: true - description: Hex-encoded recipient public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "020202020202020202020202020202020202020202020202020202020202020202" responses: - '200': - description: Successfully generated reserve proof - content: - application/json: - schema: - $ref: '#/components/schemas/ReserveProofData' - '400': - description: Bad request - invalid public key format - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponseError' - '500': - description: Internal server error + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - /tracker/signature: + /redeem/complete: post: - summary: Request tracker signature for redemption - description: | - Request Schnorr signature from tracker for redemption. - The tracker signs: key || totalDebt || timestamp - where key = blake2b256(ownerKey || receiverKey) - operationId: requestTrackerSignature + summary: Retired v1 direct-completion route + description: Compatibility tombstone with no request parsing or state effects. + operationId: completeRedemption + deprecated: true tags: - Redemption - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TrackerSignatureRequest' responses: - '200': - description: Successfully generated tracker signature - content: - application/json: - schema: - $ref: '#/components/schemas/TrackerSignatureResponse' - '400': - description: Bad request - invalid public key format + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - '500': - description: Internal server error + + /redemption/prepare: + post: + summary: Retired v1 redemption-preparation route + description: Compatibility tombstone with no request parsing, proof, or signing effects. + operationId: prepareRedemption + deprecated: true + tags: + - Redemption + responses: + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - /proof/redemption: - get: - summary: Get comprehensive redemption proof - description: | - Get all proofs needed for redemption in a single request. - Includes tracker lookup proof, reserve lookup proof, and reserve insert proof. - operationId: getRedemptionProof + /redemption/build: + post: + summary: Retired v1 transaction-build route + description: Compatibility tombstone with no request parsing or construction effects. + operationId: buildRedemption + deprecated: true tags: - - Proofs - parameters: - - name: issuer_pubkey - in: query - required: true - description: Hex-encoded issuer public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "010101010101010101010101010101010101010101010101010101010101010101" - - name: recipient_pubkey - in: query - required: true - description: Hex-encoded recipient public key (66 characters) - schema: - type: string - pattern: '^[0-9a-fA-F]{66}$' - example: "020202020202020202020202020202020202020202020202020202020202020202" - - name: amount - in: query - required: false - description: Redemption amount (optional) - schema: - type: integer - format: uint64 - example: 500000000 + - Redemption responses: - '200': - description: Successfully generated redemption proof - content: - application/json: - schema: - $ref: '#/components/schemas/RedemptionProofResponse' - '400': - description: Bad request - invalid parameters + '410': + description: Basis v1 redemption is retired content: application/json: schema: $ref: '#/components/schemas/ApiResponseError' - '500': - description: Internal server error + + /redemption/submit: + post: + summary: Retired v1 transaction-submit route + description: Compatibility tombstone with no request parsing, node, or broadcast effects. + operationId: submitRedemption + deprecated: true + tags: + - Redemption + responses: + '410': + description: Basis v1 redemption is retired content: application/json: schema: @@ -745,135 +629,6 @@ components: description: Hex-encoded issuer public key example: "010101010101010101010101010101010101010101010101010101010101010101" - RedeemRequest: - type: object - description: Redemption request - required: - - issuer_pubkey - - recipient_pubkey - - amount - - timestamp - - issuer_signature - properties: - issuer_pubkey: - type: string - description: Hex-encoded issuer public key (66 characters) - pattern: '^[0-9a-fA-F]{66}$' - example: "010101010101010101010101010101010101010101010101010101010101010101" - recipient_pubkey: - type: string - description: Hex-encoded recipient public key (66 characters) - pattern: '^[0-9a-fA-F]{66}$' - example: "020202020202020202020202020202020202020202020202020202020202020202" - amount: - type: integer - format: uint64 - description: Amount to redeem - example: 500000000 - timestamp: - type: integer - format: uint64 - description: Timestamp of the note being redeemed - example: 1234567890 - reserve_box_id: - type: string - description: Reserve box ID (optional - server will lookup if not provided) - example: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - tracker_box_id: - type: string - description: Tracker box ID (optional - server will fetch if not provided) - example: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - tracker_nft_id: - type: string - description: Tracker NFT ID (optional - server will fetch if not provided) - example: "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" - current_height: - type: integer - format: uint64 - description: Current blockchain height (optional - server will fetch if not provided) - example: 1000000 - recipient_address: - type: string - description: Recipient address (optional - server will derive from pubkey if not provided) - example: "9fD5TqXvN8Z3k2LmP7wR4sY6uH1jC8bA0eG9iK3oM5nQ2xV" - change_address: - type: string - description: Change address (optional - server will derive from tracker pubkey if not provided) - example: "9fD5TqXvN8Z3k2LmP7wR4sY6uH1jC8bA0eG9iK3oM5nQ2xV" - issuer_signature: - type: string - description: Hex-encoded Schnorr signature from issuer (130 characters = 65 bytes) - pattern: '^[0-9a-fA-F]{130}$' - example: "0303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303" - emergency: - type: boolean - description: Whether this is an emergency redemption (after 3 days tracker unavailability) - default: false - example: false - tracker_signature: - type: string - description: Hex-encoded Schnorr signature from tracker (optional - server will generate if not provided) - pattern: '^[0-9a-fA-F]{130}$' - example: "0404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404" - - RedeemResponse: - type: object - description: Redemption response - properties: - redemption_id: - type: string - description: Unique redemption identifier - example: "redeem_0101010101010101_0202020202020202" - amount: - type: integer - format: uint64 - description: Amount being redeemed - example: 500000000 - timestamp: - type: integer - format: uint64 - description: Redemption timestamp - example: 1672531200 - proof_available: - type: boolean - description: Whether proof is available - example: true - transaction_pending: - type: boolean - description: Whether redemption transaction is pending - example: false - - ProofResponse: - type: object - description: Proof response - properties: - issuer_pubkey: - type: string - description: Hex-encoded issuer public key - example: "010101010101010101010101010101010101010101010101010101010101010101" - recipient_pubkey: - type: string - description: Hex-encoded recipient public key - example: "020202020202020202020202020202020202020202020202020202020202020202" - proof_data: - type: string - description: Hex-encoded proof data - example: "proof_0101010101010101_0202020202020202" - tracker_state_digest: - type: string - description: Tracker state digest at proof generation - example: "mock_digest_1234567890abcdef" - block_height: - type: integer - format: uint64 - description: Blockchain height when proof was generated - example: 1500 - timestamp: - type: integer - format: uint64 - description: Proof generation timestamp - example: 1672531200 - # API Response Wrappers ApiResponse: type: object @@ -891,167 +646,6 @@ components: nullable: true description: Error message (null for successful responses) - # Tracker Signature Request/Response - TrackerSignatureRequest: - type: object - description: Request for tracker signature on redemption - required: - - issuer_pubkey - - recipient_pubkey - - total_debt - properties: - issuer_pubkey: - type: string - description: Hex-encoded issuer public key (66 characters) - pattern: '^[0-9a-fA-F]{66}$' - example: "010101010101010101010101010101010101010101010101010101010101010101" - recipient_pubkey: - type: string - description: Hex-encoded recipient public key (66 characters) - pattern: '^[0-9a-fA-F]{66}$' - example: "020202020202020202020202020202020202020202020202020202020202020202" - total_debt: - type: integer - format: uint64 - description: Total cumulative debt amount - example: 1000000000 - emergency: - type: boolean - description: Whether this is an emergency redemption - default: false - example: false - - TrackerSignatureResponse: - type: object - description: Tracker signature response - properties: - success: - type: boolean - description: Whether signature generation was successful - example: true - tracker_signature: - type: string - description: Hex-encoded Schnorr signature (130 characters) - example: "0404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404" - tracker_pubkey: - type: string - description: Hex-encoded tracker public key - example: "030303030303030303030303030303030303030303030303030303030303030303" - message_signed: - type: string - description: Hex-encoded message that was signed - example: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - is_emergency: - type: boolean - description: Whether this was an emergency redemption - example: false - - # Proof Data Structures - TrackerProofData: - type: object - description: Tracker lookup proof data - properties: - key: - type: string - description: Hex-encoded AVL tree key - example: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - value: - type: string - description: 'Hex-encoded value: totalDebt as 8-byte big-endian' - example: "000000003b9aca00" - proof: - type: string - description: Hex-encoded AVL proof bytes - example: "0102030405060708" - total_debt: - type: integer - format: uint64 - description: Total debt as integer - example: 1000000000 - tracker_state_digest: - type: string - description: Current tracker state digest (R5 register value) - example: "64abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - - ReserveProofData: - type: object - description: Reserve lookup proof data - properties: - key: - type: string - description: Hex-encoded AVL tree key - example: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - value: - type: string - description: 'Hex-encoded value: already_redeemed as 8-byte big-endian' - example: "000000003b9aca00" - proof: - type: string - nullable: true - description: Hex-encoded AVL proof bytes (null for first redemption) - example: "0102030405060708" - already_redeemed: - type: integer - format: uint64 - description: Already redeemed amount as integer - example: 500000000 - is_first_redemption: - type: boolean - description: Whether this is the first redemption (no lookup proof needed) - example: false - - RedemptionProofResponse: - type: object - description: Comprehensive redemption proof response - properties: - success: - type: boolean - description: Whether proof generation was successful - example: true - data: - type: object - description: Proof data - properties: - tracker_lookup_proof: - type: string - description: Hex-encoded tracker lookup proof - example: "0102030405060708" - reserve_lookup_proof: - type: string - nullable: true - description: Hex-encoded reserve lookup proof (null for first redemption) - example: "0102030405060708" - reserve_insert_proof: - type: string - description: Hex-encoded reserve insert proof - example: "0102030405060708" - tracker_state_digest: - type: string - description: Tracker state digest - example: "64abcdef" - reserve_state_digest: - type: string - description: Reserve state digest - example: "64fedcba" - total_debt: - type: integer - format: uint64 - description: Total debt amount - example: 1000000000 - already_redeemed: - type: integer - format: uint64 - description: Already redeemed amount - example: 0 - proof_valid: - type: boolean - description: Whether the proof is valid - example: true - is_first_redemption: - type: boolean - description: Whether this is the first redemption - example: true - ApiResponseEmpty: allOf: - $ref: '#/components/schemas/ApiResponse' @@ -1124,22 +718,6 @@ components: data: $ref: '#/components/schemas/KeyStatusResponse' - ApiResponseRedeem: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/RedeemResponse' - - ApiResponseProof: - allOf: - - $ref: '#/components/schemas/ApiResponse' - - type: object - properties: - data: - $ref: '#/components/schemas/ProofResponse' - responses: BadRequest: description: Bad request - invalid input parameters @@ -1174,4 +752,4 @@ tags: - name: Redemption description: Note redemption operations - name: Proofs - description: Proof generation and verification \ No newline at end of file + description: Proof generation and verification diff --git a/specs/CLI_TOOLS_ANALYSIS.md b/specs/CLI_TOOLS_ANALYSIS.md index e525c03..e81f0d6 100644 --- a/specs/CLI_TOOLS_ANALYSIS.md +++ b/specs/CLI_TOOLS_ANALYSIS.md @@ -1,5 +1,8 @@ # CLI Tools Analysis Report +> **Status: superseded v1 inventory.** Transaction-generation and local-sign +> surfaces described below have been removed from the compiled CLI. + ## Executive Summary This repository contains **4 compiled CLI binaries** and **6 shell scripts** that provide command-line interfaces for the Basis Tracker system. The primary CLI tool is `basis_cli` (Rust-based), the secondary is `basis_server` (Rust-based daemon), the third is `basis_app` (TUI wallet, also Rust-based), and the fourth is `basis_mcp` (MCP server for AI agents). Supporting shell scripts handle server lifecycle management, database cleanup, deployment, and TUI wallet launch. Integration testing is covered by Rust test suite (`cargo test`). @@ -149,7 +152,7 @@ An MCP (Model Context Protocol) server over stdio that exposes the Basis wallet #### Tools - **Read-only** (`readOnlyHint`): `server_status`, `account_list`, `account_current`, `note_list`, `note_get`, `reserve_status`, `policy_get` -- **Write**: `account_create`, `account_switch`, `account_import`, `note_create`, `note_redeem` (local signing), `reserve_create`, `policy_set` (`destructiveHint` where applicable) +- **Write**: `account_create`, `account_switch`, `account_import`, `note_create`, `reserve_create`, `policy_set` (`destructiveHint` where applicable). `note_redeem` is a retired compatibility tombstone. Private-key export is deliberately not exposed through any tool; signing happens in-process. See `docs/AGENT_INTERFACE.md` for the full tool reference and client configuration snippets. diff --git a/specs/PRODUCTION_READINESS_AUDIT.md b/specs/PRODUCTION_READINESS_AUDIT.md index e705498..db8d434 100644 --- a/specs/PRODUCTION_READINESS_AUDIT.md +++ b/specs/PRODUCTION_READINESS_AUDIT.md @@ -1,8 +1,14 @@ -# Basis Tracker - Production Readiness Audit +# Historical Basis Tracker Readiness Review (Superseded) + +> **Status: historical automated review, not a current security or production +> readiness assessment.** Later protocol review retired the v1 redemption +> manager and transaction builders from production APIs and identified broader +> contract, settlement, scanner, and signing boundaries. Statements below are +> retained only as dated project history. **Audit Date:** 2026-02-28 **Auditor:** Automated Code Review -**Status:** ✅ **PRODUCTION READY** (single redemption) - All critical placeholders resolved +**Historical Status:** Superseded; do not use for deployment approval --- @@ -412,7 +418,7 @@ Various test files contain placeholder implementations that don't affect product ## Conclusion -**Current Status:** ✅ **PRODUCTION READY** (single and sequential multiple redemptions) +**Historical conclusion:** Superseded; no current readiness claim The Basis Tracker has a complete core protocol implementation. All critical placeholders have been resolved: diff --git a/specs/REMAINING_ENHANCEMENTS.md b/specs/REMAINING_ENHANCEMENTS.md index d1acd29..f08ca9d 100644 --- a/specs/REMAINING_ENHANCEMENTS.md +++ b/specs/REMAINING_ENHANCEMENTS.md @@ -2,8 +2,8 @@ **Document Version:** 1.0 **Date:** 2026-02-28 -**Status:** Pending Implementation -**Priority:** LOW (Enhancements - core protocol is complete) +**Status:** Superseded v1 planning record +**Priority:** Historical only; no production-readiness claim --- diff --git a/specs/SCHNORR_SIGNATURE_SPEC.md b/specs/SCHNORR_SIGNATURE_SPEC.md index 3da1d57..c995fa3 100644 --- a/specs/SCHNORR_SIGNATURE_SPEC.md +++ b/specs/SCHNORR_SIGNATURE_SPEC.md @@ -1,5 +1,11 @@ # Schnorr Signature Specification for Basis Tracker +> **Status: cryptographic primitive reference only.** The legacy tracker +> signature HTTP route and delegation to the node's `/utils/schnorrSign` +> endpoint are retired. Current v2 code validates the canonical signature +> subset during bounded manifest admission but does not provide a signing +> service, wallet integration, submission or broadcast path. + ## Overview This specification defines the Schnorr signature algorithm implementation for the Basis Tracker system. It follows the chaincash-rs approach with secp256k1 elliptic curve cryptography and is designed to be compatible with Ergo blockchain requirements. @@ -438,4 +444,4 @@ Scala and Rust implementations. - **Timestamp**: `1743379202000` - **Message**: `07b67390866bedf6c19b3fab1e29993ea6878e0d0dd0577ac6b6368c96a1220b000000001dcd650000000195e97f7fd0` - **Signature**: `03517ac544f2d87d1ae0731b9c992d7359bfb09b41d18337b9c24dd59b6919b3f26d73531d00d7ba3ae8cf36168a9b9f652eed6cb6a5c7f68c8e9d8fd36641e5a5` -- **Expected verify**: `true` \ No newline at end of file +- **Expected verify**: `true` diff --git a/specs/acceptance_predicates.md b/specs/acceptance_predicates.md index 593e210..a00af49 100644 --- a/specs/acceptance_predicates.md +++ b/specs/acceptance_predicates.md @@ -25,6 +25,8 @@ pub struct PredicateContext { pub recipient_pubkey: PubKey, /// Total cumulative debt amount in the note pub total_debt: u64, + /// Conservative issuer-wide gross debt after applying the candidate note + pub projected_issuer_gross_debt: Option, /// Optional cloned reserve tracker for collateralization checks pub reserve_tracker: Option, } @@ -33,6 +35,11 @@ pub struct PredicateContext { pub trait NotePredicate: Send + Sync + std::fmt::Debug { /// Evaluate whether a note is acceptable given the context fn acceptable(&self, ctx: &PredicateContext) -> bool; + + /// Whether this predicate tree needs the issuer-wide liability snapshot + fn requires_liability_snapshot(&self) -> bool { + false + } /// Get the predicate name fn name(&self) -> &str; @@ -72,11 +79,28 @@ For a given note, compute: ``` owner_reserve = reserve associated with note owner (issuer) assets = owner_reserve.value (in nanoERG) -liabilities = owner_reserve.total_issued_debt (in nanoERG) +liabilities = projected issuer-wide gross debt (in nanoERG) accept if: assets >= liabilities * min_ratio ``` +The tracker calculates liabilities across every current issuer-to-recipient +edge. The candidate replaces its recipient edge exactly once. For each edge, +the greatest local, pending, or confirmed cumulative `totalDebt` is used, so a +lower candidate cannot reduce already observed debt. If the recipient is +omitted, the candidate is conservatively treated as a new edge. The snapshot +uses gross debt because redeemed amounts are not yet reconstructed safely +across chain reorganizations. If the snapshot is unavailable or overflows, +collateralization rejects the note. + +The snapshot is read strictly from the primary note partition. Secondary +indices are validated but are not trusted for completeness. A malformed or +key-mismatched primary record, or an index reference whose primary record is +missing, therefore makes the collateral check reject instead of treating the +record as zero debt. The tracker database remains the local authority after note +ingestion; this scan does not re-authenticate stored note signatures or detect a +same-length mutation of a stored amount. + **Variants**: - **Full collateralization** (ratio = 1.0): Assets ≥ Liabilities - **Over-collateralization** (ratio > 1.0): Assets ≥ Liabilities * ratio @@ -256,7 +280,10 @@ fn check_collateralization(ctx: &PredicateContext, min_ratio: f64) -> bool { }; let assets = reserve.base_info.collateral_amount; - let liabilities = reserve.total_debt; + let liabilities = match ctx.projected_issuer_gross_debt { + Some(value) => value, + None => return false, + }; if liabilities == 0 { return true; // No debt means fully collateralized @@ -530,12 +557,16 @@ Acceptance predicates are: - Predicates are loaded at server startup from TOML configuration - Public keys in whitelists/blacklists are stored as 33-byte compressed secp256k1 keys -- Collateralization state is derived from tracked reserves in real-time via `ReserveTracker` +- Collateral assets are derived from tracked reserves; gross liabilities are + calculated from the serialized tracker-note and confirmation snapshot ### Performance - Whitelist/blacklist checks: O(1) with HashSet -- Collateralization checks: O(1) - single reserve lookup for note owner +- Collateralization checks: O(N) in all stored notes plus one reserve lookup. + Only predicate trees containing a collateralization check request this snapshot; + tracker requests are bounded by a two-second timeout and reject on timeout or + channel saturation. - Composite predicates: Depth-first evaluation with short-circuiting - Predicate tree built once at startup, not per-request @@ -544,8 +575,15 @@ Acceptance predicates are: - Predicate configuration is server-local and not shared on-chain - Acceptance decisions are client-side; no central authority enforces rules - A note rejected by B may still be accepted by C -- Collateralization ratios depend on accurate reserve tracking -- Fail-safe: missing reserve → reject note +- Collateralization ratios depend on accurate reserve and tracker-note state +- The local tracker database is a trusted runtime boundary after note ingestion +- Collateralization fail-safe: missing reserve, unavailable liability snapshot, + or arithmetic overflow → reject note +- `POST /acceptance/check` is a point-in-time advisory decision. It does not + reserve collateral capacity, and concurrent checks can observe the same + capacity. `POST /notes` does not atomically re-run the acceptance policy, so + callers that use acceptance as an issuance limit must serialize admission and + note insertion or add an atomic server-side admission command. ## Future Extensions diff --git a/specs/agent_integration.md b/specs/agent_integration.md index b5fd37e..aa31a64 100644 --- a/specs/agent_integration.md +++ b/specs/agent_integration.md @@ -78,7 +78,7 @@ content is pretty-printed JSON. | `account_switch` | `name` | — | `{switched: name}` | | `account_import` | `name`, `private_key_hex` (64 hex chars) | — | `{name, pubkey_hex, ...}`; the key is persisted, never returned | | `note_create` | `recipient`, `amount` (nanoERG) | — | `{issuer_pubkey, recipient_pubkey, amount, timestamp, signature, reserve_status_before, reserve_status_after}`; signed with the **current** account | -| `note_redeem` | `issuer`, `amount` (nanoERG) | `destructiveHint` | redemption result incl. `tx_id` on broadcast; local-signing path (current account = recipient) | +| `note_redeem` | `issuer`, `amount` (nanoERG) | `destructiveHint` | retired compatibility tombstone; returns an error before effects | | `reserve_create` | `nft_id` (64 hex), `amount` (nanoERG) | — | reserve-creation payload (requests, fee, change address) to submit on-chain | | `policy_set` | `policy` (JSON object matching `AcceptanceConfig`) | `destructiveHint` | `{saved, uploaded, policy_hash, uploaded_at}` | @@ -116,7 +116,6 @@ basis_cli --json account info basis_cli --json account create alice basis_cli --json note list --recipient basis_cli --json note create --recipient <66-hex> --amount 1000000000 -basis_cli --json note redeem --issuer <66-hex> --amount 1000000000 basis_cli --json reserve status basis_cli --json acceptance upload --policy-file policy.toml basis_cli --json acceptance check --issuer <66-hex> --recipient <66-hex> --total-debt 1000000000 @@ -135,12 +134,11 @@ basis_cli --json acceptance check --issuer <66-hex> --recipient <66-hex> --total 3. `note_create {recipient, amount}` — signed with the current account. 4. Confirm with `note_get {issuer: , recipient}`. -### Check receipts and redeem +### Check receipts 1. `note_list {direction: "received"}` → pick notes with `outstanding > 0`. 2. `note_get {issuer, recipient: }` for details. -3. Confirm the amount with the user, then `note_redeem {issuer, amount}` — - builds, signs, and broadcasts the on-chain redemption (`destructiveHint`). -4. `reserve_status {pubkey: issuer}` or `note_get` again to confirm. +3. Do not invoke `note_redeem`; it is retired. No replacement redemption + command is active while v2 proving and signing remain unimplemented. ### Create a reserve 1. `reserve_create {nft_id, amount}` (owner = current account) — returns the unsigned @@ -187,10 +185,10 @@ and `specs/tui_wallet_lets.md` for the design specification. - **Never request, store, or echo private keys.** Use `account_import` only when the user explicitly provides a key for import. There is deliberately no key-export MCP tool; do not work around this via `basis_cli account export`. -- **Confirm before destructive/irreversible calls**: `note_redeem` (broadcasts an - on-chain transaction), `policy_set` (overwrites the published policy), and - `note_create` (creates real debt). State amount (in ERG and nanoERG) and - counterparty pubkey, and get the user's go-ahead. +- **Confirm before destructive/irreversible calls**: `policy_set` overwrites the + published policy and `note_create` creates real debt. State amount (in ERG and + nanoERG) and counterparty pubkey, and get the user's go-ahead. Redemption is + not an available agent action. - **Validate pubkeys** (66 hex chars) before passing them; a malformed key is a user error, not something to retry blindly. - **Amounts are nanoERG** — double-check unit conversion when the user says "ERG". diff --git a/specs/basis_v2_runtime.md b/specs/basis_v2_runtime.md new file mode 100644 index 0000000..08ffc89 --- /dev/null +++ b/specs/basis_v2_runtime.md @@ -0,0 +1,120 @@ +# Basis v2 runtime boundary + +## Contract identity + +The runtime recognizes one exact ERG reserve contract and one exact token +reserve contract for ABI generation 2. Their full ErgoTree bytes are committed +under `crates/basis_store/contracts/` and are compared byte-for-byte after a +configured P2S address is decoded. + +| Family | ErgoTree bytes | SHA-256 | +| --- | ---: | --- | +| ERG | 1,682 | `2690634924efb22359a776f89f5274d77e067bd8ad0619a6e358a2f96697a0c2` | +| token | 1,963 | `ba1df64e7d95ecffc4f3d49fcada8baebe59a676eb617737cd010bdb52381cb3` | + +The bytes originate from ChainCash contract source/golden commit +`9a274396d5f78f7be5ed76bacee5329c42570317`. A readable label, an unknown P2S, +or either v1 generation is not an admissible substitute. + +## Claim identity + +A v2 note is not keyed solely by `(issuer, receiver)`. Its 32-byte claim key is: + +```text +ERG: +blake2b256( + "BASIS" || 2 || mainnet || ERG || + reserveNft || trackerNft || owner || receiver +) + +token: +blake2b256( + "BASIS" || 2 || mainnet || token || + reserveNft || reserveToken || trackerNft || owner || receiver +) +``` + +The debtor and tracker sign the same 48-byte message: + +```text +claimKey || totalDebt:i64-be || timestamp:i64-be +``` + +The Rust API represents monetary values as `u64`, but rejects zero and values +above `Long.MaxValue` before serialization. Compressed public keys are parsed +before a domain is constructed. For token reserves, the singleton id and +reserve-token id must differ. + +## Authenticated tree shapes + +ABI v2 uses two distinct authenticated-state families: + +| State | Namespace | Key | Value | Operations | +| --- | --- | ---: | ---: | --- | +| tracker R5 | global v2 claim ledger | 32-byte claim key | 8-byte cumulative debt | insert + update, no remove | +| reserve R5 | one lineage per reserve NFT | 32-byte claim key | 24-byte `(timestamp,totalDebt,redeemed)` | insert + update, no remove | + +Reserve state must never be held in one process-global AVL root. Every reserve +NFT has an independent root, proof history, active box lineage and restart +checkpoint. + +For an existing reserve entry, the same signed `(timestamp,totalDebt)` may +advance `redeemed`. A newer timestamp may increase, but never decrease, +`totalDebt`. Membership and non-membership proofs are both mandatory. + +## Bounded manifest admission + +Untrusted manifest JSON enters through one raw-byte parser capped at 2 MiB. +`V2RedemptionManifest` is serializable but is not directly deserializable, so a +CLI or future transport cannot bypass that allocation boundary. Every top-level +field is required; branch-absent evidence is represented by an explicit JSON +`null`, not by omitting the key. + +Before hex decoding, the runtime enforces these limits: + +| Field family | Encoded limit | +| --- | ---: | +| fixed identifiers, keys, roots, signatures, states | exactly twice the fixed byte width | +| AVL proof | non-empty, even-length hex; at most 131,072 characters / 65,536 bytes | +| exact Sigma box | non-empty, even-length hex; at most 8,192 characters / 4,096 bytes | + +Funding is limited to 1-16 exact token-free boxes under one P2PK owner. Every +transaction output, including the reserve successor, creditor payout, miner +fee, and optional owner change, must be within +`1,000,000..=Long.MaxValue` nanoERG. Funding and transaction aggregates use +checked arithmetic; a per-box-valid set whose aggregate overflows is rejected. + +## Activation boundary + +The exact v2 identity, state primitives, manifest builder, and signer-side +validator exist, but production activation remains deliberately unavailable. +Confirmed boxes and signer tips have no public production constructor; a test +height or caller boolean cannot create chain authority. No server, CLI, or TUI +route can prove, sign, submit, or broadcast a v2 redemption. + +Activation requires a sealed header-ancestry reconciler, authoritative +BNS2/BRS2 state, and a private prover/signer that consumes only the validated +manifest and supplies all of the following as one coherent transaction: + +- R4-R9 reserve registers, including immutable emergency R8 and predecessor R9; +- fixed-width tracker and reserve AVL trees and mandatory context variables + `0..8` as required by the selected branch; +- the exact reserve-bound claim key and both signatures; +- one payout immediately after the successor, with P2PK receiver, exact amount + and R4 equal to the reserve input id; +- fee inputs and change outside reserve accounting; +- reserve-NFT-specific proof/root/box lineage and an idempotent confirmed-chain + settlement record. + +Until that join exists, v2 stays dormant. The nine legacy HTTP compatibility +routes return `410 Gone`; the global v1 reserve AVL/proof actor surface and the +v1 transaction builder, signer, navigation, and local-sign fixture are removed. +The current admission layer implements no prover, wallet integration, +submission, broadcast, settlement reconciliation, or migration. + +## Coexistence and migration + +There is no implicit conversion of a bilateral v1 note into a reserve-bound v2 +claim, and the v2 contract cannot mutate existing v1 boxes. Activation +therefore requires an explicit generation manifest, fresh v2 reserves, and a +separately approved support/sunset policy for attributable historical state. diff --git a/specs/client/offchain_redemption_signing.md b/specs/client/offchain_redemption_signing.md index 6fa781a..beef766 100644 --- a/specs/client/offchain_redemption_signing.md +++ b/specs/client/offchain_redemption_signing.md @@ -1,5 +1,9 @@ # Off-Chain (Client-Side) Redemption Signing +> **Status: historical v1 signing design — superseded.** The implementation and +> CLI command below are removed. Current v2 code validates a manifest but has no +> prover, signer, wallet, submit, or broadcast implementation. + ## Overview A Basis redemption can be signed **entirely off-chain** and then broadcast to the node for @@ -161,8 +165,9 @@ extension already in Scala order), signs the **fee input(s)** itself with the configured `tracker_secret_key`, and returns the partial transaction plus all signing material (input/data box binaries, last 10 headers). The client only adds the reserve input's `proveDlog(recipient)` over the same `bytes_to_sign` and posts the fully-signed -transaction to `POST /redemption/submit`, which broadcasts it and syncs the tracker's -note/reserve-tree state (see +transaction to `POST /redemption/submit`, which only requests node broadcast. A +node-accepted transaction is not settlement evidence; a separate confirmed-chain +reconciler derives note/reserve-tree state after active-chain confirmation (see [redemption_state_spec.md](../server/redemption_state_spec.md#tracker-assisted-2-phase-endpoints-redemptionbuild-redemptionsubmit)). Because the tracker produces the canonical extension order and the client never @@ -175,9 +180,10 @@ in [redemption_execution_report.md](../redemption_execution_report.md)). | Party | Produces in this variant | |-------|--------------------------| | Issuer (client) | issuer Schnorr signature over the 48-byte message (build request) | -| Tracker server | unsigned tx, all AVL proofs, tracker Schnorr signature (`#6`), **fee-input `proveDlog`**, broadcast, state sync | +| Tracker server | unsigned tx, all AVL proofs, tracker Schnorr signature (`#6`), **fee-input `proveDlog`**, broadcast request | | Receiver (client) | `proveDlog(receiver)` over `bytes_to_sign` spliced into the reserve input | | Ergo node | validates only; does not sign | +| Confirmed-chain reconciler | derives settlement from the authenticated active-chain successor and rolls it back on reorg | ## References diff --git a/specs/confirmed_chain_reconciliation.md b/specs/confirmed_chain_reconciliation.md new file mode 100644 index 0000000..3b1b4e0 --- /dev/null +++ b/specs/confirmed_chain_reconciliation.md @@ -0,0 +1,179 @@ +# Confirmed-chain reconciliation + +Tracker publication is an external effect. A successful HTTP response, an +unspent output, or a transaction lookup alone is not confirmation and must not +change redeemable accounting. + +## Scope + +This implementation applies only to tracker-box publications. Reserve +settlement is intentionally outside this path: it requires a complete signed +claim, payout, token-order, R4-R9, and contract-generation manifest before an +accounting effect can be defined. Enabling the incomplete v2 path fails at +startup. + +## Acceptance invariant + +A tracker publication may be applied only when one private validated ticket +binds all of the following: + +- the locally journaled signed transaction and its locally derived transaction + id; +- the exact canonical predecessor box consumed once by that transaction; +- the unique signed successor carrying the protocol NFT as a singleton at + token index zero; +- unchanged value, ErgoTree, ordered assets, R4, and R6-R9; +- an exact R5 AVL constant encoded as `0x64 || 33-byte-root || 0x03 0x20 0x00`; +- a full block whose canonical header is the selected header at the reported + inclusion height and whose transaction list contains the exact transaction; +- the header `transactionsRoot`, recomputed with the Ergo block-version rule; +- every canonical header and parent link from the inclusion block to one + coherent node tip observed identically before and after collection; and +- a configurable minimum successor depth, maximum evidence age, and explicit + reorg-monitoring horizon. + +For block version 1, each transaction Merkle leaf is the transaction id, +derived as the Blake2b-256 hash of `bytes_to_sign`. For later versions, the raw +leaf order is every transaction id followed by every witness serialized id. +Each witness id is `Blake2b256(concat(input spending proofs)).tail`, a 31-byte +leaf. Transaction and witness leaves are grouped, not interleaved; raw proofs, +full 32-byte proof hashes, and `transaction-id || proof` leaves are rejected. +This follows Ergo node v6.0.3 commit `28ebb184`. + +Node transaction metadata is used only to locate evidence. The block +association is established by the selected header, full block, exact +transaction membership, and transactions root. + +## Durable ordering and recovery + +The single-writer journal stores one checksummed, synchronously persisted state +record. Its separate checksummed manifest is bound to the exact tracker NFT, +protocol generation 1, and the derived BNS1 state identity. A missing manifest +may be created only with one-shot explicit approval for a history-free BNS1 +generation. Existing confirmed metadata or a pending publication requires the +exact existing manifest; a missing, orphaned, or differently bound journal is +rejected before replacement state is written. + +Every accepted effect records the complete finality policy snapshot: policy id +and version, acceptance depth, evidence lifetime, reorg horizon, named network, +and a digest of the configured node endpoint. It also records a digest of the +exact canonical evidence and a domain-separated decision digest covering the +effect and policy. Rollback and retirement tickets carry and revalidate the +same snapshot. A restart under a different policy, horizon, network, or source +is rejected; changing those values requires an explicit versioned migration or +fresh acceptance rule. + +BNS1 also stores one checksummed global projection receipt containing the exact +transaction, successor box, block, inclusion height, accepted depth, intent, +and AVL root. At startup that receipt must join an identical private journal +effect in both directions: BNS1 history without an effect, and an applied or +retired journal effect without BNS1 history, are rejected before node I/O. The +only absent-projection exceptions are an `AcceptanceReady` effect with the +exact still-armed transaction/root receipt, which can be replayed, and a +durable rollback, whose safe outcome is local demotion. + +The historical AVL root is reconstructed in note insertion order from every +persisted confirmed value and must reproduce the global receipt. Revalidation +restores only values that still equal their historical confirmed value. It +never overwrites a newer live AVL root; changed and later values remain +`LocalOnly` or `Pending`. + +A new publication uses this order: + +1. obtain an actor fence for one local AVL root; +2. construct and sign the transaction; +3. durably record the exact transaction id in the actor state; +4. persist the full signed intent as `Prepared`; +5. persist `SubmissionArmed` immediately before the request may cross the node + boundary; +6. broadcast the exact journaled bytes; +7. collect and validate active-chain evidence; +8. persist the validated effect as `AcceptanceReady`; +9. idempotently apply the private ticket to the actor; and +10. persist `Applied` and clear the advisory pending cache. + +Recovery from `Prepared` may submit the exact stored bytes. Recovery from +`SubmissionArmed` only queries the exact derived transaction id, because a +crash or timeout leaves the broadcast outcome unknown. A 404 remains pending; +timeouts and malformed or incoherent evidence never release the fence. This +can remain an availability wait indefinitely if the configured node loses the +transaction: the implementation never releases the fence or constructs a +competing successor, and does not yet schedule exact-byte rebroadcast retries. +Malformed successful responses and other integrity failures terminate and +quarantine the publisher. A transport failure while an already accepted anchor +is being revalidated does the same: all tracker-state and confirmation +consumers share that one-way health gate, so a stale `Confirmed` value cannot +remain readable after the sole reorg watcher stops. + +If the actor receipt exists at `AcceptanceReady`, its transaction id and root +must exactly match the journal, while the journal independently revalidates +every effect field against the retained signed intent, policy/evidence digest, +decision digest, and transition-event history. An absent receipt is permitted only because the +actor may already have completed the idempotent apply before the journal moved +to `Applied`; the actor then verifies the complete persisted provenance before +accepting replay. Any other join fails closed. + +Durable local actions are consumed before any node request. In particular, a +crash after recording `AcceptanceReady` resumes the exact actor apply, and a +crash after recording a rollback demotes the removed projection even while the +node is unavailable. + +## Reorganizations and bounded retirement + +Before the configured horizon, every applied anchor is revalidated through the +same coherent selected-chain proof used for acceptance. If the selected block +at the original inclusion height changes, a rollback ticket is derived from +that proof, persisted, and applied idempotently. Rollback clears the removed +confirmation provenance and makes the affected value non-redeemable. + +At the exact monitoring horizon, the node is sampled before and after an +inclusive selected-chain window from `inclusion` through +`inclusion + horizon`. The window is bounded to at most 4096 canonical linked +headers. A changed first block produces the same typed rollback. An unchanged +first block produces a private durable retirement ticket. Retired anchors are +restored locally from that ticket after restart and are never polled again; +this is the application's explicit bounded reorg assumption, not a consensus +finality statement. Consequently, anchors older than the horizon require +constant bounded I/O rather than a chain slice proportional to their age. + +A validated rollback has recovery priority over a newer pending publication. +The older projection is demoted first; the newer signed ticket remains durable +and resumes its prior `Prepared` or `SubmissionArmed` rule afterward. + +## Policy configuration + +- minimum successor depth: 6; +- maximum evidence age: 60 seconds; +- per-request timeout: 15 seconds; and +- v2 reconciliation activation: disabled and fail-closed. + +`confirmed_chain_reorg_monitor_depth` has no runtime default. It must be +configured explicitly, must be at least the acceptance depth, and must be at +most 4095. The example configuration leaves the illustrative `720` commented +out pending maintainer review; omitting it disables publication fail-closed. +Journal creation likewise requires the separate one-shot +`allow_fresh_reconciliation_journal` approval. + +These are application-finality controls, not consensus finality claims. + +## Bounded node and actor integration + +This integrated branch contains the bounded-node-request work rooted at exact +commit `248929c5dbb923e4ce7e3530374f4fe66be13fbc`. Every reconciler evidence +request (`/info`, `/blocks/chainSlice`, `/blockchain/transaction/byId`, +`/blocks/{id}`, and `/blockchain/box/byId`) and every tracker transaction +request consumes the same process-wide client, concurrency budget, total +deadline, and two-mebibyte buffered-body cap. `get_node_bytes` may impose a +shorter reconciliation deadline around that shared executor but cannot bypass +its admission or body limit. + +The actor exchanges in `begin_publication`, `abort_publication`, +`record_publication_attempt`, `confirm_publication`, and +`rollback_publication` use the same bounded queue-admission and response-deadline +helper as public handlers. A queue, worker, or reply failure therefore returns a +fail-closed result to the updater; callers retain or quarantine the publication +fence according to the durable phase instead of waiting indefinitely. + +These joins close the reconciler's resource-bound dependency. They do not, by +themselves, establish the separate tracker signing/witness boundary or make the +configured finality horizon a maintainer-approved deployment policy. diff --git a/specs/ergo_node_practices.md b/specs/ergo_node_practices.md index a209a19..dcdf908 100644 --- a/specs/ergo_node_practices.md +++ b/specs/ergo_node_practices.md @@ -1,5 +1,9 @@ # Ergo Node Practices for Basis Tracker Testing +> **Status: historical operational notes.** The redemption commands in this +> document are retired; current v2 runtime admission remains dormant and this +> file is not deployment authority. + This document collects practical lessons for interacting with a local Ergo node when running Basis protocol tests on mainnet (or testnet). It covers wallet checks, transaction submission formats, token issuance, fee handling, mempool monitoring, and the reserve/note/redemption flow. ## 1. Node and Wallet Health Checks @@ -112,18 +116,10 @@ curl -s -X POST http://127.0.0.1:3048/reserves/create \ ``` 2. The server returns a `ReserveCreationResponse` with `requests`, `fee`, and - `change_address`. You now have two options: - - **Option A — Submit via the tracker (recommended for testing):** - ```bash - curl -s -X POST http://127.0.0.1:3048/reserves/submit \ - -H "Content-Type: application/json" \ - -d '' - ``` - The tracker converts `token_id` to `tokenId` and forwards the payload to its - configured Ergo node's `/wallet/payment/send`. - - **Option B — Submit manually to your own Ergo node:** + `change_address`. The tracker does not proxy reserve submission. Review the + payload, then sign and submit it with the reserve owner's wallet. + + **Submit through your own Ergo node wallet:** - Replace `token_id` with **`tokenId`** (camelCase). - Pass only the `requests` array to `/wallet/payment/send`. diff --git a/specs/interactive_demo.md b/specs/interactive_demo.md index 57203af..c8e9c7e 100644 --- a/specs/interactive_demo.md +++ b/specs/interactive_demo.md @@ -1,5 +1,8 @@ # Basis Protocol Interactive Tutorial - Alice to Bob Payment & Redemption +> **Status: historical v1 tutorial — superseded.** Its redemption commands and +> server proof routes are no longer compiled or are unconditional tombstones. + A hands-on tutorial demonstrating the complete Basis protocol flow: reserve deployment, IOU note issuance (Alice → Bob), and on-chain redemption with a real tracker. ## Overview @@ -33,7 +36,7 @@ Run it from the project root directory: ./target/debug/basis_cli --help # Or add to your PATH -export PATH="$PATH:/home/kushti/chaincash/basis-tracker/target/debug" +export PATH="$PATH:$PWD/target/debug" ./target/debug/basis_cli --help ``` diff --git a/specs/legacy_runtime_quarantine.md b/specs/legacy_runtime_quarantine.md new file mode 100644 index 0000000..54cbb55 --- /dev/null +++ b/specs/legacy_runtime_quarantine.md @@ -0,0 +1,53 @@ +# Legacy v1 runtime quarantine + +The pre-v2 redemption runtime is structurally absent from production APIs. +Historical specifications remain lineage material only. + +## Enforced boundary + +- `basis_store` exports no v1 redemption manager, request, builder, proof + response, or `TrackerStateManager` proof method. +- `TrackerStateManager` has no global reserve AVL mirror and no + lookup/insert/update/digest API for redeemed state. +- The server actor has no note-proof, tracker-proof, reserve-proof, reserve + update-proof, or reserve-digest command variant. +- No `REPAIR_RESERVE_*` startup environment path exists. +- The CLI has no transaction-generation module; its remaining v1 client and + note-redemption methods are unconditional pre-network tombstones. +- The TUI has no redemption or transaction screen, menu item, or navigation + variant. +- The ignored local-sign v1 fixture is removed. + +Crate-level `compile_fail` examples and source-search integration tests guard +these absences. Generic note, signature, tracker-state, and fixed-tree tests +remain active; removing v1 redemption does not remove their coverage. + +## HTTP compatibility + +Exactly nine legacy endpoints remain visible so stale clients fail closed: + +- `POST /redeem` +- `POST /redeem/complete` +- `GET /proof/redemption` +- `GET /tracker/proof` +- `GET /reserve/proof` +- `POST /tracker/signature` +- `POST /redemption/prepare` +- `POST /redemption/build` +- `POST /redemption/submit` + +Every handler returns `410 Gone` without parsing request bodies or query +parameters and without access to application state. `GET /proof` is not a +route. The OpenAPI document marks all nine operations deprecated and documents +only their error response. + +## V2 dependency + +V2 state is reserve-scoped BRS2 state, not one process-global redeemed tree. +There is no automatic conversion from a v1 reserve mirror. V2 remains dormant +until a sealed confirmed-chain authority supplies the exact observation used +by manifest admission and a private prover/signer consumes only the validated +manifest and its exact reserve and funding boxes. + +Current v2 admission does not implement proving, signing, submission, +broadcast, reconciliation, migration, or deployment. diff --git a/specs/offchain/spec.md b/specs/offchain/spec.md index fd4054b..7335076 100644 --- a/specs/offchain/spec.md +++ b/specs/offchain/spec.md @@ -1,14 +1,22 @@ -# Basis Offchain Crate Specification +# Historical Basis v1 Offchain Crate Specification + +> **Status: superseded design reference.** The v1 transaction-builder module +> described below is compiled only for its crate's unit tests and is not a +> public or production API. Current construction must use the separately +> reviewed v2 runtime and exact generation admission; this document must not be +> used as a readiness statement. ## Overview -The `basis_offchain` crate implements the off-chain logic for the Basis protocol, which enables off-chain payments with on-chain redemption capabilities. This crate handles transaction building, Schnorr signature operations, AVL tree proof generation, and all off-chain functionality required for the Basis system. +The following sections document the retired v1 off-chain implementation kept +for historical tests. They do not describe the current production API surface. ## Core Components ### 1. Transaction Builder Module -The `transaction_builder` module contains the core logic for creating redemption transactions that interact with the Basis reserve contract on the Ergo blockchain. +The historical `transaction_builder` module contained the v1 redemption logic; +it is now test-only. #### Key Structures diff --git a/specs/redemption_cli_spec.md b/specs/redemption_cli_spec.md index e1d2b0d..10db602 100644 --- a/specs/redemption_cli_spec.md +++ b/specs/redemption_cli_spec.md @@ -1,239 +1,25 @@ -# Specification for Basis CLI Redemption Transaction Generation - -## Overview - -This document specifies the CLI command for generating unsigned Ergo redemption transactions according to the Basis protocol contract (`basis.es`). The generated transaction is ready for signing via the Ergo node's `/wallet/transaction/sign` endpoint and spends a reserve box to pay a creditor while preserving the remaining collateral in a new reserve box. - -## CLI Command Definition - -### Command Name -`basis-cli transaction generate-redemption` - -### Command Syntax -```bash -basis-cli transaction generate-redemption \ - --issuer-pubkey \ - --recipient-pubkey \ - --amount \ - [--output-file ] \ - [--emergency] \ - [--tracker-box-id ] \ - [--change-address ] -``` - -### Command Options -- `--issuer-pubkey`: Hex-encoded issuer public key (33 bytes compressed secp256k1) -- `--recipient-pubkey`: Hex-encoded recipient public key (33 bytes compressed secp256k1) -- `--amount`: Redemption amount in nanoERG (must be <= totalDebt - alreadyRedeemed) -- `--output-file`: Path to output the generated transaction JSON file (optional; defaults to stdout) -- `--emergency`: Emergency redemption flag (after 3 days / 2160 blocks tracker unavailability) -- `--tracker-box-id`: Tracker box ID to use as data input (optional; fetched from server if omitted) -- `--change-address`: Wallet change address for the fee-input change output (optional; defaults to recipient address) - -### Required External State -- The CLI must have a current account selected whose public key matches `--issuer-pubkey` (the reserve owner signs the redemption message). -- The recipient address derived from `--recipient-pubkey` must be present in the Ergo node wallet, because the node must satisfy `proveDlog(receiver)` when signing the transaction. -- The local Ergo node is expected at `http://127.0.0.1:9053` with API key `hello`. - -## Transaction Structure - -### Input Validation -Before generating the transaction, the command validates: -1. Both public keys are 33-byte compressed secp256k1 points (66 hex characters) -2. Amount is positive -3. The note exists in the tracker's state -4. Redemption amount <= (totalDebt - alreadyRedeemed) -5. A reserve box with sufficient collateral exists for the issuer -6. A tracker box exists and is available on-chain -7. The wallet has a fee input with no tokens covering the required fee - -### Public Key to Address Conversion -Addresses are derived from public keys using `ergo-lib` (compressed point -> `ProveDlog` -> P2PK address). The recipient address is used for both the redemption output and, by default, the fee-input change output. - -### Transaction Components - -#### 1. Inputs -- **Reserve Box**: The issuer's reserve box being spent, fetched from the server and verified on the Ergo node. -- **Fee Input(s)**: One or more wallet-owned P2PK boxes with no tokens, selected to cover the 1,000,000 nanoERG transaction fee. The reserve box itself is explicitly excluded from fee selection. - -#### 2. Data Inputs -- **Tracker Box**: The tracker commitment box containing: - - R4: Tracker's public key (GroupElement) - - R5: AVL tree root digest tracking `hash(ownerKey||receiverKey) -> totalDebt` - -#### 3. Outputs - -**Output 0 - Updated Reserve** (must be at index 0): -- `value`: Original reserve value minus the redeemed amount -- `ergoTree`: The Basis reserve contract P2S address (from server configuration) -- `assets`: The reserve NFT token preserved from the input -- `additionalRegisters`: - - `R4`: Issuer's public key (GroupElement) - - `R5`: Updated reserve AVL tree root digest after inserting the new redeemed entry - - `R6`: Tracker server NFT ID - -**Output 1 - Recipient Redemption**: -- `value`: The redemption amount -- `ergoTree`: P2PK contract for the recipient -- `assets`: Empty -- `additionalRegisters`: Empty - -**Output 2 - Fee**: -- `value`: 1,000,000 nanoERG -- `ergoTree`: Standard fee recipient contract -- `assets`: Empty - -**Output 3 - Change** (only if change amount > 0): -- `value`: Fee input total value minus transaction fee -- `ergoTree`: P2PK contract for the change address (recipient address by default, or `--change-address` if provided) -- `assets`: Empty - -#### 4. Context Extension Variables - -| ID | Name | Type | Description | Required | -|----|------|------|-------------|----------| -| #0 | action | Byte | `action*10 + index` (0x00 for redemption at output index 0) | Yes | -| #1 | receiver | GroupElement | Recipient's public key (33 bytes compressed) | Yes | -| #2 | reserveSig | Coll[Byte] | Reserve owner's 65-byte Schnorr signature (33-byte a + 32-byte z) | Yes | -| #3 | totalDebt | Long | Total cumulative debt amount (nanoERG) | Yes | -| #4 | timestamp | Long | Payment timestamp (milliseconds since Unix epoch) | Yes | -| #5 | insertOrUpdateProof | Coll[Byte] | AVL proof for inserting or updating the reserve tree entry | Yes | -| #6 | trackerSig | Coll[Byte] | Tracker's 65-byte Schnorr signature | Yes (normal redemption) | -| #7 | lookupProofReserve | Coll[Byte] | AVL proof for looking up `(timestamp, redeemedDebt)` in reserve tree | No (omit for first redemption) | -| #8 | lookupProofTracker | Coll[Byte] | AVL proof for looking up `totalDebt` in tracker tree | Yes | - -### Transaction Metadata -- `fee`: 1,000,000 nanoERG (0.001 ERG), paid by wallet-owned inputs -- `inputsRaw`: Serialized bytes of the reserve box and all fee input boxes -- `dataInputsRaw`: Serialized bytes of the tracker commitment box -- `secrets.dlog`: Recipient's private key (fetched from the node wallet via `/wallet/getPrivateKey`) so the node can satisfy `proveDlog(receiver)` during signing - -## Transaction Generation Process - -### Step 1: Retrieve Note Information -Query the Basis Tracker server: -- `GET /notes/issuer/{issuer_pubkey}/recipient/{recipient_pubkey}` -- Verify the note exists and the redemption amount is valid. - -### Step 2: Retrieve Issuer's Reserve Box -Query the server: -- `GET /reserves/issuer/{issuer_pubkey}` -- Select a reserve box with collateral >= redemption amount. - -### Step 3: Retrieve Tracker Box -- Use the provided `--tracker-box-id`, or -- Query `GET /tracker/latest-box-id` from the server. -- Verify the tracker box exists on the Ergo node. - -### Step 4: Retrieve AVL Proofs -- `GET /proof/redemption?issuer_pubkey={issuer}&recipient_pubkey={recipient}` returns the tracker lookup proof and total debt. -- `POST /redemption/prepare` (or equivalent server endpoint) returns the reserve insert/update proof and, for subsequent redemptions, the reserve lookup proof. - -### Step 5: Fetch Wallet Inputs and Recipient Secret -- Query the Ergo node wallet for boxes covering the fee (`/wallet/boxes/unspentByErgoTree` or similar). -- Select P2PK boxes with no tokens, excluding the reserve box. -- Fetch the recipient's private key from the node wallet via `/wallet/getPrivateKey`. - -### Step 6: Build Signing Message -Build the 48-byte message signed by both reserve owner and tracker: -``` -key = blake2b256(issuer_pubkey || recipient_pubkey) // 32 bytes -message = key || longToByteArray(totalDebt) || longToByteArray(timestamp) // 48 bytes total -``` -- `totalDebt`: 8-byte big-endian cumulative debt -- `timestamp`: 8-byte big-endian payment timestamp in milliseconds - -### Step 7: Sign with Issuer Key -Sign the message using the current CLI account's private key. The implementation retries nonces until both the challenge `e` and response `z` have their most-significant byte < 0x80 (ErgoScript signed-integer compatibility). See `specs/SCHNORR_SIGNATURE_SPEC.md`. - -### Step 8: Request Tracker Signature -Query the server: -- `POST /tracker/signature` with `{issuer_pubkey, recipient_pubkey, total_debt, timestamp, emergency}` -- Extract the tracker's 65-byte Schnorr signature. - -### Step 9: Assemble Transaction -Construct the unsigned transaction in the format expected by the Ergo node `/wallet/transaction/sign`: -```json -{ - "tx": { - "inputs": [ - { - "boxId": "", - "extension": { - "0": "0200", - "1": "07", - "2": "0e41", - "3": "05", - "4": "05", - "5": "0e", - "6": "0e41", - "8": "0e" - } - }, - { - "boxId": "", - "extension": {} - } - ], - "dataInputs": [{ "boxId": "" }], - "outputs": [ - { /* updated reserve box */ }, - { /* recipient output */ }, - { /* fee output */ }, - { /* change output */ } - ] - }, - "inputsRaw": ["", ""], - "dataInputsRaw": [""], - "secrets": { - "dlog": [""] - } -} -``` - -### Step 10: Output Generation -Write the assembled JSON to the file specified by `--output-file` or print it to stdout. - -## Signing and Broadcasting - -1. Sign the generated transaction with the Ergo node: - ```bash - curl -X POST http://127.0.0.1:9053/wallet/transaction/sign \ - -H "api_key: hello" \ - -H "Content-Type: application/json" \ - -d @transaction.json > signed_transaction.json - ``` - -2. Broadcast the signed transaction: - ```bash - curl -X POST http://127.0.0.1:9053/transactions \ - -H "api_key: hello" \ - -H "Content-Type: application/json" \ - -d @signed_transaction.json - ``` - -## Error Handling - -- `InvalidPublicKey`: Public keys are not 33-byte compressed secp256k1 -- `NoteNotFound`: No note exists for the issuer/recipient pair -- `InsufficientDebt`: Redemption amount exceeds available debt -- `NoReserveBox`: No reserve box with sufficient collateral found -- `NoTrackerBox`: No tracker box available -- `NoFeeInputs`: Wallet has no suitable P2PK/no-token boxes covering the fee -- `RecipientNotInWallet`: Node wallet does not contain the recipient's private key -- `SignatureError`: Schnorr signature generation failed (including compatibility retries) -- `AvlProofError`: AVL proof generation failed - -## Security Considerations - -1. **Private Key Handling**: The reserve owner private key is used only by the CLI account; the node never sees it. The recipient private key is fetched from the node wallet solely to satisfy the `proveDlog(receiver)` constraint during node signing. -2. **Input Validation**: All public keys, amounts, and proofs are validated before assembly. -3. **AVL Tree Consistency**: The reserve output R5 reflects the updated tree after the redemption insert. -4. **Double Redemption Prevention**: The reserve tree tracks cumulative redeemed amounts per `(owner, receiver)` pair. -5. **Emergency Redemption**: Only allowed after the tracker has been unavailable for 2160 blocks; the tracker signature field must still be present but verification is bypassed by the contract. - -## References - -- Contract: `chaincash/contracts/offchain/basis.es` -- Schnorr signature spec: `specs/SCHNORR_SIGNATURE_SPEC.md` -- Implementation: `crates/basis_cli/src/commands/transaction.rs` +# Historical v1 CLI redemption specification + +> **Status: superseded design reference.** The transaction-generation command +> described by older revisions of this file is removed. It must not be used as +> an implementation, interoperability, or readiness specification. + +## Current CLI boundary + +- There is no `basis-cli transaction generate-redemption` command. +- The retained `basis-cli note redeem` compatibility command fails before + account lookup, network access, proof generation, signing, or broadcast. +- The ignored local-sign v1 fixture is removed. +- The TUI exposes no redemption or transaction navigation. + +The v2 client accepts either an already constructed exact +`V2RedemptionManifest` or raw JSON through the 2 MiB bounded parser, then +validates it before an opaque callback. Direct serde deserialization is not an +available bypass. It has no concrete prover, signer, wallet adapter, submitter, +or broadcaster, and production manifest construction remains unavailable until +confirmed-chain authority is integrated. + +Future CLI redemption must consume only a validated v2 manifest, bind the same +exact reserve and funding boxes through proving and signing, and retain the +source-pinned v2 register/context-extension ABI. It must not restore any v1 +request, proof, server-sign, or raw-submit fallback. diff --git a/specs/redemption_execution_report.md b/specs/redemption_execution_report.md index a67b48c..821459c 100644 --- a/specs/redemption_execution_report.md +++ b/specs/redemption_execution_report.md @@ -1,5 +1,9 @@ # Basis Redemption Execution Report +> **Status: historical v1 execution record.** It documents past transactions +> and retired commands; it is not a current runbook, exposure statement, or +> deployment/readiness claim. + ## Summary This report documents successful end-to-end Basis protocol redemptions, beginning on a local Ergo testnet node and culminating in mainnet multi-redemption runs against a wallet-owned tracker. The flows covered: diff --git a/specs/scanner_snapshot_reconciliation.md b/specs/scanner_snapshot_reconciliation.md new file mode 100644 index 0000000..d544aab --- /dev/null +++ b/specs/scanner_snapshot_reconciliation.md @@ -0,0 +1,89 @@ +# Scanner Snapshot Reconciliation + +## Scope + +This change hardens the read-only node scanners used to discover reserve and +tracker boxes. It does not define rollback semantics, finality, or a durable +multi-store transaction journal. + +## Invariants + +1. Every indexed-node query supplies explicit `offset`, `limit`, ascending sort + order, `includeUnconfirmed=false`, and `excludeMempoolSpent=false`. A local + mempool spend is not a confirmed chain deletion. +2. A scan is complete only after every page succeeds, the final page is shorter + than the requested page size, every box id is unique, and the indexed/full + height pair is unchanged before and after pagination. +3. A lagging index, changed height pair, duplicate box id, malformed response, + oversized response, request timeout, page failure, or configured scan bound + produces an error. Such an error cannot trigger reserve reconciliation. +4. Reserve candidates are all parsed before the first persistent or in-memory + mutation. A malformed candidate makes the snapshot unusable for + reconciliation. +5. A successfully exhausted empty snapshot is authoritative for the indexed + node observation and removes stale reserves only when the page response is + exactly HTTP `200` with the JSON array `[]`. HTTP `404`, including on page + zero, is an ambiguous source error and never means exhaustion. An incomplete + or malformed snapshot never triggers reconciliation. +6. HTTP requests have connection and whole-request deadlines, response bodies + are bounded before JSON parsing, page/item arithmetic is checked, and a + shared semaphore bounds concurrent scanner requests. Dropping an in-flight + request returns its permit before later work is admitted. + +## Pinned Node Semantics + +The API authority for this adapter is Ergo node v6.0.3 commit +`28ebb184b0c90ee9adebe1111eb6aa3244798ba9`. +`BlockchainApiRoute.scala` returns `Future[Seq[IndexedErgoBox]]` for both +`/blockchain/box/unspent/byAddress` and +`/blockchain/box/unspent/byTokenId/{tokenId}` through `ApiResponse`. At that +commit, `ApiResponse` emits HTTP `404` only for JSON `null`; an empty sequence +encodes as the non-null array `[]` and is emitted with HTTP `200`. + +The OpenAPI file at the same commit also declares a `404` response with the +description "No unspent boxes found" for both routes. That declaration +conflicts with the route and response-wrapper implementation. The adapter does +not guess which upstream component produced a `404`: it fails closed. Only a +successfully parsed HTTP `200` array can contribute a page, and only `200 []` +can exhaust an empty page. + +## Fixed Local Resource Policy + +- Page size: 100 boxes. +- Maximum pages: 1,024. +- Maximum boxes per scan: 100,000. +- Maximum response body: 2 MiB. +- Maximum concurrent requests per scanner state: 4; excess work is rejected + immediately rather than queued without a bound. +- Connect timeout: 5 seconds. +- Whole-request timeout: 15 seconds. + +These are service limits, not Ergo consensus limits. A limit hit leaves the +previous derived state in place and requires an operator to raise the bound or +move to an application-owned block indexer. + +## Closeout Matrix + +| Invariant | Producer / enforcement | Downstream consumer | Failure if relaxed | Positive / isolated negative | +| --- | --- | --- | --- | --- | +| All pages are collected | Explicit offset loop and short-page exhaustion | Reserve acceptance and redemption discovery; tracker-box selection | A live box beyond page one disappears from derived state | `reserve_scan_paginates_past_explicit_page_size`, `tracker_scan_paginates_past_explicit_page_size` / `failed_later_page_preserves_previous_snapshot_without_partial_upserts` | +| One coherent indexed view is used | Caught-up indexed-height pair before/after scan | Reserve reconciliation | A moving or lagging index turns absence into deletion | Multi-page positives / `height_drift_preserves_previous_snapshot`, `indexed_height_lag_preserves_previous_snapshot_without_page_query` | +| Each observed box identity is unique | Cross-page `boxId` set | Snapshot membership | Offset drift can hide one live box behind a duplicate | Multi-page positives / `duplicate_across_pages_preserves_previous_snapshot` | +| Parsing completes before mutation | Full candidate parse phase | Persistent and in-memory reserve sets | One malformed candidate produces a partial or destructive view | Complete-empty and multi-page positives / `malformed_only_page_preserves_previous_snapshot` | +| Empty and incomplete are distinct | HTTP `200` array, short-page exhaustion, plus successful after-height probe | Stale reserve removal | Spent reserves remain authoritative, or live reserves are deleted after an ambiguous miss | `complete_empty_snapshot_removes_stale_reserves` / `reserve_page_zero_404_preserves_exact_previous_snapshot`, `tracker_later_page_404_is_error_without_partial_result`, page-failure, malformed, duplicate, lag and drift tests | +| Response work is bounded | Client deadlines, streaming byte budget, checked page/item limits | Scanner loop and service availability | A node stalls tasks or forces unbounded buffering | Normal page positives / `scanner_request_honors_whole_request_deadline`, both oversized-response tests | +| Concurrent work has no unbounded waiter queue | Shared four-permit `try_acquire` gate whose RAII permit is dropped with the request future | All scanner HTTP paths and clones | Concurrent callers accumulate indefinitely, or cancellation leaks capacity | Sequential/multi-page positives / `shared_gate_bounds_concurrent_scanner_requests`, `abandoned_request_returns_permit_for_exact_gate_capacity` | +| Cancellation cannot publish a partial observation | Collection and parsing precede every reserve mutation | Persistent `ReserveStorage` and in-memory `ReserveTracker` | Aborting a scan after page one publishes a mixed old/new view | Complete multi-page positive / `abort_during_second_page_preserves_exact_previous_snapshot` | +| Mempool observations do not delete confirmed state | Explicit `includeUnconfirmed=false`, `excludeMempoolSpent=false` | Confirmed reserve and tracker projections | A local pending spend erases a still-live confirmed reserve | Query assertion in `reserve_scan_paginates_past_explicit_page_size` | + +## Persistence Boundary + +The snapshot is validated before reconciliation, but `ReserveStorage` and +`ReserveTracker` remain separate stores without one atomic commit. A storage +failure during the apply phase can therefore leave a partially applied +snapshot. The state-journal workstream must provide the durable transaction or +replay receipt that closes that crash-consistency boundary. + +Likewise, the height-pair check detects ordinary page drift but does not replace +header-id checkpoints, fork lineage, rollback, or finality policy. Those remain +dependencies of the state-journal/reorg workstream. diff --git a/specs/security_boundary_remediation.md b/specs/security_boundary_remediation.md new file mode 100644 index 0000000..a4433b4 --- /dev/null +++ b/specs/security_boundary_remediation.md @@ -0,0 +1,72 @@ +# Tracker Security Boundaries + +This specification records the current fail-closed wallet, signing, +construction, and settlement boundaries. + +## Enforced invariants + +1. The HTTP service never submits reserve creation through a tracker-owned + wallet. The reserve owner must review, sign, and submit any future enabled + payload with owner authority. +2. All nine legacy redemption routes are unconditional `410 Gone` tombstones + before body/query parsing and without application state. +3. The server actor has no proof-generation or global reserve-digest command. + `TrackerStateManager` has no process-global redeemed AVL state and no + `REPAIR_RESERVE_*` recovery path. +4. The CLI has no transaction-generation module. `note redeem`, the MCP + compatibility tool, and legacy client methods fail before network, proof, + signing, submission, broadcast, or persistence effects. +5. The TUI exposes no redemption or transaction screens or navigation. +6. Node acceptance is not confirmed settlement. Only a future confirmed-chain + reconciler may advance authoritative local settlement state. +7. Exact Basis v2 contract identities can be recognized while runtime + construction remains disabled. Recognition does not activate a scanner, + builder, signer, or migration path. +8. The legacy redemption fee-input/change builder is structurally retired. No + reachable redemption path signs fee inputs or derives change from + caller-supplied box metadata; the reviewed interim implementation also + rejected any mismatch between node JSON and the exact Sigma-parsed input ID, + tree, value or assets. The separately active tracker-box publisher is bound + by a stronger exact-input and owner-derived-change rule: canonical raw bytes + must match JSON ID, value, tree, ordered assets, complete R4-R9 register + bytes/key set, and creation height; fee boxes must be token-free exact P2PK + boxes for the tracker key. +9. Node API keys and tracker signing material are redacted from every loggable + configuration `Debug` representation, including both reserve and tracker + scanner configs. Signing and broadcast failures expose status/category only, + never node response bodies or secret-bearing request payloads. +10. Compiled CLI and MCP artifacts do not export dlog secrets, query node-wallet + key-export endpoints, or serialize private keys into redemption artifacts. + The corresponding legacy commands are removed or fail before network I/O. +11. `reserve create --submit` is a retired compatibility flag that fails before + requesting a payload; user-facing output directs the owner to an external + wallet and never advertises tracker-side broadcast. +12. The tracker publisher signs only in process with ergo-lib `Wallet`. The + configured secret must derive both the configured public key and the + tracker input's `GroupElement` R4. Construction, signing, and post-sign + validation use one ordered, duplicate-free exact input set and one state + context made from 10 linked headers plus the matching `/info` parameters. + Only the signed transaction is submitted to `/transactions`; there is no + node-wallet signing request or configurable publisher change address. + +## HTTP compatibility changes + +| Surface | Current behavior | +| --- | --- | +| `POST /reserves/submit` | `410 Gone`; no node-wallet request. | +| `reserve create --submit` | Rejected; no tracker wallet proxy. | +| Nine v1 redemption/proof/signature/build/submit routes | Deprecated `410 Gone` tombstones. | +| Generic `GET /proof` | Not routed. | +| V1 success request/response models | Removed from server models and OpenAPI. | +| V2 manifest admission | Dormant Rust-only validator; no prover/sign/submit/broadcast. | + +## Settlement hand-off + +A future reconciler must authenticate the expected reserve successor, selected +chain inclusion, confirmation policy, and rollback before advancing BRS2 and +note state. A future private prover/signer must accept only the validated v2 +manifest and the same exact reserve/funding boxes committed by it. + +Until those components are integrated, the repository provides no active +redemption flow and makes no target-node, deployment, migration, confirmation, +reorg-safety, or production-readiness claim. diff --git a/specs/server/basis_server_spec.md b/specs/server/basis_server_spec.md index 42262cd..ff9ba39 100644 --- a/specs/server/basis_server_spec.md +++ b/specs/server/basis_server_spec.md @@ -1,357 +1,82 @@ # Basis Server Crate Specification -## Overview - -The `basis_server` crate is a Rust web server built with the Axum framework that provides an HTTP API for the Basis Tracker system. It serves as the core component for managing IOU notes, tracking reserve events on the Ergo blockchain, providing proof mechanisms for the Basis protocol, and facilitating redemption with tracker signatures. - -## Architecture - -### Main Components - -1. **API Module**: Contains all HTTP route handlers for the web server -2. **Reserve API Module**: Handles reserve-specific endpoints -3. **Models Module**: Defines data structures for API requests/responses -4. **Store Module**: Implements event storage functionality -5. **Config Module**: Handles application configuration -6. **Tracker Thread**: Background task that processes commands via message passing -7. **AVL Tree Manager**: Manages the tracker's AVL tree state and proof generation - -### Communication Pattern - -The server uses an actor-like pattern with a dedicated tracker thread that processes commands via a channel: - -- Web handlers send commands through an MPSC channel -- A blocking thread processes tracker commands -- Results are returned via oneshot channels - -## Dependencies - -- `axum`: Web framework for routing and HTTP handling -- `tokio`: Async runtime for concurrency -- `tracing`: Logging and instrumentation -- `serde/serde_json`: Serialization/deserialization -- `tower-http`: HTTP middleware (CORS, tracing) -- `basis_store`: Core business logic and data structures -- `ergo-lib`: Ergo blockchain interaction - -## API Endpoints - -### Core Endpoints - -- `GET /` - Root endpoint returning "Hello, Basis Tracker API!" -- `POST /notes` - Create a new IOU note -- `GET /notes` - Get all IOU notes in the system -- `GET /notes/issuer/{pubkey}` - Get all notes issued by a public key -- `GET /notes/recipient/{pubkey}` - Get all notes received by a public key -- `GET /notes/issuer/{issuer_pubkey}/recipient/{recipient_pubkey}` - Get specific note between two parties -- `POST /redeem` - Initiate redemption process -- `POST /redeem/complete` - Complete redemption process -- `POST /tracker/signature` - Request tracker signature for redemption (real Schnorr signature generation) -- `POST /redemption/prepare` - Prepare redemption with all necessary data (real AVL proofs + tracker signature) -- `GET /proof/redemption` - Get redemption-specific proof with tracker state digest - -### Reserve Endpoints - -- `GET /reserves` - Get all reserve information -- `GET /reserves/issuer/{pubkey}` - Get reserves for a specific issuer -- `GET /key-status/{pubkey}` - Get status information for a public key -- `POST /reserves/create` - Create a reserve creation payload for Ergo node's `/wallet/payment/send` API -- `POST /reserves/submit` - Submit a reserve creation payload to the tracker's configured Ergo node for broadcast - -### Event Tracking - -- `GET /events` - Get recent tracker events -- `GET /events/paginated?page=0&page_size=20` - Get paginated events - -## Data Models - -### Tracker Event Types - -- `NoteUpdated`: When an IOU note is created/modified -- `ReserveCreated`: When a new reserve box is created -- `ReserveToppedUp`: When collateral is added to a reserve -- `ReserveRedeemed`: When collateral is redeemed from a reserve -- `ReserveSpent`: When a reserve box is spent -- `Commitment`: Commitment to tracker state -- `CollateralAlert`: When collateralization ratio falls below threshold -- `DebtTransfer`: When debt is transferred between creditors (novation) - -### Tracker Box Registers - -The tracker box uses Ergo registers R4 and R5 to store commitment information: - -- **R4**: Contains the tracker public key (33-byte compressed secp256k1 point, serialized as `GroupElement`) -- **R5**: Contains the AVL tree root digest serialized as `SAvlTree` (37 bytes total: `0x64` type byte + 33-byte digest + 1-byte flags + VLQ key length + VLQ value length) - -The tracker box must also preserve the tracker NFT token (identified by `tracker_nft_id`) in its assets. - -The reserve box uses Ergo registers R4, R5, and R6 to store commitment and identification information: - -- **R4**: Contains the issuer's public key (GroupElement / 33-byte compressed secp256k1 point) that identifies the reserve owner -- **R5**: Contains the AVL tree root digest (33-byte commitment) - - Stores: `hash(ownerKey || receiverKey) -> cumulativeRedeemedAmount` - - Updated when notes are redeemed -- **R6**: Contains the NFT ID of the tracker server (bytes) - identifies which tracker server this reserve is linked to - -### IOU Note Structure - -The server handles IOU (I Owe You) notes that represent debt obligations: - -For most endpoints: -- `recipient_pubkey`: Public key of the recipient -- `amount_collected`: Total amount collected (cumulative debt) -- `amount_redeemed`: Amount already redeemed -- `timestamp`: Creation timestamp -- `signature`: Cryptographic signature (Schnorr signature on `hash(issuer||recipient) || totalDebt`) - -For the `GET /notes` endpoint (all notes), additional fields are included: -- `issuer_pubkey`: Public key of the issuer -- `age_seconds`: Age of the note in seconds (calculated from timestamp) - -### Tracker Signature Request Structure - -The `/tracker/signature` endpoint accepts requests with the following structure: -- `issuer_pubkey`: Public key of the note issuer (hex-encoded, 33 bytes) -- `recipient_pubkey`: Public key of the note recipient (hex-encoded, 33 bytes) -- `total_debt`: Total cumulative debt amount in nanoERG -- `emergency`: Boolean indicating if this is an emergency redemption (tracker signature optional after 3 days) - -### Tracker Signature Response Structure - -The `/tracker/signature` endpoint returns responses with the following structure: -- `success`: Boolean indicating if the signature generation was successful -- `tracker_signature`: 65-byte Schnorr signature (hex-encoded, 130 characters) proving tracker authorization -- `tracker_pubkey`: Tracker's public key (hex-encoded, 66 characters) -- `message_signed`: The hex-encoded message that was signed - - Normal and emergency: `hash(issuerKey||recipientKey) || longToByteArray(totalDebt) || longToByteArray(timestamp)` (48 bytes) - -### Redemption Preparation Request Structure - -The `/redemption/prepare` endpoint accepts requests with the following structure: -- `issuer_pubkey`: Public key of the note issuer (hex-encoded, 33 bytes) -- `recipient_pubkey`: Public key of the note recipient (hex-encoded, 33 bytes) -- `total_debt`: Total cumulative debt amount in nanoERG - -### Redemption Preparation Response Structure - -The `/redemption/prepare` endpoint returns responses with the following structure: -- `redemption_id`: Unique identifier for the redemption process -- `tracker_lookup_proof`: AVL tree lookup proof for tracker's tree (context var #8, hex-encoded bytes) -- `reserve_lookup_proof`: AVL tree lookup proof for reserve's tree (context var #7, optional, hex-encoded bytes) -- `reserve_insert_proof`: AVL tree insert/update proof for reserve's tree (context var #5, hex-encoded bytes) -- `tracker_signature`: 65-byte Schnorr signature from tracker (hex-encoded, 130 characters) -- `tracker_pubkey`: Tracker's public key (hex-encoded, 66 characters) -- `tracker_state_digest`: 33-byte AVL tree root digest (hex-encoded, 66 characters) representing current tracker state -- `block_height`: Current blockchain height at time of proof generation -- `is_first_redemption`: Boolean indicating if this is the first redemption (reserve_lookup_proof can be omitted) - -### Redemption Proof Response Structure - -The `/proof/redemption` endpoint returns responses with the following structure: -- `issuer_pubkey`: Public key of the note issuer (hex-encoded, 66 characters) -- `recipient_pubkey`: Public key of the note recipient (hex-encoded, 66 characters) -- `tracker_lookup_proof`: AVL tree lookup proof for tracker's tree (context var #8, hex-encoded bytes) -- `reserve_lookup_proof`: AVL tree lookup proof for reserve's tree (context var #7, optional, hex-encoded bytes) -- `reserve_insert_proof`: AVL tree insert/update proof for reserve's tree (context var #5, hex-encoded bytes) -- `tracker_state_digest`: 33-byte AVL tree root digest (hex-encoded, 66 characters) representing current tracker state -- `reserve_state_digest`: 33-byte AVL tree root digest (hex-encoded, 66 characters) representing current reserve state -- `block_height`: Current blockchain height at time of proof generation -- `timestamp`: Unix timestamp of the proof generation -- `total_debt`: Total cumulative debt from tracker's tree -- `already_redeemed`: Already redeemed amount from reserve's tree (0 if first redemption) - -### Real Cryptographic Implementation - -The server now implements real cryptographic functionality using the Ergo node's Schnorr signing API instead of mock implementations: - -#### Schnorr Signature Generation -- **Local Signing (Primary)**: If `tracker_secret_key` is configured in server config, signatures are generated locally using the tracker's secret key -- **Remote Fallback**: If no secret key is configured, falls back to Ergo node's `/utils/schnorrSign` API -- **Format**: 65-byte signatures (33 bytes for 'a' component + 32 bytes for 'z' component) -- **Structure**: Properly formatted with compressed public key prefix (0x02 or 0x03) followed by the signature components -- **Security**: Supports both local signing (secret key in config) and remote signing (private keys secured within Ergo node) -- **Authentication**: Remote requests to the signing API are authenticated using the tracker API key -- **Implementation**: Tracker signature endpoints (`/tracker/signature` and `/redemption/prepare`) try local signing first, then fall back to Ergo node API -- **Message Format**: - - Normal and emergency: `blake2b256(issuerKey||recipientKey) || longToByteArray(totalDebt) || longToByteArray(timestamp)` (48 bytes) - - Emergency redemption (after 3 days): same 48-byte message format, tracker signature becomes optional - -#### AVL Tree Proof Generation -- **Real Proofs**: All proof endpoints now generate actual AVL tree lookup and insert/update proofs from the tracker's and reserve's AVL tree state -- **Format**: Properly formatted proof data that demonstrates existence of key-value pairs in the AVL tree -- **State Commitment**: Tracker state digest properly formatted as 33-byte AVL tree root (1 byte height + 32 bytes hash) -- **Integration**: Proofs are generated by the actual tracker state manager using the AVL tree implementation -- **Context Variables**: Proofs are generated for specific context extension variables: - - #5: Reserve tree insert/update proof - - #7: Reserve tree lookup proof (optional) - - #8: Tracker tree lookup proof (required) - -#### Tracker State Management -- **Shared State**: Tracker state is maintained in shared state accessible via `state.shared_tracker_state` -- **Real Digests**: Tracker state digests come from actual AVL tree root, not mock implementations -- **Consistency**: All endpoints return consistent tracker state commitments that match the current AVL tree state -- **Debt Tracking**: Tracker maintains cumulative debt for each (issuer, recipient) pair - -### Reserve Creation Payload Structure - -The server provides an endpoint to generate reserve creation payloads for Ergo node's `/wallet/payment/send` API: - -- `POST /reserves/create` - accepts a request with: - - `nft_id`: String - the NFT ID to be stored in the reserve box (hex-encoded) - - `owner_pubkey`: String - the 33-byte compressed public key (hex-encoded) of the reserve owner - - `erg_amount`: u64 - the amount of ERG to lock in the reserve (in nanoERG) - -- Returns a JSON response compatible with Ergo's `/wallet/payment/send` API: - - `requests`: Array of payment requests - - `address`: Reserve contract P2S address (hardcoded in configuration) - - `value`: ERG amount from request - - `assets`: Array containing the NFT asset - - `token_id`: NFT ID from request (snake_case in the response; converted to `tokenId` by the submission endpoint) - - `amount`: Always 1 for NFTs - - `registers`: Map of register values - - `R4`: Owner public key from request (GroupElement) - - `R5`: Initial AVL tree (empty tree for new reserve) - - `R6`: Tracker NFT ID (bytes) - identifies which tracker server this reserve is linked to - - `fee`: Transaction fee amount from configuration - - `change_address`: Change address derived from tracker public key configuration (fallback to owner pubkey if unavailable) - -- `POST /reserves/submit` - Submit a previously generated reserve creation payload to the tracker's configured Ergo node for on-chain broadcast. - - Accepts the same `ReserveCreationResponse` JSON returned by `/reserves/create`. - - Converts `token_id` to the camelCase `tokenId` required by the Ergo node. - - Forwards the `requests` array to `POST {ergo_node}/wallet/payment/send` using the configured `api_key`. - - Returns: - - `tx_id`: String - the transaction id returned by the Ergo node. - - Errors: - - `503 Service Unavailable` if no Ergo node is configured. - - `502 Bad Gateway` if the Ergo node returns an error or is unreachable. - -### Debt Transfer Support - -The server supports debt transfer (novation) operations: - -- `POST /debt/transfer` - Request debt transfer from one creditor to another - - Request structure: - - `debtor_pubkey`: Public key of the debtor (hex-encoded) - - `current_creditor_pubkey`: Public key of the current creditor (hex-encoded) - - `new_creditor_pubkey`: Public key of the new creditor (hex-encoded) - - `transfer_amount`: Amount to transfer in nanoERG - - Process: - 1. Server verifies debtor has sufficient debt to current creditor - 2. Server requests debtor's signature on transfer message - 3. Server atomically updates both debt records - 4. Server posts updated AVL tree commitment - -## Configuration - -The server supports configuration via: - -1. Configuration files (config/basis.toml) -2. Environment variables (with BASIS_ prefix) -3. Default fallback values - -Key configuration includes: -- Server host/port -- **`server.data_dir`**: Base directory for all on-disk storage (databases, indices, - scanner metadata). Defaults to `data` relative to the server's working directory. - Can be overridden with the `BASIS_SERVER_DATA_DIR` environment variable. - The legacy `server.database_url` field is kept for compatibility but is currently unused. -- **Ergo node connection details** (required): The server will abort with exit code 1 if `ergo.node.node_url` is not provided in the configuration - no default localhost value is used -- Reserve contract P2S address -- Tracker NFT ID (for tracker scanner registration and state commitment monitoring) -- Tracker public key (for identifying the tracker server) -- Tracker API key (for authenticating requests to the Ergo node's signing API) -- Transaction fees - -**Critical Requirements**: -1. The server requires a valid Ergo node URL to be provided in the configuration (`ergo.node.node_url` field). If this is missing or empty, the server will immediately exit with status code 1 during startup. -2. The server requires access to an Ergo node with the Schnorr signing API (`/utils/schnorrSign`) enabled for endpoints that require tracker signatures. The tracker private key must be available in the Ergo node's wallet for signature generation. -3. The tracker public key must be provided in the configuration for signature verification purposes. -4. The tracker API key must be provided to authenticate requests to the Ergo node's signing API. - -## Blockchain Integration - -The server integrates with the Ergo blockchain through: - -1. **Ergo Scanner**: Monitors the blockchain for reserve box events -2. **Tracker Scanner**: Monitors tracker state commitment boxes using the tracker NFT ID to enable cross-verification and state synchronization -3. **Reserve Event Processing**: Handles reserve creation, top-ups, and redemptions -4. **Real-time Updates**: Tracks collateralization ratios and reserve status -5. **Scan Registration**: Automatically registers both reserve and tracker scans with the Ergo node using the `/scan` API -6. **AVL Tree Verification**: Verifies on-chain AVL tree commitments match off-chain state - -## Event Store - -The server maintains an in-memory event store with: -- Sequential ID generation -- Pagination support -- Thread-safe operations using async mutex -- Planned persistence layer - -## Error Handling - -The server implements comprehensive error handling: - -- Validation of hex-encoded public keys and signatures -- Proper HTTP status codes (200, 400, 500) -- Detailed error messages for debugging -- Graceful fallback when blockchain scanner is unavailable -- AVL tree proof validation errors -- Emergency redemption timeout handling - -## Security Considerations - -- CORS headers configured for cross-origin requests -- Input validation for all public keys and amounts -- Signature verification for note creation and debt transfer -- Channel-based communication to ensure thread safety -- Remote signature generation to protect private keys -- AVL tree proof verification to prevent fraud - -## Blockchain Height Caching - -The server implements intelligent blockchain height caching to reduce Ergo node API calls: - -- **Cache Storage**: Blockchain height and fetch timestamp stored in `scanner_metadata` database partition -- **TTL**: 10 minutes (600,000 milliseconds) -- **Cache Key**: `"blockchain_height"` -- **Cache Value**: 16 bytes (8 bytes height + 8 bytes timestamp, both big-endian u64) -- **Behavior**: - - Returns cached height if < 10 minutes old - - Fetches from Ergo node `/info` endpoint if cache expired or missing - - Stores new height with current timestamp after fetching - - Implemented in both `ergo_scanner.rs` and `tracker_scanner.rs` - -## Current State Summary - -The basis_server crate is a fully functional HTTP API server that: -- Manages IOU notes and redemption processes -- Monitors Ergo blockchain reserve events -- Provides real AVL tree proof mechanisms for the Basis protocol -- Generates real Schnorr signatures via Ergo node's signing API for redemption transactions -- Implements proper async/await patterns and error handling -- Supports configuration and event storage -- Includes comprehensive API endpoints for all Basis features -- Provides endpoints for real tracker signature generation (`/tracker/signature`) -- Offers redemption preparation with real proofs and signatures (`/redemption/prepare`) -- Supports redemption-specific proof generation (`/proof/redemption`) -- Integrates with shared tracker state for consistent AVL tree root commitments -- Uses secure remote signing via Ergo node API to protect private keys -- Supports debt transfer (novation) for triangular trade -- Handles emergency redemption after 3-day timeout - -This crate serves as the central hub for the Basis Tracker system, connecting the blockchain layer with client applications through a well-defined HTTP interface with real cryptographic operations while maintaining security through remote signing. - -## Context Extension Variables Reference - -For redemption transactions prepared by the server: - -| Variable | Type | Description | Required | -|----------|------|-------------|----------| -| #0 | Byte | Action byte (0x00 for redemption) | Yes | -| #1 | GroupElement | Receiver pubkey | Yes | -| #2 | Coll[Byte] | Reserve owner's signature bytes | Yes | -| #3 | Long | Total debt amount | Yes | -| #5 | Coll[Byte] | AVL proof for reserve tree insert/update | Yes | -| #6 | Coll[Byte] | Tracker's signature bytes | Yes | -| #7 | Coll[Byte] | AVL proof for reserve tree lookup | No (omit for first redemption) | -| #8 | Coll[Byte] | AVL proof for tracker tree lookup | Yes | +## Current runtime boundary + +`basis_server` exposes note, reserve-observation, acceptance-policy, event, and +tracker-state APIs. It does not expose an active redemption builder, proof +service, tracker signer, wallet proxy, transaction submitter, or settlement +completion command. + +The server keeps an actor for active note and confirmation-state operations. +The actor does not contain the retired v1 proof commands and +`TrackerStateManager` does not own a global reserve redeemed-state tree. +Reserve-scoped v2 redeemed state belongs to BRS2 and is not synthesized from +legacy process memory or environment variables. + +## Active components + +1. Axum HTTP routing for active note, reserve-observation, status, event, and + acceptance-policy endpoints. +2. A tracker actor for note storage and confirmation records. +3. Scanner and reserve-observation state. +4. Tracker-box observation/update support, subject to its separately reviewed + confirmation and publication boundaries. +5. Configuration with explicit generation-sensitive reserve contract identity. + +V2 reserve construction and redemption stay disabled when confirmed scanner +authority, BNS2/BRS2 state, or the exact v2 builder dependency is absent. + +## Retired redemption compatibility surface + +Exactly nine routes remain as unconditional compatibility tombstones: + +| Method | Route | +| --- | --- | +| `POST` | `/redeem` | +| `POST` | `/redeem/complete` | +| `GET` | `/proof/redemption` | +| `GET` | `/tracker/proof` | +| `GET` | `/reserve/proof` | +| `POST` | `/tracker/signature` | +| `POST` | `/redemption/prepare` | +| `POST` | `/redemption/build` | +| `POST` | `/redemption/submit` | + +Every handler returns `410 Gone` before request-body or query parsing. The +tombstone router is constructible without `AppState`, so it cannot reach the +actor, storage, scanner, node, signer, or broadcast effects. `GET /proof` is +absent. + +The v1 request and success-response schemas are not part of the server models +or OpenAPI components. The OpenAPI document marks all nine routes deprecated +and documents only the standard error envelope. + +## Active tracker commands + +The production actor accepts only note and confirmation-state commands: + +- add and query notes; +- read per-note or aggregate confirmation records; +- mark notes pending, confirm them, revert pending state, and reconcile a + confirmed tracker digest. + +It has no generic note-proof, tracker lookup-proof, reserve lookup-proof, +reserve update-proof, or reserve-root command. + +## Configuration and storage + +- `server.data_dir` is the base directory for persistent server state. +- `ergo.node.node_url` must be configured for runtime node access. +- Reserve contract identity is generation-sensitive; missing, legacy, or + unknown construction identity fails closed. +- `REPAIR_RESERVE_*` variables are unsupported and ignored because no global + v1 reserve AVL state exists. + +## V2 admission non-claims + +The separately versioned v2 manifest validator is dormant client admission. +It does not provide a prover, signer, wallet, node submission, broadcast, +confirmed-chain reconciliation, deployment, or migration path. Production +activation requires an opaque sealed chain observation and a private signing +primitive that accepts only the validated manifest and the exact boxes it +commits. diff --git a/specs/server/r6_register_implementation_spec.md b/specs/server/r6_register_implementation_spec.md index 2ea143c..85f0c9a 100644 --- a/specs/server/r6_register_implementation_spec.md +++ b/specs/server/r6_register_implementation_spec.md @@ -1,5 +1,12 @@ # R6 Register Implementation Specification +> **Historical v1 implementation plan — superseded.** The v1 transaction +> builder named below is quarantined as a test-only fixture and must not be +> restored as a production path. The scanner/storage notes remain useful for +> lineage analysis, but any active R6 handling must be specified and tested as +> part of the reviewed v2 generation-admission and builder workstream. See +> [`../legacy_runtime_quarantine.md`](../legacy_runtime_quarantine.md). + ## Overview This document specifies the implementation requirements for supporting the R6 register in the Basis Tracker system. The R6 register in reserve boxes contains the tracker NFT ID (identifying which tracker server the reserve is linked to). @@ -8,7 +15,7 @@ This specification focuses on the implementation changes needed to properly hand ## Problem Statement -The Basis Tracker system currently does not properly handle the R6 register in reserve boxes. According to the contract specification, reserve boxes must have an R6 register containing the tracker NFT ID to identify which tracker server the reserve is linked to. The system needs to be updated to: +At the time of this v1 design, the Basis Tracker system did not properly handle the R6 register in reserve boxes. According to the historical contract specification, reserve boxes had to contain the tracker NFT ID in R6. The proposed work was to: 1. Parse and store the R6 register value from reserve boxes 2. Include the R6 register in redemption transactions @@ -113,8 +120,9 @@ The Basis Tracker system currently does not properly handle the R6 register in r ### 2. Transaction Builder Updates #### 2.1 Redemption Transaction Creation -- **File**: `crates/basis_store/src/transaction_builder.rs` -- **Change**: Update redemption transaction creation to preserve R6 register from input to output +- **Historical file (now test-only)**: `crates/basis_store/src/transaction_builder.rs` +- **Retirement status**: Do not add or restore production behavior in this v1 builder. A reviewed v2 builder must preserve and authenticate its generation-specific register schema. +- **Historical change**: Update redemption transaction creation to preserve R6 register from input to output - **Logic**: Copy R6 register value from input reserve box to output reserve box - **Serialization Format**: When creating the output box, ensure R6 register follows the proper SColl(SByte) serialization: - The R6 register value must be a serialized collection (SColl(SByte)) using Sigma serialization @@ -637,4 +645,6 @@ fn deserialize_r6_register(box_with_bytes: &ErgoBox) -> Result<[u8; 32], String> - Ensure tracker NFT ID is properly configured before deployment - Verify that Ergo node connections can access register information -This specification ensures that the Basis Tracker system properly handles the R6 register in reserve boxes, maintaining the link between reserves and their associated tracker servers as required by the contract specification. \ No newline at end of file +Historically, this plan aimed to preserve the R6 link between v1 reserves and +their tracker servers. It is not evidence that the current runtime implements +or admits that retired transaction path. diff --git a/specs/server/redemption_state_spec.md b/specs/server/redemption_state_spec.md index b34b0a0..722d478 100644 --- a/specs/server/redemption_state_spec.md +++ b/specs/server/redemption_state_spec.md @@ -1,5 +1,14 @@ # Redemption State Specification +> **Historical v1 design — superseded.** The production Rust redemption +> manager and transaction builder described here are quarantined as test-only +> fixtures. The current `POST /redeem` and `POST /redeem/complete` handlers are +> unconditional `410 Gone` tombstones and perform no state or network effects. +> Retain this document only for lineage analysis; use +> [`../legacy_runtime_quarantine.md`](../legacy_runtime_quarantine.md) for the +> enforced boundary and require a separately reviewed v2 runtime specification +> before restoring transaction construction. + ## Overview This document specifies the state management and process flow for redemption operations in the Basis Tracker system. Redemption allows holders of IOU notes to claim collateral from the issuer's reserve based on the outstanding debt represented by the note. The contract tracks cumulative redeemed amounts using AVL trees to prevent double redemptions. @@ -272,10 +281,12 @@ After the unsigned redemption transaction is built: ## API Endpoints -### POST /redeem -Initiates a redemption process for an IOU note. +### POST /redeem (retired) -**Request Body:** +The current handler returns `410 Gone` before payload validation. The payload +and response below record the superseded v1 design only. + +**Historical v1 Request Body:** ```json { "issuer_pubkey": "hex_encoded_public_key", @@ -285,11 +296,10 @@ Initiates a redemption process for an IOU note. } ``` -**Response:** -- Success: `200 OK` with redemption details -- Failure: `400 Bad Request` or `500 Internal Server Error` with error message +**Current Response:** +- `410 Gone`; no redemption is built, signed, submitted, or recorded. -**Success Response:** +**Historical v1 Success Response:** ```json { "success": true, @@ -451,60 +461,51 @@ tree entry; the on-chain contract only sees the spent reserve's tree. ### POST /redemption/submit -Broadcasts the fully-signed transaction via the node and then **syncs local state**. +Broadcasts the fully-signed transaction via the node. Node acceptance is not +active-chain confirmation, so this endpoint performs no local settlement +mutation and returns `202 Accepted` with the node-accepted transaction id. **Request Body:** ```json { - "signed_tx": { "...": "fully-signed transaction JSON" }, - "issuer_pubkey": "hex_encoded_33_byte_key", - "recipient_pubkey": "hex_encoded_33_byte_key", - "redeemed_amount": 100000000, - "new_already_redeemed": 100000000 + "signed_tx": { "...": "fully-signed transaction JSON" } } ``` -**State-sync contract (required):** after a successful broadcast the tracker MUST: - -1. Increment the note's `amount_redeemed` by `redeemed_amount` (note accounting; - cumulative redeemed against total debt), refreshing the note timestamp. -2. Sync the reserve AVL tree entry to `new_already_redeemed` keyed with the note's - **pre-refresh** payment timestamp (the on-chain reserve tree value is - `payment_timestamp || already_redeemed`). This value comes from the build response, - not from the note record — the two diverge for fresh reserves or repaired state. +Unknown request fields are rejected. In particular, a submitter cannot attach +issuer, recipient, amount, or cumulative-redemption assertions to a transaction. +After active-chain confirmation, a separate reconciler must derive the exact +settlement transition from the authenticated reserve successor and must own reorg +rollback. That reconciler is the only authority for local note and reserve-tree +state. -If the sync fails, the tx is already on-chain: the error is logged and the response -still returns the tx id; state must then be repaired manually (see below). A submit -that skips this sync leaves the reserve tree stale, and the next `/redemption/build` -will find no reserve whose on-chain R5 matches the local digest. +### POST /redeem/complete (retired) -### POST /redeem/complete (manual completion / repair) - -The legacy completion endpoint accepts an optional `new_already_redeemed` field; when -provided it is used as the reserve-tree value instead of the note's cumulative amount. -This is the supported way to repair local state after an out-of-band redemption: - -```json -{ - "redemption_id": "repair-", - "issuer_pubkey": "...", - "recipient_pubkey": "...", - "redeemed_amount": 100000000, - "new_already_redeemed": 100000000 -} -``` +This caller-asserted completion route is retained only as a compatibility +tombstone and returns `410 Gone`. A transaction id plus caller-provided accounting +fields is not confirmation evidence and cannot be used for state repair. ### Known contract limitation / upgrade The legacy reserve contract (`contract/basis.es:345`) used strict `insert` into the reserve AVL tree. With that contract a redemption only verifies against a reserve whose tree does not yet contain the note key — in practice a freshly created empty-tree reserve. Repeated redemptions against one reserve fail local/node evaluation with `AvlTree: Incorrect insert`. -The current compiled reserve contract uses `insertOrUpdate` for the reserve AVL tree, which removes this limitation: a note can be redeemed multiple times against the same reserve as long as the cumulative redeemed amount is increased correctly and the R7 refund initiation height is preserved. The tracker code reflects this: +The tracker transaction builder uses `insertOrUpdate` for the reserve AVL tree, +which can support repeated redemption only when paired with a contract compiled +from the matching source. The tracker code reflects this intended successor +semantics: - `basis_trees/src/avl_tree.rs::generate_insert_proof` uses `Operation::InsertOrUpdate` so proofs are valid for both new and existing reserve-tree keys. - `RedemptionRequest` carries `reserve_refund_initiation_height` and the transaction builder preserves the value in the updated reserve output's `R7` register. - Acceptance policies can include a `no_pending_refund` predicate to reject notes backed by a reserve with a non-zero R7 refund height. -Deploying systems should ensure the configured reserve contract P2S matches the contract they intend to use; the tracker will emit the correct transaction format for either, but the strict-insert contract cannot support consecutive redemptions. The legacy P2S begins with `4ZhBzJfN...`; the current default P2S begins with `3PQnJ92K...`. Both constants are maintained in `crates/basis_store/src/contract_compiler.rs`. +The runtime now pins the full byte-exact Basis v2 ERG and token ErgoTrees from +the pinned ChainCash candidate source-to-byte receipt. Unknown identities and any one-byte +mutation are rejected; the historical identity remains only as a temporary +compatibility mode, and exact v2 activation remains startup-disabled. The old builder +does not emit the reserve-bound claim domain, fixed 32/24 R5, mandatory +prior-state proof, R8/R9 lineage, or exact payout. Contract identity therefore +cannot be inferred from an address prefix and does not by itself activate +scanning or construction. ## Integration with Blockchain Scanner @@ -521,7 +522,7 @@ The redemption process integrates with the blockchain scanner to: The redemption process integrates with the Ergo node API to: 1. **Tracker Schnorr Signatures**: The tracker server either signs redemption messages locally using a configured `tracker_secret_key`, or delegates to the Ergo node's `/utils/schnorrSign` endpoint. Redeemers request the tracker signature through the tracker server's `/tracker/signature` API, not directly from the Ergo node. -2. **Transaction Signing**: Redemption transactions are built in the format expected by `/wallet/transaction/sign`, with `inputsRaw`, `dataInputsRaw`, and `secrets.dlog`, so the node can satisfy the recipient's `proveDlog` spend condition. +2. **Transaction Signing**: Private keys stay inside the local or assisted signing boundary. Transaction artifacts contain public transaction and context material, never `secrets.dlog` or another private-key field. 3. **Transaction Broadcast**: Signed redemption transactions are broadcast to the network via `/transactions`. 4. **State Verification**: Access current blockchain state for redemption validation, including reserve boxes, tracker boxes, and current height. 5. **Tracker Box Lookup**: Query tracker box information including creation height and registers. diff --git a/specs/server/redemption_transaction_format_spec.md b/specs/server/redemption_transaction_format_spec.md index 56ca876..d9be2f6 100644 --- a/specs/server/redemption_transaction_format_spec.md +++ b/specs/server/redemption_transaction_format_spec.md @@ -1,5 +1,12 @@ # Redemption Transaction Format Specification +> **Status: historical v1 wire-format reference — superseded.** The node-wallet +> signing, secret-bearing request, proof, build, submit and broadcast flows +> described below are removed from the compiled clients or exposed only as +> unconditional pre-effect tombstones. Current v2 code performs bounded +> manifest admission only and has no prover, signer, wallet, submitter or +> broadcaster. Do not use this document as an operational runbook. + ## Overview This document specifies the format for redemption transactions that spend reserve boxes to pay out to note holders. The unsigned transaction can be signed either by the Ergo node (`/wallet/transaction/sign`) or entirely off-chain by the client, and is then broadcast via `/transactions`. It includes all necessary context extension variables for the Basis reserve contract validation. For the off-chain (client-side) signing path and its serialization requirements, see [offchain_redemption_signing.md](../client/offchain_redemption_signing.md). diff --git a/specs/server/tracker_box_update_spec.md b/specs/server/tracker_box_update_spec.md index 26025a6..60a3f42 100644 --- a/specs/server/tracker_box_update_spec.md +++ b/specs/server/tracker_box_update_spec.md @@ -1,4 +1,10 @@ -# Tracker Box Update Mechanism Specification +# Historical Tracker Box Update Mechanism Specification + +> **Status: mixed current/historical reference.** The local-signing boundary in +> this document describes the active tracker publisher. The v1 redemption +> builder and raw completion command shown below are retired from production +> APIs. Actor-owned durable state, confirmed-chain reconciliation, and exact v2 +> generation admission are defined separately. ## Overview @@ -63,9 +69,7 @@ pub struct TrackerBoxUpdateConfig { pub api_key: Option, /// Transaction fee in nanoERG paid by wallet inputs for each tracker update pub fee: u64, - /// Optional change address for the fee-input change output - pub change_address: Option, - /// Optional tracker secret key (32 bytes) used as a dlog secret when signing + /// Tracker secret key (32 bytes), required when the publisher signs an update pub tracker_secret_key: Option<[u8; 32]>, } ``` @@ -168,25 +172,31 @@ impl SharedTrackerState { The background task executes the following algorithm in a continuous loop: 1. **Wait for Interval**: Use tokio::time::interval to wait for the configured update period (10 minutes) -2. **Check Pending Transaction**: If a transaction was previously submitted but not yet confirmed, check its confirmation status via `/blockchain/transaction/byId`. Only update `last_submitted_digest` after confirmation. -3. **Access Shared State**: Read the current AVL tree root digest and tracker public key -4. **Skip Unchanged State**: If the AVL root digest hasn't changed since the last confirmed submission, skip the update cycle -5. **Find Tracker Box**: Query the blockchain for the tracker box using the tracker NFT ID via `/blockchain/box/unspent/byTokenId` -6. **Check On-Chain State**: If the on-chain tracker box already has the current AVL root digest in R5, skip the update -7. **Create Register Constants**: +2. **Check Publication Health**: Stop all commitment processing, including confirmation handling for an older in-flight transaction, after the shared one-way health signal is quarantined. +3. **Check Pending Transaction**: If a transaction was previously submitted but not yet confirmed, check its confirmation status via `/blockchain/transaction/byId`. Only update `last_submitted_digest` after confirmation. +4. **Access Shared State**: Read the current AVL tree root digest and tracker public key only through the health-gated publication accessor. +5. **Find Tracker Box**: Query the blockchain for the tracker box using the tracker NFT ID via `/blockchain/box/unspent/byTokenId`. +6. **Validate Generation**: Require the state manager to validate the observed NFT and R5 against the checksummed persistent generation manifest before reconciliation or submission. +7. **Check On-Chain State**: If the on-chain tracker box already has the current AVL root digest in R5, skip the update. +8. **Create Register Constants**: - R4: Tracker public key as EcPoint constant (33 bytes, compressed secp256k1 point) - identifies the tracker server - R5: Serialized `SAvlTree` constant containing the current AVL tree root digest (37 bytes total; see "R5 Register Serialization Format" below) - R6: Serialized `Coll[Byte]` constant containing the tracker NFT ID (preserved from the input tracker box) -8. **Build Unsigned Transaction**: - - **Inputs**: the current tracker box (spends it) plus one or more wallet-owned P2PK/no-token boxes to pay the configured fee - - **Outputs**: new tracker box with the same value and updated R4/R5/R6; fee output to the standard fee contract; optional change output to the change address - - **inputsRaw**: serialized bytes of the tracker box and all fee inputs - - **secrets.dlog**: the configured tracker secret key (hex) so the node can satisfy `proveDlog(trackerPubkey)`; may be omitted if the tracker key is already in the node wallet -9. **Submit Transaction**: - - POST the unsigned transaction to `/wallet/transaction/sign` to obtain a signed transaction - - POST the signed transaction to `/transactions` to broadcast it +9. **Bind Inputs and State Context**: + - Fetch each selected input through both the node JSON view and `/utxo/byIdBinary/{boxId}`. + - Parse the binary with Sigma serialization and require exact equality for box ID, value, ErgoTree bytes, ordered assets, R4-R9 bytes/key set, and creation height. + - Require one ordered, duplicate-free input list containing the tracker box followed by the same exact boxes supplied to the signer. + - Fetch exactly 10 newest-first headers from `/blocks/lastHeaders/10`; require a descending parent-linked chain and bind `fullHeight`, `bestFullHeaderId`, block version, and the complete nested signing parameter set from `/info` to the same tip. +10. **Authorize and Build**: + - Require the configured secret to derive the configured tracker public key and require the exact tracker input R4 to be that key as a `GroupElement`. + - Select only token-free fee inputs whose exact ErgoTree is the P2PK tree derived from that same key. + - Preserve the tracker value, ErgoTree, ordered tokens, and R6-R9; replace only R4/R5. Pay the miner fee and send any checked, non-dust change to the derived P2PK tree. There is no configurable publisher change address. +11. **Sign and Submit**: + - Build a typed ergo-lib unsigned transaction using checked value arithmetic and current dust parameters. + - Sign locally with `Wallet`, then validate the signed transaction again against the same exact inputs and `ErgoStateContext`. + - POST only the signed transaction to `/transactions`; the secret and raw-input signing bundle never cross the HTTP boundary. - Log the transaction ID on successful broadcast and mark it as pending confirmation -10. **Error Handling**: +12. **Error Handling**: - If any step fails, log an appropriate ERROR message - Continue with the scheduled interval regardless of failures @@ -288,7 +298,7 @@ impl TrackerBoxUpdater { // Logs warning if multiple boxes found (indicates inconsistent state) } - /// Submit a tracker box update transaction via /wallet/transaction/sign and broadcast via /transactions + /// Bind exact inputs, sign locally, and broadcast via /transactions async fn submit_tracker_update( config: &TrackerBoxUpdateConfig, tracker_box: &ErgoBoxApi, @@ -297,23 +307,15 @@ impl TrackerBoxUpdater { ) -> Result { // Build R4 (GroupElement), R5 (SAvlTree), and R6 (Coll[Byte]) registers // Select wallet fee inputs covering config.fee - // Fetch raw bytes for the tracker box and all fee inputs - // Assemble UnsignedErgoTransaction and sign with /wallet/transaction/sign + // Fetch and bind raw bytes for the tracker box and all fee inputs + // Build a typed UnsignedTransaction and sign it locally with ergo-lib Wallet + // Validate the signed transaction against the same exact inputs/context // Broadcast signed transaction with /transactions // Returns transaction ID } - /// Check if a transaction has been confirmed on-chain - pub async fn check_transaction_confirmation( - config: &TrackerBoxUpdateConfig, - tx_id: &str, - ) -> Result { - // Query /blockchain/transaction/byId/{tx_id} - // Returns true if found (200), false if not yet confirmed (404) - } } ``` -``` ### AVL Tree State Management @@ -406,9 +408,10 @@ impl AvlTreeState { This ensures that the AVL tree root digest is properly updated after each operation, which is critical for the R5 register value. The AVL tree is now properly initialized with an initial proof to ensure it has a valid root digest even when empty. -### Redemption Transaction Builder +### Historical Redemption Transaction Builder (retired) -The redemption transaction builder now properly implements the transaction building logic: +The following v1 builder is retained only as historical documentation and unit +test material; downstream crates cannot call it. ```rust pub struct RedemptionTransactionBuilder; @@ -445,7 +448,8 @@ impl RedemptionTransactionBuilder { } ``` -The redemption transaction builder now includes proper validation, transaction structure creation, and serialization of all required components for the Basis redemption process, including R6 register preservation with tracker NFT ID. +This description does not apply to the active public API. The v2 builder must +be admitted against its exact contract and register manifest. ### Integration with Server Startup @@ -464,9 +468,10 @@ The tracker box updater is integrated into the server startup flow: The main tracker thread is enhanced to update the shared state: 1. **AddNote Command**: After successfully adding a note to the tracker, update the shared AVL root digest via update_state() call -2. **CompleteRedemption Command**: After successfully completing a redemption, update the shared AVL root digest via update_state() call -3. **AVL Tree Operations**: Each AVL tree operation (insert/update/delete) triggers proof generation to update internal tree state -4. **State Consistency**: Ensure the shared state remains consistent with the main tracker state and AVL tree root +2. **Historical CompleteRedemption Command**: retired; durable settlement must be derived from validated confirmed-chain evidence +3. **Generation Validation Command**: Validate the configured tracker NFT and first observed R5 against the durable generation manifest before publication +4. **Durable Note Updates**: Each admitted note update produces a validated durable snapshot before the shared root changes +5. **State Consistency**: A one-way health gate removes every cached root from the publication path after manager quarantine ## Logging Specifications @@ -518,7 +523,7 @@ The service handles the following error conditions: 4. **No Tracker Box Found**: Tracker NFT ID not found on chain 5. **No Fee Inputs**: Wallet has no suitable P2PK/no-token boxes to pay the update fee 6. **Insufficient Fee Inputs**: Wallet boxes don't cover the configured fee -7. **Signing Failed**: `/wallet/transaction/sign` rejected the unsigned transaction (bad inputs, missing secrets, etc.) +7. **Signing Failed**: local authorization, proof generation, or post-sign validation failed 8. **Broadcast Failed**: `/transactions` rejected the signed transaction 9. **Transaction Not Found**: Submitted transaction ID not found on chain after extended waiting 10. **Serialization Errors**: Failed to decode ergoTree hex, parse ergoTree bytes, or encode addresses @@ -536,8 +541,10 @@ The service handles the following error conditions: 2. **Resource Management**: Proper handling of async resources and channels 3. **Log Security**: No sensitive cryptographic information exposed in logs 4. **Rate Limiting**: Built-in 10-minute interval prevents excessive resource usage -5. **Secret Handling**: The configured `tracker_secret_key` is passed only to the node's `/wallet/transaction/sign` endpoint as a `dlog` secret; it is never logged or broadcast -6. **Pending Transaction State**: Prevents duplicate submissions while waiting for confirmation +5. **Secret Handling**: The configured `tracker_secret_key` is consumed only by the in-process ergo-lib wallet. It is redacted from `Debug`, never placed in a JSON artifact, and never sent to the node. +6. **Exact Fee Authority**: Every fee input is token-free and protected by the exact P2PK tree derived from the tracker key; change returns only to that same tree. +7. **Pinned Validation Context**: Header order, links, height, version, and current `/info` parameters are validated once and reused for construction, signing, and post-sign validation. +8. **Pending Transaction State**: Prevents duplicate submissions while waiting for confirmation ## Performance Characteristics @@ -566,10 +573,10 @@ The service handles the following error conditions: ### Tracker Thread Integration 1. **State Updates**: Update shared AVL root digest after successful `AddNote` operations -2. **Redemption Handling**: Update shared AVL root digest after successful `CompleteRedemption` operations -3. **Synchronization**: Use thread-safe access to shared state to prevent data races -4. **Initialization**: The tracker thread initializes the shared AVL root digest with the empty tree state on startup -5. **Event Store**: Tracker events (AddNote, CompleteRedemption) are stored in the event store for audit trail +2. **Settlement Handling**: No raw completion command exists; settlement changes require a future confirmed-chain evidence consumer +3. **Synchronization**: Use thread-safe access and a shared one-way health signal to prevent stale-root publication +4. **Initialization**: The tracker thread loads a checksummed snapshot bound to the configured tracker NFT +5. **Bootstrap Gate**: A new empty generation requires explicit approval and its first observed on-chain R5 must match the persisted bootstrap root ## Future Extensions @@ -581,4 +588,4 @@ This implementation provides a foundation for future extensions including: 4. **Metrics Collection**: Add metrics for monitoring update frequency, success rates, and confirmation latency 5. **Multiple Tracker Box Handling**: Implement proper resolution strategy if multiple tracker boxes are found (indicates chain reorg or race condition) -This specification accurately reflects the implemented tracker box update mechanism that periodically submits transactions to the Ergo blockchain to update R4 and R5 register values, with proper transaction confirmation tracking and error handling. \ No newline at end of file +This specification accurately reflects the implemented tracker box update mechanism that periodically submits transactions to the Ergo blockchain to update R4 and R5 register values, with proper transaction confirmation tracking and error handling. diff --git a/specs/service_resource_bounds.md b/specs/service_resource_bounds.md new file mode 100644 index 0000000..47469b9 --- /dev/null +++ b/specs/service_resource_bounds.md @@ -0,0 +1,50 @@ +# Service Resource Bounds + +This document defines the fail-closed resource limits applied by the Basis +server. They protect availability; they do not authenticate node responses or +make point-in-time policy decisions atomic with later issuance. + +## Inbound state and pagination + +- The in-memory event log retains at most 10,000 events. Adding a new event + evicts the oldest entry. +- Event pages contain 1 through 100 entries. Offset multiplication and event ID + increments use checked arithmetic; an offset past the retained window returns + an empty page. +- Issuer outstanding-debt aggregation and cumulative redeemed amounts use + checked addition and reject overflow. + +## Tracker actor requests + +- HTTP handlers use non-blocking `try_send`; a full or closed command queue is + rejected rather than awaited. +- The response deadline is five seconds. When the requester has expired, the + single tracker worker drops the command before starting its work. +- Work that already started is not asynchronously interrupted. It remains + serialized by the single worker, so this bound prevents an unbounded queued + backlog rather than promising cancellation of an in-progress state operation. + +## Outbound node HTTP + +All node calls made by the API, redemption builder, tracker-box updater, reserve +scanner, and tracker scanner use one process-wide bounded admission budget: + +- at most 16 concurrent requests, acquired without waiting; +- a 15-second total request deadline and a three-second connection cap; +- at most 2 MiB of response body, enforced while reading chunks even when the + server omits `Content-Length`; +- bounded error bodies and JSON parsing only after the body cap succeeds. +- Schnorr-signing failures expose only the HTTP status. Node error bodies are + neither returned nor logged because they may echo sensitive request context. + +The negative matrix covers a stalled loopback server, a saturated permit set, +an oversized declared `Content-Length`, a chunked response that crosses the +cap, and redaction of a signing error body. No test contacts a live Ergo node. + +## Integration boundary + +The state-journal and confirmed-settlement workstreams may replace tracker +commands and updater sequencing. They must preserve these queue and HTTP bounds +when integrating their actor-owned publication lease. Reorg handling, durable +settlement evidence, inbound authentication, and operator-configurable limits +remain separate workstreams. diff --git a/specs/spec.md b/specs/spec.md index cd7b041..9df775b 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -299,7 +299,7 @@ An MCP (Model Context Protocol) server over stdio exposing wallet operations as **Key capabilities:** - Read-only tools: `server_status`, `account_list`, `account_current`, `note_list`, `note_get`, `reserve_status`, `policy_get` -- Write tools: `account_create`, `account_switch`, `account_import`, `note_create`, `note_redeem`, `reserve_create`, `policy_set` +- Write tools: `account_create`, `account_switch`, `account_import`, `note_create`, `reserve_create`, `policy_set`; `note_redeem` is a retired compatibility tombstone. - Signing happens in-process; private keys are never exposed through any tool ## Possible Extensions diff --git a/specs/trees/FULL_FJALL_PERSISTENCE_IMPLEMENTATION.md b/specs/trees/FULL_FJALL_PERSISTENCE_IMPLEMENTATION.md index b18e51c..fa47581 100644 --- a/specs/trees/FULL_FJALL_PERSISTENCE_IMPLEMENTATION.md +++ b/specs/trees/FULL_FJALL_PERSISTENCE_IMPLEMENTATION.md @@ -1,4 +1,8 @@ -# In-Memory Tree Implementation - Session Summary +# Historical In-Memory Tree Session Summary (Superseded) + +> This November 2025 session note predates the versioned bounded snapshot and +> generation-manifest design. It is retained as history, not as current +> persistence, recovery, or production-readiness authority. ## Overview @@ -8,7 +12,7 @@ This document summarizes the implementation approach for the Basis tracker AVL t **Session Date**: November 24, 2025 **Duration**: Comprehensive implementation session -**Status**: ✅ **COMPLETED** - Production Ready +**Historical Status**: Superseded ## Key Achievements @@ -214,9 +218,9 @@ The in-memory tree implementation successfully addresses the resolver limitation 1. **High Performance**: Direct in-memory operations without I/O overhead 2. **Reliable Recovery**: Operation-based recovery with checkpoint optimization 3. **Simplified Architecture**: No complex resolver-based node persistence -4. **Production Readiness**: Comprehensive test coverage and validation +4. **Historical validation**: test coverage recorded for the superseded design 5. **Maintainable Code**: Simpler implementation without external dependencies The implementation successfully addresses the resolver limitation by using an in-memory approach that maintains compatibility with the existing `ergo_avltree_rust` library while providing superior performance. -**Status**: ✅ **PRODUCTION READY** \ No newline at end of file +**Historical Status**: Superseded; no current readiness claim diff --git a/specs/trees/RESOLVER_IMPLEMENTATION.md b/specs/trees/RESOLVER_IMPLEMENTATION.md index 998cfc5..0d4f34b 100644 --- a/specs/trees/RESOLVER_IMPLEMENTATION.md +++ b/specs/trees/RESOLVER_IMPLEMENTATION.md @@ -1,4 +1,8 @@ -# Resolver Implementation for In-Memory Trees +# Historical Resolver Implementation for In-Memory Trees (Superseded) + +> This design note predates the versioned bounded snapshot and generation +> manifest. It does not describe the current persistence authority and is not a +> production-readiness assessment. ## Problem Statement @@ -100,4 +104,5 @@ The resolver limitation is a fundamental architectural constraint, but our curre - ✅ Supports efficient recovery through checkpoints - ✅ Allows for future optimizations and enhancements -The system is production-ready with the current implementation, addressing the resolver limitation by avoiding node-level persistence entirely. \ No newline at end of file +This historical approach was later superseded; no current production-readiness +claim is made here. diff --git a/specs/trees/basis-v2-fixed-avl-receipt.md b/specs/trees/basis-v2-fixed-avl-receipt.md new file mode 100644 index 0000000..43de0c6 --- /dev/null +++ b/specs/trees/basis-v2-fixed-avl-receipt.md @@ -0,0 +1,59 @@ +# Basis v2 fixed AVL receipt + +## Scope and pins + +The v2 runtime exposes two concrete authenticated-tree types: + +- `TrackerAvlTree`: 32-byte keys and fixed 8-byte values; +- `ReserveAvlTree`: 32-byte keys and fixed 24-byte values. + +Both shapes allow insert and update and exclude remove. No public const-generic +constructor remains, so another value width cannot be selected by a caller. + +The cross-runtime fixture is derived from +`BetterMoneyLabs/chaincash@04031626f09c6590a20ad20d5583c6eccc14412d` +with Scala 2.12.17, plasma-toolkit 1.1.0 and Ergo Appkit 6.0.0. The complete +inputs, roots and proof bytes are stored in +`crates/basis_trees/tests/fixtures/basis_v2_avl_scala_0403162.json`. + +## Closed invariants + +| Invariant | Producer and consumer | Failure if relaxed | Isolated evidence | +| --- | --- | --- | --- | +| Only 32/8 and 32/24 shapes are callable | concrete Rust types; v2 tracker and reserve R5 builders | an off-chain proof uses metadata rejected by the contract | public API compile-fail doctest and exact shape test | +| Replay preserves first-insertion order | ordered entry vector; prover rebuild | restart can derive a different authenticated root | deterministic replay control and reversed-order negative | +| Lookup always carries membership or non-membership evidence | prover; v2 proof consumer | an absent or mismatched record is accepted without authenticated evidence | membership, absence, key, value, root and proof single-fault negatives | +| Transition witnesses bind the predecessor root and exact operation | temporary prover; successor builder | an update or new-key insert advertises the wrong successor root | independent update and new-key insert transition tests | +| Malformed proof bytes fail as errors | vendored verifier; untrusted proof ingress | input-derived indexing or stack underflow can abort the process | truncation/structure matrix and an abort-mode executable harness | +| Rust output matches the pinned Scala model | Rust prover and ChainCash PlasmaMap | cross-runtime proof or root bytes drift | byte-for-byte 32/8 lookup/update and 32/24 absence/insert golden test | + +The pinned Scala proof identities are: + +- 32/8 lookup and update: 143 bytes, SHA-256 + `5b3e3451d8137978f6b86ea2021bdc0d8e31a80ecbbe8ed48f9d67e9c1f3ca54`; +- 32/24 absence and insert: 125 bytes, SHA-256 + `80f33ed16eee7c83ba7d316d07a5bb988a2e5c8f83a50487e4698829dfe02d76`. + +## Validation commands + +```text +cargo test --locked -p basis_trees fixed_avl --lib +cargo test --locked -p basis_trees --doc +RUSTFLAGS="-C panic=abort" cargo run --locked -p basis_trees --example malformed_proof_abort_harness +cargo clippy --locked -p basis_trees --lib --examples -- -D warnings +``` + +## Cost and evidence boundary + +`transition_witness` reconstructs a temporary prover from the authoritative +ordered entries before applying one operation. It therefore performs O(n) +replay operations and uses O(n) temporary memory; with per-entry AVL insertion, +the conservative time bound is O(n log n). Reopening from an ordered snapshot +has the same replay count. This is deliberate for deterministic isolation and +is not a scalability claim. Ordinary in-memory key lookup uses the position +index, while authenticated proof work retains the AVL implementation's +tree-height behavior. + +These checks establish source-model and Rust/Scala byte parity for the frozen +fixtures. They do not establish node reduction cost, persistence/reorg safety, +deployment identity or production capacity. diff --git a/specs/trees/basis-v2-state-snapshots.md b/specs/trees/basis-v2-state-snapshots.md new file mode 100644 index 0000000..c320c77 --- /dev/null +++ b/specs/trees/basis-v2-state-snapshots.md @@ -0,0 +1,112 @@ +# Basis v2 bounded state snapshots + +## Status and scope + +These formats persist the local state needed to reconstruct the fixed-shape +Basis v2 AVL commitments. They do not activate v2 HTTP routes, chain scanners, +transaction builders, contracts, networking, or reorganization handling. + +Each data directory contains one Fjall partition, one authoritative snapshot +row, and one exclusive writer lease. A fresh directory is initialized only +with explicit `FreshV2StateApproval::Approve`. Existing v1, foreign, partial, +or ambiguous directories require an explicit migration or reset; they are +never converted in place. + +All integers use unsigned big-endian encoding. All lengths are fixed except for +the bounded record sequence. The entry count is limited to 50,000 and the exact +snapshot length is checked before record allocation. Startup checks the +single-row partition invariant by reading at most two keys; it never performs +an unbounded row count before rejecting foreign or corrupt state. + +## Full v2 claim record + +Both snapshots embed the complete 244-byte signed claim in this order: + +| Field | Bytes | +| --- | ---: | +| reserve NFT id | 32 | +| tracker NFT id | 32 | +| owner compressed public key | 33 | +| receiver compressed public key | 33 | +| asset kind (`0` ERG, `1` token) | 1 | +| token id, or all-zero for ERG | 32 | +| cumulative total debt | 8 | +| timestamp | 8 | +| Schnorr signature | 65 | + +On every write and restart, the implementation reconstructs the +`ClaimDomainV2`, derives its 32-byte `claimKey`, reconstructs the `ClaimV2` with +`ClaimV2::from_signed`, and verifies the complete signature and domain. The +derived key, never a caller-supplied alias, is the AVL key and lookup authority. + +## BNS2 tracker-claim snapshot + +- Partition: `basis_v2_tracker_claims` +- Row key: `bns2_snapshot` +- AVL shape: 32-byte key, 8-byte cumulative-debt value + +| Field | Bytes | +| --- | ---: | +| magic `BNS2` | 4 | +| ABI generation `2` | 1 | +| tracker NFT id | 32 | +| claim count | 4 | +| tracker AVL root | 33 | +| full claim records | `count * 244` | +| checksum | 32 | + +The checksum is Blake2b-256 over +`"basis-v2-tracker-claims-bns2" || all preceding bytes`. + +New claim keys append in first-insertion order. A valid monotone successor for +an existing key replaces the record at its original position. Restart rebuilds +`TrackerAvlTree` in that order and requires its root to equal the stored root. +Claims for another tracker NFT are rejected. + +## BRS2 per-reserve redeemed-state snapshot + +- Partition: `basis_v2_reserve_redeemed` +- Row key: `brs2_snapshot` +- AVL shape: 32-byte key, 24-byte `RedeemedStateV2` value +- Directory ownership: exactly one reserve NFT lineage and asset binding + +| Field | Bytes | +| --- | ---: | +| magic `BRS2` | 4 | +| ABI generation `2` | 1 | +| tracker NFT id | 32 | +| reserve NFT id | 32 | +| asset kind (`0` ERG, `1` token) | 1 | +| token id, or all-zero for ERG | 32 | +| redeemed-state count | 4 | +| reserve AVL root | 33 | +| claim plus redeemed-state records | `count * (244 + 24)` | +| checksum | 32 | + +The checksum is Blake2b-256 over +`"basis-v2-reserve-redeemed-brs2" || all preceding bytes`. + +Every embedded claim must match the stored tracker NFT, reserve NFT, and asset. +Each 24-byte state is decoded by `RedeemedStateV2::decode` and must repeat the +claim timestamp and cumulative debt exactly. New keys append; successors retain +their first-insertion position. Restart rebuilds one independent +`ReserveAvlTree` for the bound reserve and requires the computed root to equal +the stored root. + +The reserve store exposes reads only. Its raw state transition is private until +a confirmed-chain scanner owns the commit boundary, so a route or transaction +builder cannot directly advance redeemed progress. + +## Durability and recovery + +Every mutation is built and validated as a complete candidate in memory. The +single authoritative row is then replaced and persisted with Fjall +`PersistMode::SyncData`; only after that succeeds is the in-memory candidate +installed. An insert or durability error makes the live store terminally +poisoned because the durable outcome is unknown. Recovery requires dropping the +store and reopening the directory, which accepts only a fully valid old or new +snapshot. + +The writer lease prevents concurrent in-process or cross-process writers for a +directory. Tracker and reserve directories are separate; each reserve NFT must +use its own BRS2 directory and root. diff --git a/specs/trees/note_state_snapshot.md b/specs/trees/note_state_snapshot.md new file mode 100644 index 0000000..06c1f70 --- /dev/null +++ b/specs/trees/note_state_snapshot.md @@ -0,0 +1,127 @@ +# Authoritative Note State Snapshot + +## Persistence invariant + +The tracker persists one versioned `BNS2` value in the `iou_notes` partition. +That value contains: + +1. the expected 33-byte AVL root digest; +2. a Blake2b-256 checksum over the complete snapshot domain, count, root and + records; +3. the ordered live set of issuer-recipient notes; and +4. each note's tracker-derived redeemed progress. + +The vector position is the key's immutable first-insertion order. Updating a +signed cumulative-debt successor or settlement progress replaces the record in +place. It never appends mutation history. The live-note count is capped at +50,000, making disk use, strict reads and restart work `O(K)` in live edges. + +Before a mutation is acknowledged, the tracker builds an isolated AVL +candidate, serializes the corresponding complete snapshot and expected root, +replaces the single authoritative value, and calls +`Keyspace::persist(PersistMode::SyncData)`. No Fjall multi-key batch is used for +this note-state boundary. + +Before every rewrite, the manager rereads and checksums the complete persisted +snapshot, verifies every issuer signature and redeemed bound, rebuilds every +physical issuer-recipient AVL key, and requires the rebuilt, persisted and live +roots to agree. A valid successor cannot launder a malformed predecessor. + +The checksum detects accidental corruption and incomplete writes. It is not a +MAC or adversarial authentication mechanism: a party able to rewrite the data +directory can recompute it. `amount_redeemed` therefore remains trusted local +state until the confirmed-chain reconciler replaces it with replayable, +lineage-bound settlement evidence. + +## Single writer and unknown outcomes + +Opening note storage obtains an exclusive file lock for that database path. +The server owns one `TrackerStateManager`; scanner clones do not reopen the +note database. A second in-process or cross-process writer is rejected. + +If the authoritative write or durability call fails after mutation begins, the +outcome is treated as unknown. The manager becomes quarantined and rejects +reads, mutations and publication of its root. Recovery requires dropping the +manager, reopening the store, strictly validating the durable snapshot, and +rebuilding the AVL tree before any state is exposed. + +Quarantine also flips a one-way health signal shared with the tracker-box +updater. A cached pre-failure root is not publishable after the manager enters +an unknown or structurally invalid state. + +## External publication receipt + +Before a signed tracker-box transaction crosses the node broadcast boundary, +the actor stores a checksummed `BPA1` receipt containing the exact local root, +the 32-byte transaction id derived from the signed transaction, and the +submission height. The publication lease remains held after that write. A +success response releases nothing unless its transaction id exactly matches the +locally derived id. + +An HTTP error, malformed response, mismatched id, process crash, or partial +per-note confirmation write leaves the receipt durable. Startup restores the +same fence and polls only that transaction id. Once the exact transaction is +observed, confirmation is replayed idempotently from the complete authenticated +snapshot and the receipt is cleared only after every advisory confirmation row +is durable. An orphaned or malformed receipt blocks fresh-generation +initialization without rewriting the authoritative records. + +This receipt prevents a restart from authorizing a competing tracker +publication after an indeterminate broadcast. It does not establish active-chain +lineage, confirmation depth, or reorg safety; those remain gates of the +confirmed-chain reconciler. + +## Recovery + +Startup parses the entire authoritative value with exact length and count +checks, rejects duplicate logical edges and verifies every issuer signature. +It rebuilds a fresh AVL tree in the stored order and requires the resulting +digest to equal the persisted root. Redeemed progress must not exceed signed +cumulative debt. Only after all checks pass does the candidate replace the +live tree. + +Unexpected rows, a missing state value, an unsupported schema, malformed +records, invalid signatures, duplicate edges, order/root mismatch or an +out-of-range redeemed amount fail closed. + +## Generation binding and bootstrap + +The note schema partition contains a checksummed, durable `BNG1` manifest +binding the data directory to exactly one 32-byte tracker NFT, the approved +empty bootstrap root, and (after first observation) its on-chain anchor root. +Opening an unbound data directory requires an explicit fresh-generation +approval. Existing generations open with approval denied, and corruption or a +different configured NFT is rejected. + +Before any tracker-box successor may be submitted, the updater asks the state +manager to validate the observed NFT and R5. For an unanchored generation, the +first observed R5 must equal the persisted bootstrap root; otherwise the +manager and publisher are quarantined. This prevents an empty or wrong data +directory from overwriting a non-empty generation under the same configured +NFT. + +## Legacy data + +A database containing BNS1 or legacy per-note rows is returned as +`MigrationRequired`; the runtime does not reorder, rewrite or delete it. +Operators must choose one of two separately reviewed procedures: + +- export and import the original order while proving the intended tracker-box + root; or +- preserve the old database as evidence, retire that tracker generation, start + a fresh generation, and have issuers resubmit signed cumulative notes. + +## Settlement boundary + +`amount_redeemed` is local settlement state and is not covered by the issuer's +signature. The generic note-ingestion path always initializes it to zero and +preserves existing progress across signed successors. No production API or +store method accepts raw settlement scalars; the historical direct-completion +route is a `410 Gone` tombstone and broadcast acceptance does not mutate local +accounting. A future internal transition must consume validated confirmed-chain +evidence rather than caller metadata; signed fields remain unchanged. + +This snapshot establishes local note/root consistency. It does not by itself +prove transaction inclusion, confirmation depth, active-chain lineage, reserve +successor validity or reorg rollback. Those properties belong to the confirmed +settlement reconciler and its versioned chain-evidence journal. diff --git a/temp/vendors/ergo_avltree_rust/src/batch_avl_verifier.rs b/temp/vendors/ergo_avltree_rust/src/batch_avl_verifier.rs index 956bfaa..1402fed 100644 --- a/temp/vendors/ergo_avltree_rust/src/batch_avl_verifier.rs +++ b/temp/vendors/ergo_avltree_rust/src/batch_avl_verifier.rs @@ -31,6 +31,11 @@ pub struct BatchAVLVerifier { last_right_step: usize, // Keeps track of where we are when replaying directions a second time; needed for deletions replay_index: usize, + // The AuthenticatedTreeOps trait exposes direction reads as infallible + // callbacks. Remember an exhausted proof here and convert it into an + // error at the public operation boundary instead of indexing past the + // attacker-controlled proof buffer. + proof_exhausted: bool, } impl BatchAVLVerifier { @@ -49,6 +54,7 @@ impl BatchAVLVerifier { directions_index: 0, last_right_step: 0, replay_index: 0, + proof_exhausted: false, }; verifier.reconstruct_tree(starting_digest)?; Ok(verifier) @@ -58,7 +64,10 @@ impl BatchAVLVerifier { fn reconstruct_tree(&mut self, starting_digest: &ADDigest) -> Result<()> { ensure!(self.base.tree.key_length > 0); ensure!(starting_digest.len() == DIGEST_LENGTH + 1); - self.base.tree.height = (starting_digest.last().unwrap() & 0xffu8) as usize; + self.base.tree.height = (*starting_digest + .last() + .ok_or_else(|| anyhow!("missing tree height"))? + & 0xffu8) as usize; let max_nodes = if self.max_num_operations.is_some() { // compute the maximum number of nodes the proof can contain according to @@ -92,16 +101,31 @@ impl BatchAVLVerifier { let mut previous_leaf: Option = None; let mut stack: Vec = Vec::new(); let key_length = self.base.tree.key_length; - while self.proof[i] != END_OF_TREE_IN_PACKAGED_PROOF { - let n = self.proof[i]; - i += 1; + loop { + let n = *self + .proof + .get(i) + .ok_or_else(|| anyhow!("proof ended before tree terminator"))?; + if n == END_OF_TREE_IN_PACKAGED_PROOF { + break; + } + i = i + .checked_add(1) + .ok_or_else(|| anyhow!("proof offset overflow"))?; num_nodes += 1; ensure!(self.max_num_operations.is_none() || num_nodes <= max_nodes); match n { LABEL_IN_PACKAGED_PROOF => { let mut label: Digest32 = Default::default(); - label.copy_from_slice(&self.proof[i..i + DIGEST_LENGTH]); - i += DIGEST_LENGTH; + let end = i + .checked_add(DIGEST_LENGTH) + .ok_or_else(|| anyhow!("proof offset overflow"))?; + let encoded = self + .proof + .get(i..end) + .ok_or_else(|| anyhow!("truncated label"))?; + label.copy_from_slice(encoded); + i = end; stack.push(Node::new_label(&label)); previous_leaf = None; } @@ -110,35 +134,73 @@ impl BatchAVLVerifier { Bytes::copy_from_slice(&self.base.tree.next_node_key(&prev)) } else { let start = i; - i += self.base.tree.key_length; - Bytes::copy_from_slice(&self.proof[start..i]) + i = i + .checked_add(self.base.tree.key_length) + .ok_or_else(|| anyhow!("proof offset overflow"))?; + Bytes::copy_from_slice( + self.proof + .get(start..i) + .ok_or_else(|| anyhow!("truncated leaf key"))?, + ) + }; + let next_key_end = i + .checked_add(key_length) + .ok_or_else(|| anyhow!("proof offset overflow"))?; + let next_leaf_key = Bytes::copy_from_slice( + self.proof + .get(i..next_key_end) + .ok_or_else(|| anyhow!("truncated next leaf key"))?, + ); + i = next_key_end; + let value_length = match self.base.tree.value_length { + Some(value_length) => value_length, + None => { + let length_end = i + .checked_add(4) + .ok_or_else(|| anyhow!("proof offset overflow"))?; + let encoded_length = self + .proof + .get(i..length_end) + .ok_or_else(|| anyhow!("truncated value length"))?; + i = length_end; + BigEndian::read_u32(encoded_length) as usize + } }; - let next_leaf_key = Bytes::copy_from_slice(&self.proof[i..i + key_length]); - i += key_length; - let value_length = self.base.tree.value_length.unwrap_or_else(|| { - let vl = BigEndian::read_u32(&self.proof[i..i + 4]) as usize; - i += 4; - vl - }); - let value = Bytes::copy_from_slice(&self.proof[i..i + value_length]); - i += value_length; + let value_end = i + .checked_add(value_length) + .ok_or_else(|| anyhow!("proof offset overflow"))?; + let value = Bytes::copy_from_slice( + self.proof + .get(i..value_end) + .ok_or_else(|| anyhow!("truncated leaf value"))?, + ); + i = value_end; let leaf = LeafNode::new(&key, &value, &next_leaf_key); stack.push(leaf.clone()); previous_leaf = Some(leaf); } _ => { - let right = stack.pop().unwrap(); - let left = stack.pop().unwrap(); + let right = stack + .pop() + .ok_or_else(|| anyhow!("internal node missing right child"))?; + let left = stack + .pop() + .ok_or_else(|| anyhow!("internal node missing left child"))?; stack.push(InternalNode::new(None, &left, &right, n as Balance)); } } } ensure!(stack.len() == 1); - let root = stack.pop().unwrap(); + let root = stack + .pop() + .ok_or_else(|| anyhow!("proof contains no root"))?; ensure!(starting_digest.starts_with(&self.base.tree.label(&root))); self.base.tree.root = Some(root); - self.directions_index = (i + 1) * 8; // Directions start right after the packed tree, which we just finished + self.directions_index = i + .checked_add(1) + .and_then(|offset| offset.checked_mul(8)) + .ok_or_else(|| anyhow!("direction offset overflow"))?; // Directions start right after the packed tree, which we just finished Ok(()) } @@ -163,7 +225,10 @@ impl BatchAVLVerifier { .as_ref() .ok_or(anyhow!("Empty tree"))? .clone(); - let res = self.return_result_of_one_operation(operation, &root); + let mut res = self.return_result_of_one_operation(operation, &root); + if self.proof_exhausted && res.is_ok() { + res = Err(anyhow!("proof direction bits exhausted")); + } if res.is_err() { self.base.tree.root = None; self.base.tree.height = 0; @@ -191,13 +256,19 @@ impl AuthenticatedTreeOps for BatchAVLVerifier { /// fn next_direction_is_left(&mut self, _key: &ADKey, _r: &InternalNode) -> bool { // Decode bits of the proof as Booleans - let ret = - if self.proof[self.directions_index >> 3] & (1 << (self.directions_index & 7)) != 0 { - true - } else { - self.last_right_step = self.directions_index; - false - }; + let direction_byte = match self.proof.get(self.directions_index >> 3) { + Some(byte) => *byte, + None => { + self.proof_exhausted = true; + 0 + } + }; + let ret = if direction_byte & (1 << (self.directions_index & 7)) != 0 { + true + } else { + self.last_right_step = self.directions_index; + false + }; self.directions_index += 1; ret } @@ -216,7 +287,11 @@ impl AuthenticatedTreeOps for BatchAVLVerifier { // checks that the key is either equal to the leaf's key // or is between the leaf's key and its nextLeafKey // See https://eprint.iacr.org/2016/994 Appendix B paragraph "Our Algorithms" - let leaf_key = leaf.hdr.key.as_ref().unwrap(); + let leaf_key = leaf + .hdr + .key + .as_ref() + .ok_or_else(|| anyhow!("leaf key is missing"))?; if *key == *leaf_key { Ok(true) } else { @@ -237,9 +312,16 @@ impl AuthenticatedTreeOps for BatchAVLVerifier { /// @return - result of previous comparison of key and relevant node's key /// fn replay_comparison(&mut self) -> i32 { + let direction_byte = match self.proof.get(self.replay_index >> 3) { + Some(byte) => *byte, + None => { + self.proof_exhausted = true; + 0 + } + }; let ret = if self.replay_index == self.last_right_step { 0 - } else if (self.proof[self.replay_index >> 3] & (1 << (self.replay_index & 7))) == 0 + } else if (direction_byte & (1 << (self.replay_index & 7))) == 0 && self.replay_index < self.last_right_step { 1 diff --git a/tests/end_to_end_flow.rs b/tests/end_to_end_flow.rs deleted file mode 100644 index 4c65f42..0000000 --- a/tests/end_to_end_flow.rs +++ /dev/null @@ -1,244 +0,0 @@ -use basis_store::{IouNote, RedemptionRequest, schnorr::{self, generate_keypair}}; - -#[tokio::test] -async fn test_complete_issuance_redemption_flow() { - println!("=== Starting Complete Issuance → Tracking → Redemption Flow Test ==="); - - // Step 1: Generate test keypairs - println!("Step 1: Generating test keypairs..."); - let (issuer_secret, issuer_pubkey) = generate_keypair(); - let (recipient_secret, recipient_pubkey) = generate_keypair(); - - println!("Issuer pubkey: {}", hex::encode(issuer_pubkey)); - println!("Recipient pubkey: {}", hex::encode(recipient_pubkey)); - - // Step 2: Create and sign IOU note - println!("\nStep 2: Creating and signing IOU note..."); - let amount = 1000; - let timestamp = 1672531200; // Old timestamp for immediate redemption - - let note = IouNote::create_and_sign( - recipient_pubkey, - amount, - timestamp, - &issuer_secret.secret_bytes(), - ).expect("Failed to create and sign note"); - - println!("Note created successfully:"); - println!(" Amount: {}", note.amount_collected); - println!(" Timestamp: {}", note.timestamp); - println!(" Outstanding debt: {}", note.outstanding_debt()); - - // Step 3: Verify note signature - println!("\nStep 3: Verifying note signature..."); - let signature_valid = note.verify_signature(&issuer_pubkey).is_ok(); - assert!(signature_valid, "Note signature should be valid"); - println!("✓ Signature verification passed"); - - // Step 4: Create redemption request - println!("\nStep 4: Creating redemption request..."); - let redemption_request = RedemptionRequest { - issuer_pubkey: hex::encode(issuer_pubkey), - recipient_pubkey: hex::encode(recipient_pubkey), - amount: 500, // Partial redemption - timestamp, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "test_tracker_nft_1".to_string(), - current_height: 1000, - recipient_address: "test_recipient_address".to_string(), - change_address: "test_change_address".to_string(), - issuer_signature: "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101".to_string(), - emergency: false, - tracker_signature: Some("020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202".to_string()), - reserve_box_value: 1000000000, - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - println!("Redemption request created:"); - println!(" Amount: {}", redemption_request.amount); - println!(" Reserve box: {}", redemption_request.reserve_box_id); - - // Step 5: Verify redemption validation - println!("\nStep 5: Verifying redemption validation..."); - - // Check that redemption amount doesn't exceed outstanding debt - let redemption_valid = redemption_request.amount <= note.outstanding_debt(); - assert!(redemption_valid, "Redemption amount should not exceed outstanding debt"); - println!("✓ Redemption validation passed"); - - // Step 6: Simulate redemption completion - println!("\nStep 6: Simulating redemption completion..."); - let redeemed_amount = redemption_request.amount; - let remaining_debt = note.outstanding_debt() - redeemed_amount; - - println!("Redemption completed:"); - println!(" Redeemed: {}", redeemed_amount); - println!(" Remaining debt: {}", remaining_debt); - - // Step 7: Verify final state - println!("\nStep 7: Verifying final state..."); - assert!(remaining_debt >= 0, "Remaining debt should not be negative"); - assert!(remaining_debt <= note.amount_collected, "Remaining debt should not exceed original amount"); - - if remaining_debt == 0 { - println!("✓ Note fully redeemed"); - } else { - println!("✓ Note partially redeemed, {} remaining", remaining_debt); - } - - println!("\n=== Complete Flow Test Passed ===\n"); -} - -#[tokio::test] -async fn test_multiple_issuers_flow() { - println!("=== Starting Multiple Issuers Flow Test ==="); - - // Generate multiple issuer keypairs - let issuers: Vec<_> = (0..3) - .map(|_| generate_keypair()) - .collect(); - - let (recipient_secret, recipient_pubkey) = generate_keypair(); - - println!("Testing with {} issuers", issuers.len()); - - // Each issuer creates a note for the same recipient - let mut total_debt = 0; - for (i, (issuer_secret, issuer_pubkey)) in issuers.iter().enumerate() { - let amount = 1000 * (i as u64 + 1); - let timestamp = 1672531200 + (i as u64 * 60); - - let note = IouNote::create_and_sign( - recipient_pubkey, - amount, - timestamp, - &issuer_secret.secret_bytes(), - ).expect("Failed to create note"); - - // Verify each note independently - let signature_valid = note.verify_signature(issuer_pubkey).is_ok(); - assert!(signature_valid, "Note {} signature should be valid", i); - - total_debt += note.outstanding_debt(); - - println!("Issuer {}: created note for {} (total debt: {})", i, amount, total_debt); - } - - println!("Total outstanding debt across all issuers: {}", total_debt); - assert!(total_debt > 0, "Total debt should be positive"); - - println!("\n=== Multiple Issuers Flow Test Passed ===\n"); -} - -#[tokio::test] -async fn test_error_conditions_flow() { - println!("=== Starting Error Conditions Flow Test ==="); - - let (issuer_secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - let (wrong_issuer_secret, wrong_issuer_pubkey) = generate_keypair(); - - // Test 1: Invalid signature verification - println!("Test 1: Invalid signature verification..."); - - let valid_note = IouNote::create_and_sign( - recipient_pubkey, - 1000, - 1672531200, - &issuer_secret.secret_bytes(), - ).unwrap(); - - // Try to verify with wrong issuer - let wrong_verification = valid_note.verify_signature(&wrong_issuer_pubkey); - assert!(wrong_verification.is_err(), "Should fail with wrong issuer pubkey"); - println!("✓ Wrong issuer detection passed"); - - // Test 2: Excessive redemption amount - println!("\nTest 2: Excessive redemption amount..."); - - let note = IouNote::create_and_sign( - recipient_pubkey, - 1000, - 1672531200, - &issuer_secret.secret_bytes(), - ).unwrap(); - - let excessive_redemption = RedemptionRequest { - issuer_pubkey: hex::encode(issuer_pubkey), - recipient_pubkey: hex::encode(recipient_pubkey), - amount: 2000, // More than outstanding debt - timestamp: 1672531200, - reserve_box_id: "test_reserve_box_1".to_string(), - tracker_box_id: "test_tracker_box_1".to_string(), - tracker_nft_id: "test_tracker_nft_1".to_string(), - current_height: 1000, - recipient_address: "test_recipient_address".to_string(), - change_address: "test_change_address".to_string(), - issuer_signature: "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101".to_string(), - emergency: false, - tracker_signature: Some("020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202".to_string()), - reserve_box_value: 1000000000, - fee_input_box_ids: Vec::new(), - fee_input_total_value: 0, - reserve_refund_initiation_height: 0, - }; - - let redemption_valid = excessive_redemption.amount <= note.outstanding_debt(); - assert!(!redemption_valid, "Should detect excessive redemption amount"); - println!("✓ Excessive redemption detection passed"); - - // Test 3: Time lock validation - println!("\nTest 3: Time lock validation..."); - - let recent_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let recent_note = IouNote::create_and_sign( - recipient_pubkey, - 1000, - recent_timestamp, - &issuer_secret.secret_bytes(), - ).unwrap(); - - // Note: Time lock enforcement is now handled by the contract based on tracker creation height. - // Emergency redemption is available after 3 days (3*720 blocks) from tracker creation. - // Normal redemption requires both owner and tracker signatures with no time restriction. - // The transaction builder no longer enforces time locks. - - // Verify the note was created successfully - assert_eq!(recent_note.amount_collected, 1000); - println!("✓ Note created successfully (time lock now enforced by contract)"); - - println!("\n=== Error Conditions Flow Test Passed ===\n"); -} - -#[tokio::test] -async fn test_signature_tampering_detection() { - println!("=== Starting Signature Tampering Detection Test ==="); - - let (issuer_secret, issuer_pubkey) = generate_keypair(); - let (_, recipient_pubkey) = generate_keypair(); - - // Create a valid note - let mut note = IouNote::create_and_sign( - recipient_pubkey, - 1000, - 1672531200, - &issuer_secret.secret_bytes(), - ).unwrap(); - - // Tamper with the signature - note.signature[0] ^= 0x01; // Flip one bit - - // Verification should fail - let verification_result = note.verify_signature(&issuer_pubkey); - assert!(verification_result.is_err(), "Should detect tampered signature"); - - println!("✓ Signature tampering detection passed"); - println!("\n=== Signature Tampering Detection Test Passed ===\n"); -} \ No newline at end of file